46 Commits

Author SHA1 Message Date
Usman
42ed7d91dd Merge pull request #32 from ciphera-net/staging
[PULSE-57] Billing UX: renewal display, design fixes, React crash fix
2026-02-20 18:32:33 +01:00
Usman Baig
b8cb7e177e chore: update CHANGELOG for version 0.8.0-alpha, adding new features, changes, and fixes related to billing and subscription management 2026-02-20 18:32:12 +01:00
Usman Baig
fa3982001d feat: enhance HomePage and OrganizationSettings to display detailed subscription information and improve user interaction with invoice links 2026-02-20 18:05:59 +01:00
Usman Baig
6817f0c9fa fix: streamline invoice preview logic in OrganizationSettings to improve performance and user feedback during plan changes 2026-02-20 17:50:46 +01:00
Usman Baig
5b1d3d8f0e refactor: update PricingSection styles for improved layout and accessibility; enhance OrganizationSettings to handle plan changes and display past due notices 2026-02-20 16:50:43 +01:00
Usman Baig
12975f671d fix: update invoice preview handling in OrganizationSettings to reset state and provide user feedback on calculation errors 2026-02-20 16:21:35 +01:00
Usman Baig
cc89a27972 feat: add invoice preview functionality in OrganizationSettings to enhance user experience with upcoming billing details 2026-02-20 16:18:00 +01:00
Usman Baig
99e9235f1f feat: add resume subscription functionality in OrganizationSettings for improved user control over billing 2026-02-20 16:07:17 +01:00
Usman Baig
53ed7493c6 style: update download and view invoice links in OrganizationSettings for improved UI consistency and accessibility 2026-02-20 16:04:05 +01:00
Usman Baig
a4f2bebd10 feat: enhance OrganizationSettings to display Tax IDs alongside business name for improved billing clarity 2026-02-20 15:36:50 +01:00
Usman Baig
2d37d065c0 fix: remove CheckoutSuccessToast component and its usage in SettingsPage for cleaner settings interface 2026-02-20 04:02:11 +01:00
Usman Baig
17106517d9 refactor: remove embedded checkout components and update billing API integration for streamlined checkout flow 2026-02-20 03:51:20 +01:00
Usman Baig
96b3919e52 fix: refactor CheckoutReturnPage to use Suspense for loading state and separate content into CheckoutReturnContent component 2026-02-20 03:47:10 +01:00
Usman Baig
0bbbb8a1af feat: integrate Stripe for embedded checkout; update billing API to return client_secret and adjust checkout flow in components 2026-02-20 03:41:35 +01:00
Usman Baig
6d277b126e feat: display billing information with business name in OrganizationSettings component for improved user clarity 2026-02-20 03:10:08 +01:00
Usman Baig
4410366ccf feat: add optional business_name field to SubscriptionDetails interface in billing API for enhanced billing information 2026-02-20 03:03:21 +01:00
Usman Baig
826dbdbe63 feat: implement site limits based on subscription plans across dashboard and new site creation; enhance UI feedback for plan limits 2026-02-20 02:46:23 +01:00
Usman
c842d80183 Merge pull request #31 from ciphera-net/staging
chore: update CHANGELOG.md and DESIGN_SYSTEM.md
2026-02-17 21:25:46 +01:00
Usman Baig
f9eb6bf5c0 chore: clarify usage of color-brand-orange-rgb in DESIGN_SYSTEM.md for better documentation and utility reference 2026-02-17 21:22:56 +01:00
Usman Baig
ce20205488 chore: update CHANGELOG.md and DESIGN_SYSTEM.md to reflect footer layout alignment and new color variable usage for SVG/Recharts 2026-02-17 21:16:52 +01:00
Usman
5ed4afd389 Merge pull request #30 from ciphera-net/staging
[PULSE-56] Consolidate pulse-frontend with ciphera-ui (design system migration)
2026-02-17 21:09:39 +01:00
Usman Baig
3e8cd8d046 chore: release version 0.7.0-alpha; consolidate components from ciphera-ui, update form card styles, and remove dead components 2026-02-17 21:02:39 +01:00
Usman Baig
ae91147b6c chore: update @ciphera-net/ui dependency to version 0.0.57 in package.json and package-lock.json; refactor imports across multiple components for consistency 2026-02-17 20:49:55 +01:00
Usman Baig
3b6757126e refactor: remove selection background color from multiple pages for a cleaner UI 2026-02-17 20:42:05 +01:00
Usman Baig
ada99c2ba9 chore: update @ciphera-net/ui dependency to version 0.0.56 in package.json and package-lock.json; adjust color variables in ResponseTimeChart and DESIGN_SYSTEM.md for consistency 2026-02-17 20:36:58 +01:00
Usman Baig
462ce622e3 chore: update @ciphera-net/ui dependency to version 0.0.55 in package.json and package-lock.json 2026-02-17 20:27:04 +01:00
Usman Baig
1574d5e473 chore: update @ciphera-net/ui dependency to version 0.0.54 in package.json and package-lock.json, and adjust footer layout for improved responsiveness 2026-02-17 20:19:49 +01:00
Usman Baig
d028b044b9 chore: update @ciphera-net/ui dependency to version 0.0.53 in package.json and package-lock.json 2026-02-17 20:05:18 +01:00
Usman Baig
ccf1cc170a chore: update package dependencies and remove unused CSS styles for improved performance and maintainability 2026-02-17 19:57:38 +01:00
Usman Baig
32d8b90284 feat: add rewrites for documentation URLs to improve navigation and accessibility 2026-02-16 21:53:58 +01:00
Usman
a900e46e63 Merge pull request #29 from ciphera-net/staging
fix: extract notification utility functions for better code organizat…
2026-02-16 20:55:13 +01:00
Usman Baig
3b9f33b838 fix: extract notification utility functions for better code organization and reuse in NotificationsPage and NotificationCenter 2026-02-16 20:46:36 +01:00
Usman
e5f5539eef Merge pull request #28 from ciphera-net/staging
[PULSE-55] In-app notification center, settings tab, and notifications page
2026-02-16 20:46:02 +01:00
Usman Baig
56b99dfcef fix: improve error handling in notifications and organization settings for better user feedback 2026-02-16 20:34:35 +01:00
Usman Baig
4a48945486 fix: update empty state messaging in NotificationCenter for improved user guidance 2026-02-16 12:02:14 +01:00
Usman Baig
c6373d5f2d feat: enhance notifications system with UX improvements, new settings management links, and audit log for notification preferences 2026-02-16 11:55:08 +01:00
Usman Baig
4b61f1a397 refactor: replace Checkbox with button for toggling notification settings in OrganizationSettings, enhancing accessibility and visual feedback 2026-02-14 12:22:10 +01:00
Usman Baig
a83f3727b1 refactor: enhance notification settings layout in OrganizationSettings for better usability and visual clarity 2026-02-13 15:14:14 +01:00
Usman Baig
be27dbf992 feat: add notification settings tab in organization settings for owners and admins 2026-02-13 14:46:21 +01:00
Usman Baig
7f7312a7cd feat: add pageview limit, trial ending soon, and subscription canceled notifications for owners and admins 2026-02-13 14:34:56 +01:00
Usman Baig
c37613e823 feat: add payment failed notifications to in-app notification center for owners and admins 2026-02-13 14:23:19 +01:00
Usman Baig
43d40e5735 fix: add loading delay for notifications fetching in NotificationCenter to improve user experience 2026-02-13 13:41:55 +01:00
Usman Baig
3efcd4875d chore: remove deprecated @ciphera-net/ui dependency from package-lock.json and clean up unnecessary resolved and integrity fields 2026-02-13 10:08:50 +01:00
Usman Baig
4add41293b fix: ensure safe handling of organizations and notifications data in LayoutContent and NotificationCenter components 2026-02-13 10:01:32 +01:00
Usman Baig
a389c2a751 fix: regenerate package-lock.json with registry resolution for @ciphera-net/ui (fixes Coolify npm ci)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 09:55:14 +01:00
Usman Baig
18a54401ef chore: update CHANGELOG for version 0.6.0-alpha, add in-app notification center, and update package dependencies 2026-02-13 09:36:18 +01:00
57 changed files with 2443 additions and 677 deletions

View File

@@ -4,6 +4,59 @@ All notable changes to Pulse (frontend and product) are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and Pulse uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) with a **0.x.y** version scheme while in initial development. The leading `0` indicates that the public API and behaviour may change until we release **1.0.0**. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and Pulse uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) with a **0.x.y** version scheme while in initial development. The leading `0` indicates that the public API and behaviour may change until we release **1.0.0**.
## [Unreleased]
## [0.8.0-alpha] - 2026-02-20
### Added
- **Renewal date and amount.** The dashboard and billing tab now show when your subscription renews and how much you'll be charged.
- **Invoice preview when changing plans.** Before you switch plans, you can see exactly what your next invoice will be (including prorations).
- **Pay now for open invoices.** Unpaid invoices show a clear "Pay now" button so you can settle them quickly.
- **Enterprise contact.** The pricing page Enterprise plan now links to email us directly instead of checkout.
- **Past due alert.** If your payment fails, a red banner appears with a link to update your payment method.
- **Pageview usage bar.** Your billing card shows a color-coded bar so you can see at a glance how close you are to your limit (green, then amber, then red).
### Changed
- **Change plan flow.** Cleaner plan selector with Solo, Team, and Business options. Shows which plan you're on and a preview of your next invoice. If the preview can't be calculated, you'll see a friendly message instead of a blank screen.
- **Billing tab layout.** Improved spacing, clearer headings, and better focus when using keyboard navigation.
- **Pricing page layout.** Updated spacing and typography. Slider and billing toggle are more accessible.
- **Billing Portal return.** After updating your payment method in Stripe's portal, you're taken back to the billing tab instead of the general settings page.
### Fixed
- **Theme toggle crash.** Fixed a crash that could occur when switching between light and dark mode on the pricing page and then opening organization settings.
## [0.7.0-alpha] - 2026-02-17
### Changed
- **ciphera-ui consolidation.** Migrated shared components and utilities to @ciphera-net/ui (v0.0.57): SwissFlagIcon, CodeBlock, Spinner, format utilities (formatNumber, formatDuration, formatDate, getDateRange, formatUpdatedAgo, formatRelativeTime), and auth error utilities (getAuthErrorMessage, authMessageFromStatus, authMessageFromErrorType). Removed 6 local duplicate files (LoadingOverlay, SwissFlagIcon, CodeBlock, authErrors.ts, format.ts).
- **Form card border radius.** Login, signup, invite accept, verify, reset-password, forgot-password, and organization create cards now use rounded-2xl to match design system.
- **Hardcoded brand colors.** Uptime page chart uses CSS variable var(--color-brand-orange) instead of #FD5E0F.
- **Selection styling.** Removed redundant selection:bg-brand-orange/20 from page wrappers; relies on ciphera-ui base styles.
- **Inline spinners.** Dashboard widgets (TopReferrers, Locations, TechSpecs, Campaigns, ContentStats), notifications page, and OrganizationSettings now use Spinner from ciphera-ui.
- **Footer layout.** Authenticated footer container aligned to max-w-6xl (matches site dashboard and page-container-app).
### Removed
- **Dead components.** LoadingOverlay.tsx (unused; all usage already from ciphera-ui).
## [0.6.0-alpha] - 2026-02-13
### Added
- **Notification settings.** New Notifications tab in organization settings lets owners and admins toggle billing and uptime notification categories. Disabling a category stops new notifications of that type from being created.
- **In-app notification center.** Bell icon in the header with dropdown of recent notifications. Uptime monitor status changes (down, degraded, recovered) create in-app notifications with links to the uptime page.
- **Notifications UX improvements.** Bell dropdown links to "Manage settings" and "View all" notifications page. Unread count polls every 90 seconds. Full notifications page at /notifications with pagination.
- **Notifications tab visibility.** Notifications tab in organization settings is hidden from members (owners and admins only).
- **Audit log for notification settings.** Changes to notification preferences are recorded in the organization audit log.
- **Payment failed notifications.** When Stripe sends `invoice.payment_failed`, owners and admins receive an in-app notification with a link to update payment method. Members do not see billing notifications.
- **Pageview limit notifications.** Owners and admins are notified when usage reaches 80%, 90%, or 100% of the plan limit (checked every 6 hours).
- **Trial ending soon.** When a trial ends within 7 days, owners and admins receive a notification. Triggered by Stripe webhooks and a periodic checker.
- **Subscription canceled.** When a subscription is canceled, owners and admins are notified with a link to billing.
## [0.5.1-alpha] - 2026-02-12 ## [0.5.1-alpha] - 2026-02-12
### Changed ### Changed
@@ -51,7 +104,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
--- ---
[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.5.1-alpha...HEAD [Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.8.0-alpha...HEAD
[0.8.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.7.0-alpha...v0.8.0-alpha
[0.7.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.6.0-alpha...v0.7.0-alpha
[0.6.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.5.1-alpha...v0.6.0-alpha
[0.5.1-alpha]: https://github.com/ciphera-net/pulse/compare/v0.5.0-alpha...v0.5.1-alpha [0.5.1-alpha]: https://github.com/ciphera-net/pulse/compare/v0.5.0-alpha...v0.5.1-alpha
[0.5.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.4.0-alpha...v0.5.0-alpha [0.5.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.4.0-alpha...v0.5.0-alpha
[0.4.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.3.0-alpha...v0.4.0-alpha [0.4.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.3.0-alpha...v0.4.0-alpha

View File

@@ -57,7 +57,7 @@ function ComparisonTable({ title, competitors }: { title: string, competitors: {
export default function AboutPage() { export default function AboutPage() {
return ( return (
<div className="relative min-h-screen flex flex-col overflow-hidden selection:bg-brand-orange/20"> <div className="relative min-h-screen flex flex-col overflow-hidden">
{/* * --- ATMOSPHERE (Background) --- */} {/* * --- ATMOSPHERE (Background) --- */}
<div className="absolute inset-0 -z-10 pointer-events-none"> <div className="absolute inset-0 -z-10 pointer-events-none">
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" /> <div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" />

View File

@@ -5,7 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation'
import { useAuth } from '@/lib/auth/context' import { useAuth } from '@/lib/auth/context'
import { AUTH_URL, default as apiRequest } from '@/lib/api/client' import { AUTH_URL, default as apiRequest } from '@/lib/api/client'
import { exchangeAuthCode, setSessionAction } from '@/app/actions/auth' import { exchangeAuthCode, setSessionAction } from '@/app/actions/auth'
import { authMessageFromErrorType, type AuthErrorType } from '@/lib/utils/authErrors' import { authMessageFromErrorType, type AuthErrorType } from '@ciphera-net/ui'
import { LoadingOverlay } from '@ciphera-net/ui' import { LoadingOverlay } from '@ciphera-net/ui'
function AuthCallbackContent() { function AuthCallbackContent() {

View File

@@ -106,7 +106,7 @@ const trustSignals = [
export default function FeaturesPage() { export default function FeaturesPage() {
return ( return (
<div className="relative min-h-screen flex flex-col overflow-hidden selection:bg-brand-orange/20"> <div className="relative min-h-screen flex flex-col overflow-hidden">
{/* * --- ATMOSPHERE (Background) --- */} {/* * --- ATMOSPHERE (Background) --- */}
<div className="absolute inset-0 -z-10 pointer-events-none"> <div className="absolute inset-0 -z-10 pointer-events-none">
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" /> <div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" />

View File

@@ -4,7 +4,7 @@ import React from 'react'
export default function InstallationPage() { export default function InstallationPage() {
return ( return (
<div className="relative min-h-screen flex flex-col overflow-hidden selection:bg-brand-orange/20"> <div className="relative min-h-screen flex flex-col overflow-hidden">
{/* * --- 1. ATMOSPHERE (Background) --- */} {/* * --- 1. ATMOSPHERE (Background) --- */}
<div className="absolute inset-0 -z-10 pointer-events-none"> <div className="absolute inset-0 -z-10 pointer-events-none">

View File

@@ -5,7 +5,7 @@ import { ArrowLeftIcon } from '@ciphera-net/ui'
export default function NextJsIntegrationPage() { export default function NextJsIntegrationPage() {
return ( return (
<div className="relative min-h-screen flex flex-col overflow-hidden selection:bg-brand-orange/20"> <div className="relative min-h-screen flex flex-col overflow-hidden">
{/* * --- ATMOSPHERE (Background) --- */} {/* * --- ATMOSPHERE (Background) --- */}
<div className="absolute inset-0 -z-10 pointer-events-none"> <div className="absolute inset-0 -z-10 pointer-events-none">
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" /> <div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" />

View File

@@ -90,7 +90,7 @@ export default function IntegrationsPage() {
}, []) }, [])
return ( return (
<div className="relative min-h-screen flex flex-col overflow-hidden selection:bg-brand-orange/20"> <div className="relative min-h-screen flex flex-col overflow-hidden">
{/* * --- ATMOSPHERE (Background) --- */} {/* * --- ATMOSPHERE (Background) --- */}
<div className="absolute inset-0 -z-10 pointer-events-none"> <div className="absolute inset-0 -z-10 pointer-events-none">
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" /> <div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" />

View File

@@ -5,7 +5,7 @@ import { ArrowLeftIcon } from '@ciphera-net/ui'
export default function ReactIntegrationPage() { export default function ReactIntegrationPage() {
return ( return (
<div className="relative min-h-screen flex flex-col overflow-hidden selection:bg-brand-orange/20"> <div className="relative min-h-screen flex flex-col overflow-hidden">
{/* * --- ATMOSPHERE (Background) --- */} {/* * --- ATMOSPHERE (Background) --- */}
<div className="absolute inset-0 -z-10 pointer-events-none"> <div className="absolute inset-0 -z-10 pointer-events-none">
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" /> <div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" />

View File

@@ -5,7 +5,7 @@ import { ArrowLeftIcon } from '@ciphera-net/ui'
export default function VueIntegrationPage() { export default function VueIntegrationPage() {
return ( return (
<div className="relative min-h-screen flex flex-col overflow-hidden selection:bg-brand-orange/20"> <div className="relative min-h-screen flex flex-col overflow-hidden">
{/* * --- ATMOSPHERE (Background) --- */} {/* * --- ATMOSPHERE (Background) --- */}
<div className="absolute inset-0 -z-10 pointer-events-none"> <div className="absolute inset-0 -z-10 pointer-events-none">
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" /> <div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" />

View File

@@ -5,7 +5,7 @@ import { ArrowLeftIcon } from '@ciphera-net/ui'
export default function WordPressIntegrationPage() { export default function WordPressIntegrationPage() {
return ( return (
<div className="relative min-h-screen flex flex-col overflow-hidden selection:bg-brand-orange/20"> <div className="relative min-h-screen flex flex-col overflow-hidden">
{/* * --- ATMOSPHERE (Background) --- */} {/* * --- ATMOSPHERE (Background) --- */}
<div className="absolute inset-0 -z-10 pointer-events-none"> <div className="absolute inset-0 -z-10 pointer-events-none">
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" /> <div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" />

View File

@@ -2,7 +2,8 @@
import { OfflineBanner } from '@/components/OfflineBanner' import { OfflineBanner } from '@/components/OfflineBanner'
import { Footer } from '@/components/Footer' import { Footer } from '@/components/Footer'
import { Header, GridIcon } from '@ciphera-net/ui' import { Header } from '@ciphera-net/ui'
import NotificationCenter from '@/components/notifications/NotificationCenter'
import { useAuth } from '@/lib/auth/context' import { useAuth } from '@/lib/auth/context'
import { useOnlineStatus } from '@/lib/hooks/useOnlineStatus' import { useOnlineStatus } from '@/lib/hooks/useOnlineStatus'
import Link from 'next/link' import Link from 'next/link'
@@ -21,7 +22,7 @@ export default function LayoutContent({ children }: { children: React.ReactNode
useEffect(() => { useEffect(() => {
if (auth.user) { if (auth.user) {
getUserOrganizations() getUserOrganizations()
.then((organizations) => setOrgs(organizations)) .then((organizations) => setOrgs(Array.isArray(organizations) ? organizations : []))
.catch(err => console.error('Failed to fetch orgs for header', err)) .catch(err => console.error('Failed to fetch orgs for header', err))
} }
}, [auth.user]) }, [auth.user])
@@ -63,6 +64,7 @@ export default function LayoutContent({ children }: { children: React.ReactNode
showSecurity={false} showSecurity={false}
showPricing={true} showPricing={true}
topOffset={showOfflineBar ? `${barHeightRem}rem` : undefined} topOffset={showOfflineBar ? `${barHeightRem}rem` : undefined}
rightSideActions={auth.user ? <NotificationCenter /> : null}
customNavItems={ customNavItems={
<> <>
{!auth.user && ( {!auth.user && (

View File

@@ -3,7 +3,7 @@ import { Button } from '@ciphera-net/ui'
export default function NotFound() { export default function NotFound() {
return ( return (
<div className="relative min-h-[80vh] flex flex-col items-center justify-center overflow-hidden selection:bg-brand-orange/20"> <div className="relative min-h-[80vh] flex flex-col items-center justify-center overflow-hidden">
{/* * --- ATMOSPHERE (Background) --- */} {/* * --- ATMOSPHERE (Background) --- */}
<div className="absolute inset-0 -z-10 pointer-events-none"> <div className="absolute inset-0 -z-10 pointer-events-none">
{/* * Center Orange Glow */} {/* * Center Orange Glow */}

211
app/notifications/page.tsx Normal file
View File

@@ -0,0 +1,211 @@
'use client'
/**
* @file Full notifications list page (View all).
*/
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { useAuth } from '@/lib/auth/context'
import {
listNotifications,
markNotificationRead,
markAllNotificationsRead,
type Notification,
} from '@/lib/api/notifications'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { formatTimeAgo, getTypeIcon } from '@/lib/utils/notifications'
import { Button, ArrowLeftIcon, Spinner } from '@ciphera-net/ui'
import { toast } from '@ciphera-net/ui'
const PAGE_SIZE = 50
export default function NotificationsPage() {
const { user } = useAuth()
const [notifications, setNotifications] = useState<Notification[]>([])
const [unreadCount, setUnreadCount] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [offset, setOffset] = useState(0)
const [hasMore, setHasMore] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const fetchPage = async (pageOffset: number, append: boolean) => {
if (append) setLoadingMore(true)
else setLoading(true)
setError(null)
try {
const res = await listNotifications({ limit: PAGE_SIZE, offset: pageOffset })
const list = Array.isArray(res?.notifications) ? res.notifications : []
setNotifications((prev) => (append ? [...prev, ...list] : list))
setUnreadCount(typeof res?.unread_count === 'number' ? res.unread_count : 0)
setHasMore(list.length === PAGE_SIZE)
} catch (err) {
setError(getAuthErrorMessage(err as Error) || 'Failed to load notifications')
setNotifications((prev) => (append ? prev : []))
} finally {
setLoading(false)
setLoadingMore(false)
}
}
useEffect(() => {
if (!user?.org_id) {
setLoading(false)
return
}
fetchPage(0, false)
}, [user?.org_id])
const handleLoadMore = () => {
const next = offset + PAGE_SIZE
setOffset(next)
fetchPage(next, true)
}
const handleMarkRead = async (id: string) => {
try {
await markNotificationRead(id)
setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, read: true } : n)))
setUnreadCount((c) => Math.max(0, c - 1))
} catch {
// Ignore
}
}
const handleMarkAllRead = async () => {
try {
await markAllNotificationsRead()
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })))
setUnreadCount(0)
toast.success('All notifications marked as read')
} catch (err) {
toast.error(getAuthErrorMessage(err as Error) || 'Failed to mark all as read')
}
}
const handleNotificationClick = (n: Notification) => {
if (!n.read) handleMarkRead(n.id)
}
if (!user?.org_id) {
return (
<div className="min-h-screen pt-24 pb-12 px-4 sm:px-6">
<div className="max-w-2xl mx-auto text-center py-12">
<p className="text-neutral-500">Switch to an organization to view notifications.</p>
<Link href="/welcome" className="text-brand-orange hover:underline mt-4 inline-block">
Go to workspace
</Link>
</div>
</div>
)
}
return (
<div className="min-h-screen pt-24 pb-12 px-4 sm:px-6">
<div className="max-w-2xl mx-auto">
<div className="flex items-center justify-between mb-6">
<Link
href="/"
className="inline-flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors"
>
<ArrowLeftIcon className="w-4 h-4" />
Back
</Link>
{unreadCount > 0 && (
<Button variant="ghost" onClick={handleMarkAllRead}>
Mark all read
</Button>
)}
</div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">Notifications</h1>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-6">
Manage which notifications you receive in{' '}
<Link href="/org-settings?tab=notifications" className="text-brand-orange hover:underline">
Organization Settings Notifications
</Link>
</p>
{loading ? (
<div className="flex justify-center py-12">
<Spinner />
</div>
) : error ? (
<div className="p-8 text-center text-red-500 bg-red-50 dark:bg-red-900/10 rounded-2xl border border-red-200 dark:border-red-800">
{error}
</div>
) : notifications.length === 0 ? (
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400 rounded-2xl border border-neutral-200 dark:border-neutral-800">
<p>No notifications yet</p>
<p className="text-sm mt-2">
Manage which notifications you receive in{' '}
<Link href="/org-settings?tab=notifications" className="text-brand-orange hover:underline">
Organization Settings Notifications
</Link>
</p>
</div>
) : (
<div className="space-y-2">
{notifications.map((n) => (
<div key={n.id}>
{n.link_url ? (
<Link
href={n.link_url}
onClick={() => handleNotificationClick(n)}
className={`block p-4 rounded-xl border border-neutral-200 dark:border-neutral-800 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors ${!n.read ? 'bg-brand-orange/5 dark:bg-brand-orange/10' : ''}`}
>
<div className="flex gap-3">
{getTypeIcon(n.type)}
<div className="min-w-0 flex-1">
<p className={`text-sm ${!n.read ? 'font-medium' : ''} text-neutral-900 dark:text-white`}>
{n.title}
</p>
{n.body && (
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">{n.body}</p>
)}
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
{formatTimeAgo(n.created_at)}
</p>
</div>
</div>
</Link>
) : (
<div
role="button"
tabIndex={0}
onClick={() => handleNotificationClick(n)}
onKeyDown={(e) => e.key === 'Enter' && handleNotificationClick(n)}
className={`block p-4 rounded-xl border border-neutral-200 dark:border-neutral-800 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 cursor-pointer ${!n.read ? 'bg-brand-orange/5 dark:bg-brand-orange/10' : ''}`}
>
<div className="flex gap-3">
{getTypeIcon(n.type)}
<div className="min-w-0 flex-1">
<p className={`text-sm ${!n.read ? 'font-medium' : ''} text-neutral-900 dark:text-white`}>
{n.title}
</p>
{n.body && (
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">{n.body}</p>
)}
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
{formatTimeAgo(n.created_at)}
</p>
</div>
</div>
</div>
)}
</div>
))}
{hasMore && (
<div className="pt-4 text-center">
<Button variant="ghost" onClick={handleLoadMore} isLoading={loadingMore}>
Load more
</Button>
</div>
)}
</div>
)}
</div>
</div>
)
}

View File

@@ -4,7 +4,7 @@ import { useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { createOrganization } from '@/lib/api/organization' import { createOrganization } from '@/lib/api/organization'
import { useAuth } from '@/lib/auth/context' import { useAuth } from '@/lib/auth/context'
import { getAuthErrorMessage } from '@/lib/utils/authErrors' import { getAuthErrorMessage } from '@ciphera-net/ui'
import { LoadingOverlay } from '@ciphera-net/ui' import { LoadingOverlay } from '@ciphera-net/ui'
import { Button, Input } from '@ciphera-net/ui' import { Button, Input } from '@ciphera-net/ui'

View File

@@ -12,7 +12,8 @@ import SiteList from '@/components/sites/SiteList'
import { Button } from '@ciphera-net/ui' import { Button } from '@ciphera-net/ui'
import { BarChartIcon, LockIcon, ZapIcon, CheckCircleIcon, XIcon, GlobeIcon } from '@ciphera-net/ui' import { BarChartIcon, LockIcon, ZapIcon, CheckCircleIcon, XIcon, GlobeIcon } from '@ciphera-net/ui'
import { toast } from '@ciphera-net/ui' import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@/lib/utils/authErrors' import { getAuthErrorMessage } from '@ciphera-net/ui'
import { getSitesLimitForPlan } from '@/lib/plans'
function DashboardPreview() { function DashboardPreview() {
return ( return (
@@ -172,7 +173,7 @@ export default function HomePage() {
if (!user) { if (!user) {
return ( return (
<div className="relative min-h-screen flex flex-col overflow-hidden selection:bg-brand-orange/20"> <div className="relative min-h-screen flex flex-col overflow-hidden">
{/* * --- 1. ATMOSPHERE (Background) --- */} {/* * --- 1. ATMOSPHERE (Background) --- */}
<div className="absolute inset-0 -z-10 pointer-events-none"> <div className="absolute inset-0 -z-10 pointer-events-none">
@@ -337,10 +338,13 @@ export default function HomePage() {
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">Your Sites</h1> <h1 className="text-2xl font-bold text-neutral-900 dark:text-white">Your Sites</h1>
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">Manage your analytics sites and view insights.</p> <p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">Manage your analytics sites and view insights.</p>
</div> </div>
{subscription?.plan_id === 'solo' && sites.length >= 1 ? ( {(() => {
const siteLimit = getSitesLimitForPlan(subscription?.plan_id)
const atLimit = siteLimit != null && sites.length >= siteLimit
return atLimit ? (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-sm font-medium text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800 px-3 py-1.5 rounded-lg border border-neutral-200 dark:border-neutral-700"> <span className="text-sm font-medium text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800 px-3 py-1.5 rounded-lg border border-neutral-200 dark:border-neutral-700">
Limit reached (1/1) Limit reached ({sites.length}/{siteLimit})
</span> </span>
<Link href="/pricing"> <Link href="/pricing">
<Button variant="primary" className="text-sm"> <Button variant="primary" className="text-sm">
@@ -348,7 +352,8 @@ export default function HomePage() {
</Button> </Button>
</Link> </Link>
</div> </div>
) : ( ) : null
})() ?? (
<Link href="/sites/new"> <Link href="/sites/new">
<Button variant="primary" className="text-sm"> <Button variant="primary" className="text-sm">
Add New Site Add New Site
@@ -385,15 +390,34 @@ export default function HomePage() {
return `${label} Plan` return `${label} Plan`
})()} })()}
</p> </p>
{(typeof subscription.sites_count === 'number' || (subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number')) && ( {(typeof subscription.sites_count === 'number' || (subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number') || (subscription.next_invoice_amount_due != null && subscription.next_invoice_currency && !subscription.cancel_at_period_end && (subscription.subscription_status === 'active' || subscription.subscription_status === 'trialing'))) && (
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1"> <p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
{typeof subscription.sites_count === 'number' && ( {typeof subscription.sites_count === 'number' && (
<span>Sites: {subscription.plan_id === 'solo' && subscription.sites_count > 0 ? `${subscription.sites_count}/1` : subscription.sites_count}</span> <span>Sites: {(() => {
const limit = getSitesLimitForPlan(subscription.plan_id)
return limit != null && typeof subscription.sites_count === 'number' ? `${subscription.sites_count}/${limit}` : subscription.sites_count
})()}</span>
)} )}
{typeof subscription.sites_count === 'number' && subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number' && ' · '} {typeof subscription.sites_count === 'number' && (subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number') && ' · '}
{subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number' && ( {subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number' && (
<span>Pageviews: {subscription.pageview_usage.toLocaleString()}/{subscription.pageview_limit.toLocaleString()}</span> <span>Pageviews: {subscription.pageview_usage.toLocaleString()}/{subscription.pageview_limit.toLocaleString()}</span>
)} )}
{subscription.next_invoice_amount_due != null && subscription.next_invoice_currency && !subscription.cancel_at_period_end && (subscription.subscription_status === 'active' || subscription.subscription_status === 'trialing') && (
<span className="block mt-1">
Renews {(() => {
const ts = subscription.next_invoice_period_end ?? subscription.current_period_end
const d = ts ? new Date(typeof ts === 'number' ? ts * 1000 : ts) : null
const dateStr = d && !Number.isNaN(d.getTime()) && d.getTime() !== 0
? d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
: null
const amount = (subscription.next_invoice_amount_due / 100).toLocaleString('en-US', {
style: 'currency',
currency: subscription.next_invoice_currency.toUpperCase(),
})
return dateStr ? `${dateStr} for ${amount}` : amount
})()}
</span>
)}
</p> </p>
)} )}
<div className="mt-2 flex gap-2"> <div className="mt-2 flex gap-2">

View File

@@ -1,6 +1,4 @@
import { Suspense } from 'react'
import ProfileSettings from '@/components/settings/ProfileSettings' import ProfileSettings from '@/components/settings/ProfileSettings'
import CheckoutSuccessToast from '@/components/checkout/CheckoutSuccessToast'
export const metadata = { export const metadata = {
title: 'Settings - Pulse', title: 'Settings - Pulse',
@@ -10,9 +8,6 @@ export const metadata = {
export default function SettingsPage() { export default function SettingsPage() {
return ( return (
<div className="min-h-screen pt-12 pb-12 px-4 sm:px-6"> <div className="min-h-screen pt-12 pb-12 px-4 sm:px-6">
<Suspense fallback={null}>
<CheckoutSuccessToast />
</Suspense>
<ProfileSettings /> <ProfileSettings />
</div> </div>
) )

View File

@@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from 'react'
import { useParams, useSearchParams, useRouter } from 'next/navigation' import { useParams, useSearchParams, useRouter } from 'next/navigation'
import { getPublicDashboard, getPublicStats, getPublicDailyStats, getPublicRealtime, getPublicPerformanceByPage, type DashboardData, type Stats, type DailyStat, type PerformanceByPageStat } from '@/lib/api/stats' import { getPublicDashboard, getPublicStats, getPublicDailyStats, getPublicRealtime, getPublicPerformanceByPage, type DashboardData, type Stats, type DailyStat, type PerformanceByPageStat } from '@/lib/api/stats'
import { toast } from '@ciphera-net/ui' import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@/lib/utils/authErrors' import { getAuthErrorMessage } from '@ciphera-net/ui'
import { LoadingOverlay, Button } from '@ciphera-net/ui' import { LoadingOverlay, Button } from '@ciphera-net/ui'
import Chart from '@/components/dashboard/Chart' import Chart from '@/components/dashboard/Chart'
import TopPages from '@/components/dashboard/ContentStats' import TopPages from '@/components/dashboard/ContentStats'

View File

@@ -16,7 +16,7 @@ import {
ResponsiveContainer, ResponsiveContainer,
Cell Cell
} from 'recharts' } from 'recharts'
import { getDateRange } from '@/lib/utils/format' import { getDateRange } from '@ciphera-net/ui'
const CHART_COLORS_LIGHT = { const CHART_COLORS_LIGHT = {
border: '#E5E5E5', border: '#E5E5E5',

View File

@@ -6,9 +6,9 @@ import { useParams, useRouter } from 'next/navigation'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { getSite, type Site } from '@/lib/api/sites' import { getSite, type Site } from '@/lib/api/sites'
import { getStats, getRealtime, getDailyStats, getTopPages, getTopReferrers, getCountries, getCities, getRegions, getBrowsers, getOS, getDevices, getScreenResolutions, getEntryPages, getExitPages, getDashboard, getCampaigns, getPerformanceByPage, type Stats, type DailyStat, type PerformanceByPageStat } from '@/lib/api/stats' import { getStats, getRealtime, getDailyStats, getTopPages, getTopReferrers, getCountries, getCities, getRegions, getBrowsers, getOS, getDevices, getScreenResolutions, getEntryPages, getExitPages, getDashboard, getCampaigns, getPerformanceByPage, type Stats, type DailyStat, type PerformanceByPageStat } from '@/lib/api/stats'
import { formatNumber, formatDuration, getDateRange } from '@/lib/utils/format' import { formatNumber, formatDuration, getDateRange } from '@ciphera-net/ui'
import { toast } from '@ciphera-net/ui' import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@/lib/utils/authErrors' import { getAuthErrorMessage } from '@ciphera-net/ui'
import { LoadingOverlay, Button } from '@ciphera-net/ui' import { LoadingOverlay, Button } from '@ciphera-net/ui'
import { Select, DatePicker, DownloadIcon } from '@ciphera-net/ui' import { Select, DatePicker, DownloadIcon } from '@ciphera-net/ui'
import ExportModal from '@/components/dashboard/ExportModal' import ExportModal from '@/components/dashboard/ExportModal'

View File

@@ -5,7 +5,7 @@ import { useParams, useRouter } from 'next/navigation'
import { getSite, type Site } from '@/lib/api/sites' import { getSite, type Site } from '@/lib/api/sites'
import { getRealtimeVisitors, getSessionDetails, type Visitor, type SessionEvent } from '@/lib/api/realtime' import { getRealtimeVisitors, getSessionDetails, type Visitor, type SessionEvent } from '@/lib/api/realtime'
import { toast } from '@ciphera-net/ui' import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@/lib/utils/authErrors' import { getAuthErrorMessage } from '@ciphera-net/ui'
import { LoadingOverlay, UserIcon } from '@ciphera-net/ui' import { LoadingOverlay, UserIcon } from '@ciphera-net/ui'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'

View File

@@ -5,7 +5,7 @@ import { useParams, useRouter } from 'next/navigation'
import { getSite, updateSite, resetSiteData, deleteSite, type Site, type GeoDataLevel } from '@/lib/api/sites' import { getSite, updateSite, resetSiteData, deleteSite, type Site, type GeoDataLevel } from '@/lib/api/sites'
import { listGoals, createGoal, updateGoal, deleteGoal, type Goal } from '@/lib/api/goals' import { listGoals, createGoal, updateGoal, deleteGoal, type Goal } from '@/lib/api/goals'
import { toast } from '@ciphera-net/ui' import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@/lib/utils/authErrors' import { getAuthErrorMessage } from '@ciphera-net/ui'
import { LoadingOverlay } from '@ciphera-net/ui' import { LoadingOverlay } from '@ciphera-net/ui'
import VerificationModal from '@/components/sites/VerificationModal' import VerificationModal from '@/components/sites/VerificationModal'
import ScriptSetupBlock from '@/components/sites/ScriptSetupBlock' import ScriptSetupBlock from '@/components/sites/ScriptSetupBlock'

View File

@@ -19,7 +19,7 @@ import {
} from '@/lib/api/uptime' } from '@/lib/api/uptime'
import { toast } from '@ciphera-net/ui' import { toast } from '@ciphera-net/ui'
import { useTheme } from '@ciphera-net/ui' import { useTheme } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@/lib/utils/authErrors' import { getAuthErrorMessage } from '@ciphera-net/ui'
import { LoadingOverlay, Button, Modal } from '@ciphera-net/ui' import { LoadingOverlay, Button, Modal } from '@ciphera-net/ui'
import { import {
AreaChart, AreaChart,
@@ -313,7 +313,7 @@ function ResponseTimeChart({ checks }: { checks: UptimeCheck[] }) {
}} }}
> >
<div className="font-medium mb-0.5">{label}</div> <div className="font-medium mb-0.5">{label}</div>
<div style={{ color: '#FD5E0F' }} className="font-semibold"> <div style={{ color: 'var(--color-brand-orange)' }} className="font-semibold">
{payload[0].value}ms {payload[0].value}ms
</div> </div>
</div> </div>
@@ -330,8 +330,8 @@ function ResponseTimeChart({ checks }: { checks: UptimeCheck[] }) {
<AreaChart data={data} margin={{ top: 5, right: 5, left: -20, bottom: 0 }}> <AreaChart data={data} margin={{ top: 5, right: 5, left: -20, bottom: 0 }}>
<defs> <defs>
<linearGradient id="responseTimeGradient" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="responseTimeGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#FD5E0F" stopOpacity={0.3} /> <stop offset="0%" stopColor="var(--color-brand-orange)" stopOpacity={0.3} />
<stop offset="100%" stopColor="#FD5E0F" stopOpacity={0.02} /> <stop offset="100%" stopColor="var(--color-brand-orange)" stopOpacity={0.02} />
</linearGradient> </linearGradient>
</defs> </defs>
<CartesianGrid <CartesianGrid
@@ -357,11 +357,11 @@ function ResponseTimeChart({ checks }: { checks: UptimeCheck[] }) {
<Area <Area
type="monotone" type="monotone"
dataKey="ms" dataKey="ms"
stroke="#FD5E0F" stroke="var(--color-brand-orange)"
strokeWidth={2} strokeWidth={2}
fill="url(#responseTimeGradient)" fill="url(#responseTimeGradient)"
dot={false} dot={false}
activeDot={{ r: 4, fill: '#FD5E0F', strokeWidth: 0 }} activeDot={{ r: 4, fill: 'var(--color-brand-orange)', strokeWidth: 0 }}
/> />
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>

View File

@@ -5,9 +5,10 @@ import { useRouter } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import { createSite, listSites, getSite, type Site } from '@/lib/api/sites' import { createSite, listSites, getSite, type Site } from '@/lib/api/sites'
import { getSubscription } from '@/lib/api/billing' import { getSubscription } from '@/lib/api/billing'
import { getSitesLimitForPlan } from '@/lib/plans'
import { trackSiteCreatedFromDashboard, trackSiteCreatedScriptCopied } from '@/lib/welcomeAnalytics' import { trackSiteCreatedFromDashboard, trackSiteCreatedScriptCopied } from '@/lib/welcomeAnalytics'
import { toast } from '@ciphera-net/ui' import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@/lib/utils/authErrors' import { getAuthErrorMessage } from '@ciphera-net/ui'
import { Button, Input } from '@ciphera-net/ui' import { Button, Input } from '@ciphera-net/ui'
import { CheckCircleIcon } from '@ciphera-net/ui' import { CheckCircleIcon } from '@ciphera-net/ui'
import ScriptSetupBlock from '@/components/sites/ScriptSetupBlock' import ScriptSetupBlock from '@/components/sites/ScriptSetupBlock'
@@ -57,9 +58,10 @@ export default function NewSitePage() {
getSubscription() getSubscription()
]) ])
if (subscription?.plan_id === 'solo' && sites.length >= 1) { const siteLimit = subscription?.plan_id ? getSitesLimitForPlan(subscription.plan_id) : null
if (siteLimit != null && sites.length >= siteLimit) {
setAtLimit(true) setAtLimit(true)
toast.error('Solo plan limit reached (1 site). Please upgrade to add more sites.') toast.error(`${subscription.plan_id} plan limit reached (${siteLimit} site${siteLimit === 1 ? '' : 's'}). Please upgrade to add more sites.`)
router.replace('/') router.replace('/')
} }
} catch (error) { } catch (error) {

View File

@@ -21,7 +21,7 @@ import { createSite, type Site } from '@/lib/api/sites'
import { setSessionAction } from '@/app/actions/auth' import { setSessionAction } from '@/app/actions/auth'
import { useAuth } from '@/lib/auth/context' import { useAuth } from '@/lib/auth/context'
import apiRequest from '@/lib/api/client' import apiRequest from '@/lib/api/client'
import { getAuthErrorMessage } from '@/lib/utils/authErrors' import { getAuthErrorMessage } from '@ciphera-net/ui'
import { import {
trackWelcomeStepView, trackWelcomeStepView,
trackWelcomeWorkspaceSelected, trackWelcomeWorkspaceSelected,

View File

@@ -1,28 +0,0 @@
/**
* @file Reusable code block component for integration guide pages.
*
* Renders a VS-Code-style code block with a filename tab header.
*/
interface CodeBlockProps {
/** Filename displayed in the tab header */
filename: string
/** The code string to render inside the block */
children: string
}
/**
* Renders a dark-themed code snippet with a filename tab.
*/
export function CodeBlock({ filename, children }: CodeBlockProps) {
return (
<div className="bg-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6">
<div className="flex items-center px-4 py-2 bg-[#252526] border-b border-neutral-800">
<span className="text-xs text-neutral-400 font-mono">{filename}</span>
</div>
<div className="p-4 overflow-x-auto">
<pre className="text-sm font-mono text-neutral-300">{children}</pre>
</div>
</div>
)
}

View File

@@ -2,8 +2,7 @@
import Link from 'next/link' import Link from 'next/link'
import Image from 'next/image' import Image from 'next/image'
import { GithubIcon, TwitterIcon } from '@ciphera-net/ui' import { GithubIcon, TwitterIcon, SwissFlagIcon } from '@ciphera-net/ui'
import SwissFlagIcon from './SwissFlagIcon'
interface FooterProps { interface FooterProps {
LinkComponent?: any LinkComponent?: any
@@ -47,7 +46,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
if (isAuthenticated) { if (isAuthenticated) {
return ( return (
<footer className="w-full py-8 mt-auto border-t border-neutral-100 dark:border-neutral-800 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm"> <footer className="w-full py-8 mt-auto border-t border-neutral-100 dark:border-neutral-800 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex flex-col md:flex-row justify-between items-center gap-4"> <div className="flex flex-col md:flex-row justify-between items-center gap-4">
<div className="text-sm text-neutral-500 dark:text-neutral-400"> <div className="text-sm text-neutral-500 dark:text-neutral-400">
© 2024-{year} Ciphera. All rights reserved. © 2024-{year} Ciphera. All rights reserved.

View File

@@ -36,7 +36,7 @@ export function IntegrationGuide({ integration, children }: IntegrationGuideProp
.slice(0, 4) .slice(0, 4)
return ( return (
<div className="relative min-h-screen flex flex-col overflow-hidden selection:bg-brand-orange/20"> <div className="relative min-h-screen flex flex-col overflow-hidden">
{/* * --- ATMOSPHERE (Background) --- */} {/* * --- ATMOSPHERE (Background) --- */}
<div className="absolute inset-0 -z-10 pointer-events-none"> <div className="absolute inset-0 -z-10 pointer-events-none">
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" /> <div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" />

View File

@@ -1,42 +0,0 @@
'use client'
import React, { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
interface LoadingOverlayProps {
logoSrc?: string
title?: string
}
export default function LoadingOverlay({
logoSrc = "/ciphera_icon_no_margins.png",
title = "Pulse"
}: LoadingOverlayProps) {
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
return () => setMounted(false)
}, [])
if (!mounted) return null
return createPortal(
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-white dark:bg-neutral-950 animate-in fade-in duration-200">
<div className="flex flex-col items-center gap-6">
<div className="flex items-center gap-3">
<img
src={logoSrc}
alt={typeof title === 'string' ? title : "Pulse"}
className="h-12 w-auto object-contain"
/>
<span className="text-3xl tracking-tight text-neutral-900 dark:text-white">
<span className="font-bold">Ciphera</span><span className="font-light">Pulse</span>
</span>
</div>
<div className="h-8 w-8 animate-spin rounded-full border-4 border-neutral-200 border-t-brand-orange dark:border-neutral-800 dark:border-t-brand-orange" />
</div>
</div>,
document.body
)
}

View File

@@ -219,10 +219,10 @@ export default function PricingSection() {
transition={{ duration: 0.5 }} transition={{ duration: 0.5 }}
className="text-center mb-12" className="text-center mb-12"
> >
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-6"> <h2 className="text-3xl font-bold text-neutral-900 dark:text-white mb-4">
Transparent Pricing Transparent Pricing
</h2> </h2>
<p className="text-xl text-neutral-600 dark:text-neutral-400"> <p className="text-lg text-neutral-600 dark:text-neutral-400">
Scale with your traffic. No hidden fees. Scale with your traffic. No hidden fees.
</p> </p>
</motion.div> </motion.div>
@@ -232,11 +232,11 @@ export default function PricingSection() {
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1 }} transition={{ duration: 0.5, delay: 0.1 }}
className="max-w-6xl mx-auto border border-neutral-200 dark:border-neutral-800 rounded-3xl bg-white/50 dark:bg-neutral-900/50 backdrop-blur-xl shadow-sm overflow-hidden mb-20" className="max-w-6xl mx-auto border border-neutral-200 dark:border-neutral-800 rounded-2xl bg-white/50 dark:bg-neutral-900/50 backdrop-blur-xl shadow-sm overflow-hidden mb-20"
> >
{/* Top Toolbar */} {/* Top Toolbar */}
<div className="p-8 border-b border-neutral-200 dark:border-neutral-800 flex flex-col md:flex-row items-center justify-between gap-8 bg-neutral-50/50 dark:bg-neutral-900/50"> <div className="p-6 border-b border-neutral-200 dark:border-neutral-800 flex flex-col md:flex-row items-center justify-between gap-8 bg-neutral-50/50 dark:bg-neutral-900/50">
<div className="w-full md:w-2/3"> <div className="w-full md:w-2/3">
<div className="flex justify-between text-sm font-medium text-neutral-500 mb-4"> <div className="flex justify-between text-sm font-medium text-neutral-500 mb-4">
<span>10k</span> <span>10k</span>
@@ -252,7 +252,9 @@ export default function PricingSection() {
step="1" step="1"
value={sliderIndex} value={sliderIndex}
onChange={(e) => setSliderIndex(parseInt(e.target.value))} onChange={(e) => setSliderIndex(parseInt(e.target.value))}
className="w-full h-2 bg-neutral-200 rounded-lg appearance-none cursor-pointer dark:bg-neutral-700 accent-brand-orange" aria-label="Monthly pageview limit"
aria-valuetext={`${currentTraffic.label} pageviews per month`}
className="w-full h-2 bg-neutral-200 rounded-lg appearance-none cursor-pointer dark:bg-neutral-700 accent-brand-orange focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
/> />
</div> </div>
@@ -260,10 +262,12 @@ export default function PricingSection() {
<span className="text-[10px] text-neutral-500 dark:text-neutral-400 font-medium uppercase tracking-wide"> <span className="text-[10px] text-neutral-500 dark:text-neutral-400 font-medium uppercase tracking-wide">
Get 1 month free with yearly Get 1 month free with yearly
</span> </span>
<div className="bg-neutral-200 dark:bg-neutral-800 p-1 rounded-lg flex"> <div className="bg-neutral-200 dark:bg-neutral-800 p-1 rounded-lg flex" role="radiogroup" aria-label="Billing interval">
<button <button
onClick={() => setIsYearly(false)} onClick={() => setIsYearly(false)}
className={`min-w-[88px] px-4 py-2 rounded-lg text-sm font-medium transition-all ${ role="radio"
aria-checked={!isYearly}
className={`min-w-[88px] px-4 py-2 rounded-lg text-sm font-medium transition-all focus:outline-none focus:ring-2 focus:ring-brand-orange ${
!isYearly !isYearly
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm' ? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
: 'text-neutral-500 hover:text-neutral-900 dark:hover:text-white' : 'text-neutral-500 hover:text-neutral-900 dark:hover:text-white'
@@ -273,7 +277,9 @@ export default function PricingSection() {
</button> </button>
<button <button
onClick={() => setIsYearly(true)} onClick={() => setIsYearly(true)}
className={`min-w-[88px] px-4 py-2 rounded-lg text-sm font-medium transition-all ${ role="radio"
aria-checked={isYearly}
className={`min-w-[88px] px-4 py-2 rounded-lg text-sm font-medium transition-all focus:outline-none focus:ring-2 focus:ring-brand-orange ${
isYearly isYearly
? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm' ? 'bg-white dark:bg-neutral-700 text-neutral-900 dark:text-white shadow-sm'
: 'text-neutral-500 hover:text-neutral-900 dark:hover:text-white' : 'text-neutral-500 hover:text-neutral-900 dark:hover:text-white'
@@ -292,7 +298,7 @@ export default function PricingSection() {
const isTeam = plan.id === 'team' const isTeam = plan.id === 'team'
return ( return (
<div key={plan.id} className={`p-8 flex flex-col relative transition-colors ${isTeam ? 'bg-brand-orange/[0.02]' : 'hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50'}`}> <div key={plan.id} className={`p-6 flex flex-col relative transition-colors ${isTeam ? 'bg-brand-orange/[0.02]' : 'hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50'}`}>
{isTeam && ( {isTeam && (
<> <>
<div className="absolute top-0 left-0 w-full h-1 bg-brand-orange" /> <div className="absolute top-0 left-0 w-full h-1 bg-brand-orange" />
@@ -361,7 +367,7 @@ export default function PricingSection() {
})} })}
{/* Enterprise Section */} {/* Enterprise Section */}
<div className="p-8 bg-neutral-50/50 dark:bg-neutral-900/50 flex flex-col"> <div className="p-6 bg-neutral-50/50 dark:bg-neutral-900/50 flex flex-col">
<div className="mb-8"> <div className="mb-8">
<h3 className="text-lg font-bold text-neutral-900 dark:text-white mb-2">Enterprise</h3> <h3 className="text-lg font-bold text-neutral-900 dark:text-white mb-2">Enterprise</h3>
<p className="text-sm text-neutral-500 min-h-[40px] mb-4">For high volume sites and custom needs</p> <p className="text-sm text-neutral-500 min-h-[40px] mb-4">For high volume sites and custom needs</p>
@@ -370,9 +376,12 @@ export default function PricingSection() {
</div> </div>
</div> </div>
<Button variant="secondary" className="w-full mb-8 border-neutral-200 dark:border-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-800"> <a
href="mailto:business@ciphera.net?subject=Enterprise%20Plan%20Inquiry"
className="inline-flex items-center justify-center w-full mb-8 rounded-lg border border-neutral-200 dark:border-neutral-700 px-4 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange"
>
Contact us Contact us
</Button> </a>
<ul className="space-y-4"> <ul className="space-y-4">
{[ {[

View File

@@ -1,24 +0,0 @@
'use client'
import type { SVGProps } from 'react'
// * Swiss flag icon official proportions (cross 1/6 height). Uses real Swiss federal red.
const SWISS_RED = '#E41E26' // * Official Swiss flag red (federal identity)
export default function SwissFlagIcon(props: SVGProps<SVGSVGElement>) {
const { className, ...rest } = props
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
aria-hidden
className={`shrink-0 ${className ?? ''}`.trim()}
{...rest}
>
<rect width="24" height="24" rx="3" fill={SWISS_RED} />
<rect x="10" y="0" width="4" height="24" fill="#FFF" />
<rect x="0" y="10" width="24" height="4" fill="#FFF" />
</svg>
)
}

View File

@@ -1,26 +0,0 @@
'use client'
import { useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
import { toast } from '@ciphera-net/ui'
/**
* Shows a success toast when redirected from Stripe Checkout with success=true,
* then clears the query params from the URL.
*/
export default function CheckoutSuccessToast() {
const searchParams = useSearchParams()
useEffect(() => {
const success = searchParams.get('success')
if (success === 'true') {
toast.success('Thank you for subscribing! Your subscription is now active.')
const url = new URL(window.location.href)
url.searchParams.delete('success')
url.searchParams.delete('session_id')
window.history.replaceState({}, '', url.pathname + url.search)
}
}, [searchParams])
return null
}

View File

@@ -2,8 +2,8 @@
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect, useMemo } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { formatNumber } from '@/lib/utils/format' import { formatNumber } from '@ciphera-net/ui'
import { Modal, ArrowRightIcon, Button } from '@ciphera-net/ui' import { Modal, ArrowRightIcon, Button, Spinner } from '@ciphera-net/ui'
import { ChevronDownIcon, DownloadIcon } from '@ciphera-net/ui' import { ChevronDownIcon, DownloadIcon } from '@ciphera-net/ui'
import { getCampaigns, CampaignStat } from '@/lib/api/stats' import { getCampaigns, CampaignStat } from '@/lib/api/stats'
import { getReferrerFavicon, getReferrerIcon, getReferrerDisplayName } from '@/lib/utils/icons' import { getReferrerFavicon, getReferrerIcon, getReferrerDisplayName } from '@/lib/utils/icons'
@@ -293,7 +293,7 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2"> <div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
{isLoadingFull ? ( {isLoadingFull ? (
<div className="py-8 flex flex-col items-center gap-2"> <div className="py-8 flex flex-col items-center gap-2">
<div className="animate-spin w-6 h-6 border-2 border-neutral-300 dark:border-neutral-700 border-t-brand-orange rounded-full" /> <Spinner />
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p> <p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
</div> </div>
) : ( ) : (

View File

@@ -13,7 +13,7 @@ import {
ReferenceLine, ReferenceLine,
} from 'recharts' } from 'recharts'
import type { TooltipProps } from 'recharts' import type { TooltipProps } from 'recharts'
import { formatNumber, formatDuration, formatUpdatedAgo } from '@/lib/utils/format' import { formatNumber, formatDuration, formatUpdatedAgo } from '@ciphera-net/ui'
import { ArrowUpRightIcon, ArrowDownRightIcon, BarChartIcon, Select, Button, DownloadIcon } from '@ciphera-net/ui' import { ArrowUpRightIcon, ArrowDownRightIcon, BarChartIcon, Select, Button, DownloadIcon } from '@ciphera-net/ui'
import { Checkbox } from '@ciphera-net/ui' import { Checkbox } from '@ciphera-net/ui'

View File

@@ -1,9 +1,9 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { formatNumber } from '@/lib/utils/format' import { formatNumber } from '@ciphera-net/ui'
import { TopPage, getTopPages, getEntryPages, getExitPages } from '@/lib/api/stats' import { TopPage, getTopPages, getEntryPages, getExitPages } from '@/lib/api/stats'
import { Modal, ArrowUpRightIcon, LayoutDashboardIcon } from '@ciphera-net/ui' import { Modal, ArrowUpRightIcon, LayoutDashboardIcon, Spinner } from '@ciphera-net/ui'
interface ContentStatsProps { interface ContentStatsProps {
topPages: TopPage[] topPages: TopPage[]
@@ -174,7 +174,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2"> <div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
{isLoadingFull ? ( {isLoadingFull ? (
<div className="py-8 flex flex-col items-center gap-2"> <div className="py-8 flex flex-col items-center gap-2">
<div className="animate-spin w-6 h-6 border-2 border-neutral-300 dark:border-neutral-700 border-t-brand-orange rounded-full" /> <Spinner />
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p> <p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
</div> </div>
) : ( ) : (

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import { formatNumber } from '@/lib/utils/format' import { formatNumber } from '@ciphera-net/ui'
import * as Flags from 'country-flag-icons/react/3x2' import * as Flags from 'country-flag-icons/react/3x2'
import WorldMap from './WorldMap' import WorldMap from './WorldMap'
import { GlobeIcon } from '@ciphera-net/ui' import { GlobeIcon } from '@ciphera-net/ui'

View File

@@ -6,7 +6,7 @@ import * as XLSX from 'xlsx'
import jsPDF from 'jspdf' import jsPDF from 'jspdf'
import autoTable from 'jspdf-autotable' import autoTable from 'jspdf-autotable'
import type { DailyStat } from './Chart' import type { DailyStat } from './Chart'
import { formatNumber, formatDuration } from '@/lib/utils/format' import { formatNumber, formatDuration } from '@ciphera-net/ui'
import { getReferrerDisplayName, mergeReferrersByDisplayName } from '@/lib/utils/icons' import { getReferrerDisplayName, mergeReferrersByDisplayName } from '@/lib/utils/icons'
import type { TopPage, TopReferrer, CampaignStat } from '@/lib/api/stats' import type { TopPage, TopReferrer, CampaignStat } from '@/lib/api/stats'

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import Link from 'next/link' import Link from 'next/link'
import { formatNumber } from '@/lib/utils/format' import { formatNumber } from '@ciphera-net/ui'
import { BookOpenIcon, ArrowRightIcon } from '@ciphera-net/ui' import { BookOpenIcon, ArrowRightIcon } from '@ciphera-net/ui'
import type { GoalCountStat } from '@/lib/api/stats' import type { GoalCountStat } from '@/lib/api/stats'

View File

@@ -1,12 +1,12 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { formatNumber } from '@/lib/utils/format' import { formatNumber } from '@ciphera-net/ui'
import * as Flags from 'country-flag-icons/react/3x2' import * as Flags from 'country-flag-icons/react/3x2'
// @ts-ignore // @ts-ignore
import iso3166 from 'iso-3166-2' import iso3166 from 'iso-3166-2'
import WorldMap from './WorldMap' import WorldMap from './WorldMap'
import { Modal, GlobeIcon } from '@ciphera-net/ui' import { Modal, GlobeIcon, Spinner } from '@ciphera-net/ui'
import { SiTorproject } from 'react-icons/si' import { SiTorproject } from 'react-icons/si'
import { FaUserSecret, FaSatellite } from 'react-icons/fa' import { FaUserSecret, FaSatellite } from 'react-icons/fa'
import { getCountries, getCities, getRegions } from '@/lib/api/stats' import { getCountries, getCities, getRegions } from '@/lib/api/stats'
@@ -289,7 +289,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2"> <div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
{isLoadingFull ? ( {isLoadingFull ? (
<div className="py-8 flex flex-col items-center gap-2"> <div className="py-8 flex flex-col items-center gap-2">
<div className="animate-spin w-6 h-6 border-2 border-neutral-300 dark:border-neutral-700 border-t-brand-orange rounded-full" /> <Spinner />
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p> <p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
</div> </div>
) : ( ) : (

View File

@@ -1,10 +1,10 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { formatNumber } from '@/lib/utils/format' import { formatNumber } from '@ciphera-net/ui'
import { getBrowserIcon, getOSIcon, getDeviceIcon } from '@/lib/utils/icons' import { getBrowserIcon, getOSIcon, getDeviceIcon } from '@/lib/utils/icons'
import { MdMonitor } from 'react-icons/md' import { MdMonitor } from 'react-icons/md'
import { Modal, GridIcon } from '@ciphera-net/ui' import { Modal, GridIcon, Spinner } from '@ciphera-net/ui'
import { getBrowsers, getOS, getDevices, getScreenResolutions } from '@/lib/api/stats' import { getBrowsers, getOS, getDevices, getScreenResolutions } from '@/lib/api/stats'
interface TechSpecsProps { interface TechSpecsProps {
@@ -190,7 +190,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2"> <div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
{isLoadingFull ? ( {isLoadingFull ? (
<div className="py-8 flex flex-col items-center gap-2"> <div className="py-8 flex flex-col items-center gap-2">
<div className="animate-spin w-6 h-6 border-2 border-neutral-300 dark:border-neutral-700 border-t-brand-orange rounded-full" /> <Spinner />
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p> <p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
</div> </div>
) : ( ) : (

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { formatNumber } from '@/lib/utils/format' import { formatNumber } from '@ciphera-net/ui'
import { LayoutDashboardIcon } from '@ciphera-net/ui' import { LayoutDashboardIcon } from '@ciphera-net/ui'
interface TopPagesProps { interface TopPagesProps {

View File

@@ -1,9 +1,9 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { formatNumber } from '@/lib/utils/format' import { formatNumber } from '@ciphera-net/ui'
import { getReferrerDisplayName, getReferrerFavicon, getReferrerIcon, mergeReferrersByDisplayName } from '@/lib/utils/icons' import { getReferrerDisplayName, getReferrerFavicon, getReferrerIcon, mergeReferrersByDisplayName } from '@/lib/utils/icons'
import { Modal, GlobeIcon } from '@ciphera-net/ui' import { Modal, GlobeIcon, Spinner } from '@ciphera-net/ui'
import { getTopReferrers, TopReferrer } from '@/lib/api/stats' import { getTopReferrers, TopReferrer } from '@/lib/api/stats'
interface TopReferrersProps { interface TopReferrersProps {
@@ -135,7 +135,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2"> <div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
{isLoadingFull ? ( {isLoadingFull ? (
<div className="py-8 flex flex-col items-center gap-2"> <div className="py-8 flex flex-col items-center gap-2">
<div className="animate-spin w-6 h-6 border-2 border-neutral-300 dark:border-neutral-700 border-t-brand-orange rounded-full" /> <Spinner />
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p> <p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
</div> </div>
) : ( ) : (

View File

@@ -0,0 +1,248 @@
'use client'
/**
* @file Notification center: bell icon with dropdown of recent notifications.
*/
import { useEffect, useState, useRef } from 'react'
import Link from 'next/link'
import { listNotifications, markNotificationRead, markAllNotificationsRead, type Notification } from '@/lib/api/notifications'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { formatTimeAgo, getTypeIcon } from '@/lib/utils/notifications'
import { SettingsIcon } from '@ciphera-net/ui'
// * Bell icon (simple SVG, no extra deps)
function BellIcon({ className }: { className?: string }) {
return (
<svg
className={className}
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
)
}
const LOADING_DELAY_MS = 250
const POLL_INTERVAL_MS = 90_000
export default function NotificationCenter() {
const [open, setOpen] = useState(false)
const [notifications, setNotifications] = useState<Notification[]>([])
const [unreadCount, setUnreadCount] = useState(0)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const fetchUnreadCount = async () => {
try {
const res = await listNotifications({ limit: 1 })
setUnreadCount(typeof res?.unread_count === 'number' ? res.unread_count : 0)
} catch {
// Ignore polling errors
}
}
const fetchNotifications = async () => {
setError(null)
const loadingTimer = setTimeout(() => setLoading(true), LOADING_DELAY_MS)
try {
const res = await listNotifications({})
setNotifications(Array.isArray(res?.notifications) ? res.notifications : [])
setUnreadCount(typeof res?.unread_count === 'number' ? res.unread_count : 0)
} catch (err) {
setError(getAuthErrorMessage(err as Error) || 'Failed to load notifications')
setNotifications([])
setUnreadCount(0)
} finally {
clearTimeout(loadingTimer)
setLoading(false)
}
}
useEffect(() => {
if (open) {
fetchNotifications()
}
}, [open])
// * Poll unread count in background (when authenticated)
useEffect(() => {
fetchUnreadCount()
const id = setInterval(fetchUnreadCount, POLL_INTERVAL_MS)
return () => clearInterval(id)
}, [])
// * Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
if (open) {
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}
}, [open])
const handleMarkRead = async (id: string) => {
try {
await markNotificationRead(id)
setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, read: true } : n)))
setUnreadCount((c) => Math.max(0, c - 1))
} catch {
// Ignore; user can retry
}
}
const handleMarkAllRead = async () => {
try {
await markAllNotificationsRead()
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })))
setUnreadCount(0)
} catch {
// Ignore
}
}
const handleNotificationClick = (n: Notification) => {
if (!n.read) {
handleMarkRead(n.id)
}
setOpen(false)
}
return (
<div className="relative" ref={dropdownRef}>
<button
type="button"
onClick={() => setOpen(!open)}
className="relative p-2 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white rounded-lg hover:bg-neutral-100/50 dark:hover:bg-neutral-800/50 transition-colors"
aria-label={unreadCount > 0 ? `${unreadCount} unread notifications` : 'Notifications'}
>
<BellIcon />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 w-2 h-2 bg-brand-orange rounded-full" />
)}
</button>
{open && (
<div className="absolute right-0 top-full mt-2 w-80 sm:w-96 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl overflow-hidden z-[100]">
<div className="flex items-center justify-between px-4 py-3 border-b border-neutral-200 dark:border-neutral-700">
<h3 className="font-semibold text-neutral-900 dark:text-white">Notifications</h3>
{unreadCount > 0 && (
<button
type="button"
onClick={handleMarkAllRead}
className="text-sm text-brand-orange hover:underline"
>
Mark all read
</button>
)}
</div>
<div className="max-h-80 overflow-y-auto">
{loading && (
<div className="p-6 text-center text-neutral-500 dark:text-neutral-400 text-sm">
Loading
</div>
)}
{error && (
<div className="p-6 text-center text-red-500 text-sm">{error}</div>
)}
{!loading && !error && (notifications?.length ?? 0) === 0 && (
<div className="p-6 text-center text-neutral-500 dark:text-neutral-400 text-sm">
No notifications yet
</div>
)}
{!loading && !error && (notifications?.length ?? 0) > 0 && (
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
{(notifications ?? []).map((n) => (
<li key={n.id}>
{n.link_url ? (
<Link
href={n.link_url}
onClick={() => handleNotificationClick(n)}
className={`block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors ${!n.read ? 'bg-brand-orange/5 dark:bg-brand-orange/10' : ''}`}
>
<div className="flex gap-3">
{getTypeIcon(n.type)}
<div className="min-w-0 flex-1">
<p className={`text-sm ${!n.read ? 'font-medium' : ''} text-neutral-900 dark:text-white`}>
{n.title}
</p>
{n.body && (
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5 line-clamp-2">
{n.body}
</p>
)}
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
{formatTimeAgo(n.created_at)}
</p>
</div>
</div>
</Link>
) : (
<div
role="button"
tabIndex={0}
onClick={() => handleNotificationClick(n)}
onKeyDown={(e) => e.key === 'Enter' && handleNotificationClick(n)}
className={`block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 cursor-pointer ${!n.read ? 'bg-brand-orange/5 dark:bg-brand-orange/10' : ''}`}
>
<div className="flex gap-3">
{getTypeIcon(n.type)}
<div className="min-w-0 flex-1">
<p className={`text-sm ${!n.read ? 'font-medium' : ''} text-neutral-900 dark:text-white`}>
{n.title}
</p>
{n.body && (
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5 line-clamp-2">
{n.body}
</p>
)}
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
{formatTimeAgo(n.created_at)}
</p>
</div>
</div>
</div>
)}
</li>
))}
</ul>
)}
</div>
<div className="border-t border-neutral-200 dark:border-neutral-700 px-4 py-3 flex items-center justify-between gap-2">
<Link
href="/notifications"
onClick={() => setOpen(false)}
className="text-sm text-brand-orange hover:underline"
>
View all
</Link>
<Link
href="/org-settings?tab=notifications"
onClick={() => setOpen(false)}
className="flex items-center gap-1.5 text-sm text-neutral-500 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors"
>
<SettingsIcon className="w-4 h-4" />
Manage settings
</Link>
</div>
</div>
)}
</div>
)
}

View File

@@ -16,11 +16,12 @@ import {
OrganizationInvitation, OrganizationInvitation,
Organization Organization
} from '@/lib/api/organization' } from '@/lib/api/organization'
import { getSubscription, createPortalSession, getInvoices, cancelSubscription, changePlan, createCheckoutSession, SubscriptionDetails, Invoice } from '@/lib/api/billing' import { getSubscription, createPortalSession, getInvoices, cancelSubscription, resumeSubscription, changePlan, previewInvoice, createCheckoutSession, SubscriptionDetails, Invoice, PreviewInvoiceResult } from '@/lib/api/billing'
import { TRAFFIC_TIERS, PLAN_ID_SOLO, getTierIndexForLimit, getLimitForTierIndex } from '@/lib/plans' import { TRAFFIC_TIERS, PLAN_ID_SOLO, PLAN_ID_TEAM, PLAN_ID_BUSINESS, getTierIndexForLimit, getLimitForTierIndex, getSitesLimitForPlan } from '@/lib/plans'
import { getAuditLog, AuditLogEntry, GetAuditLogParams } from '@/lib/api/audit' import { getAuditLog, AuditLogEntry, GetAuditLogParams } from '@/lib/api/audit'
import { getNotificationSettings, updateNotificationSettings } from '@/lib/api/notification-settings'
import { toast } from '@ciphera-net/ui' import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@/lib/utils/authErrors' import { getAuthErrorMessage } from '@ciphera-net/ui'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import { import {
AlertTriangleIcon, AlertTriangleIcon,
@@ -33,8 +34,19 @@ import {
BookOpenIcon, BookOpenIcon,
DownloadIcon, DownloadIcon,
ExternalLinkIcon, ExternalLinkIcon,
LayoutDashboardIcon LayoutDashboardIcon,
Spinner,
} from '@ciphera-net/ui' } from '@ciphera-net/ui'
// * Bell icon for notifications tab
function BellIcon({ className }: { className?: string }) {
return (
<svg className={className} xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
)
}
// @ts-ignore // @ts-ignore
import { Button, Input } from '@ciphera-net/ui' import { Button, Input } from '@ciphera-net/ui'
@@ -43,13 +55,13 @@ export default function OrganizationSettings() {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
// Initialize from URL, default to 'general' // Initialize from URL, default to 'general'
const [activeTab, setActiveTab] = useState<'general' | 'members' | 'billing' | 'audit'>(() => { const [activeTab, setActiveTab] = useState<'general' | 'members' | 'billing' | 'notifications' | 'audit'>(() => {
const tab = searchParams.get('tab') const tab = searchParams.get('tab')
return (tab === 'billing' || tab === 'members' || tab === 'audit') ? tab : 'general' return (tab === 'billing' || tab === 'members' || tab === 'notifications' || tab === 'audit') ? tab : 'general'
}) })
// Sync URL with state without triggering navigation/reload // Sync URL with state without triggering navigation/reload
const handleTabChange = (tab: 'general' | 'members' | 'billing' | 'audit') => { const handleTabChange = (tab: 'general' | 'members' | 'billing' | 'notifications' | 'audit') => {
setActiveTab(tab) setActiveTab(tab)
const url = new URL(window.location.href) const url = new URL(window.location.href)
url.searchParams.set('tab', tab) url.searchParams.set('tab', tab)
@@ -71,9 +83,13 @@ export default function OrganizationSettings() {
const [isRedirectingToPortal, setIsRedirectingToPortal] = useState(false) const [isRedirectingToPortal, setIsRedirectingToPortal] = useState(false)
const [cancelLoadingAction, setCancelLoadingAction] = useState<'period_end' | 'immediate' | null>(null) const [cancelLoadingAction, setCancelLoadingAction] = useState<'period_end' | 'immediate' | null>(null)
const [showCancelPrompt, setShowCancelPrompt] = useState(false) const [showCancelPrompt, setShowCancelPrompt] = useState(false)
const [isResuming, setIsResuming] = useState(false)
const [showChangePlanModal, setShowChangePlanModal] = useState(false) const [showChangePlanModal, setShowChangePlanModal] = useState(false)
const [changePlanId, setChangePlanId] = useState<string>(PLAN_ID_SOLO)
const [changePlanTierIndex, setChangePlanTierIndex] = useState(2) const [changePlanTierIndex, setChangePlanTierIndex] = useState(2)
const [changePlanYearly, setChangePlanYearly] = useState(false) const [changePlanYearly, setChangePlanYearly] = useState(false)
const [invoicePreview, setInvoicePreview] = useState<PreviewInvoiceResult | null>(null)
const [isLoadingPreview, setIsLoadingPreview] = useState(false)
const [isChangingPlan, setIsChangingPlan] = useState(false) const [isChangingPlan, setIsChangingPlan] = useState(false)
const [invoices, setInvoices] = useState<Invoice[]>([]) const [invoices, setInvoices] = useState<Invoice[]>([])
const [isLoadingInvoices, setIsLoadingInvoices] = useState(false) const [isLoadingInvoices, setIsLoadingInvoices] = useState(false)
@@ -107,6 +123,12 @@ export default function OrganizationSettings() {
const [auditStartDate, setAuditStartDate] = useState('') const [auditStartDate, setAuditStartDate] = useState('')
const [auditEndDate, setAuditEndDate] = useState('') const [auditEndDate, setAuditEndDate] = useState('')
// Notification settings state
const [notificationSettings, setNotificationSettings] = useState<Record<string, boolean>>({})
const [notificationCategories, setNotificationCategories] = useState<{ id: string; label: string; description: string }[]>([])
const [isLoadingNotificationSettings, setIsLoadingNotificationSettings] = useState(false)
const [isSavingNotificationSettings, setIsSavingNotificationSettings] = useState(false)
// Refs for filters to keep loadAudit stable and avoid rapid re-renders // Refs for filters to keep loadAudit stable and avoid rapid re-renders
const filtersRef = useRef({ const filtersRef = useRef({
action: auditActionFilter, action: auditActionFilter,
@@ -248,6 +270,53 @@ export default function OrganizationSettings() {
} }
}, [activeTab, currentOrgId, loadAudit, auditFetchTrigger]) }, [activeTab, currentOrgId, loadAudit, auditFetchTrigger])
const loadNotificationSettings = useCallback(async () => {
if (!currentOrgId) return
setIsLoadingNotificationSettings(true)
try {
const res = await getNotificationSettings()
setNotificationSettings(res.settings || {})
setNotificationCategories(res.categories || [])
} catch (error) {
console.error('Failed to load notification settings', error)
toast.error(getAuthErrorMessage(error as Error) || 'Failed to load notification settings')
} finally {
setIsLoadingNotificationSettings(false)
}
}, [currentOrgId])
useEffect(() => {
if (activeTab === 'notifications' && currentOrgId && user?.role !== 'member') {
loadNotificationSettings()
}
}, [activeTab, currentOrgId, loadNotificationSettings, user?.role])
// * Redirect members away from Notifications tab (owners/admins only)
useEffect(() => {
if (activeTab === 'notifications' && user?.role === 'member') {
handleTabChange('general')
}
}, [activeTab, user?.role, handleTabChange])
const hasActiveSubscription = subscription?.subscription_status === 'active' || subscription?.subscription_status === 'trialing'
useEffect(() => {
if (!showChangePlanModal || !hasActiveSubscription) {
setInvoicePreview(null)
return
}
let cancelled = false
setIsLoadingPreview(true)
setInvoicePreview(null)
const interval = changePlanYearly ? 'year' : 'month'
const limit = getLimitForTierIndex(changePlanTierIndex)
previewInvoice({ plan_id: changePlanId, interval, limit })
.then((res) => { if (!cancelled) setInvoicePreview(res ?? null) })
.catch(() => { if (!cancelled) { setInvoicePreview(null) } })
.finally(() => { if (!cancelled) setIsLoadingPreview(false) })
return () => { cancelled = true }
}, [showChangePlanModal, hasActiveSubscription, changePlanId, changePlanTierIndex, changePlanYearly])
// If no org ID, we are in personal organization context, so don't show org settings // If no org ID, we are in personal organization context, so don't show org settings
if (!currentOrgId) { if (!currentOrgId) {
return ( return (
@@ -282,30 +351,48 @@ export default function OrganizationSettings() {
} }
} }
const handleResumeSubscription = async () => {
setIsResuming(true)
try {
await resumeSubscription()
toast.success('Subscription will continue. Cancellation has been undone.')
loadSubscription()
} catch (error: any) {
toast.error(getAuthErrorMessage(error) || error.message || 'Failed to resume subscription')
} finally {
setIsResuming(false)
}
}
const openChangePlanModal = () => { const openChangePlanModal = () => {
const currentPlan = subscription?.plan_id
if (currentPlan === PLAN_ID_TEAM || currentPlan === PLAN_ID_BUSINESS) {
setChangePlanId(currentPlan)
} else {
setChangePlanId(PLAN_ID_SOLO)
}
if (subscription?.pageview_limit != null && subscription.pageview_limit > 0) { if (subscription?.pageview_limit != null && subscription.pageview_limit > 0) {
setChangePlanTierIndex(getTierIndexForLimit(subscription.pageview_limit)) setChangePlanTierIndex(getTierIndexForLimit(subscription.pageview_limit))
} else { } else {
setChangePlanTierIndex(2) setChangePlanTierIndex(2)
} }
setChangePlanYearly(subscription?.billing_interval === 'year') setChangePlanYearly(subscription?.billing_interval === 'year')
setInvoicePreview(null)
setShowChangePlanModal(true) setShowChangePlanModal(true)
} }
const hasActiveSubscription = subscription?.subscription_status === 'active' || subscription?.subscription_status === 'trialing'
const handleChangePlanSubmit = async () => { const handleChangePlanSubmit = async () => {
const interval = changePlanYearly ? 'year' : 'month' const interval = changePlanYearly ? 'year' : 'month'
const limit = getLimitForTierIndex(changePlanTierIndex) const limit = getLimitForTierIndex(changePlanTierIndex)
setIsChangingPlan(true) setIsChangingPlan(true)
try { try {
if (hasActiveSubscription) { if (hasActiveSubscription) {
await changePlan({ plan_id: PLAN_ID_SOLO, interval, limit }) await changePlan({ plan_id: changePlanId, interval, limit })
toast.success('Plan updated. Changes may take a moment to reflect.') toast.success('Plan updated. Changes may take a moment to reflect.')
setShowChangePlanModal(false) setShowChangePlanModal(false)
loadSubscription() loadSubscription()
} else { } else {
const { url } = await createCheckoutSession({ plan_id: PLAN_ID_SOLO, interval, limit }) const { url } = await createCheckoutSession({ plan_id: changePlanId, interval, limit })
if (url) window.location.href = url if (url) window.location.href = url
else throw new Error('No checkout URL') else throw new Error('No checkout URL')
} }
@@ -460,6 +547,21 @@ export default function OrganizationSettings() {
<BoxIcon className="w-5 h-5" /> <BoxIcon className="w-5 h-5" />
Billing Billing
</button> </button>
{(user?.role === 'owner' || user?.role === 'admin') && (
<button
onClick={() => handleTabChange('notifications')}
role="tab"
aria-selected={activeTab === 'notifications'}
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2 ${
activeTab === 'notifications'
? 'bg-brand-orange/10 text-brand-orange'
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
}`}
>
<BellIcon className="w-5 h-5" />
Notifications
</button>
)}
<button <button
onClick={() => handleTabChange('audit')} onClick={() => handleTabChange('audit')}
role="tab" role="tab"
@@ -639,7 +741,7 @@ export default function OrganizationSettings() {
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden divide-y divide-neutral-200 dark:divide-neutral-800"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden divide-y divide-neutral-200 dark:divide-neutral-800">
{isLoadingMembers ? ( {isLoadingMembers ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<div className="w-6 h-6 border-2 border-brand-orange/30 border-t-brand-orange rounded-full animate-spin" /> <Spinner />
</div> </div>
) : members.length === 0 ? ( ) : members.length === 0 ? (
<div className="p-8 text-center text-neutral-500">No members found.</div> <div className="p-8 text-center text-neutral-500">No members found.</div>
@@ -720,7 +822,7 @@ export default function OrganizationSettings() {
{isLoadingSubscription ? ( {isLoadingSubscription ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<div className="w-6 h-6 border-2 border-brand-orange/30 border-t-brand-orange rounded-full animate-spin" /> <Spinner />
</div> </div>
) : !subscription ? ( ) : !subscription ? (
<div className="p-8 text-center bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-200 dark:border-neutral-800"> <div className="p-8 text-center bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-200 dark:border-neutral-800">
@@ -750,9 +852,33 @@ export default function OrganizationSettings() {
</div> </div>
)} )}
{/* Past due notice */}
{subscription.subscription_status === 'past_due' && (
<div className="p-4 bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-800 rounded-2xl flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex-1">
<p className="text-sm font-medium text-red-800 dark:text-red-200">
Payment past due
</p>
<p className="text-xs text-red-700 dark:text-red-300 mt-0.5">
We couldn't charge your payment method. Please update your billing info to avoid service interruption.
</p>
</div>
<Button
variant="secondary"
onClick={handleManageSubscription}
disabled={isRedirectingToPortal}
isLoading={isRedirectingToPortal}
className="shrink-0"
>
Update payment method
</Button>
</div>
)}
{/* Cancel-at-period-end notice */} {/* Cancel-at-period-end notice */}
{subscription.cancel_at_period_end && ( {subscription.cancel_at_period_end && (
<div className="p-4 bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-800 rounded-2xl"> <div className="p-4 bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-800 rounded-2xl flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<p className="text-sm font-medium text-amber-800 dark:text-amber-200"> <p className="text-sm font-medium text-amber-800 dark:text-amber-200">
Your subscription will end on{' '} Your subscription will end on{' '}
<span className="font-semibold"> <span className="font-semibold">
@@ -766,6 +892,16 @@ export default function OrganizationSettings() {
You keep full access until then. Your data is retained for 30 days after. Use "Change plan" to resubscribe. You keep full access until then. Your data is retained for 30 days after. Use "Change plan" to resubscribe.
</p> </p>
</div> </div>
<Button
variant="secondary"
onClick={handleResumeSubscription}
disabled={isResuming}
isLoading={isResuming}
className="shrink-0"
>
Keep my subscription
</Button>
</div>
)} )}
{/* Plan & Usage card */} {/* Plan & Usage card */}
@@ -781,9 +917,11 @@ export default function OrganizationSettings() {
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
: subscription.subscription_status === 'trialing' : subscription.subscription_status === 'trialing'
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300'
: subscription.subscription_status === 'past_due'
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
: 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300' : 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300'
}`}> }`}>
{subscription.subscription_status === 'trialing' ? 'Trial' : (subscription.subscription_status || 'Free')} {subscription.subscription_status === 'trialing' ? 'Trial' : subscription.subscription_status === 'past_due' ? 'Past Due' : (subscription.subscription_status || 'Free')}
</span> </span>
{subscription.billing_interval && ( {subscription.billing_interval && (
<span className="text-xs text-neutral-500 capitalize"> <span className="text-xs text-neutral-500 capitalize">
@@ -795,16 +933,33 @@ export default function OrganizationSettings() {
Change plan Change plan
</Button> </Button>
</div> </div>
{(subscription.business_name || (subscription.tax_ids && subscription.tax_ids.length > 0)) && (
<div className="px-6 pb-2 -mt-2 space-y-1 text-sm text-neutral-500 dark:text-neutral-400">
{subscription.business_name && (
<div>Billing for: {subscription.business_name}</div>
)}
{subscription.tax_ids && subscription.tax_ids.length > 0 && (
<div>
Tax ID{subscription.tax_ids.length > 1 ? 's' : ''}:{' '}
{subscription.tax_ids.map((t) => {
const label = t.type === 'eu_vat' ? 'VAT' : t.type === 'us_ein' ? 'EIN' : t.type.replace(/_/g, ' ').toUpperCase()
return `${label} ${t.value}${t.country ? ` (${t.country})` : ''}`
}).join(', ')}
</div>
)}
</div>
)}
{/* Usage stats */} {/* Usage stats */}
<div className="border-t border-neutral-200 dark:border-neutral-800 px-6 py-5 grid grid-cols-2 md:grid-cols-4 gap-y-4 gap-x-6"> <div className="border-t border-neutral-200 dark:border-neutral-800 p-6 grid grid-cols-2 md:grid-cols-4 gap-y-4 gap-x-6">
<div> <div>
<div className="text-xs text-neutral-500 uppercase tracking-wider mb-1">Sites</div> <div className="text-xs text-neutral-500 uppercase tracking-wider mb-1">Sites</div>
<div className="text-lg font-semibold text-neutral-900 dark:text-white"> <div className="text-lg font-semibold text-neutral-900 dark:text-white">
{typeof subscription.sites_count === 'number' {typeof subscription.sites_count === 'number'
? subscription.plan_id === 'solo' ? (() => {
? `${subscription.sites_count} / 1` const limit = getSitesLimitForPlan(subscription.plan_id)
: `${subscription.sites_count}` return limit != null ? `${subscription.sites_count} / ${limit}` : `${subscription.sites_count}`
})()
: ''} : ''}
</div> </div>
</div> </div>
@@ -815,6 +970,22 @@ export default function OrganizationSettings() {
? `${subscription.pageview_usage.toLocaleString()} / ${subscription.pageview_limit.toLocaleString()}` ? `${subscription.pageview_usage.toLocaleString()} / ${subscription.pageview_limit.toLocaleString()}`
: ''} : ''}
</div> </div>
{subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number' && (
<div className="mt-2 h-1.5 w-full rounded-full bg-neutral-200 dark:bg-neutral-700 overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
subscription.pageview_usage / subscription.pageview_limit >= 1
? 'bg-red-500'
: subscription.pageview_usage / subscription.pageview_limit >= 0.9
? 'bg-red-400'
: subscription.pageview_usage / subscription.pageview_limit >= 0.8
? 'bg-amber-400'
: 'bg-green-500'
}`}
style={{ width: `${Math.min(100, (subscription.pageview_usage / subscription.pageview_limit) * 100)}%` }}
/>
</div>
)}
</div> </div>
<div> <div>
<div className="text-xs text-neutral-500 uppercase tracking-wider mb-1"> <div className="text-xs text-neutral-500 uppercase tracking-wider mb-1">
@@ -822,8 +993,18 @@ export default function OrganizationSettings() {
</div> </div>
<div className="text-lg font-semibold text-neutral-900 dark:text-white"> <div className="text-lg font-semibold text-neutral-900 dark:text-white">
{(() => { {(() => {
const d = subscription.current_period_end ? new Date(subscription.current_period_end as string) : null const ts = subscription.next_invoice_period_end ?? subscription.current_period_end
return d && !Number.isNaN(d.getTime()) && d.getTime() !== 0 ? d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—' const d = ts ? new Date(typeof ts === 'number' ? ts * 1000 : ts) : null
const dateStr = d && !Number.isNaN(d.getTime()) && d.getTime() !== 0
? d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
: ''
const amount = subscription.next_invoice_amount_due != null && subscription.next_invoice_currency
? (subscription.next_invoice_amount_due / 100).toLocaleString('en-US', {
style: 'currency',
currency: subscription.next_invoice_currency.toUpperCase(),
})
: null
return amount && dateStr !== '' ? `${dateStr} for ${amount}` : dateStr
})()} })()}
</div> </div>
</div> </div>
@@ -843,7 +1024,7 @@ export default function OrganizationSettings() {
type="button" type="button"
onClick={handleManageSubscription} onClick={handleManageSubscription}
disabled={isRedirectingToPortal} disabled={isRedirectingToPortal}
className="inline-flex items-center gap-1.5 text-sm text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors disabled:opacity-50" className="inline-flex items-center gap-1.5 text-sm text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
> >
<ExternalLinkIcon className="w-4 h-4" /> <ExternalLinkIcon className="w-4 h-4" />
Payment method & invoices Payment method & invoices
@@ -853,7 +1034,7 @@ export default function OrganizationSettings() {
<button <button
type="button" type="button"
onClick={() => setShowCancelPrompt(true)} onClick={() => setShowCancelPrompt(true)}
className="inline-flex items-center gap-1.5 rounded-lg border border-neutral-200 dark:border-neutral-700 px-3.5 py-1.5 text-sm text-neutral-600 dark:text-neutral-400 hover:border-red-300 hover:text-red-600 dark:hover:border-red-800 dark:hover:text-red-400 transition-colors" className="inline-flex items-center gap-1.5 rounded-xl border border-neutral-200 dark:border-neutral-700 px-3.5 py-1.5 text-sm text-neutral-600 dark:text-neutral-400 hover:border-red-300 hover:text-red-600 dark:hover:border-red-800 dark:hover:text-red-400 transition-colors focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
> >
Cancel subscription Cancel subscription
</button> </button>
@@ -862,11 +1043,11 @@ export default function OrganizationSettings() {
{/* Invoice History */} {/* Invoice History */}
<div> <div>
<h3 className="text-sm font-medium text-neutral-500 uppercase tracking-wider mb-3">Recent invoices</h3> <h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-3">Recent invoices</h3>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden divide-y divide-neutral-200 dark:divide-neutral-800"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden divide-y divide-neutral-200 dark:divide-neutral-800">
{isLoadingInvoices ? ( {isLoadingInvoices ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<div className="w-6 h-6 border-2 border-brand-orange/30 border-t-brand-orange rounded-full animate-spin" /> <Spinner />
</div> </div>
) : invoices.length === 0 ? ( ) : invoices.length === 0 ? (
<div className="p-8 text-center text-neutral-500">No invoices found.</div> <div className="p-8 text-center text-neutral-500">No invoices found.</div>
@@ -896,14 +1077,21 @@ export default function OrganizationSettings() {
</span> </span>
{invoice.invoice_pdf && ( {invoice.invoice_pdf && (
<a href={invoice.invoice_pdf} target="_blank" rel="noopener noreferrer" <a href={invoice.invoice_pdf} target="_blank" rel="noopener noreferrer"
className="p-1.5 text-neutral-400 hover:text-neutral-900 dark:hover:text-white rounded-lg transition-colors" title="Download PDF"> className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange" title="Download PDF">
<DownloadIcon className="w-4 h-4" /> <DownloadIcon className="w-3.5 h-3.5" />
Download PDF
</a> </a>
)} )}
{invoice.hosted_invoice_url && ( {invoice.hosted_invoice_url && (
<a href={invoice.hosted_invoice_url} target="_blank" rel="noopener noreferrer" <a href={invoice.hosted_invoice_url} target="_blank" rel="noopener noreferrer"
className="p-1.5 text-neutral-400 hover:text-neutral-900 dark:hover:text-white rounded-lg transition-colors" title="View invoice"> className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange ${
<ExternalLinkIcon className="w-4 h-4" /> invoice.status === 'open'
? 'bg-brand-orange text-white hover:bg-brand-orange-hover'
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800'
}`}
title={invoice.status === 'open' ? 'Pay now' : 'View invoice'}>
<ExternalLinkIcon className="w-3.5 h-3.5" />
{invoice.status === 'open' ? 'Pay now' : 'View invoice'}
</a> </a>
)} )}
</div> </div>
@@ -919,6 +1107,73 @@ export default function OrganizationSettings() {
</div> </div>
)} )}
{activeTab === 'notifications' && (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">Notification Settings</h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-6">
Choose which notification types you want to receive. These apply to the notification center for owners and admins.
</p>
</div>
{isLoadingNotificationSettings ? (
<div className="flex items-center justify-center py-12">
<Spinner />
</div>
) : (
<div className="space-y-4">
<h3 className="text-sm font-medium text-neutral-500 uppercase tracking-wider">Notification categories</h3>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden divide-y divide-neutral-200 dark:divide-neutral-800">
{notificationCategories.map((cat) => (
<div
key={cat.id}
className="p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-4 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors"
>
<div className="flex-1">
<p className="text-sm font-medium text-neutral-900 dark:text-white">{cat.label}</p>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">{cat.description}</p>
</div>
<div className="flex items-center shrink-0">
<button
type="button"
role="switch"
aria-checked={notificationSettings[cat.id] !== false}
aria-label={`${notificationSettings[cat.id] !== false ? 'Disable' : 'Enable'} ${cat.label} notifications`}
onClick={() => {
const prev = { ...notificationSettings }
const next = { ...notificationSettings, [cat.id]: notificationSettings[cat.id] === false }
setNotificationSettings(next)
setIsSavingNotificationSettings(true)
updateNotificationSettings(next)
.then(() => {
toast.success('Notification settings updated')
})
.catch((err) => {
toast.error(getAuthErrorMessage(err) || 'Failed to update settings')
setNotificationSettings(prev)
})
.finally(() => setIsSavingNotificationSettings(false))
}}
disabled={isSavingNotificationSettings}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${
notificationSettings[cat.id] !== false ? 'bg-brand-orange' : 'bg-neutral-200 dark:bg-neutral-700'
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
notificationSettings[cat.id] !== false ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{activeTab === 'audit' && ( {activeTab === 'audit' && (
<div className="space-y-12"> <div className="space-y-12">
<div> <div>
@@ -990,7 +1245,7 @@ export default function OrganizationSettings() {
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden">
{isLoadingAudit ? ( {isLoadingAudit ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<div className="w-6 h-6 border-2 border-brand-orange/30 border-t-brand-orange rounded-full animate-spin" /> <Spinner />
</div> </div>
) : (auditEntries ?? []).length === 0 ? ( ) : (auditEntries ?? []).length === 0 ? (
<div className="p-8 text-center text-neutral-500">No audit events found.</div> <div className="p-8 text-center text-neutral-500">No audit events found.</div>
@@ -1210,8 +1465,9 @@ export default function OrganizationSettings() {
<button <button
type="button" type="button"
onClick={() => setShowChangePlanModal(false)} onClick={() => setShowChangePlanModal(false)}
className="text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-400" className="text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-400 focus:outline-none focus:ring-2 focus:ring-brand-orange rounded-lg p-1"
disabled={isChangingPlan} disabled={isChangingPlan}
aria-label="Close dialog"
> >
<XIcon className="w-5 h-5" /> <XIcon className="w-5 h-5" />
</button> </button>
@@ -1220,6 +1476,41 @@ export default function OrganizationSettings() {
Choose your pageview limit and billing interval. {hasActiveSubscription ? 'Your next invoice will reflect prorations.' : 'Youll start a new subscription.'} Choose your pageview limit and billing interval. {hasActiveSubscription ? 'Your next invoice will reflect prorations.' : 'Youll start a new subscription.'}
</p> </p>
<div className="space-y-4"> <div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Plan</label>
<div className="grid grid-cols-3 gap-2">
{([
{ id: PLAN_ID_SOLO, name: 'Solo', sites: '1 site' },
{ id: PLAN_ID_TEAM, name: 'Team', sites: 'Up to 5 sites' },
{ id: PLAN_ID_BUSINESS, name: 'Business', sites: 'Up to 10 sites' },
] as const).map((plan) => {
const isCurrentPlan = subscription?.plan_id === plan.id
const isSelected = changePlanId === plan.id
return (
<button
key={plan.id}
type="button"
onClick={() => setChangePlanId(plan.id)}
className={`relative p-3 rounded-xl border text-left transition-all focus:outline-none focus:ring-2 focus:ring-brand-orange ${
isSelected
? 'border-brand-orange bg-brand-orange/5 dark:bg-brand-orange/10'
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
}`}
>
<span className={`block text-sm font-semibold ${isSelected ? 'text-brand-orange' : 'text-neutral-900 dark:text-white'}`}>
{plan.name}
</span>
<span className="block text-xs text-neutral-500 mt-0.5">{plan.sites}</span>
{isCurrentPlan && (
<span className="absolute -top-2 right-2 px-1.5 py-0.5 text-[10px] font-medium bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 rounded-full border border-neutral-200 dark:border-neutral-700">
Current
</span>
)}
</button>
)
})}
</div>
</div>
<div> <div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Pageviews per month</label> <label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Pageviews per month</label>
<select <select
@@ -1240,20 +1531,44 @@ export default function OrganizationSettings() {
<button <button
type="button" type="button"
onClick={() => setChangePlanYearly(false)} onClick={() => setChangePlanYearly(false)}
className={`flex-1 py-2 text-sm font-medium rounded-md transition-colors ${!changePlanYearly ? 'bg-brand-orange text-white' : 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'}`} className={`flex-1 py-2 text-sm font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange ${!changePlanYearly ? 'bg-brand-orange text-white' : 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'}`}
> >
Monthly Monthly
</button> </button>
<button <button
type="button" type="button"
onClick={() => setChangePlanYearly(true)} onClick={() => setChangePlanYearly(true)}
className={`flex-1 py-2 text-sm font-medium rounded-md transition-colors ${changePlanYearly ? 'bg-brand-orange text-white' : 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'}`} className={`flex-1 py-2 text-sm font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange ${changePlanYearly ? 'bg-brand-orange text-white' : 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'}`}
> >
Yearly Yearly
</button> </button>
</div> </div>
</div> </div>
</div> </div>
{hasActiveSubscription && (
<div className="mt-4 p-4 rounded-lg bg-neutral-100 dark:bg-neutral-800/50 border border-neutral-200 dark:border-neutral-700">
{isLoadingPreview ? (
<div className="flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400">
<Spinner className="w-4 h-4" />
Calculating next invoice…
</div>
) : invoicePreview ? (
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Next invoice:{' '}
{(invoicePreview.amount_due / 100).toLocaleString('en-US', {
style: 'currency',
currency: invoicePreview.currency.toUpperCase(),
})}{' '}
on {new Date(invoicePreview.period_end * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}{' '}
<span className="text-neutral-500">(prorated)</span>
</p>
) : (
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Unable to calculate preview. Your next invoice will reflect prorations.
</p>
)}
</div>
)}
<div className="flex gap-2 mt-6"> <div className="flex gap-2 mt-6">
<Button <Button
onClick={handleChangePlanSubmit} onClick={handleChangePlanSubmit}

View File

@@ -21,10 +21,15 @@ This document defines the visual language and design patterns for Pulse Analytic
--brand-orange: #FD5E0F; --brand-orange: #FD5E0F;
--brand-orange-hover: #E54E00; /* Darker for hover states */ --brand-orange-hover: #E54E00; /* Darker for hover states */
/* Injected by @ciphera-net/ui preset — use in SVG, Recharts, rgba() */
--color-brand-orange: #FD5E0F;
--color-brand-orange-rgb: 253, 94, 15; /* Used by .glow-orange utility; also for custom rgba(var(--color-brand-orange-rgb), 0.5) */
/* Usage */ /* Usage */
- Primary CTAs, links, focus rings - Primary CTAs, links, focus rings
- Accent elements, badges - Accent elements, badges
- Never use for large backgrounds (too vibrant) - Never use for large backgrounds (too vibrant)
- var(--color-brand-orange) for SVG/Recharts where Tailwind classes don't apply
``` ```
### Neutral Scale ### Neutral Scale
@@ -285,7 +290,7 @@ Glass card effect with backdrop blur (premium feel):
Orange gradient for emphasized text: Orange gradient for emphasized text:
```css ```css
.gradient-text { .gradient-text {
@apply bg-gradient-to-r from-brand-orange to-[#E54E00] bg-clip-text text-transparent; @apply bg-gradient-to-r from-brand-orange to-brand-orange-hover bg-clip-text text-transparent;
} }
``` ```

View File

@@ -1,5 +1,11 @@
import { API_URL } from './client' import { API_URL } from './client'
export interface TaxID {
type: string
value: string
country?: string
}
export interface SubscriptionDetails { export interface SubscriptionDetails {
plan_id: string plan_id: string
subscription_status: string subscription_status: string
@@ -13,6 +19,16 @@ export interface SubscriptionDetails {
sites_count?: number sites_count?: number
/** Pageviews in current billing period (when pageview_limit > 0). Present when backend supports usage API. */ /** Pageviews in current billing period (when pageview_limit > 0). Present when backend supports usage API. */
pageview_usage?: number pageview_usage?: number
/** Business name from Stripe Tax ID collection / business purchase flow (optional). */
business_name?: string
/** Tax IDs collected on the Stripe customer (VAT, EIN, etc.) for invoice verification. */
tax_ids?: TaxID[]
/** Next invoice amount in cents (for "Renews on X for €Y" display). */
next_invoice_amount_due?: number
/** Currency for next invoice (e.g. eur). */
next_invoice_currency?: string
/** Unix timestamp when next invoice period ends. */
next_invoice_period_end?: number
} }
async function billingFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> { async function billingFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
@@ -64,12 +80,36 @@ export async function cancelSubscription(params?: CancelSubscriptionParams): Pro
}) })
} }
/** Clears cancel_at_period_end so the subscription continues past the current period. */
export async function resumeSubscription(): Promise<{ ok: boolean }> {
return await billingFetch<{ ok: boolean }>('/api/billing/resume', {
method: 'POST',
})
}
export interface ChangePlanParams { export interface ChangePlanParams {
plan_id: string plan_id: string
interval: string interval: string
limit: number limit: number
} }
export interface PreviewInvoiceResult {
amount_due: number
currency: string
period_end: number
}
export async function previewInvoice(params: ChangePlanParams): Promise<PreviewInvoiceResult | null> {
const res = await billingFetch<PreviewInvoiceResult | Record<string, never>>('/api/billing/preview-invoice', {
method: 'POST',
body: JSON.stringify(params),
})
if (res && typeof res === 'object' && 'amount_due' in res && typeof (res as PreviewInvoiceResult).amount_due === 'number') {
return res as PreviewInvoiceResult
}
return null
}
export async function changePlan(params: ChangePlanParams): Promise<{ ok: boolean }> { export async function changePlan(params: ChangePlanParams): Promise<{ ok: boolean }> {
return await billingFetch<{ ok: boolean }>('/api/billing/change-plan', { return await billingFetch<{ ok: boolean }>('/api/billing/change-plan', {
method: 'POST', method: 'POST',

View File

@@ -2,7 +2,7 @@
* HTTP client wrapper for API calls * HTTP client wrapper for API calls
*/ */
import { authMessageFromStatus, AUTH_ERROR_MESSAGES } from '@/lib/utils/authErrors' import { authMessageFromStatus, AUTH_ERROR_MESSAGES } from '@ciphera-net/ui'
/** Request timeout in ms; network errors surface as user-facing "Network error, please try again." */ /** Request timeout in ms; network errors surface as user-facing "Network error, please try again." */
const FETCH_TIMEOUT_MS = 30_000 const FETCH_TIMEOUT_MS = 30_000

View File

@@ -0,0 +1,22 @@
/**
* @file Notification settings API client
*/
import apiRequest from './client'
export interface NotificationSettingsResponse {
settings: Record<string, boolean>
categories: { id: string; label: string; description: string }[]
}
export async function getNotificationSettings(): Promise<NotificationSettingsResponse> {
return apiRequest<NotificationSettingsResponse>('/notification-settings')
}
export async function updateNotificationSettings(settings: Record<string, boolean>): Promise<void> {
return apiRequest<void>('/notification-settings', {
method: 'PATCH',
body: JSON.stringify({ settings }),
headers: { 'Content-Type': 'application/json' },
})
}

49
lib/api/notifications.ts Normal file
View File

@@ -0,0 +1,49 @@
/**
* @file Notifications API client
*/
import apiRequest from './client'
export interface Notification {
id: string
organization_id: string
type: string
title: string
body?: string
read: boolean
link_url?: string
link_label?: string
metadata?: Record<string, unknown>
created_at: string
}
export interface ListNotificationsResponse {
notifications: Notification[]
unread_count: number
}
export interface ListNotificationsParams {
limit?: number
offset?: number
}
export async function listNotifications(params?: ListNotificationsParams): Promise<ListNotificationsResponse> {
const q = new URLSearchParams()
if (params?.limit != null) q.set('limit', String(params.limit))
if (params?.offset != null) q.set('offset', String(params.offset))
const query = q.toString()
const url = query ? `/notifications?${query}` : '/notifications'
return apiRequest<ListNotificationsResponse>(url)
}
export async function markNotificationRead(id: string): Promise<void> {
return apiRequest<void>(`/notifications/${id}/read`, {
method: 'PATCH',
})
}
export async function markAllNotificationsRead(): Promise<void> {
return apiRequest<void>('/notifications/mark-all-read', {
method: 'POST',
})
}

View File

@@ -8,7 +8,7 @@
*/ */
import { type ReactNode } from 'react' import { type ReactNode } from 'react'
import { CodeBlock } from '@/components/CodeBlock' import { CodeBlock } from '@ciphera-net/ui'
// * ─── Guide registry ───────────────────────────────────────────────────────── // * ─── Guide registry ─────────────────────────────────────────────────────────

View File

@@ -1,9 +1,22 @@
/** /**
* Shared plan and traffic tier definitions for pricing and billing (Change plan). * Shared plan and traffic tier definitions for pricing and billing (Change plan).
* Backend supports plan_id "solo" and limit 10k10M; month/year interval. * Backend supports plan_id solo, team, business and limit 10k10M; month/year interval.
*/ */
export const PLAN_ID_SOLO = 'solo' export const PLAN_ID_SOLO = 'solo'
export const PLAN_ID_TEAM = 'team'
export const PLAN_ID_BUSINESS = 'business'
/** Sites limit per plan. Returns null for free (no limit enforced in UI). */
export function getSitesLimitForPlan(planId: string | null | undefined): number | null {
if (!planId || planId === 'free') return null
switch (planId) {
case 'solo': return 1
case 'team': return 5
case 'business': return 10
default: return null
}
}
/** Traffic tiers available for Solo plan (pageview limits). */ /** Traffic tiers available for Solo plan (pageview limits). */
export const TRAFFIC_TIERS = [ export const TRAFFIC_TIERS = [

View File

@@ -1,63 +0,0 @@
/**
* Auth error message mapping for user-facing copy.
* Maps status codes and error types to safe, actionable messages (no sensitive details).
*/
export const AUTH_ERROR_MESSAGES = {
/** Shown when session/token is expired; prompts re-login. */
SESSION_EXPIRED: 'Session expired, please sign in again.',
/** Shown when credentials are invalid (e.g. wrong password, invalid token). */
INVALID_CREDENTIALS: 'Invalid credentials',
/** Shown on network failure or timeout; prompts retry. */
NETWORK: 'Network error, please try again.',
/** Generic fallback for server/unknown errors. */
GENERIC: 'Something went wrong, please try again.',
} as const
/**
* Returns the user-facing message for a given HTTP status from an API/auth response.
* Used when building ApiError messages and when mapping server-returned error types.
*/
export function authMessageFromStatus(status: number): string {
if (status === 401) return AUTH_ERROR_MESSAGES.SESSION_EXPIRED
if (status === 403) return AUTH_ERROR_MESSAGES.INVALID_CREDENTIALS
if (status >= 500) return AUTH_ERROR_MESSAGES.GENERIC
return AUTH_ERROR_MESSAGES.GENERIC
}
/** Error type returned by auth server actions for mapping to user-facing copy. */
export type AuthErrorType = 'network' | 'expired' | 'invalid' | 'server'
/**
* Maps server-action error type (e.g. from exchangeAuthCode) to user-facing message.
* Used in auth callback so no sensitive details are shown.
*/
export function authMessageFromErrorType(type: AuthErrorType): string {
switch (type) {
case 'expired':
return AUTH_ERROR_MESSAGES.SESSION_EXPIRED
case 'invalid':
return AUTH_ERROR_MESSAGES.INVALID_CREDENTIALS
case 'network':
return AUTH_ERROR_MESSAGES.NETWORK
case 'server':
default:
return AUTH_ERROR_MESSAGES.GENERIC
}
}
/**
* Maps an error (e.g. ApiError, network/abort) to a safe user-facing message.
* Use this when displaying API/auth errors in the UI so expired, invalid, and network
* cases show the correct copy without exposing sensitive details.
*/
export function getAuthErrorMessage(error: unknown): string {
if (!error) return AUTH_ERROR_MESSAGES.GENERIC
const err = error as { status?: number; name?: string; message?: string }
if (typeof err.status === 'number') return authMessageFromStatus(err.status)
if (err.name === 'AbortError') return AUTH_ERROR_MESSAGES.NETWORK
if (err instanceof Error && (err.name === 'TypeError' || /fetch|network|failed to fetch/i.test(err.message || ''))) {
return AUTH_ERROR_MESSAGES.NETWORK
}
return AUTH_ERROR_MESSAGES.GENERIC
}

View File

@@ -1,70 +0,0 @@
/**
* Format numbers with commas
*/
export function formatNumber(num: number): string {
return new Intl.NumberFormat('en-US').format(num)
}
/**
* Format date to YYYY-MM-DD
*/
export function formatDate(date: Date): string {
return date.toISOString().split('T')[0]
}
/**
* Get date range for last N days
*/
export function getDateRange(days: number): { start: string; end: string } {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - days)
return {
start: formatDate(start),
end: formatDate(end),
}
}
/**
* Format "updated X ago" for polling indicators (e.g. "Just now", "12 seconds ago")
*/
export function formatUpdatedAgo(timestamp: number): string {
const diff = Math.floor((Date.now() - timestamp) / 1000)
if (diff < 5) return 'Just now'
if (diff < 60) return `${diff} seconds ago`
if (diff < 120) return '1 minute ago'
const minutes = Math.floor(diff / 60)
return `${minutes} minutes ago`
}
/**
* Format relative time (e.g., "2 hours ago")
*/
export function formatRelativeTime(date: string | Date): string {
const d = typeof date === 'string' ? new Date(date) : date
const now = new Date()
const diff = now.getTime() - d.getTime()
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`
if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`
if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`
return 'Just now'
}
/**
* Format duration in seconds to "1m 30s" or "30s"
*/
export function formatDuration(seconds: number): string {
if (!seconds) return '0s'
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
if (m > 0) {
return `${m}m ${s}s`
}
return `${s}s`
}

View File

@@ -0,0 +1,28 @@
import { AlertTriangleIcon, CheckCircleIcon } from '@ciphera-net/ui'
/**
* Formats a date string as a human-readable relative time (e.g. "5m ago", "2h ago").
*/
export function formatTimeAgo(dateStr: string): string {
const d = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - d.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMins < 1) return 'Just now'
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays < 7) return `${diffDays}d ago`
return d.toLocaleDateString()
}
/**
* Returns the icon for a notification type (alert for down/degraded/billing, check for success).
*/
export function getTypeIcon(type: string) {
if (type.includes('down') || type.includes('degraded') || type.startsWith('billing_')) {
return <AlertTriangleIcon className="w-4 h-4 shrink-0 text-amber-500" />
}
return <CheckCircleIcon className="w-4 h-4 shrink-0 text-emerald-500" />
}

View File

@@ -21,6 +21,18 @@ const nextConfig: NextConfig = {
}, },
] ]
}, },
async rewrites() {
return [
{
source: '/docs',
destination: 'https://ciphera-e9ed055e.mintlify.dev/docs',
},
{
source: '/docs/:path*',
destination: 'https://ciphera-e9ed055e.mintlify.dev/docs/:path*',
},
]
},
} }
export default withPWA(nextConfig) export default withPWA(nextConfig)

1535
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "pulse-frontend", "name": "pulse-frontend",
"version": "0.5.0-alpha", "version": "0.8.0-alpha",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -10,8 +10,11 @@
"type-check": "tsc --noEmit" "type-check": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@ciphera-net/ui": "^0.0.49", "@ciphera-net/ui": "^0.0.57",
"@ducanh2912/next-pwa": "^10.2.9", "@ducanh2912/next-pwa": "^10.2.9",
"@radix-ui/react-icons": "^1.3.0",
"@stripe/react-stripe-js": "^5.6.0",
"@stripe/stripe-js": "^8.7.0",
"axios": "^1.13.2", "axios": "^1.13.2",
"country-flag-icons": "^1.6.4", "country-flag-icons": "^1.6.4",
"d3-scale": "^4.0.2", "d3-scale": "^4.0.2",
@@ -49,6 +52,6 @@
"eslint-config-next": "^16.1.1", "eslint-config-next": "^16.1.1",
"postcss": "^8.4.40", "postcss": "^8.4.40",
"tailwindcss": "^3.4.7", "tailwindcss": "^3.4.7",
"typescript": "^5.5.4" "typescript": "5.9.3"
} }
} }

View File

@@ -19,43 +19,8 @@
} }
} }
@layer components {
/* * TODO: Move these shared utilities to @ciphera-net/ui to avoid duplication with website */
/* * Glass Card Effect - Crucial for the "Premium" feel */
.card-glass {
@apply bg-white/80 dark:bg-neutral-900/80;
@apply backdrop-blur-xl;
@apply border border-neutral-200/50 dark:border-neutral-800/50;
@apply rounded-2xl;
@apply transition-all duration-300 ease-out;
}
/* * Gradient Text for Headlines */
.gradient-text {
@apply bg-gradient-to-r from-brand-orange to-[#E54E00] bg-clip-text text-transparent;
}
/* * The "Pulse" Badge */
.badge-primary {
@apply inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold uppercase tracking-wider;
@apply bg-brand-orange/10 text-brand-orange border border-brand-orange/20;
}
}
@layer utilities { @layer utilities {
/* * The Background Grid */ /* * 3D Transform Utilities - Pulse-specific */
.bg-grid-pattern {
background-image: radial-gradient(circle at 1px 1px, currentColor 1px, transparent 0);
background-size: 32px 32px;
}
/* * Glow Effects */
.glow-orange {
box-shadow: 0 0 40px -10px rgba(253, 94, 15, 0.5);
}
/* * 3D Transform Utilities */
.perspective-1000 { .perspective-1000 {
perspective: 1000px; perspective: 1000px;
} }