111 Commits

Author SHA1 Message Date
Usman
91ec37be53 Merge pull request #35 from ciphera-net/staging
[PULSE-60] Frontend hardening, UX polish, and security
2026-02-22 22:43:06 +01:00
Usman Baig
31de661888 chore: update CHANGELOG.md to reflect recent fixes in Content Security Policy and date range validation, enhancing clarity and accuracy 2026-02-22 22:41:49 +01:00
Usman Baig
43a0954e5f chore: update dashboard preview image to version 2, replacing the old file for improved design consistency 2026-02-22 22:21:23 +01:00
Usman Baig
93028efa0d chore: increase dashboard preview image height for better visibility and update the image file to reflect design improvements 2026-02-22 22:16:37 +01:00
Usman Baig
414908b6ce chore: update dashboard preview image to enhance visual representation and align with recent design changes 2026-02-22 22:11:26 +01:00
Usman Baig
14ca762305 refactor: remove mock data and streamline DashboardPreview component for improved performance and maintainability 2026-02-22 22:06:22 +01:00
Usman Baig
6545b006de fix: enhance landing page dashboard preview and resolve logout redirect loop, improving user experience and visual consistency 2026-02-22 21:56:30 +01:00
Usman Baig
19df3c6c75 fix: resolve logout redirect loop by directing users to the Pulse homepage after signing out, improving user experience 2026-02-22 21:48:33 +01:00
Usman Baig
c1325bc573 fix: resolve Content Security Policy issue by ensuring the backend CSP header is set correctly, preventing captcha integration failures 2026-02-22 21:43:59 +01:00
Usman Baig
7215eb17b0 feat: introduce a limit of 50 excluded paths for sites to enhance event processing efficiency and prevent performance issues 2026-02-22 21:41:27 +01:00
Usman Baig
e53d37a388 fix: add date range validation for analytics, funnel, and uptime queries to prevent invalid inputs and enhance data integrity 2026-02-22 21:37:27 +01:00
Usman Baig
bd19288f52 fix: safer error messages by preventing exposure of internal details in server responses, enhancing security and user experience 2026-02-22 21:31:45 +01:00
Usman Baig
270b970f43 fix: improve audit log reliability by logging failed writes to the server, enabling detection of gaps in the audit trail 2026-02-22 21:25:19 +01:00
Usman Baig
65e5c727de feat: implement database connection pooling to limit and recycle connections, improving performance under load 2026-02-22 21:20:33 +01:00
Usman Baig
a1e9a6b8f7 feat: implement graceful server shutdown to ensure in-flight requests and background tasks are completed before deployment termination 2026-02-22 21:08:06 +01:00
Usman Baig
19be64c43a feat: optimize icon imports for smaller page downloads by enabling tree-shaking in the build process 2026-02-22 21:04:05 +01:00
Usman Baig
39eac4100e feat: update favicon retrieval to use a centralized service URL for consistency across the application 2026-02-22 21:02:11 +01:00
Usman Baig
b88a31c612 feat: add character limits to site name and domain input fields to enhance form validation and user experience 2026-02-22 20:59:31 +01:00
Usman Baig
2d0307d328 fix: enhance error logging by replacing console.error with a centralized logger across the application to improve security and maintainability 2026-02-22 20:57:21 +01:00
Usman Baig
837c677b51 fix: update dark mode support for uptime chart tooltips to align with user theme preferences 2026-02-22 20:53:21 +01:00
Usman Baig
c73c300620 feat: improve organization switching experience with a branded loading overlay and session management for smoother transitions 2026-02-22 20:48:09 +01:00
Usman Baig
8007900940 feat: enhance accessibility across the application by improving keyboard navigation and screen reader support for various components 2026-02-22 20:39:18 +01:00
Usman Baig
06f54176f1 refactor: enhance type safety by replacing any types with stricter types across the codebase, improving error handling and reducing potential bugs 2026-02-22 20:29:16 +01:00
Usman Baig
1947c6a886 fix: remove debug logs from authentication and organization switching to enhance security and prevent sensitive information leakage 2026-02-22 20:18:06 +01:00
Usman Baig
18d9f59e5d fix: correct organization context switching to ensure secure session storage when switching away from deleted organizations 2026-02-22 20:14:18 +01:00
Usman Baig
acac536590 feat: enforce tighter character limits for site, funnel, and monitor names to improve UI consistency and usability 2026-02-22 20:07:00 +01:00
Usman Baig
da0366603e feat: improve form usability with auto-focus, character limits, and unsaved changes warnings for better user experience 2026-02-22 20:02:50 +01:00
Usman Baig
5d234b30d6 feat: implement security headers for enhanced protection against clickjacking, MIME-sniffing, and other vulnerabilities 2026-02-22 19:55:52 +01:00
Usman Baig
e0bae5a728 feat: add graceful error recovery with user-friendly error screens and retry options for improved user experience 2026-02-22 19:49:27 +01:00
Usman Baig
ca805c9790 feat: implement faster login redirects to improve user experience when accessing dashboards and settings 2026-02-22 19:42:29 +01:00
Usman Baig
5c148a0547 feat: enhance page titles and link previews for improved user experience and sharing capabilities 2026-02-22 19:40:00 +01:00
Usman Baig
94fb7c60e0 feat: optimize favicon loading across the application using Next.js image component for better performance and caching 2026-02-22 19:21:28 +01:00
Usman Baig
156d9986df fix: improve error messaging for various components to provide clearer feedback on failures 2026-02-22 19:17:20 +01:00
Usman Baig
ac6a9429d4 chore: release version 0.11.0-alpha with enhanced loading experience and layout stability improvements 2026-02-22 19:14:58 +01:00
Usman Baig
d571b6156f refactor: integrate useMinimumLoading hook for enhanced loading state management across multiple pages 2026-02-22 18:38:35 +01:00
Usman Baig
c100277955 refactor: replace loading overlays with skeleton components for improved user experience across various pages 2026-02-22 18:01:45 +01:00
Usman Baig
574462a275 style: update loading state background colors to brand colors for enhanced visual consistency 2026-02-22 00:49:33 +01:00
Usman Baig
afa0cec88b style: update loading state background colors for improved visual consistency 2026-02-22 00:46:17 +01:00
Usman Baig
b124fa49ef style: enhance layout stability by adding min-height to overview cards and improving loading state visuals 2026-02-22 00:42:44 +01:00
Usman Baig
a2419d681c refactor: simplify site statistics fetching by removing daily stats and updating related components 2026-02-22 00:25:36 +01:00
Usman Baig
ccefdcc384 fix: handle rejected site statistics fetches by providing default empty stats 2026-02-22 00:22:02 +01:00
Usman Baig
2aedc656d7 feat: implement site statistics fetching and display in SiteList component 2026-02-22 00:20:54 +01:00
Usman
20959683e5 Merge pull request #34 from ciphera-net/staging
[PULSE-59] Design consistency audit fixes
2026-02-22 00:09:41 +01:00
Usman Baig
1a970279b5 chore: release version 0.10.0-alpha with design consistency improvements across various components 2026-02-22 00:06:26 +01:00
Usman Baig
ee25d87097 chore: update package versions and dependencies for improved functionality 2026-02-21 23:58:39 +01:00
Usman Baig
4dead4b399 style: standardize gap sizes across multiple components for improved visual consistency 2026-02-21 23:48:03 +01:00
Usman Baig
aada06c207 style: update domain name in OrganizationSettings component for consistency with new branding 2026-02-21 23:45:51 +01:00
Usman Baig
947e37168d style: update background colors and border styles in integration and installation pages for improved visual consistency 2026-02-21 23:45:05 +01:00
Usman Baig
d08c8f00a0 style: add transition effects to shadow properties across multiple components for improved visual feedback 2026-02-21 23:42:12 +01:00
Usman Baig
0b68db58be style: standardize min-width values across multiple components for improved layout consistency 2026-02-21 23:39:29 +01:00
Usman Baig
fb47cb0c86 style: update padding in integration pages and IntegrationGuide component for improved layout consistency 2026-02-21 23:36:54 +01:00
Usman Baig
8f8761ed3d style: standardize padding across multiple components for improved layout consistency 2026-02-21 23:29:50 +01:00
Usman Baig
fb3490feb9 refactor: replace anchor tag with Button component in PricingSection for improved styling and consistency 2026-02-21 23:25:00 +01:00
Usman Baig
65ba7ccba2 style: enhance dark mode support by updating text colors across multiple components for improved readability 2026-02-21 23:13:52 +01:00
Usman Baig
f1e6d5a48e style: refactor chart color variables across multiple components to use CSS variables for improved theming consistency 2026-02-21 23:09:34 +01:00
Usman Baig
72c06816fe style: update layout of multiple pages to use consistent max-width and padding for improved responsiveness 2026-02-21 22:53:26 +01:00
Usman Baig
23ba5f77a9 refactor: replace button elements with a unified Button component in SiteSettingsPage and VerificationModal for consistency and improved styling 2026-02-21 22:41:43 +01:00
Usman Baig
e8e304e238 style: update heading sizes across various pages for improved typography consistency 2026-02-21 22:29:26 +01:00
Usman
4ffd61963c Merge pull request #33 from ciphera-net/staging
[PULSE-58] Data retention settings in Site Settings
2026-02-21 20:03:25 +01:00
Usman Baig
d1d82f5b3c feat: refine data retention adjustment logic in SiteSettingsPage to snap to nearest valid option upon subscription load 2026-02-21 19:58:48 +01:00
Usman Baig
98eef9c366 feat: adjust default data retention to 6 months in SiteSettingsPage and add error handling for subscription loading failures 2026-02-21 19:50:27 +01:00
Usman Baig
5c0babe273 feat: implement data retention clamping in SiteSettingsPage to ensure user settings align with subscription plan limits 2026-02-21 19:45:35 +01:00
Usman Baig
22b2c036ac chore: update CHANGELOG and package version to 0.9.0-alpha, adding data retention features and settings for site owners 2026-02-21 19:40:33 +01:00
Usman Baig
1e41bedc86 fix: update maximum data retention for business plan from 60 to 36 months and adjust retention options accordingly 2026-02-21 18:28:56 +01:00
Usman Baig
1ae20dba4c feat: add data retention settings to SiteSettingsPage, including subscription-based options and UI updates for user interaction 2026-02-21 18:21:43 +01:00
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
106 changed files with 4605 additions and 1291 deletions

View File

@@ -4,6 +4,123 @@ 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.11.0-alpha] - 2026-02-22
### Added
- **Better page titles.** Browser tabs now show which site and page you're on (e.g. "Uptime · example.com | Pulse") instead of the same generic title everywhere.
- **Link previews for public dashboards.** Sharing a public dashboard link on social media now shows a proper preview with the site name and description.
- **Faster login redirects.** If you're not signed in and try to open a dashboard or settings page, you're redirected to login immediately instead of seeing a blank page first. Already-signed-in users who visit the login page are sent straight to the dashboard.
- **Graceful error recovery.** If a page crashes, you now see a friendly error screen with a "Try again" button instead of a blank white page. Each section of the app has its own error message so you know exactly what went wrong.
- **Security headers.** All pages now include clickjacking protection, MIME-sniffing prevention, a strict referrer policy, and HSTS. Browser APIs like camera and microphone are explicitly disabled.
- **Better form experience.** Forms now auto-focus the first field when they open, text inputs enforce character limits with a visible counter when you're close, and the settings page warns you before navigating away with unsaved changes.
- **Accessibility improvements.** The notification bell, workspace switcher, and all dashboard tabs are now fully keyboard-navigable. Screen readers announce unread counts, active organizations, and tab changes correctly. Decorative icons are hidden from assistive technology.
- **Smooth organization switching.** Switching between organizations now shows a branded loading screen instead of a blank flash while the page reloads.
- **Graceful server shutdown.** Deployments no longer kill in-flight requests or interrupt background tasks. The server finishes ongoing work before shutting down.
- **Database connection pooling.** The backend now limits and recycles database connections, preventing exhaustion under load and reducing query latency.
- **Date range validation.** Analytics, funnel, and uptime queries now reject invalid date ranges (end before start, or spans longer than a year) instead of silently returning empty or oversized results.
- **Excluded paths limit.** Sites can now have up to 50 excluded paths. Previously there was no cap, which could slow down event processing.
### Changed
- **Smoother loading experience.** Pages now show a subtle preview of the layout while data loads instead of a blank screen or spinner. This applies everywhere — dashboards, settings, uptime, funnels, notifications, billing, and detail modals.
- **Clearer error messages.** When something goes wrong, the error message now tells you what failed (e.g. "Failed to load uptime monitors") instead of a generic "Failed to load data".
- **Faster favicon loading.** Site icons in the dashboard, referrers, and campaigns now use Next.js image optimization for better caching and lazy loading.
- **Tighter name limits.** Site, funnel, and monitor names are now capped at 100 characters instead of 255 — long enough for any real name, short enough to not break the UI.
- **Stricter type safety.** Eliminated all `any` types and `@ts-ignore` suppressions across the codebase, so the TypeScript compiler catches more bugs at build time.
- **Smaller page downloads.** Icon imports are now tree-shaken so only the icons actually used are included in the bundle.
- **Removed debug logs.** Auth and organization-switching details no longer leak into the browser console in production. Error logs are now also suppressed in production and only appear during development.
### Fixed
- **Landing page dashboard preview.** The homepage now shows a realistic preview of the Pulse dashboard instead of an empty placeholder.
- **Logout redirect loop.** Signing out no longer bounces you straight to Ciphera Auth. You now land on the Pulse homepage where you can choose to sign back in.
- **No more loading flicker.** Fast-loading pages no longer flash a loading state for a split second before showing content.
- **Organization context switch.** Switching away from a deleted organization now stores the session correctly instead of using an insecure fallback.
- **Dark mode uptime chart.** The response time chart on the uptime page now correctly follows your dark mode preference instead of always showing a white tooltip background.
- **Onboarding form limits.** The welcome page now enforces the same character limits as the rest of the app.
- **Audit log reliability.** Failed audit log writes are now logged to the server instead of being silently ignored, so gaps in the audit trail are detectable.
- **Safer error messages.** Server errors no longer expose internal details (database errors, stack traces) to the browser. You see a clear message like "Failed to create site" while the full error is logged server-side for debugging.
- **Content Security Policy.** The backend CSP header was being overwritten by a duplicate, and the captcha service was incorrectly whitelisted under image sources instead of connection sources. Both are now fixed.
- **Logout redirect loop.** Signing out no longer bounces you straight to Ciphera Auth. You now land on the Pulse homepage where you can choose to sign back in.
- **Date range edge case.** The maximum date range check could be off by a day due to an internal time adjustment. It now compares calendar days accurately.
## [0.10.0-alpha] - 2026-02-21
### Changed
- **Design consistency (PULSE-59).** Pulse now feels more cohesive across all pages — headings, buttons, and layout are consistent.
- **Headings.** Marketing and integration pages use the same heading sizes for a clearer visual hierarchy.
- **Buttons.** Settings pages and the verification modal use consistent button styles. The Enterprise "Contact us" button on pricing now matches the rest.
- **Settings layout.** Profile settings, Organization Settings, and Site Settings now span the full width of the page, matching the dashboard.
- **Charts and maps.** Analytics charts, funnel views, and the uptime map now use Pulse's brand colors correctly in both light and dark mode.
- **Integration guides.** Code examples in the integration and installation guides look cleaner and work better in dark mode.
- **Dark mode.** Text and backgrounds across settings, pricing, and funnels are easier to read when you switch themes.
- **Cards and panels.** All cards use consistent padding for a more even layout.
- **Integration pages.** Integration setup guides have more comfortable spacing at the top.
- **Org slug.** The organization URL prefix correctly shows `pulse.ciphera.net/` instead of the wrong domain.
## [0.9.0-alpha] - 2026-02-21
### Added
- **Data retention settings (PULSE-58).** Site owners can choose how long raw event data is kept (1 month to 3 years depending on plan). Events older than the retention period are automatically deleted every 24 hours. Aggregated daily stats are preserved so historical charts remain intact.
- **Data Retention section in Site Settings.** Under Data & Privacy, a dropdown lets you set retention; options are capped by your plan (free: up to 6 months, solo: 1 year, team: 2 years, business: 3 years).
- **Privacy snippet includes retention.** The generated privacy policy text now mentions when raw data is automatically deleted.
## [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 +168,13 @@ 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.11.0-alpha...HEAD
[0.11.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.10.0-alpha...v0.11.0-alpha
[0.10.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.9.0-alpha...v0.10.0-alpha
[0.9.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.8.0-alpha...v0.9.0-alpha
[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

19
app/about/layout.tsx Normal file
View File

@@ -0,0 +1,19 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'About | Pulse',
description: 'Pulse is built by Ciphera — privacy-first web analytics made in Switzerland.',
openGraph: {
title: 'About | Pulse',
description: 'Pulse is built by Ciphera — privacy-first web analytics made in Switzerland.',
siteName: 'Pulse by Ciphera',
},
}
export default function AboutLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}

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

@@ -1,6 +1,7 @@
'use server' 'use server'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import { logger } from '@/lib/utils/logger'
const AUTH_API_URL = process.env.NEXT_PUBLIC_AUTH_API_URL || process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:8081' const AUTH_API_URL = process.env.NEXT_PUBLIC_AUTH_API_URL || process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:8081'
@@ -102,7 +103,7 @@ export async function exchangeAuthCode(code: string, codeVerifier: string, redir
} }
} catch (error: unknown) { } catch (error: unknown) {
console.error('Auth Exchange Error:', error) logger.error('Auth Exchange Error:', error)
const isNetwork = const isNetwork =
error instanceof TypeError || error instanceof TypeError ||
(error instanceof Error && (error.name === 'AbortError' || /fetch|network|ECONNREFUSED|ETIMEDOUT/i.test(error.message))) (error instanceof Error && (error.name === 'AbortError' || /fetch|network|ECONNREFUSED|ETIMEDOUT/i.test(error.message)))
@@ -112,19 +113,14 @@ export async function exchangeAuthCode(code: string, codeVerifier: string, redir
export async function setSessionAction(accessToken: string, refreshToken?: string) { export async function setSessionAction(accessToken: string, refreshToken?: string) {
try { try {
console.log('[setSessionAction] Decoding token...')
if (!accessToken) throw new Error('Access token is missing') if (!accessToken) throw new Error('Access token is missing')
const payloadPart = accessToken.split('.')[1] const payloadPart = accessToken.split('.')[1]
const payload: UserPayload = JSON.parse(Buffer.from(payloadPart, 'base64').toString()) const payload: UserPayload = JSON.parse(Buffer.from(payloadPart, 'base64').toString())
console.log('[setSessionAction] Token Payload:', { sub: payload.sub, org_id: payload.org_id })
const cookieStore = await cookies() const cookieStore = await cookies()
const cookieDomain = getCookieDomain() const cookieDomain = getCookieDomain()
console.log('[setSessionAction] Setting cookies with domain:', cookieDomain)
cookieStore.set('access_token', accessToken, { cookieStore.set('access_token', accessToken, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: process.env.NODE_ENV === 'production',
@@ -146,8 +142,6 @@ export async function setSessionAction(accessToken: string, refreshToken?: strin
}) })
} }
console.log('[setSessionAction] Cookies set successfully')
return { return {
success: true, success: true,
user: { user: {
@@ -159,7 +153,7 @@ export async function setSessionAction(accessToken: string, refreshToken?: strin
} }
} }
} catch (e) { } catch (e) {
console.error('[setSessionAction] Error:', e) logger.error('[setSessionAction] Error:', e)
return { success: false as const, error: 'invalid' } return { success: false as const, error: 'invalid' }
} }
} }

View File

@@ -1,11 +1,12 @@
'use client' 'use client'
import { useEffect, useState, Suspense, useRef, useCallback } from 'react' import { useEffect, useState, Suspense, useRef, useCallback } from 'react'
import { logger } from '@/lib/utils/logger'
import { useRouter, useSearchParams } from 'next/navigation' 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() {
@@ -96,7 +97,7 @@ function AuthCallbackContent() {
return return
} }
if (state !== storedState) { if (state !== storedState) {
console.error('State mismatch', { received: state, stored: storedState }) logger.error('State mismatch', { received: state, stored: storedState })
setError('Invalid state') setError('Invalid state')
return return
} }

View File

@@ -18,7 +18,7 @@ export default function ChangelogPage() {
return ( return (
<div className="mx-auto max-w-3xl px-4 sm:px-6 py-8"> <div className="mx-auto max-w-3xl px-4 sm:px-6 py-8">
<h1 className="text-3xl font-bold text-neutral-900 dark:text-white mb-2"> <h1 className="text-4xl md:text-5xl font-bold tracking-tight text-neutral-900 dark:text-white mb-2">
Changelog Changelog
</h1> </h1>
<p className="text-neutral-600 dark:text-neutral-400 mb-8 text-sm"> <p className="text-neutral-600 dark:text-neutral-400 mb-8 text-sm">

13
app/error.tsx Normal file
View File

@@ -0,0 +1,13 @@
'use client'
import ErrorDisplay from '@/components/ErrorDisplay'
export default function GlobalError({ reset }: { error: Error; reset: () => void }) {
return (
<ErrorDisplay
title="Something went wrong"
message="An unexpected error occurred. Please try again or go back to the dashboard."
onRetry={reset}
/>
)
}

19
app/faq/layout.tsx Normal file
View File

@@ -0,0 +1,19 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'FAQ | Pulse',
description: 'Frequently asked questions about Pulse, privacy, GDPR compliance, and how it works.',
openGraph: {
title: 'FAQ | Pulse',
description: 'Frequently asked questions about Pulse, privacy, GDPR compliance, and how it works.',
siteName: 'Pulse by Ciphera',
},
}
export default function FaqLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}

19
app/features/layout.tsx Normal file
View File

@@ -0,0 +1,19 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Features | Pulse',
description: 'Dashboards, funnels, uptime monitoring, realtime visitors, and more — all without cookies.',
openGraph: {
title: 'Features | Pulse',
description: 'Dashboards, funnels, uptime monitoring, realtime visitors, and more — all without cookies.',
siteName: 'Pulse by Ciphera',
},
}
export default function FeaturesLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}

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" />
@@ -129,7 +129,7 @@ export default function FeaturesPage() {
<span className="w-1.5 h-1.5 rounded-full bg-brand-orange animate-pulse" /> <span className="w-1.5 h-1.5 rounded-full bg-brand-orange animate-pulse" />
Product Tour Product Tour
</span> </span>
<h1 className="text-4xl md:text-6xl font-bold tracking-tight text-neutral-900 dark:text-white mb-6"> <h1 className="text-4xl md:text-5xl font-bold tracking-tight text-neutral-900 dark:text-white mb-6">
Everything you need. <br /> Everything you need. <br />
<span className="gradient-text">Nothing you don&apos;t.</span> <span className="gradient-text">Nothing you don&apos;t.</span>
</h1> </h1>
@@ -147,7 +147,7 @@ export default function FeaturesPage() {
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ duration: 0.5, delay: i * 0.1 }} transition={{ duration: 0.5, delay: i * 0.1 }}
className="card-glass p-8 hover:-translate-y-1 hover:shadow-xl transition-all duration-300 group" className="card-glass p-6 hover:-translate-y-1 hover:shadow-xl transition-all duration-300 group"
> >
<div className="w-12 h-12 rounded-xl bg-brand-orange/10 flex items-center justify-center mb-6 text-brand-orange group-hover:scale-110 transition-transform duration-300"> <div className="w-12 h-12 rounded-xl bg-brand-orange/10 flex items-center justify-center mb-6 text-brand-orange group-hover:scale-110 transition-transform duration-300">
<feature.icon className="w-6 h-6" /> <feature.icon className="w-6 h-6" />
@@ -171,7 +171,7 @@ export default function FeaturesPage() {
className="mb-28" className="mb-28"
> >
<div className="text-center mb-14"> <div className="text-center mb-14">
<h2 className="text-3xl md:text-4xl font-bold text-neutral-900 dark:text-white mb-4"> <h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-4">
Powerful analytics, <span className="gradient-text">simplified</span> Powerful analytics, <span className="gradient-text">simplified</span>
</h2> </h2>
<p className="text-lg text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto"> <p className="text-lg text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto">
@@ -215,7 +215,7 @@ export default function FeaturesPage() {
> >
<div className="grid md:grid-cols-2 gap-10 items-center"> <div className="grid md:grid-cols-2 gap-10 items-center">
<div> <div>
<h2 className="text-3xl font-bold text-neutral-900 dark:text-white mb-4"> <h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-4">
Content that <span className="gradient-text">performs</span> Content that <span className="gradient-text">performs</span>
</h2> </h2>
<p className="text-neutral-600 dark:text-neutral-400 leading-relaxed mb-6"> <p className="text-neutral-600 dark:text-neutral-400 leading-relaxed mb-6">
@@ -285,7 +285,7 @@ export default function FeaturesPage() {
className="mb-28" className="mb-28"
> >
<div className="text-center mb-12"> <div className="text-center mb-12">
<h2 className="text-3xl md:text-4xl font-bold text-neutral-900 dark:text-white mb-4"> <h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-4">
Built for trust Built for trust
</h2> </h2>
<p className="text-lg text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto"> <p className="text-lg text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto">
@@ -341,7 +341,7 @@ export default function FeaturesPage() {
className="mb-28" className="mb-28"
> >
<div className="text-center mb-14"> <div className="text-center mb-14">
<h2 className="text-3xl md:text-4xl font-bold text-neutral-900 dark:text-white mb-4"> <h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-4">
Up and running in <span className="gradient-text">3 minutes</span> Up and running in <span className="gradient-text">3 minutes</span>
</h2> </h2>
<p className="text-lg text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto"> <p className="text-lg text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto">
@@ -390,7 +390,7 @@ export default function FeaturesPage() {
transition={{ duration: 0.5 }} transition={{ duration: 0.5 }}
className="text-center mb-20" className="text-center mb-20"
> >
<h2 className="text-3xl md:text-4xl font-bold text-neutral-900 dark:text-white mb-4"> <h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-4">
Ready to see it in action? Ready to see it in action?
</h2> </h2>
<p className="text-neutral-600 dark:text-neutral-400 mb-8 max-w-lg mx-auto"> <p className="text-neutral-600 dark:text-neutral-400 mb-8 max-w-lg mx-auto">

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">
@@ -33,8 +33,8 @@ export default function InstallationPage() {
<h2 className="text-2xl font-bold mb-8 text-neutral-900 dark:text-white">Add the snippet</h2> <h2 className="text-2xl font-bold mb-8 text-neutral-900 dark:text-white">Add the snippet</h2>
<p className="text-neutral-500 mb-8">Just add this snippet to your &lt;head&gt; tag in your layout or index file.</p> <p className="text-neutral-500 mb-8">Just add this snippet to your &lt;head&gt; tag in your layout or index file.</p>
<div className="max-w-2xl mx-auto bg-[#1e1e1e] rounded-xl overflow-hidden shadow-2xl text-left border border-neutral-800"> <div className="max-w-2xl mx-auto bg-neutral-900 rounded-xl overflow-hidden shadow-2xl text-left border border-neutral-800">
<div className="flex items-center px-4 py-3 bg-[#252526] border-b border-neutral-800"> <div className="flex items-center px-4 py-3 bg-neutral-800 border-b border-neutral-800">
<div className="flex gap-2"> <div className="flex gap-2">
<div className="w-3 h-3 rounded-full bg-red-500/20" /> <div className="w-3 h-3 rounded-full bg-red-500/20" />
<div className="w-3 h-3 rounded-full bg-yellow-500/20" /> <div className="w-3 h-3 rounded-full bg-yellow-500/20" />
@@ -63,8 +63,8 @@ export default function InstallationPage() {
<p className="text-neutral-500 mb-6 max-w-xl mx-auto"> <p className="text-neutral-500 mb-6 max-w-xl mx-auto">
Track custom events (e.g. signup, purchase) with <code className="px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-700 text-sm font-mono">pulse.track(&apos;event_name&apos;)</code>. Use letters, numbers, and underscores only. Define goals in your site Settings Goals & Events to see counts in the dashboard. Track custom events (e.g. signup, purchase) with <code className="px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-700 text-sm font-mono">pulse.track(&apos;event_name&apos;)</code>. Use letters, numbers, and underscores only. Define goals in your site Settings Goals & Events to see counts in the dashboard.
</p> </p>
<div className="max-w-2xl mx-auto bg-[#1e1e1e] rounded-xl overflow-hidden shadow-2xl text-left border border-neutral-800"> <div className="max-w-2xl mx-auto bg-neutral-900 rounded-xl overflow-hidden shadow-2xl text-left border border-neutral-800">
<div className="flex items-center px-4 py-3 bg-[#252526] border-b border-neutral-800"> <div className="flex items-center px-4 py-3 bg-neutral-800 border-b border-neutral-800">
<div className="flex gap-2"> <div className="flex gap-2">
<div className="w-3 h-3 rounded-full bg-red-500/20" /> <div className="w-3 h-3 rounded-full bg-red-500/20" />
<div className="w-3 h-3 rounded-full bg-yellow-500/20" /> <div className="w-3 h-3 rounded-full bg-yellow-500/20" />

View File

@@ -0,0 +1,19 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Integrations | Pulse',
description: 'Add Pulse analytics to Next.js, React, Vue, WordPress, and more in under a minute.',
openGraph: {
title: 'Integrations | Pulse',
description: 'Add Pulse analytics to Next.js, React, Vue, WordPress, and more in under a minute.',
siteName: 'Pulse by Ciphera',
},
}
export default function IntegrationsLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}

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" />
@@ -16,7 +16,7 @@ export default function NextJsIntegrationPage() {
/> />
</div> </div>
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-12 pb-10 z-10"> <div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-20 pb-10 z-10">
<Link <Link
href="/integrations" href="/integrations"
className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors" className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors"
@@ -31,7 +31,7 @@ export default function NextJsIntegrationPage() {
<path d="M64 0C28.7 0 0 28.7 0 64s28.7 64 64 64 64-28.7 64-64S99.3 0 64 0zm27.6 93.9c-.8.9-2.2 1-3.1.2L42.8 52.8V88c0 1.3-1.1 2.3-2.3 2.3h-7.4c-1.3 0-2.3-1.1-2.3-2.3V40c0-1.3 1.1-2.3 2.3-2.3h7.4c1 0 1.9.6 2.2 1.5l48.6 44.8V40c0-1.3 1.1-2.3 2.3-2.3h7.4c1.3 0 2.3 1.1 2.3 2.3v48c0 1.3-1.1 2.3-2.3 2.3h-6.8c-.9 0-1.7-.5-2.1-1.3z" /> <path d="M64 0C28.7 0 0 28.7 0 64s28.7 64 64 64 64-28.7 64-64S99.3 0 64 0zm27.6 93.9c-.8.9-2.2 1-3.1.2L42.8 52.8V88c0 1.3-1.1 2.3-2.3 2.3h-7.4c-1.3 0-2.3-1.1-2.3-2.3V40c0-1.3 1.1-2.3 2.3-2.3h7.4c1 0 1.9.6 2.2 1.5l48.6 44.8V40c0-1.3 1.1-2.3 2.3-2.3h7.4c1.3 0 2.3 1.1 2.3 2.3v48c0 1.3-1.1 2.3-2.3 2.3h-6.8c-.9 0-1.7-.5-2.1-1.3z" />
</svg> </svg>
</div> </div>
<h1 className="text-3xl md:text-4xl font-bold text-neutral-900 dark:text-white"> <h1 className="text-4xl md:text-5xl font-bold tracking-tight text-neutral-900 dark:text-white">
Next.js Integration Next.js Integration
</h1> </h1>
</div> </div>
@@ -48,8 +48,8 @@ export default function NextJsIntegrationPage() {
Add the script to your root layout file (usually <code>app/layout.tsx</code> or <code>app/layout.js</code>). Add the script to your root layout file (usually <code>app/layout.tsx</code> or <code>app/layout.js</code>).
</p> </p>
<div className="bg-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6"> <div className="bg-neutral-900 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"> <div className="flex items-center px-4 py-2 bg-neutral-800 border-b border-neutral-800">
<span className="text-xs text-neutral-400 font-mono">app/layout.tsx</span> <span className="text-xs text-neutral-400 font-mono">app/layout.tsx</span>
</div> </div>
<div className="p-4 overflow-x-auto"> <div className="p-4 overflow-x-auto">
@@ -84,8 +84,8 @@ export default function RootLayout({
If you are using the older Pages Router, add the script to your custom <code>_app.tsx</code> or <code>_document.tsx</code>. If you are using the older Pages Router, add the script to your custom <code>_app.tsx</code> or <code>_document.tsx</code>.
</p> </p>
<div className="bg-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6"> <div className="bg-neutral-900 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"> <div className="flex items-center px-4 py-2 bg-neutral-800 border-b border-neutral-800">
<span className="text-xs text-neutral-400 font-mono">pages/_app.tsx</span> <span className="text-xs text-neutral-400 font-mono">pages/_app.tsx</span>
</div> </div>
<div className="p-4 overflow-x-auto"> <div className="p-4 overflow-x-auto">

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" />
@@ -158,7 +158,7 @@ export default function IntegrationsPage() {
</button> </button>
) : ( ) : (
<div className="absolute inset-y-0 right-0 flex items-center pr-4 pointer-events-none"> <div className="absolute inset-y-0 right-0 flex items-center pr-4 pointer-events-none">
<kbd className="hidden sm:inline-flex items-center px-1.5 py-0.5 rounded text-[11px] font-mono font-medium bg-neutral-200/80 dark:bg-neutral-700/80 text-neutral-500 dark:text-neutral-400 border border-neutral-300 dark:border-neutral-600"> <kbd className="hidden sm:inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono font-medium bg-neutral-200/80 dark:bg-neutral-700/80 text-neutral-500 dark:text-neutral-400 border border-neutral-300 dark:border-neutral-600">
/ /
</kbd> </kbd>
</div> </div>
@@ -285,7 +285,7 @@ export default function IntegrationsPage() {
> >
<Link <Link
href={`/integrations/${integration.id}`} href={`/integrations/${integration.id}`}
className="group relative p-8 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm border border-neutral-200 dark:border-neutral-800 rounded-2xl hover:border-brand-orange/50 dark:hover:border-brand-orange/50 transition-all duration-300 hover:-translate-y-1 hover:shadow-xl block h-full focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2" className="group relative p-6 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm border border-neutral-200 dark:border-neutral-800 rounded-2xl hover:border-brand-orange/50 dark:hover:border-brand-orange/50 transition-all duration-300 hover:-translate-y-1 hover:shadow-xl block h-full focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
> >
<div className="flex items-start justify-between mb-6"> <div className="flex items-start justify-between mb-6">
<div className="p-3 bg-neutral-100 dark:bg-neutral-800 rounded-xl group-hover:scale-110 transition-transform duration-300"> <div className="p-3 bg-neutral-100 dark:bg-neutral-800 rounded-xl group-hover:scale-110 transition-transform duration-300">
@@ -351,7 +351,7 @@ export default function IntegrationsPage() {
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ duration: 0.5 }} transition={{ duration: 0.5 }}
className="max-w-md mx-auto mt-12 p-8 border border-dashed border-neutral-300 dark:border-neutral-700 rounded-2xl flex flex-col items-center justify-center text-center" className="max-w-md mx-auto mt-12 p-6 border border-dashed border-neutral-300 dark:border-neutral-700 rounded-2xl flex flex-col items-center justify-center text-center"
> >
<h3 className="text-xl font-bold text-neutral-900 dark:text-white mb-2"> <h3 className="text-xl font-bold text-neutral-900 dark:text-white mb-2">
Missing something? Missing something?

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" />
@@ -16,7 +16,7 @@ export default function ReactIntegrationPage() {
/> />
</div> </div>
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-12 pb-10 z-10"> <div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-20 pb-10 z-10">
<Link <Link
href="/integrations" href="/integrations"
className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors" className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors"
@@ -32,7 +32,7 @@ export default function ReactIntegrationPage() {
<circle cx="64" cy="64" r="10.6" /> <circle cx="64" cy="64" r="10.6" />
</svg> </svg>
</div> </div>
<h1 className="text-3xl md:text-4xl font-bold text-neutral-900 dark:text-white"> <h1 className="text-4xl md:text-5xl font-bold tracking-tight text-neutral-900 dark:text-white">
React Integration React Integration
</h1> </h1>
</div> </div>
@@ -49,8 +49,8 @@ export default function ReactIntegrationPage() {
The simplest way is to add the script tag directly to the <code>&lt;head&gt;</code> of your <code>index.html</code> file. The simplest way is to add the script tag directly to the <code>&lt;head&gt;</code> of your <code>index.html</code> file.
</p> </p>
<div className="bg-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6"> <div className="bg-neutral-900 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"> <div className="flex items-center px-4 py-2 bg-neutral-800 border-b border-neutral-800">
<span className="text-xs text-neutral-400 font-mono">public/index.html</span> <span className="text-xs text-neutral-400 font-mono">public/index.html</span>
</div> </div>
<div className="p-4 overflow-x-auto"> <div className="p-4 overflow-x-auto">
@@ -83,8 +83,8 @@ export default function ReactIntegrationPage() {
If you need to load the script dynamically (e.g., only in production), you can use a <code>useEffect</code> hook in your main App component. If you need to load the script dynamically (e.g., only in production), you can use a <code>useEffect</code> hook in your main App component.
</p> </p>
<div className="bg-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6"> <div className="bg-neutral-900 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"> <div className="flex items-center px-4 py-2 bg-neutral-800 border-b border-neutral-800">
<span className="text-xs text-neutral-400 font-mono">src/App.tsx</span> <span className="text-xs text-neutral-400 font-mono">src/App.tsx</span>
</div> </div>
<div className="p-4 overflow-x-auto"> <div className="p-4 overflow-x-auto">

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" />
@@ -16,7 +16,7 @@ export default function VueIntegrationPage() {
/> />
</div> </div>
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-12 pb-10 z-10"> <div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-20 pb-10 z-10">
<Link <Link
href="/integrations" href="/integrations"
className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors" className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors"
@@ -32,7 +32,7 @@ export default function VueIntegrationPage() {
<path d="M64 24.6H39L64 67.4l25-42.8H64z" fill="#35495E" /> <path d="M64 24.6H39L64 67.4l25-42.8H64z" fill="#35495E" />
</svg> </svg>
</div> </div>
<h1 className="text-3xl md:text-4xl font-bold text-neutral-900 dark:text-white"> <h1 className="text-4xl md:text-5xl font-bold tracking-tight text-neutral-900 dark:text-white">
Vue.js Integration Vue.js Integration
</h1> </h1>
</div> </div>
@@ -49,8 +49,8 @@ export default function VueIntegrationPage() {
Add the script tag to the <code>&lt;head&gt;</code> section of your <code>index.html</code> file. This works for both Vue 2 and Vue 3 projects created with Vue CLI or Vite. Add the script tag to the <code>&lt;head&gt;</code> section of your <code>index.html</code> file. This works for both Vue 2 and Vue 3 projects created with Vue CLI or Vite.
</p> </p>
<div className="bg-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6"> <div className="bg-neutral-900 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"> <div className="flex items-center px-4 py-2 bg-neutral-800 border-b border-neutral-800">
<span className="text-xs text-neutral-400 font-mono">index.html</span> <span className="text-xs text-neutral-400 font-mono">index.html</span>
</div> </div>
<div className="p-4 overflow-x-auto"> <div className="p-4 overflow-x-auto">
@@ -84,8 +84,8 @@ export default function VueIntegrationPage() {
For Nuxt.js applications, you should add the script to your <code>nuxt.config.js</code> or <code>nuxt.config.ts</code> file. For Nuxt.js applications, you should add the script to your <code>nuxt.config.js</code> or <code>nuxt.config.ts</code> file.
</p> </p>
<div className="bg-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6"> <div className="bg-neutral-900 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"> <div className="flex items-center px-4 py-2 bg-neutral-800 border-b border-neutral-800">
<span className="text-xs text-neutral-400 font-mono">nuxt.config.ts</span> <span className="text-xs text-neutral-400 font-mono">nuxt.config.ts</span>
</div> </div>
<div className="p-4 overflow-x-auto"> <div className="p-4 overflow-x-auto">

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" />
@@ -16,7 +16,7 @@ export default function WordPressIntegrationPage() {
/> />
</div> </div>
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-12 pb-10 z-10"> <div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-20 pb-10 z-10">
<Link <Link
href="/integrations" href="/integrations"
className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors" className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors"
@@ -31,7 +31,7 @@ export default function WordPressIntegrationPage() {
<path d="M116.6 64c0-19.2-10.4-36-26-45.2l28.6 78.4c-1 3.2-2.2 6.2-3.6 9.2-11.4 12.4-27.8 20.2-46 20.2-6.2 0-12.2-.8-17.8-2.4l26.2-76.4c1.2.2 2.4.4 3.6.4 5.4 0 13.8-.8 13.8-.8 2.8-.2 3.2 4 .4 4.2 0 0-2.8.2-6 .4l19 56.6 5.4-18c2.4-7.4 4.2-12.8 4.2-17.4 0-6-2.2-10.2-7.6-12.6-2.8-1.2-2.2-5.4 1.4-5.4h4.4zM64 121.2c-15.8 0-30.2-6.4-40.8-16.8L46.6 36.8c-2.8-.2-5.8-.4-5.8-.4-2.8-.2-2.4-4.4.4-4.2 0 0 8.4.8 13.6.8 5.4 0 13.6-.8 13.6-.8 2.8-.2 3.2 4 .4 4.2 0 0-2.8.2-5.8.4l18.2 54.4 10.6-31.8L64 121.2zM11.4 64c0 17 8.2 32.2 20.8 41.8L18.8 66.8c-.8-3.4-1.2-6.6-1.2-9.2 0-6.8 2.6-13 6.2-17.8C15.6 47.4 11.4 55.2 11.4 64zM64 6.8c16.2 0 30.8 6.8 41.4 17.6-1.4-.2-2.8-.2-4.2-.2-7.8 0-14.2 1.4-14.2 1.4-2.8.6-2.2 4.8.6 4.2 0 0 5-1 10.6-1 2.2 0 4.6.2 6.6.4L88.2 53 71.4 6.8h-7.4z" /> <path d="M116.6 64c0-19.2-10.4-36-26-45.2l28.6 78.4c-1 3.2-2.2 6.2-3.6 9.2-11.4 12.4-27.8 20.2-46 20.2-6.2 0-12.2-.8-17.8-2.4l26.2-76.4c1.2.2 2.4.4 3.6.4 5.4 0 13.8-.8 13.8-.8 2.8-.2 3.2 4 .4 4.2 0 0-2.8.2-6 .4l19 56.6 5.4-18c2.4-7.4 4.2-12.8 4.2-17.4 0-6-2.2-10.2-7.6-12.6-2.8-1.2-2.2-5.4 1.4-5.4h4.4zM64 121.2c-15.8 0-30.2-6.4-40.8-16.8L46.6 36.8c-2.8-.2-5.8-.4-5.8-.4-2.8-.2-2.4-4.4.4-4.2 0 0 8.4.8 13.6.8 5.4 0 13.6-.8 13.6-.8 2.8-.2 3.2 4 .4 4.2 0 0-2.8.2-5.8.4l18.2 54.4 10.6-31.8L64 121.2zM11.4 64c0 17 8.2 32.2 20.8 41.8L18.8 66.8c-.8-3.4-1.2-6.6-1.2-9.2 0-6.8 2.6-13 6.2-17.8C15.6 47.4 11.4 55.2 11.4 64zM64 6.8c16.2 0 30.8 6.8 41.4 17.6-1.4-.2-2.8-.2-4.2-.2-7.8 0-14.2 1.4-14.2 1.4-2.8.6-2.2 4.8.6 4.2 0 0 5-1 10.6-1 2.2 0 4.6.2 6.6.4L88.2 53 71.4 6.8h-7.4z" />
</svg> </svg>
</div> </div>
<h1 className="text-3xl md:text-4xl font-bold text-neutral-900 dark:text-white"> <h1 className="text-4xl md:text-5xl font-bold tracking-tight text-neutral-900 dark:text-white">
WordPress Integration WordPress Integration
</h1> </h1>
</div> </div>
@@ -50,8 +50,8 @@ export default function WordPressIntegrationPage() {
<li>Paste the following code snippet:</li> <li>Paste the following code snippet:</li>
</ol> </ol>
<div className="bg-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6"> <div className="bg-neutral-900 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"> <div className="flex items-center px-4 py-2 bg-neutral-800 border-b border-neutral-800">
<span className="text-xs text-neutral-400 font-mono">Header Script</span> <span className="text-xs text-neutral-400 font-mono">Header Script</span>
</div> </div>
<div className="p-4 overflow-x-auto"> <div className="p-4 overflow-x-auto">

View File

@@ -2,27 +2,45 @@
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'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { logger } from '@/lib/utils/logger'
import { getUserOrganizations, switchContext } from '@/lib/api/organization' import { getUserOrganizations, switchContext } from '@/lib/api/organization'
import { setSessionAction } from '@/app/actions/auth' import { setSessionAction } from '@/app/actions/auth'
import { LoadingOverlay } from '@ciphera-net/ui'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
const ORG_SWITCH_KEY = 'pulse_switching_org'
export default function LayoutContent({ children }: { children: React.ReactNode }) { export default function LayoutContent({ children }: { children: React.ReactNode }) {
const auth = useAuth() const auth = useAuth()
const router = useRouter() const router = useRouter()
const isOnline = useOnlineStatus() const isOnline = useOnlineStatus()
const [orgs, setOrgs] = useState<any[]>([]) const [orgs, setOrgs] = useState<any[]>([])
const [isSwitchingOrg, setIsSwitchingOrg] = useState(() => {
if (typeof window === 'undefined') return false
return sessionStorage.getItem(ORG_SWITCH_KEY) === 'true'
})
// * Clear the switching flag once the page has settled after reload
useEffect(() => {
if (isSwitchingOrg) {
sessionStorage.removeItem(ORG_SWITCH_KEY)
const timer = setTimeout(() => setIsSwitchingOrg(false), 600)
return () => clearTimeout(timer)
}
}, [isSwitchingOrg])
// * Fetch organizations for the header organization switcher // * Fetch organizations for the header organization switcher
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 => logger.error('Failed to fetch orgs for header', err))
} }
}, [auth.user]) }, [auth.user])
@@ -31,9 +49,10 @@ export default function LayoutContent({ children }: { children: React.ReactNode
try { try {
const { access_token } = await switchContext(orgId) const { access_token } = await switchContext(orgId)
await setSessionAction(access_token) await setSessionAction(access_token)
sessionStorage.setItem(ORG_SWITCH_KEY, 'true')
window.location.reload() window.location.reload()
} catch (err) { } catch (err) {
console.error('Failed to switch organization', err) logger.error('Failed to switch organization', err)
} }
} }
@@ -46,6 +65,10 @@ export default function LayoutContent({ children }: { children: React.ReactNode
const headerHeightRem = 6; const headerHeightRem = 6;
const mainTopPaddingRem = barHeightRem + headerHeightRem; const mainTopPaddingRem = barHeightRem + headerHeightRem;
if (isSwitchingOrg) {
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" portal={false} />
}
return ( return (
<> <>
{auth.user && <OfflineBanner isOnline={isOnline} />} {auth.user && <OfflineBanner isOnline={isOnline} />}
@@ -63,6 +86,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 */}

View File

@@ -0,0 +1,13 @@
'use client'
import ErrorDisplay from '@/components/ErrorDisplay'
export default function NotificationsError({ reset }: { error: Error; reset: () => void }) {
return (
<ErrorDisplay
title="Notifications failed to load"
message="We couldn't load your notifications. Please try again."
onRetry={reset}
/>
)
}

View File

@@ -0,0 +1,15 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Notifications | Pulse',
description: 'View your alerts and activity updates.',
robots: { index: false, follow: false },
}
export default function NotificationsLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}

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 } from '@ciphera-net/ui'
import { NotificationsListSkeleton, useMinimumLoading } from '@/components/skeletons'
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 showSkeleton = useMinimumLoading(loading)
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="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<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="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<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>
{showSkeleton ? (
<NotificationsListSkeleton />
) : error ? (
<div className="p-6 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-6 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

@@ -0,0 +1,13 @@
'use client'
import ErrorDisplay from '@/components/ErrorDisplay'
export default function OrgSettingsError({ reset }: { error: Error; reset: () => void }) {
return (
<ErrorDisplay
title="Organization settings failed to load"
message="We couldn't load your organization settings. Please try again."
onRetry={reset}
/>
)
}

View File

@@ -1,5 +1,6 @@
import { Suspense } from 'react' import { Suspense } from 'react'
import OrganizationSettings from '@/components/settings/OrganizationSettings' import OrganizationSettings from '@/components/settings/OrganizationSettings'
import { SettingsFormSkeleton } from '@/components/skeletons'
export const metadata = { export const metadata = {
title: 'Organization Settings - Pulse', title: 'Organization Settings - Pulse',
@@ -8,9 +9,19 @@ export const metadata = {
export default function OrgSettingsPage() { export default function OrgSettingsPage() {
return ( return (
<div className="min-h-screen pt-24 pb-12 px-4 sm:px-6"> <div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="max-w-4xl mx-auto"> <div>
<Suspense fallback={<div className="p-8 text-center text-neutral-500">Loading...</div>}> <Suspense fallback={
<div className="space-y-8">
<div>
<div className="h-8 w-56 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800 mb-2" />
<div className="h-4 w-80 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
</div>
<div className="bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-6 md:p-8">
<SettingsFormSkeleton />
</div>
</div>
}>
<OrganizationSettings /> <OrganizationSettings />
</Suspense> </Suspense>
</div> </div>

View File

@@ -6,39 +6,50 @@ import { motion } from 'framer-motion'
import { useAuth } from '@/lib/auth/context' import { useAuth } from '@/lib/auth/context'
import { initiateOAuthFlow, initiateSignupFlow } from '@/lib/api/oauth' import { initiateOAuthFlow, initiateSignupFlow } from '@/lib/api/oauth'
import { listSites, deleteSite, type Site } from '@/lib/api/sites' import { listSites, deleteSite, type Site } from '@/lib/api/sites'
import { getStats } from '@/lib/api/stats'
import type { Stats } from '@/lib/api/stats'
import { getSubscription, type SubscriptionDetails } from '@/lib/api/billing' import { getSubscription, type SubscriptionDetails } from '@/lib/api/billing'
import { LoadingOverlay } from '@ciphera-net/ui' import { LoadingOverlay } from '@ciphera-net/ui'
import SiteList from '@/components/sites/SiteList' import SiteList from '@/components/sites/SiteList'
import { Button } from '@ciphera-net/ui' import { Button } from '@ciphera-net/ui'
import Image from 'next/image'
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 (
<div className="relative w-full max-w-7xl mx-auto mt-20 mb-32 h-[600px] flex items-center justify-center"> <div className="relative w-full max-w-7xl mx-auto mt-20 mb-32">
{/* * Glow behind the image */}
<div className="absolute inset-0 bg-brand-orange/20 blur-[100px] -z-10 rounded-full opacity-50" /> <div className="absolute inset-0 bg-brand-orange/20 blur-[100px] -z-10 rounded-full opacity-50" />
{/* * Static Container */} <motion.div
<div initial={{ opacity: 0, y: 40 }}
className="relative w-full h-full rounded-xl border border-neutral-200/50 dark:border-neutral-800/50 bg-neutral-900/50 backdrop-blur-sm shadow-2xl overflow-hidden" animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.4 }}
className="relative rounded-xl border border-neutral-200/50 dark:border-neutral-800/50 shadow-2xl overflow-hidden"
> >
{/* * Header of the fake browser window */} {/* * Browser chrome */}
<div className="h-8 bg-neutral-800/50 border-b border-white/5 flex items-center px-4 gap-2"> <div className="h-8 bg-neutral-100 dark:bg-neutral-800/80 border-b border-neutral-200 dark:border-white/5 flex items-center px-4 gap-2">
<div className="w-3 h-3 rounded-full bg-red-500/50" /> <div className="w-3 h-3 rounded-full bg-red-400/60" />
<div className="w-3 h-3 rounded-full bg-yellow-500/50" /> <div className="w-3 h-3 rounded-full bg-yellow-400/60" />
<div className="w-3 h-3 rounded-full bg-green-500/50" /> <div className="w-3 h-3 rounded-full bg-green-400/60" />
<div className="ml-4 flex-1 max-w-xs h-5 rounded bg-neutral-200 dark:bg-neutral-700/50" />
</div> </div>
{/* * Placeholder for actual dashboard screenshot - replace src with real image later */} {/* * Screenshot with bottom fade */}
<div className="w-full h-[calc(100%-2rem)] bg-neutral-900 flex items-center justify-center text-neutral-700"> <div className="relative max-h-[900px] overflow-hidden">
<div className="text-center"> <Image
<BarChartIcon className="w-16 h-16 mx-auto mb-4 opacity-20" /> src="/dashboard-preview-v2.png"
<p>Dashboard Preview</p> alt="Pulse analytics dashboard showing visitor stats, charts, top pages, referrers, locations, and technology breakdown"
</div> width={1920}
</div> height={3000}
className="w-full h-auto object-cover object-top"
priority
/>
<div className="absolute inset-0 pointer-events-none bg-gradient-to-b from-transparent from-60% to-white dark:to-neutral-950" />
</div> </div>
</motion.div>
</div> </div>
) )
} }
@@ -96,10 +107,13 @@ function ComparisonSection() {
} }
type SiteStatsMap = Record<string, { stats: Stats }>
export default function HomePage() { export default function HomePage() {
const { user, loading: authLoading } = useAuth() const { user, loading: authLoading } = useAuth()
const [sites, setSites] = useState<Site[]>([]) const [sites, setSites] = useState<Site[]>([])
const [sitesLoading, setSitesLoading] = useState(true) const [sitesLoading, setSitesLoading] = useState(true)
const [siteStats, setSiteStats] = useState<SiteStatsMap>({})
const [subscription, setSubscription] = useState<SubscriptionDetails | null>(null) const [subscription, setSubscription] = useState<SubscriptionDetails | null>(null)
const [subscriptionLoading, setSubscriptionLoading] = useState(false) const [subscriptionLoading, setSubscriptionLoading] = useState(false)
const [showFinishSetupBanner, setShowFinishSetupBanner] = useState(true) const [showFinishSetupBanner, setShowFinishSetupBanner] = useState(true)
@@ -111,6 +125,37 @@ export default function HomePage() {
} }
}, [user]) }, [user])
useEffect(() => {
if (sites.length === 0) {
setSiteStats({})
return
}
let cancelled = false
const today = new Date().toISOString().split('T')[0]
const emptyStats: Stats = { pageviews: 0, visitors: 0, bounce_rate: 0, avg_duration: 0 }
const load = async () => {
const results = await Promise.allSettled(
sites.map(async (site) => {
const statsRes = await getStats(site.id, today, today)
return { siteId: site.id, stats: statsRes }
})
)
if (cancelled) return
const map: SiteStatsMap = {}
results.forEach((r, i) => {
const site = sites[i]
if (r.status === 'fulfilled') {
map[site.id] = { stats: r.value.stats }
} else {
map[site.id] = { stats: emptyStats }
}
})
setSiteStats(map)
}
load()
return () => { cancelled = true }
}, [sites])
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return if (typeof window === 'undefined') return
if (localStorage.getItem('pulse_welcome_completed') === 'true') setShowFinishSetupBanner(false) if (localStorage.getItem('pulse_welcome_completed') === 'true') setShowFinishSetupBanner(false)
@@ -132,8 +177,8 @@ export default function HomePage() {
setSitesLoading(true) setSitesLoading(true)
const data = await listSites() const data = await listSites()
setSites(Array.isArray(data) ? data : []) setSites(Array.isArray(data) ? data : [])
} catch (error: any) { } catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to load sites: ' + ((error as Error)?.message || 'Unknown error')) toast.error(getAuthErrorMessage(error) || 'Failed to load your sites')
setSites([]) setSites([])
} finally { } finally {
setSitesLoading(false) setSitesLoading(false)
@@ -161,8 +206,8 @@ export default function HomePage() {
await deleteSite(id) await deleteSite(id)
toast.success('Site deleted successfully') toast.success('Site deleted successfully')
loadSites() loadSites()
} catch (error: any) { } catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to delete site: ' + ((error as Error)?.message || 'Unknown error')) toast.error(getAuthErrorMessage(error) || 'Failed to delete site')
} }
} }
@@ -172,7 +217,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">
@@ -263,7 +308,7 @@ export default function HomePage() {
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ duration: 0.5, delay: i * 0.1 }} transition={{ duration: 0.5, delay: i * 0.1 }}
className="card-glass p-8 hover:-translate-y-1 hover:shadow-xl transition-all duration-300 group" className="card-glass p-6 hover:-translate-y-1 hover:shadow-xl transition-all duration-300 group"
> >
<div className="w-12 h-12 rounded-xl bg-brand-orange/10 flex items-center justify-center mb-6 text-brand-orange group-hover:scale-110 transition-transform duration-300"> <div className="w-12 h-12 rounded-xl bg-brand-orange/10 flex items-center justify-center mb-6 text-brand-orange group-hover:scale-110 transition-transform duration-300">
<feature.icon className="w-6 h-6" /> <feature.icon className="w-6 h-6" />
@@ -337,10 +382,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 +396,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
@@ -357,20 +406,29 @@ export default function HomePage() {
)} )}
</div> </div>
{/* * Global Overview */} {/* * Global Overview - min-h ensures no layout shift when Plan & usage loads */}
<div className="mb-8 grid grid-cols-1 gap-4 sm:grid-cols-3"> <div className="mb-8 grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="rounded-2xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900"> <div className="flex min-h-[160px] flex-col rounded-2xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
<p className="text-sm text-neutral-500 dark:text-neutral-400">Total Sites</p> <p className="text-sm text-neutral-500 dark:text-neutral-400">Total Sites</p>
<p className="text-2xl font-bold text-neutral-900 dark:text-white">{sites.length}</p> <p className="text-2xl font-bold text-neutral-900 dark:text-white">{sites.length}</p>
</div> </div>
<div className="rounded-2xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900"> <div className="flex min-h-[160px] flex-col rounded-2xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
<p className="text-sm text-neutral-500 dark:text-neutral-400">Total Visitors (24h)</p> <p className="text-sm text-neutral-500 dark:text-neutral-400">Total Visitors (24h)</p>
<p className="text-2xl font-bold text-neutral-900 dark:text-white">--</p> <p className="text-2xl font-bold text-neutral-900 dark:text-white">
{sites.length === 0 || Object.keys(siteStats).length < sites.length
? '--'
: Object.values(siteStats).reduce((sum, { stats }) => sum + (stats?.visitors ?? 0), 0).toLocaleString()}
</p>
</div> </div>
<div className="rounded-2xl border border-neutral-200 bg-brand-orange/10 p-4 dark:border-neutral-800"> <div className="flex min-h-[160px] flex-col rounded-2xl border border-neutral-200 bg-brand-orange/10 p-4 dark:border-neutral-800">
<p className="text-sm text-brand-orange">Plan & usage</p> <p className="text-sm text-brand-orange">Plan & usage</p>
{subscriptionLoading ? ( {subscriptionLoading ? (
<p className="text-lg font-bold text-brand-orange">...</p> <div className="animate-pulse space-y-2">
<div className="h-6 w-24 rounded bg-brand-orange/25 dark:bg-brand-orange/20" />
<div className="h-4 w-full rounded bg-brand-orange/25 dark:bg-brand-orange/20" />
<div className="h-4 w-3/4 rounded bg-brand-orange/25 dark:bg-brand-orange/20" />
<div className="h-4 w-20 rounded bg-brand-orange/25 dark:bg-brand-orange/20 pt-2" />
</div>
) : subscription ? ( ) : subscription ? (
<> <>
<p className="text-lg font-bold text-brand-orange"> <p className="text-lg font-bold text-brand-orange">
@@ -385,15 +443,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">
@@ -415,7 +492,7 @@ export default function HomePage() {
</div> </div>
{!sitesLoading && sites.length === 0 && ( {!sitesLoading && sites.length === 0 && (
<div className="mb-8 rounded-2xl border-2 border-dashed border-brand-orange/30 bg-brand-orange/5 p-8 text-center dark:bg-brand-orange/10"> <div className="mb-8 rounded-2xl border-2 border-dashed border-brand-orange/30 bg-brand-orange/5 p-6 text-center dark:bg-brand-orange/10">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-brand-orange/20 text-brand-orange mb-4"> <div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-brand-orange/20 text-brand-orange mb-4">
<GlobeIcon className="h-7 w-7" /> <GlobeIcon className="h-7 w-7" />
</div> </div>
@@ -432,7 +509,7 @@ export default function HomePage() {
)} )}
{(sitesLoading || sites.length > 0) && ( {(sitesLoading || sites.length > 0) && (
<SiteList sites={sites} loading={sitesLoading} onDelete={handleDelete} /> <SiteList sites={sites} siteStats={siteStats} loading={sitesLoading} onDelete={handleDelete} />
)} )}
</div> </div>
) )

View File

@@ -1,10 +1,30 @@
import { Suspense } from 'react' import { Suspense } from 'react'
import type { Metadata } from 'next'
import PricingSection from '@/components/PricingSection' import PricingSection from '@/components/PricingSection'
import { PricingCardsSkeleton } from '@/components/skeletons'
export const metadata: Metadata = {
title: 'Pricing | Pulse',
description: 'Simple, transparent pricing for privacy-first web analytics. Free tier included.',
openGraph: {
title: 'Pricing | Pulse',
description: 'Simple, transparent pricing for privacy-first web analytics. Free tier included.',
siteName: 'Pulse by Ciphera',
},
}
export default function PricingPage() { export default function PricingPage() {
return ( return (
<div className="min-h-screen pt-20"> <div className="min-h-screen pt-20">
<Suspense fallback={<div className="min-h-screen pt-20 flex items-center justify-center">Loading...</div>}> <Suspense fallback={
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-16">
<div className="text-center mb-12">
<div className="h-10 w-64 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800 mx-auto mb-4" />
<div className="h-5 w-96 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800 mx-auto" />
</div>
<PricingCardsSkeleton />
</div>
}>
<PricingSection /> <PricingSection />
</Suspense> </Suspense>
</div> </div>

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',
@@ -9,10 +7,7 @@ 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="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<Suspense fallback={null}>
<CheckoutSuccessToast />
</Suspense>
<ProfileSettings /> <ProfileSettings />
</div> </div>
) )

13
app/share/[id]/error.tsx Normal file
View File

@@ -0,0 +1,13 @@
'use client'
import ErrorDisplay from '@/components/ErrorDisplay'
export default function ShareError({ reset }: { error: Error; reset: () => void }) {
return (
<ErrorDisplay
title="Dashboard failed to load"
message="We couldn't load this public dashboard. It may be temporarily unavailable — try again."
onRetry={reset}
/>
)
}

73
app/share/[id]/layout.tsx Normal file
View File

@@ -0,0 +1,73 @@
import type { Metadata } from 'next'
import { FAVICON_SERVICE_URL } from '@/lib/utils/icons'
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8082'
interface SharePageParams {
params: Promise<{ id: string }>
}
export async function generateMetadata({ params }: SharePageParams): Promise<Metadata> {
const { id } = await params
const fallback: Metadata = {
title: 'Public Dashboard | Pulse',
description: 'Privacy-first web analytics — view this site\'s public stats.',
openGraph: {
title: 'Public Dashboard | Pulse',
description: 'Privacy-first web analytics — view this site\'s public stats.',
siteName: 'Pulse by Ciphera',
type: 'website',
},
twitter: {
card: 'summary',
title: 'Public Dashboard | Pulse',
description: 'Privacy-first web analytics — view this site\'s public stats.',
},
}
try {
const res = await fetch(`${API_URL}/public/sites/${id}/dashboard?limit=1`, {
next: { revalidate: 3600 },
})
if (!res.ok) return fallback
const data = await res.json()
const domain = data?.site?.domain
if (!domain) return fallback
const title = `${domain} analytics | Pulse`
const description = `Live, privacy-first analytics for ${domain} — powered by Pulse.`
return {
title,
description,
openGraph: {
title,
description,
siteName: 'Pulse by Ciphera',
type: 'website',
images: [{
url: `${FAVICON_SERVICE_URL}?domain=${domain}&sz=128`,
width: 128,
height: 128,
alt: `${domain} favicon`,
}],
},
twitter: {
card: 'summary',
title,
description,
},
}
} catch {
return fallback
}
}
export default function ShareLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}

View File

@@ -1,10 +1,12 @@
'use client' 'use client'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import Image from 'next/image'
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 { ApiError } from '@/lib/api/client'
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'
@@ -13,7 +15,9 @@ import Locations from '@/components/dashboard/Locations'
import TechSpecs from '@/components/dashboard/TechSpecs' import TechSpecs from '@/components/dashboard/TechSpecs'
import PerformanceStats from '@/components/dashboard/PerformanceStats' import PerformanceStats from '@/components/dashboard/PerformanceStats'
import { Select, DatePicker as DatePickerModal, Captcha, DownloadIcon, ZapIcon } from '@ciphera-net/ui' import { Select, DatePicker as DatePickerModal, Captcha, DownloadIcon, ZapIcon } from '@ciphera-net/ui'
import { DashboardSkeleton, useMinimumLoading } from '@/components/skeletons'
import ExportModal from '@/components/dashboard/ExportModal' import ExportModal from '@/components/dashboard/ExportModal'
import { FAVICON_SERVICE_URL } from '@/lib/utils/icons'
// Helper to get date ranges // Helper to get date ranges
const getDateRange = (days: number) => { const getDateRange = (days: number) => {
@@ -152,8 +156,9 @@ export default function PublicDashboardPage() {
setCaptchaId('') setCaptchaId('')
setCaptchaSolution('') setCaptchaSolution('')
setCaptchaToken('') setCaptchaToken('')
} catch (error: any) { } catch (error: unknown) {
if ((error.status === 401 || error.response?.status === 401) && (error.data?.is_protected || error.response?.data?.is_protected)) { const apiErr = error instanceof ApiError ? error : null
if (apiErr?.status === 401 && (apiErr.data as Record<string, unknown>)?.is_protected) {
setIsPasswordProtected(true) setIsPasswordProtected(true)
if (password) { if (password) {
toast.error('Invalid password or captcha') toast.error('Invalid password or captcha')
@@ -162,10 +167,10 @@ export default function PublicDashboardPage() {
setCaptchaSolution('') setCaptchaSolution('')
setCaptchaToken('') setCaptchaToken('')
} }
} else if (error.status === 404 || error.response?.status === 404) { } else if (apiErr?.status === 404) {
toast.error('Site not found') toast.error('Site not found')
} else if (!silent) { } else if (!silent) {
toast.error(getAuthErrorMessage(error) || 'Failed to load dashboard: ' + ((error as Error)?.message || 'Unknown error')) toast.error(getAuthErrorMessage(error) || 'Failed to load public dashboard')
} }
} finally { } finally {
if (!silent) setLoading(false) if (!silent) setLoading(false)
@@ -192,14 +197,16 @@ export default function PublicDashboardPage() {
loadDashboard() loadDashboard()
} }
if (loading && !data && !isPasswordProtected) { const showSkeleton = useMinimumLoading(loading && !data && !isPasswordProtected)
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />
if (showSkeleton) {
return <DashboardSkeleton />
} }
if (isPasswordProtected && !data) { if (isPasswordProtected && !data) {
return ( return (
<div className="min-h-screen flex items-center justify-center px-4"> <div className="min-h-screen flex items-center justify-center px-4">
<div className="max-w-md w-full bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-8 shadow-lg"> <div className="max-w-md w-full bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 shadow-lg transition-shadow duration-300">
<div className="text-center mb-6"> <div className="text-center mb-6">
<div className="w-12 h-12 bg-brand-orange/10 rounded-xl flex items-center justify-center mx-auto mb-4 text-brand-orange"> <div className="w-12 h-12 bg-brand-orange/10 rounded-xl flex items-center justify-center mx-auto mb-4 text-brand-orange">
<ZapIcon className="w-6 h-6" /> <ZapIcon className="w-6 h-6" />
@@ -279,13 +286,16 @@ export default function PublicDashboardPage() {
<span className="text-sm font-medium text-brand-orange uppercase tracking-wider">Public Dashboard</span> <span className="text-sm font-medium text-brand-orange uppercase tracking-wider">Public Dashboard</span>
</div> </div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white flex items-center gap-3"> <h1 className="text-2xl font-bold text-neutral-900 dark:text-white flex items-center gap-3">
<img <Image
src={`https://www.google.com/s2/favicons?domain=${site.domain}&sz=64`} src={`${FAVICON_SERVICE_URL}?domain=${site.domain}&sz=64`}
alt={site.name} alt={site.name}
width={32}
height={32}
className="w-8 h-8 rounded-lg" className="w-8 h-8 rounded-lg"
onError={(e) => { onError={(e) => {
(e.target as HTMLImageElement).src = '/globe.svg' (e.target as HTMLImageElement).src = '/globe.svg'
}} }}
unoptimized
/> />
{site.domain} {site.domain}
</h1> </h1>

13
app/sites/[id]/error.tsx Normal file
View File

@@ -0,0 +1,13 @@
'use client'
import ErrorDisplay from '@/components/ErrorDisplay'
export default function DashboardError({ reset }: { error: Error; reset: () => void }) {
return (
<ErrorDisplay
title="Dashboard failed to load"
message="We couldn't load your site analytics. This might be a temporary issue — try again."
onRetry={reset}
/>
)
}

View File

@@ -4,7 +4,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { useParams, useRouter } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import { ApiError } from '@/lib/api/client' import { ApiError } from '@/lib/api/client'
import { getFunnel, getFunnelStats, deleteFunnel, type Funnel, type FunnelStats } from '@/lib/api/funnels' import { getFunnel, getFunnelStats, deleteFunnel, type Funnel, type FunnelStats } from '@/lib/api/funnels'
import { toast, LoadingOverlay, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, useTheme, Button } from '@ciphera-net/ui' import { toast, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, useTheme, Button } from '@ciphera-net/ui'
import { FunnelDetailSkeleton, useMinimumLoading } from '@/components/skeletons'
import Link from 'next/link' import Link from 'next/link'
import { import {
BarChart, BarChart,
@@ -16,23 +17,23 @@ 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: 'var(--color-neutral-200)',
axis: '#A3A3A3', axis: 'var(--color-neutral-400)',
tooltipBg: '#ffffff', tooltipBg: '#ffffff',
tooltipBorder: '#E5E5E5', tooltipBorder: 'var(--color-neutral-200)',
} }
const CHART_COLORS_DARK = { const CHART_COLORS_DARK = {
border: '#404040', border: 'var(--color-neutral-700)',
axis: '#737373', axis: 'var(--color-neutral-500)',
tooltipBg: '#262626', tooltipBg: 'var(--color-neutral-800)',
tooltipBorder: '#404040', tooltipBorder: 'var(--color-neutral-700)',
} }
const BRAND_ORANGE = '#FD5E0F' const BRAND_ORANGE = 'var(--color-brand-orange)'
export default function FunnelReportPage() { export default function FunnelReportPage() {
const params = useParams() const params = useParams()
@@ -63,7 +64,7 @@ export default function FunnelReportPage() {
if (status === 404) setLoadError('not_found') if (status === 404) setLoadError('not_found')
else if (status === 403) setLoadError('forbidden') else if (status === 403) setLoadError('forbidden')
else setLoadError('error') else setLoadError('error')
if (status !== 404 && status !== 403) toast.error('Failed to load funnel data') if (status !== 404 && status !== 403) toast.error('Failed to load funnel details')
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -91,8 +92,10 @@ export default function FunnelReportPage() {
} }
} }
if (loading && !funnel) { const showSkeleton = useMinimumLoading(loading && !funnel)
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />
if (showSkeleton) {
return <FunnelDetailSkeleton />
} }
if (loadError === 'not_found' || (!funnel && !stats && !loadError)) { if (loadError === 'not_found' || (!funnel && !stats && !loadError)) {
@@ -225,7 +228,7 @@ export default function FunnelReportPage() {
const data = payload[0].payload; const data = payload[0].payload;
return ( return (
<div <div
className="p-3 rounded-xl shadow-lg border" className="p-3 rounded-xl shadow-lg border transition-shadow duration-300"
style={{ style={{
backgroundColor: chartColors.tooltipBg, backgroundColor: chartColors.tooltipBg,
borderColor: chartColors.tooltipBorder, borderColor: chartColors.tooltipBorder,
@@ -267,10 +270,10 @@ export default function FunnelReportPage() {
<table className="w-full text-left text-sm"> <table className="w-full text-left text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-800/50 border-b border-neutral-200 dark:border-neutral-800"> <thead className="bg-neutral-50 dark:bg-neutral-800/50 border-b border-neutral-200 dark:border-neutral-800">
<tr> <tr>
<th className="px-6 py-4 font-medium text-neutral-500 uppercase tracking-wider">Step</th> <th className="px-6 py-4 font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Step</th>
<th className="px-6 py-4 font-medium text-neutral-500 uppercase tracking-wider text-right">Visitors</th> <th className="px-6 py-4 font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider text-right">Visitors</th>
<th className="px-6 py-4 font-medium text-neutral-500 uppercase tracking-wider text-right">Drop-off</th> <th className="px-6 py-4 font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider text-right">Drop-off</th>
<th className="px-6 py-4 font-medium text-neutral-500 uppercase tracking-wider text-right">Conversion</th> <th className="px-6 py-4 font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider text-right">Conversion</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800"> <tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
@@ -283,7 +286,7 @@ export default function FunnelReportPage() {
</span> </span>
<div> <div>
<p className="font-medium text-neutral-900 dark:text-white">{step.step.name}</p> <p className="font-medium text-neutral-900 dark:text-white">{step.step.name}</p>
<p className="text-neutral-500 text-xs font-mono mt-0.5">{step.step.value}</p> <p className="text-neutral-500 dark:text-neutral-400 text-xs font-mono mt-0.5">{step.step.value}</p>
</div> </div>
</div> </div>
</td> </td>

View File

@@ -0,0 +1,13 @@
'use client'
import ErrorDisplay from '@/components/ErrorDisplay'
export default function FunnelsError({ reset }: { error: Error; reset: () => void }) {
return (
<ErrorDisplay
title="Funnels failed to load"
message="We couldn't load your funnels. This might be a temporary issue — try again."
onRetry={reset}
/>
)
}

View File

@@ -0,0 +1,15 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Funnels | Pulse',
description: 'Track conversion funnels and user journeys.',
robots: { index: false, follow: false },
}
export default function FunnelsLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}

View File

@@ -84,7 +84,7 @@ export default function CreateFunnelPage() {
toast.success('Funnel created') toast.success('Funnel created')
router.push(`/sites/${siteId}/funnels`) router.push(`/sites/${siteId}/funnels`)
} catch (error) { } catch (error) {
toast.error('Failed to create funnel') toast.error('Failed to create funnel. Please try again.')
} finally { } finally {
setSaving(false) setSaving(false)
} }
@@ -120,8 +120,13 @@ export default function CreateFunnelPage() {
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="e.g. Signup Flow" placeholder="e.g. Signup Flow"
autoFocus
required required
maxLength={100}
/> />
{name.length > 80 && (
<span className={`text-xs tabular-nums mt-1 ${name.length > 90 ? 'text-amber-500' : 'text-neutral-400'}`}>{name.length}/100</span>
)}
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1"> <label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">

View File

@@ -3,7 +3,8 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import { listFunnels, deleteFunnel, type Funnel } from '@/lib/api/funnels' import { listFunnels, deleteFunnel, type Funnel } from '@/lib/api/funnels'
import { toast, LoadingOverlay, PlusIcon, ArrowRightIcon, ChevronLeftIcon, TrashIcon, Button } from '@ciphera-net/ui' import { toast, PlusIcon, ArrowRightIcon, ChevronLeftIcon, TrashIcon, Button } from '@ciphera-net/ui'
import { FunnelsListSkeleton, useMinimumLoading } from '@/components/skeletons'
import Link from 'next/link' import Link from 'next/link'
export default function FunnelsPage() { export default function FunnelsPage() {
@@ -20,7 +21,7 @@ export default function FunnelsPage() {
const data = await listFunnels(siteId) const data = await listFunnels(siteId)
setFunnels(data) setFunnels(data)
} catch (error) { } catch (error) {
toast.error('Failed to load funnels') toast.error('Failed to load your funnels')
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -43,8 +44,10 @@ export default function FunnelsPage() {
} }
} }
if (loading) { const showSkeleton = useMinimumLoading(loading)
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />
if (showSkeleton) {
return <FunnelsListSkeleton />
} }
return ( return (

15
app/sites/[id]/layout.tsx Normal file
View File

@@ -0,0 +1,15 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Dashboard | Pulse',
description: 'View your site analytics, traffic, and performance.',
robots: { index: false, follow: false },
}
export default function SiteLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}

View File

@@ -1,16 +1,18 @@
'use client' 'use client'
import { useAuth } from '@/lib/auth/context' import { useAuth } from '@/lib/auth/context'
import { logger } from '@/lib/utils/logger'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation' 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 { DashboardSkeleton, useMinimumLoading } from '@/components/skeletons'
import ExportModal from '@/components/dashboard/ExportModal' import ExportModal from '@/components/dashboard/ExportModal'
import ContentStats from '@/components/dashboard/ContentStats' import ContentStats from '@/components/dashboard/ContentStats'
import TopReferrers from '@/components/dashboard/TopReferrers' import TopReferrers from '@/components/dashboard/TopReferrers'
@@ -84,7 +86,7 @@ export default function SiteDashboardPage() {
if (settings.multiDayInterval) setMultiDayInterval(settings.multiDayInterval) if (settings.multiDayInterval) setMultiDayInterval(settings.multiDayInterval)
} }
} catch (e) { } catch (e) {
console.error('Failed to load dashboard settings', e) logger.error('Failed to load dashboard settings', e)
} finally { } finally {
setIsSettingsLoaded(true) setIsSettingsLoaded(true)
} }
@@ -102,7 +104,7 @@ export default function SiteDashboardPage() {
} }
localStorage.setItem('pulse_dashboard_settings', JSON.stringify(settings)) localStorage.setItem('pulse_dashboard_settings', JSON.stringify(settings))
} catch (e) { } catch (e) {
console.error('Failed to save dashboard settings', e) logger.error('Failed to save dashboard settings', e)
} }
} }
@@ -190,7 +192,7 @@ export default function SiteDashboardPage() {
setLastUpdatedAt(Date.now()) setLastUpdatedAt(Date.now())
} catch (error: unknown) { } catch (error: unknown) {
if (!silent) { if (!silent) {
toast.error(getAuthErrorMessage(error) || 'Failed to load data: ' + ((error as Error)?.message || 'Unknown error')) toast.error(getAuthErrorMessage(error) || 'Failed to load dashboard analytics')
} }
} finally { } finally {
if (!silent) setLoading(false) if (!silent) setLoading(false)
@@ -215,13 +217,19 @@ export default function SiteDashboardPage() {
return () => clearInterval(interval) return () => clearInterval(interval)
}, [siteId, dateRange, todayInterval, multiDayInterval, isSettingsLoaded, loadData, loadRealtime]) }, [siteId, dateRange, todayInterval, multiDayInterval, isSettingsLoaded, loadData, loadRealtime])
if (loading) { useEffect(() => {
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" /> if (site?.domain) document.title = `${site.domain} | Pulse`
}, [site?.domain])
const showSkeleton = useMinimumLoading(loading)
if (showSkeleton) {
return <DashboardSkeleton />
} }
if (!site) { if (!site) {
return ( return (
<div className="container mx-auto px-4 py-8"> <div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<p className="text-neutral-600 dark:text-neutral-400">Site not found</p> <p className="text-neutral-600 dark:text-neutral-400">Site not found</p>
</div> </div>
) )

View File

@@ -0,0 +1,13 @@
'use client'
import ErrorDisplay from '@/components/ErrorDisplay'
export default function RealtimeError({ reset }: { error: Error; reset: () => void }) {
return (
<ErrorDisplay
title="Realtime view failed to load"
message="We couldn't connect to the realtime data stream. Please try again."
onRetry={reset}
/>
)
}

View File

@@ -0,0 +1,15 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Realtime | Pulse',
description: 'See who is on your site right now.',
robots: { index: false, follow: false },
}
export default function RealtimeLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}

View File

@@ -5,8 +5,9 @@ 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 { UserIcon } from '@ciphera-net/ui'
import { RealtimeSkeleton, SessionEventsSkeleton, useMinimumLoading } from '@/components/skeletons'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
function formatTimeAgo(dateString: string) { function formatTimeAgo(dateString: string) {
@@ -47,7 +48,7 @@ export default function RealtimePage() {
handleSelectVisitor(visitorsData[0]) handleSelectVisitor(visitorsData[0])
} }
} catch (error: unknown) { } catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to load data') toast.error(getAuthErrorMessage(error) || 'Failed to load realtime visitors')
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -84,13 +85,19 @@ export default function RealtimePage() {
const events = await getSessionDetails(siteId, visitor.session_id) const events = await getSessionDetails(siteId, visitor.session_id)
setSessionEvents(events || []) setSessionEvents(events || [])
} catch (error: unknown) { } catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to load session details') toast.error(getAuthErrorMessage(error) || 'Failed to load session events')
} finally { } finally {
setLoadingEvents(false) setLoadingEvents(false)
} }
} }
if (loading) return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Realtime" /> useEffect(() => {
if (site?.domain) document.title = `Realtime · ${site.domain} | Pulse`
}, [site?.domain])
const showSkeleton = useMinimumLoading(loading)
if (showSkeleton) return <RealtimeSkeleton />
if (!site) return <div className="p-8">Site not found</div> if (!site) return <div className="p-8">Site not found</div>
return ( return (
@@ -197,9 +204,7 @@ export default function RealtimePage() {
Select a visitor on the left to see their activity. Select a visitor on the left to see their activity.
</div> </div>
) : loadingEvents ? ( ) : loadingEvents ? (
<div className="h-full flex items-center justify-center"> <SessionEventsSkeleton />
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-neutral-900 dark:border-white"></div>
</div>
) : ( ) : (
<div className="relative pl-6 border-l-2 border-neutral-100 dark:border-neutral-800 space-y-8"> <div className="relative pl-6 border-l-2 border-neutral-100 dark:border-neutral-800 space-y-8">
{sessionEvents.map((event, idx) => ( {sessionEvents.map((event, idx) => (

View File

@@ -0,0 +1,13 @@
'use client'
import ErrorDisplay from '@/components/ErrorDisplay'
export default function SiteSettingsError({ reset }: { error: Error; reset: () => void }) {
return (
<ErrorDisplay
title="Settings failed to load"
message="We couldn't load your site settings. Please try again."
onRetry={reset}
/>
)
}

View File

@@ -0,0 +1,15 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Site Settings | Pulse',
description: 'Configure your site tracking, privacy, and goals.',
robots: { index: false, follow: false },
}
export default function SiteSettingsLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}

View File

@@ -1,18 +1,21 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState, useRef } from 'react'
import { useParams, useRouter } from 'next/navigation' 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 { SettingsFormSkeleton, GoalsListSkeleton, useMinimumLoading } from '@/components/skeletons'
import VerificationModal from '@/components/sites/VerificationModal' import VerificationModal from '@/components/sites/VerificationModal'
import ScriptSetupBlock from '@/components/sites/ScriptSetupBlock' import ScriptSetupBlock from '@/components/sites/ScriptSetupBlock'
import { PasswordInput } from '@ciphera-net/ui' import { PasswordInput } from '@ciphera-net/ui'
import { Select, Modal, Button } from '@ciphera-net/ui' import { Select, Modal, Button } from '@ciphera-net/ui'
import { APP_URL } from '@/lib/api/client' import { APP_URL } from '@/lib/api/client'
import { generatePrivacySnippet } from '@/lib/utils/privacySnippet' import { generatePrivacySnippet } from '@/lib/utils/privacySnippet'
import { useUnsavedChanges } from '@/lib/hooks/useUnsavedChanges'
import { getSubscription, type SubscriptionDetails } from '@/lib/api/billing'
import { getRetentionOptionsForPlan, formatRetentionMonths } from '@/lib/plans'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import { useAuth } from '@/lib/auth/context' import { useAuth } from '@/lib/auth/context'
import { import {
@@ -68,8 +71,12 @@ export default function SiteSettingsPage() {
// Performance insights setting // Performance insights setting
enable_performance_insights: false, enable_performance_insights: false,
// Bot and noise filtering // Bot and noise filtering
filter_bots: true filter_bots: true,
// Data retention (6 = free-tier max; safe default)
data_retention_months: 6
}) })
const [subscription, setSubscription] = useState<SubscriptionDetails | null>(null)
const [subscriptionLoadFailed, setSubscriptionLoadFailed] = useState(false)
const [linkCopied, setLinkCopied] = useState(false) const [linkCopied, setLinkCopied] = useState(false)
const [snippetCopied, setSnippetCopied] = useState(false) const [snippetCopied, setSnippetCopied] = useState(false)
const [showVerificationModal, setShowVerificationModal] = useState(false) const [showVerificationModal, setShowVerificationModal] = useState(false)
@@ -80,9 +87,11 @@ export default function SiteSettingsPage() {
const [editingGoal, setEditingGoal] = useState<Goal | null>(null) const [editingGoal, setEditingGoal] = useState<Goal | null>(null)
const [goalForm, setGoalForm] = useState({ name: '', event_name: '' }) const [goalForm, setGoalForm] = useState({ name: '', event_name: '' })
const [goalSaving, setGoalSaving] = useState(false) const [goalSaving, setGoalSaving] = useState(false)
const initialFormRef = useRef<string>('')
useEffect(() => { useEffect(() => {
loadSite() loadSite()
loadSubscription()
}, [siteId]) }, [siteId])
useEffect(() => { useEffect(() => {
@@ -91,6 +100,30 @@ export default function SiteSettingsPage() {
} }
}, [activeTab, siteId]) }, [activeTab, siteId])
const loadSubscription = async () => {
try {
setSubscriptionLoadFailed(false)
const sub = await getSubscription()
setSubscription(sub)
} catch (e) {
setSubscriptionLoadFailed(true)
toast.error(getAuthErrorMessage(e as Error) || 'Could not load plan limits. Showing default options.')
}
}
// * Snap data_retention_months to nearest valid option when subscription loads
useEffect(() => {
if (!subscription) return
const opts = getRetentionOptionsForPlan(subscription.plan_id)
const values = opts.map(o => o.value)
const maxVal = Math.max(...values)
setFormData(prev => {
if (values.includes(prev.data_retention_months)) return prev
const bestFit = values.filter(v => v <= prev.data_retention_months).pop() ?? maxVal
return { ...prev, data_retention_months: Math.min(bestFit, maxVal) }
})
}, [subscription])
const loadSite = async () => { const loadSite = async () => {
try { try {
setLoading(true) setLoading(true)
@@ -111,15 +144,31 @@ export default function SiteSettingsPage() {
// Performance insights setting (default to false) // Performance insights setting (default to false)
enable_performance_insights: data.enable_performance_insights ?? false, enable_performance_insights: data.enable_performance_insights ?? false,
// Bot and noise filtering (default to true) // Bot and noise filtering (default to true)
filter_bots: data.filter_bots ?? true filter_bots: data.filter_bots ?? true,
// Data retention (default 6 = free-tier max; avoids flash-then-clamp for existing sites)
data_retention_months: data.data_retention_months ?? 6
})
initialFormRef.current = JSON.stringify({
name: data.name,
timezone: data.timezone || 'UTC',
is_public: data.is_public || false,
excluded_paths: (data.excluded_paths || []).join('\n'),
collect_page_paths: data.collect_page_paths ?? true,
collect_referrers: data.collect_referrers ?? true,
collect_device_info: data.collect_device_info ?? true,
collect_geo_data: data.collect_geo_data || 'full',
collect_screen_resolution: data.collect_screen_resolution ?? true,
enable_performance_insights: data.enable_performance_insights ?? false,
filter_bots: data.filter_bots ?? true,
data_retention_months: data.data_retention_months ?? 6
}) })
if (data.has_password) { if (data.has_password) {
setIsPasswordEnabled(true) setIsPasswordEnabled(true)
} else { } else {
setIsPasswordEnabled(false) setIsPasswordEnabled(false)
} }
} catch (error: any) { } catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to load site: ' + ((error as Error)?.message || 'Unknown error')) toast.error(getAuthErrorMessage(error) || 'Failed to load site settings')
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -226,12 +275,28 @@ export default function SiteSettingsPage() {
// Performance insights setting // Performance insights setting
enable_performance_insights: formData.enable_performance_insights, enable_performance_insights: formData.enable_performance_insights,
// Bot and noise filtering // Bot and noise filtering
filter_bots: formData.filter_bots filter_bots: formData.filter_bots,
// Data retention
data_retention_months: formData.data_retention_months
}) })
toast.success('Site updated successfully') toast.success('Site updated successfully')
initialFormRef.current = JSON.stringify({
name: formData.name,
timezone: formData.timezone,
is_public: formData.is_public,
excluded_paths: formData.excluded_paths,
collect_page_paths: formData.collect_page_paths,
collect_referrers: formData.collect_referrers,
collect_device_info: formData.collect_device_info,
collect_geo_data: formData.collect_geo_data,
collect_screen_resolution: formData.collect_screen_resolution,
enable_performance_insights: formData.enable_performance_insights,
filter_bots: formData.filter_bots,
data_retention_months: formData.data_retention_months
})
loadSite() loadSite()
} catch (error: any) { } catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to update site: ' + ((error as Error)?.message || 'Unknown error')) toast.error(getAuthErrorMessage(error) || 'Failed to save site settings')
} finally { } finally {
setSaving(false) setSaving(false)
} }
@@ -245,8 +310,8 @@ export default function SiteSettingsPage() {
try { try {
await resetSiteData(siteId) await resetSiteData(siteId)
toast.success('All site data has been reset') toast.success('All site data has been reset')
} catch (error: any) { } catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to reset data: ' + ((error as Error)?.message || 'Unknown error')) toast.error(getAuthErrorMessage(error) || 'Failed to reset site data')
} }
} }
@@ -261,8 +326,8 @@ export default function SiteSettingsPage() {
await deleteSite(siteId) await deleteSite(siteId)
toast.success('Site deleted successfully') toast.success('Site deleted successfully')
router.push('/') router.push('/')
} catch (error: any) { } catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to delete site: ' + ((error as Error)?.message || 'Unknown error')) toast.error(getAuthErrorMessage(error) || 'Failed to delete site')
} }
} }
@@ -282,21 +347,63 @@ export default function SiteSettingsPage() {
setTimeout(() => setSnippetCopied(false), 2000) setTimeout(() => setSnippetCopied(false), 2000)
} }
if (loading) { const isFormDirty = initialFormRef.current !== '' && JSON.stringify({
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" /> name: formData.name,
timezone: formData.timezone,
is_public: formData.is_public,
excluded_paths: formData.excluded_paths,
collect_page_paths: formData.collect_page_paths,
collect_referrers: formData.collect_referrers,
collect_device_info: formData.collect_device_info,
collect_geo_data: formData.collect_geo_data,
collect_screen_resolution: formData.collect_screen_resolution,
enable_performance_insights: formData.enable_performance_insights,
filter_bots: formData.filter_bots,
data_retention_months: formData.data_retention_months
}) !== initialFormRef.current
useUnsavedChanges(isFormDirty)
useEffect(() => {
if (site?.domain) document.title = `Settings · ${site.domain} | Pulse`
}, [site?.domain])
const showSkeleton = useMinimumLoading(loading)
if (showSkeleton) {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="space-y-8">
<div>
<div className="h-8 w-40 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800 mb-2" />
<div className="h-4 w-64 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
</div>
<div className="flex flex-col md:flex-row gap-8">
<nav className="w-full md:w-64 flex-shrink-0 space-y-1">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-12 animate-pulse rounded-xl bg-neutral-100 dark:bg-neutral-800" />
))}
</nav>
<div className="flex-1 bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-6 md:p-8">
<SettingsFormSkeleton />
</div>
</div>
</div>
</div>
)
} }
if (!site) { if (!site) {
return ( return (
<div className="container mx-auto px-4 py-8"> <div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<p className="text-neutral-600 dark:text-neutral-400">Site not found</p> <p className="text-neutral-600 dark:text-neutral-400">Site not found</p>
</div> </div>
) )
} }
return ( return (
<div className="min-h-screen pt-12 pb-12 px-4 sm:px-6"> <div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="max-w-4xl mx-auto space-y-8"> <div className="space-y-8">
<div> <div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">Site Settings</h1> <h1 className="text-2xl font-bold text-neutral-900 dark:text-white">Site Settings</h1>
<p className="mt-2 text-neutral-600 dark:text-neutral-400"> <p className="mt-2 text-neutral-600 dark:text-neutral-400">
@@ -394,11 +501,15 @@ export default function SiteSettingsPage() {
type="text" type="text"
id="name" id="name"
required required
maxLength={100}
value={formData.name} value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-neutral-50/50 dark:bg-neutral-900/50 focus:bg-white dark:focus:bg-neutral-900 className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-neutral-50/50 dark:bg-neutral-900/50 focus:bg-white dark:focus:bg-neutral-900
focus:border-brand-orange focus:ring-4 focus:ring-brand-orange/10 outline-none transition-all duration-200 dark:text-white" focus:border-brand-orange focus:ring-4 focus:ring-brand-orange/10 outline-none transition-all duration-200 dark:text-white"
/> />
{formData.name.length > 80 && (
<span className={`text-xs tabular-nums ${formData.name.length > 90 ? 'text-amber-500' : 'text-neutral-400'}`}>{formData.name.length}/100</span>
)}
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
@@ -452,7 +563,7 @@ export default function SiteSettingsPage() {
<ZapIcon className="w-4 h-4" /> <ZapIcon className="w-4 h-4" />
Verify Installation Verify Installation
</button> </button>
<p className="text-xs text-neutral-500 dark:text-neutral-500"> <p className="text-xs text-neutral-500 dark:text-neutral-400">
Check if your site is sending data correctly. Check if your site is sending data correctly.
</p> </p>
</div> </div>
@@ -460,21 +571,9 @@ export default function SiteSettingsPage() {
<div className="pt-4 border-t border-neutral-100 dark:border-neutral-800 flex justify-end"> <div className="pt-4 border-t border-neutral-100 dark:border-neutral-800 flex justify-end">
{canEdit && ( {canEdit && (
<button <Button type="submit" disabled={saving} isLoading={saving}>
type="submit"
disabled={saving}
className="flex items-center gap-2 px-6 py-2.5 bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 rounded-xl font-medium
hover:bg-neutral-800 dark:hover:bg-neutral-100 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
>
{saving ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
<CheckIcon className="w-4 h-4" />
Save Changes Save Changes
</> </Button>
)}
</button>
)} )}
</div> </div>
</form> </form>
@@ -526,7 +625,7 @@ export default function SiteSettingsPage() {
<p className="text-sm text-neutral-500 dark:text-neutral-400">Manage who can view your dashboard.</p> <p className="text-sm text-neutral-500 dark:text-neutral-400">Manage who can view your dashboard.</p>
</div> </div>
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800"> <div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-2 bg-white dark:bg-neutral-800 rounded-lg text-neutral-400"> <div className="p-2 bg-white dark:bg-neutral-800 rounded-lg text-neutral-400">
@@ -578,7 +677,7 @@ export default function SiteSettingsPage() {
{linkCopied ? 'Copied!' : 'Copy Link'} {linkCopied ? 'Copied!' : 'Copy Link'}
</button> </button>
</div> </div>
<p className="mt-2 text-xs text-neutral-500"> <p className="mt-2 text-xs text-neutral-500 dark:text-neutral-400">
Share this link with others to view the dashboard. Share this link with others to view the dashboard.
</p> </p>
</div> </div>
@@ -617,7 +716,7 @@ export default function SiteSettingsPage() {
onChange={(e) => setFormData({ ...formData, password: e.target.value })} onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder={site.has_password ? "Change password (leave empty to keep current)" : "Set a password"} placeholder={site.has_password ? "Change password (leave empty to keep current)" : "Set a password"}
/> />
<p className="mt-2 text-xs text-neutral-500"> <p className="mt-2 text-xs text-neutral-500 dark:text-neutral-400">
Visitors will need to enter this password to view the dashboard. Visitors will need to enter this password to view the dashboard.
</p> </p>
</motion.div> </motion.div>
@@ -631,21 +730,9 @@ export default function SiteSettingsPage() {
<div className="pt-4 border-t border-neutral-100 dark:border-neutral-800 flex justify-end"> <div className="pt-4 border-t border-neutral-100 dark:border-neutral-800 flex justify-end">
{canEdit && ( {canEdit && (
<button <Button type="submit" disabled={saving} isLoading={saving}>
type="submit"
disabled={saving}
className="flex items-center gap-2 px-6 py-2.5 bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 rounded-xl font-medium
hover:bg-neutral-800 dark:hover:bg-neutral-100 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
>
{saving ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
<CheckIcon className="w-4 h-4" />
Save Changes Save Changes
</> </Button>
)}
</button>
)} )}
</div> </div>
</form> </form>
@@ -665,7 +752,7 @@ export default function SiteSettingsPage() {
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Data Collection</h3> <h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Data Collection</h3>
{/* Page Paths Toggle */} {/* Page Paths Toggle */}
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800"> <div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h4 className="font-medium text-neutral-900 dark:text-white">Page Paths</h4> <h4 className="font-medium text-neutral-900 dark:text-white">Page Paths</h4>
@@ -686,7 +773,7 @@ export default function SiteSettingsPage() {
</div> </div>
{/* Referrers Toggle */} {/* Referrers Toggle */}
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800"> <div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h4 className="font-medium text-neutral-900 dark:text-white">Referrers</h4> <h4 className="font-medium text-neutral-900 dark:text-white">Referrers</h4>
@@ -707,7 +794,7 @@ export default function SiteSettingsPage() {
</div> </div>
{/* Device Info Toggle */} {/* Device Info Toggle */}
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800"> <div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h4 className="font-medium text-neutral-900 dark:text-white">Device Info</h4> <h4 className="font-medium text-neutral-900 dark:text-white">Device Info</h4>
@@ -728,7 +815,7 @@ export default function SiteSettingsPage() {
</div> </div>
{/* Geographic Data Dropdown */} {/* Geographic Data Dropdown */}
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800"> <div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h4 className="font-medium text-neutral-900 dark:text-white">Geographic Data</h4> <h4 className="font-medium text-neutral-900 dark:text-white">Geographic Data</h4>
@@ -752,7 +839,7 @@ export default function SiteSettingsPage() {
</div> </div>
{/* Screen Resolution Toggle */} {/* Screen Resolution Toggle */}
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800"> <div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h4 className="font-medium text-neutral-900 dark:text-white">Screen Resolution</h4> <h4 className="font-medium text-neutral-900 dark:text-white">Screen Resolution</h4>
@@ -776,7 +863,7 @@ export default function SiteSettingsPage() {
{/* Bot and noise filtering */} {/* Bot and noise filtering */}
<div className="space-y-4 pt-6 border-t border-neutral-100 dark:border-neutral-800"> <div className="space-y-4 pt-6 border-t border-neutral-100 dark:border-neutral-800">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Filtering</h3> <h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Filtering</h3>
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800"> <div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h4 className="font-medium text-neutral-900 dark:text-white">Filter bots and referrer spam</h4> <h4 className="font-medium text-neutral-900 dark:text-white">Filter bots and referrer spam</h4>
@@ -800,7 +887,7 @@ export default function SiteSettingsPage() {
{/* Performance Insights Toggle */} {/* Performance Insights Toggle */}
<div className="space-y-4 pt-6 border-t border-neutral-100 dark:border-neutral-800"> <div className="space-y-4 pt-6 border-t border-neutral-100 dark:border-neutral-800">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Performance Insights</h3> <h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Performance Insights</h3>
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800"> <div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h4 className="font-medium text-neutral-900 dark:text-white">Performance Insights (Add-on)</h4> <h4 className="font-medium text-neutral-900 dark:text-white">Performance Insights (Add-on)</h4>
@@ -821,6 +908,58 @@ export default function SiteSettingsPage() {
</div> </div>
</div> </div>
{/* Data Retention */}
<div className="space-y-4 pt-6 border-t border-neutral-100 dark:border-neutral-800">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Data Retention</h3>
{subscriptionLoadFailed && (
<div className="p-3 rounded-xl border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 flex items-center justify-between gap-3">
<p className="text-sm text-amber-800 dark:text-amber-200">
Plan limits could not be loaded. Options shown may be limited.
</p>
<button
type="button"
onClick={loadSubscription}
className="shrink-0 text-sm font-medium text-amber-800 dark:text-amber-200 hover:underline"
>
Retry
</button>
</div>
)}
<div className="p-6 bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-100 dark:border-neutral-800">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium text-neutral-900 dark:text-white">Keep raw event data for</h4>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
Events older than this are automatically deleted. Aggregated daily stats are kept permanently.
</p>
</div>
<Select
value={String(formData.data_retention_months)}
onChange={(v) => setFormData({ ...formData, data_retention_months: Number(v) })}
options={getRetentionOptionsForPlan(subscription?.plan_id).map(opt => ({
value: String(opt.value),
label: opt.label,
}))}
variant="input"
align="right"
className="min-w-[160px]"
/>
</div>
{subscription?.plan_id && subscription.plan_id !== 'free' && (
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-3">
Your {subscription.plan_id} plan supports up to {formatRetentionMonths(
getRetentionOptionsForPlan(subscription.plan_id).at(-1)?.value ?? 6
)} of data retention.
</p>
)}
{(!subscription?.plan_id || subscription.plan_id === 'free') && (
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-3">
Free plan supports up to 6 months. <a href="/pricing" className="text-brand-orange hover:underline">Upgrade</a> for longer retention.
</p>
)}
</div>
</div>
{/* Excluded Paths */} {/* Excluded Paths */}
<div className="space-y-4 pt-6 border-t border-neutral-100 dark:border-neutral-800"> <div className="space-y-4 pt-6 border-t border-neutral-100 dark:border-neutral-800">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Path Filtering</h3> <h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">Path Filtering</h3>
@@ -878,7 +1017,7 @@ export default function SiteSettingsPage() {
{snippetCopied ? ( {snippetCopied ? (
<CheckIcon className="w-4 h-4 text-green-600" /> <CheckIcon className="w-4 h-4 text-green-600" />
) : ( ) : (
<svg className="w-4 h-4 text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="w-4 h-4 text-neutral-500 dark:text-neutral-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> <rect x="9" y="9" width="13" height="13" rx="2" ry="2" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg> </svg>
@@ -889,21 +1028,9 @@ export default function SiteSettingsPage() {
<div className="pt-4 border-t border-neutral-100 dark:border-neutral-800 flex justify-end"> <div className="pt-4 border-t border-neutral-100 dark:border-neutral-800 flex justify-end">
{canEdit && ( {canEdit && (
<button <Button type="submit" disabled={saving} isLoading={saving}>
type="submit"
disabled={saving}
className="flex items-center gap-2 px-6 py-2.5 bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 rounded-xl font-medium
hover:bg-neutral-800 dark:hover:bg-neutral-100 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
>
{saving ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
<CheckIcon className="w-4 h-4" />
Save Changes Save Changes
</> </Button>
)}
</button>
)} )}
</div> </div>
</form> </form>
@@ -919,7 +1046,7 @@ export default function SiteSettingsPage() {
</p> </p>
</div> </div>
{goalsLoading ? ( {goalsLoading ? (
<div className="py-8 text-center text-neutral-500 dark:text-neutral-400">Loading goals</div> <GoalsListSkeleton />
) : ( ) : (
<> <>
{canEdit && ( {canEdit && (
@@ -986,6 +1113,7 @@ export default function SiteSettingsPage() {
value={goalForm.name} value={goalForm.name}
onChange={(e) => setGoalForm({ ...goalForm, name: e.target.value })} onChange={(e) => setGoalForm({ ...goalForm, name: e.target.value })}
placeholder="e.g. Signups" placeholder="e.g. Signups"
autoFocus
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white" className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white"
required required
/> />
@@ -997,10 +1125,14 @@ export default function SiteSettingsPage() {
value={goalForm.event_name} value={goalForm.event_name}
onChange={(e) => setGoalForm({ ...goalForm, event_name: e.target.value })} onChange={(e) => setGoalForm({ ...goalForm, event_name: e.target.value })}
placeholder="e.g. signup_click (letters, numbers, underscores only)" placeholder="e.g. signup_click (letters, numbers, underscores only)"
maxLength={64}
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white" className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white"
required required
/> />
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">Only letters, numbers, and underscores; spaces become underscores. Invalid characters cannot be used. Max 64 characters after formatting.</p> <div className="flex justify-between mt-1">
<p className="text-xs text-neutral-500 dark:text-neutral-400">Letters, numbers, and underscores only. Spaces become underscores.</p>
<span className={`text-xs tabular-nums ${goalForm.event_name.length > 56 ? 'text-amber-500' : 'text-neutral-400'}`}>{goalForm.event_name.length}/64</span>
</div>
{editingGoal && goalForm.event_name.trim().toLowerCase().replace(/\s+/g, '_') !== editingGoal.event_name && ( {editingGoal && goalForm.event_name.trim().toLowerCase().replace(/\s+/g, '_') !== editingGoal.event_name && (
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">Changing event name does not reassign events already tracked under the previous name.</p> <p className="mt-2 text-xs text-amber-600 dark:text-amber-400">Changing event name does not reassign events already tracked under the previous name.</p>
)} )}

View File

@@ -0,0 +1,13 @@
'use client'
import ErrorDisplay from '@/components/ErrorDisplay'
export default function UptimeError({ reset }: { error: Error; reset: () => void }) {
return (
<ErrorDisplay
title="Uptime page failed to load"
message="We couldn't load your uptime monitors. This might be a temporary issue — try again."
onRetry={reset}
/>
)
}

View File

@@ -0,0 +1,15 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Uptime | Pulse',
description: 'Monitor your site uptime and response times.',
robots: { index: false, follow: false },
}
export default function UptimeLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}

View File

@@ -19,8 +19,9 @@ 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 { Button, Modal } from '@ciphera-net/ui'
import { UptimeSkeleton, ChecksSkeleton, useMinimumLoading } from '@/components/skeletons'
import { import {
AreaChart, AreaChart,
Area, Area,
@@ -34,20 +35,20 @@ import type { TooltipProps } from 'recharts'
// * Chart theme colors (consistent with main Pulse chart) // * Chart theme colors (consistent with main Pulse chart)
const CHART_COLORS_LIGHT = { const CHART_COLORS_LIGHT = {
border: '#E5E5E5', border: 'var(--color-neutral-200)',
text: '#171717', text: 'var(--color-neutral-900)',
textMuted: '#737373', textMuted: 'var(--color-neutral-500)',
axis: '#A3A3A3', axis: 'var(--color-neutral-400)',
tooltipBg: '#ffffff', tooltipBg: '#ffffff',
tooltipBorder: '#E5E5E5', tooltipBorder: 'var(--color-neutral-200)',
} }
const CHART_COLORS_DARK = { const CHART_COLORS_DARK = {
border: '#404040', border: 'var(--color-neutral-700)',
text: '#fafafa', text: 'var(--color-neutral-50)',
textMuted: '#a3a3a3', textMuted: 'var(--color-neutral-400)',
axis: '#737373', axis: 'var(--color-neutral-500)',
tooltipBg: '#262626', tooltipBg: 'var(--color-neutral-800)',
tooltipBorder: '#404040', tooltipBorder: 'var(--color-neutral-700)',
} }
// * Status color mapping // * Status color mapping
@@ -189,7 +190,7 @@ function StatusBarTooltip({
className="fixed z-50 pointer-events-none" className="fixed z-50 pointer-events-none"
style={{ left: position.x, top: position.y - 10, transform: 'translate(-50%, -100%)' }} style={{ left: position.x, top: position.y - 10, transform: 'translate(-50%, -100%)' }}
> >
<div className="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-lg px-3 py-2.5 text-xs min-w-[160px]"> <div className="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-lg transition-shadow duration-300 px-3 py-2.5 text-xs min-w-40">
<div className="font-semibold text-neutral-900 dark:text-white mb-1.5">{formattedDate}</div> <div className="font-semibold text-neutral-900 dark:text-white mb-1.5">{formattedDate}</div>
{stat && stat.total_checks > 0 ? ( {stat && stat.total_checks > 0 ? (
<div className="space-y-1"> <div className="space-y-1">
@@ -256,7 +257,7 @@ function UptimeStatusBar({
className="relative" className="relative"
onMouseLeave={() => setHoveredDay(null)} onMouseLeave={() => setHoveredDay(null)}
> >
<div className="flex items-center gap-[2px] w-full"> <div className="flex items-center gap-0.5 w-full">
{dateRange.map((date) => { {dateRange.map((date) => {
const stat = statsMap.get(date) const stat = statsMap.get(date)
const barColor = getDayBarColor(stat) const barColor = getDayBarColor(stat)
@@ -264,7 +265,7 @@ function UptimeStatusBar({
return ( return (
<div <div
key={date} key={date}
className={`flex-1 h-8 rounded-[2px] ${barColor} transition-all duration-150 hover:opacity-80 cursor-pointer min-w-[3px]`} className={`flex-1 h-8 rounded-sm ${barColor} transition-all duration-150 hover:opacity-80 cursor-pointer min-w-[3px]`}
onMouseEnter={(e) => handleMouseEnter(e, date, stat)} onMouseEnter={(e) => handleMouseEnter(e, date, stat)}
onMouseLeave={() => setHoveredDay(null)} onMouseLeave={() => setHoveredDay(null)}
/> />
@@ -283,8 +284,8 @@ function UptimeStatusBar({
// * Component: Response time chart (Recharts area chart) // * Component: Response time chart (Recharts area chart)
function ResponseTimeChart({ checks }: { checks: UptimeCheck[] }) { function ResponseTimeChart({ checks }: { checks: UptimeCheck[] }) {
const { theme } = useTheme() const { resolvedTheme } = useTheme()
const colors = theme === 'dark' ? CHART_COLORS_DARK : CHART_COLORS_LIGHT const colors = resolvedTheme === 'dark' ? CHART_COLORS_DARK : CHART_COLORS_LIGHT
// * Prepare data in chronological order (oldest first) // * Prepare data in chronological order (oldest first)
const data = [...checks] const data = [...checks]
@@ -305,7 +306,7 @@ function ResponseTimeChart({ checks }: { checks: UptimeCheck[] }) {
if (!active || !payload?.length) return null if (!active || !payload?.length) return null
return ( return (
<div <div
className="rounded-xl px-3 py-2 text-xs shadow-lg border" className="rounded-xl px-3 py-2 text-xs shadow-lg border transition-shadow duration-300"
style={{ style={{
background: colors.tooltipBg, background: colors.tooltipBg,
borderColor: colors.tooltipBorder, borderColor: colors.tooltipBorder,
@@ -313,7 +314,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 +331,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 +358,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>
@@ -473,7 +474,7 @@ function MonitorCard({
<div className="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-1"> <div className="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-1">
Status Status
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${getStatusDotColor(monitor.last_status)}`} /> <div className={`w-2 h-2 rounded-full ${getStatusDotColor(monitor.last_status)}`} />
<span className="text-sm font-medium text-neutral-900 dark:text-white"> <span className="text-sm font-medium text-neutral-900 dark:text-white">
{getStatusLabel(monitor.last_status)} {getStatusLabel(monitor.last_status)}
@@ -510,9 +511,7 @@ function MonitorCard({
{/* Response time chart */} {/* Response time chart */}
{loadingChecks ? ( {loadingChecks ? (
<div className="text-center py-4 text-neutral-500 dark:text-neutral-400 text-sm"> <ChecksSkeleton />
Loading checks...
</div>
) : checks.length > 0 ? ( ) : checks.length > 0 ? (
<> <>
<ResponseTimeChart checks={checks} /> <ResponseTimeChart checks={checks} />
@@ -616,7 +615,7 @@ export default function UptimePage() {
setSite(siteData) setSite(siteData)
setUptimeData(statusData) setUptimeData(statusData)
} catch (error: unknown) { } catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to load uptime data') toast.error(getAuthErrorMessage(error) || 'Failed to load uptime monitors')
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -704,7 +703,13 @@ export default function UptimePage() {
setShowEditModal(true) setShowEditModal(true)
} }
if (loading) return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Uptime" /> useEffect(() => {
if (site?.domain) document.title = `Uptime · ${site.domain} | Pulse`
}, [site?.domain])
const showSkeleton = useMinimumLoading(loading)
if (showSkeleton) return <UptimeSkeleton />
if (!site) return <div className="p-8 text-neutral-500">Site not found</div> if (!site) return <div className="p-8 text-neutral-500">Site not found</div>
const monitors = Array.isArray(uptimeData?.monitors) ? uptimeData.monitors : [] const monitors = Array.isArray(uptimeData?.monitors) ? uptimeData.monitors : []
@@ -932,8 +937,13 @@ function MonitorForm({
value={formData.name} value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g. API, Website, CDN" placeholder="e.g. API, Website, CDN"
autoFocus
maxLength={100}
className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:border-transparent text-sm" className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:border-transparent text-sm"
/> />
{formData.name.length > 80 && (
<span className={`text-xs tabular-nums mt-1 ${formData.name.length > 90 ? 'text-amber-500' : 'text-neutral-400'}`}>{formData.name.length}/100</span>
)}
</div> </div>
{/* URL with protocol dropdown + domain prefix */} {/* URL with protocol dropdown + domain prefix */}
@@ -955,7 +965,7 @@ function MonitorForm({
</svg> </svg>
</button> </button>
{showProtocolDropdown && ( {showProtocolDropdown && (
<div className="absolute top-full left-0 mt-1 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-lg z-10 min-w-[100px]"> <div className="absolute top-full left-0 mt-1 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-lg transition-shadow duration-300 z-10 min-w-[100px]">
<button <button
type="button" type="button"
onClick={() => handleProtocolChange('https://')} onClick={() => handleProtocolChange('https://')}

View File

@@ -1,13 +1,15 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { logger } from '@/lib/utils/logger'
import { useRouter } from 'next/navigation' 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,13 +59,14 @@ 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) {
console.error('Failed to check limits', error) logger.error('Failed to check limits', error)
} finally { } finally {
setLimitsChecked(true) setLimitsChecked(true)
} }
@@ -85,7 +88,7 @@ export default function NewSitePage() {
sessionStorage.setItem(LAST_CREATED_SITE_KEY, JSON.stringify({ id: site.id })) sessionStorage.setItem(LAST_CREATED_SITE_KEY, JSON.stringify({ id: site.id }))
} }
} catch (error: unknown) { } catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || 'Failed to create site: ' + ((error as Error)?.message || 'Unknown error')) toast.error(getAuthErrorMessage(error) || 'Failed to create site. Please try again.')
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -104,8 +107,8 @@ export default function NewSitePage() {
// * Step 2: Framework picker + script (same as /welcome after adding first site) // * Step 2: Framework picker + script (same as /welcome after adding first site)
if (createdSite) { if (createdSite) {
return ( return (
<div className="container mx-auto px-4 py-8 max-w-2xl"> <div className="w-full max-w-2xl mx-auto px-4 sm:px-6 py-8">
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-8"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<div className="text-center"> <div className="text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-green-500/10 text-green-600 dark:text-green-400 mb-6"> <div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-green-500/10 text-green-600 dark:text-green-400 mb-6">
<CheckCircleIcon className="h-7 w-7" /> <CheckCircleIcon className="h-7 w-7" />
@@ -150,10 +153,10 @@ export default function NewSitePage() {
</div> </div>
<div className="mt-8 flex flex-col sm:flex-row gap-3 justify-center"> <div className="mt-8 flex flex-col sm:flex-row gap-3 justify-center">
<Button variant="primary" onClick={goToDashboard} className="min-w-[160px]"> <Button variant="primary" onClick={goToDashboard} className="min-w-40">
Back to dashboard Back to dashboard
</Button> </Button>
<Button variant="secondary" onClick={() => router.push(`/sites/${createdSite.id}`)} className="min-w-[160px]"> <Button variant="secondary" onClick={() => router.push(`/sites/${createdSite.id}`)} className="min-w-40">
View {createdSite.name} View {createdSite.name}
</Button> </Button>
</div> </div>
@@ -170,7 +173,7 @@ export default function NewSitePage() {
// * Step 1: Name & domain form // * Step 1: Name & domain form
return ( return (
<div className="container mx-auto px-4 py-8 max-w-2xl"> <div className="w-full max-w-2xl mx-auto px-4 sm:px-6 py-8">
<h1 className="text-2xl font-bold mb-8 text-neutral-900 dark:text-white"> <h1 className="text-2xl font-bold mb-8 text-neutral-900 dark:text-white">
Create New Site Create New Site
</h1> </h1>
@@ -189,6 +192,8 @@ export default function NewSitePage() {
<Input <Input
id="name" id="name"
required required
autoFocus
maxLength={100}
value={formData.name} value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="My Website" placeholder="My Website"
@@ -202,6 +207,7 @@ export default function NewSitePage() {
<Input <Input
id="domain" id="domain"
required required
maxLength={253}
value={formData.domain} value={formData.domain}
onChange={(e) => setFormData({ ...formData, domain: e.target.value.toLowerCase().trim() })} onChange={(e) => setFormData({ ...formData, domain: e.target.value.toLowerCase().trim() })}
placeholder="example.com" placeholder="example.com"

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,
@@ -162,7 +162,7 @@ function WelcomeContent() {
setStep(3) setStep(3)
} }
} catch (err) { } catch (err) {
toast.error(getAuthErrorMessage(err) || 'Failed to switch organization') toast.error(getAuthErrorMessage(err) || 'Failed to switch workspace')
} finally { } finally {
setSwitchingOrgId(null) setSwitchingOrgId(null)
} }
@@ -332,13 +332,13 @@ function WelcomeContent() {
} }
const cardClass = const cardClass =
'bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl shadow-sm p-8 max-w-lg mx-auto' 'bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl shadow-sm p-6 max-w-lg mx-auto'
return ( return (
<div className="flex-1 flex flex-col items-center justify-center bg-neutral-50 dark:bg-neutral-950 px-4 py-12"> <div className="flex-1 flex flex-col items-center justify-center bg-neutral-50 dark:bg-neutral-950 px-4 py-12">
<div className="w-full max-w-lg"> <div className="w-full max-w-lg">
<div <div
className="flex justify-center gap-1.5 mb-8" className="flex justify-center gap-2 mb-8"
role="progressbar" role="progressbar"
aria-valuenow={step} aria-valuenow={step}
aria-valuemin={1} aria-valuemin={1}
@@ -475,7 +475,7 @@ function WelcomeContent() {
<button <button
type="button" type="button"
onClick={() => setStep(1)} onClick={() => setStep(1)}
className="flex items-center gap-1.5 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus:outline-none focus:ring-2 focus:ring-brand-orange rounded" className="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus:outline-none focus:ring-2 focus:ring-brand-orange rounded"
aria-label="Back to welcome" aria-label="Back to welcome"
> >
<ArrowLeftIcon className="h-4 w-4" /> <ArrowLeftIcon className="h-4 w-4" />
@@ -485,7 +485,7 @@ function WelcomeContent() {
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-brand-orange/10 text-brand-orange mb-4"> <div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-brand-orange/10 text-brand-orange mb-4">
<BarChartIcon className="h-7 w-7" /> <BarChartIcon className="h-7 w-7" />
</div> </div>
<h1 className="text-xl font-bold text-neutral-900 dark:text-white"> <h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
Name your organization Name your organization
</h1> </h1>
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400"> <p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
@@ -546,7 +546,7 @@ function WelcomeContent() {
<button <button
type="button" type="button"
onClick={() => setStep(2)} onClick={() => setStep(2)}
className="flex items-center gap-1.5 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus:outline-none focus:ring-2 focus:ring-brand-orange rounded" className="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus:outline-none focus:ring-2 focus:ring-brand-orange rounded"
aria-label="Back to organization" aria-label="Back to organization"
> >
<ArrowLeftIcon className="h-4 w-4" /> <ArrowLeftIcon className="h-4 w-4" />
@@ -556,7 +556,7 @@ function WelcomeContent() {
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-green-500/10 text-green-600 dark:text-green-400 mb-4"> <div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-green-500/10 text-green-600 dark:text-green-400 mb-4">
<CheckCircleIcon className="h-7 w-7" /> <CheckCircleIcon className="h-7 w-7" />
</div> </div>
<h1 className="text-xl font-bold text-neutral-900 dark:text-white"> <h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
{showPendingCheckoutInStep3 ? 'Complete your plan' : "You're on the free plan"} {showPendingCheckoutInStep3 ? 'Complete your plan' : "You're on the free plan"}
</h1> </h1>
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400"> <p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
@@ -631,7 +631,7 @@ function WelcomeContent() {
<button <button
type="button" type="button"
onClick={() => setStep(3)} onClick={() => setStep(3)}
className="flex items-center gap-1.5 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus:outline-none focus:ring-2 focus:ring-brand-orange rounded" className="flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 mb-6 focus:outline-none focus:ring-2 focus:ring-brand-orange rounded"
aria-label="Back to plan" aria-label="Back to plan"
> >
<ArrowLeftIcon className="h-4 w-4" /> <ArrowLeftIcon className="h-4 w-4" />
@@ -641,7 +641,7 @@ function WelcomeContent() {
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-brand-orange/10 text-brand-orange mb-4"> <div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-brand-orange/10 text-brand-orange mb-4">
<GlobeIcon className="h-7 w-7" /> <GlobeIcon className="h-7 w-7" />
</div> </div>
<h1 className="text-xl font-bold text-neutral-900 dark:text-white"> <h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
Add your first site Add your first site
</h1> </h1>
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400"> <p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
@@ -659,6 +659,7 @@ function WelcomeContent() {
placeholder="My Website" placeholder="My Website"
value={siteName} value={siteName}
onChange={(e) => setSiteName(e.target.value)} onChange={(e) => setSiteName(e.target.value)}
maxLength={100}
className="w-full" className="w-full"
/> />
</div> </div>
@@ -672,6 +673,7 @@ function WelcomeContent() {
placeholder="example.com" placeholder="example.com"
value={siteDomain} value={siteDomain}
onChange={(e) => setSiteDomain(e.target.value.toLowerCase().trim())} onChange={(e) => setSiteDomain(e.target.value.toLowerCase().trim())}
maxLength={253}
className="w-full" className="w-full"
/> />
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400"> <p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
@@ -759,11 +761,11 @@ function WelcomeContent() {
)} )}
<div className="mt-8 flex flex-col sm:flex-row gap-3 justify-center"> <div className="mt-8 flex flex-col sm:flex-row gap-3 justify-center">
<Button variant="primary" onClick={goToDashboard} className="min-w-[160px]"> <Button variant="primary" onClick={goToDashboard} className="min-w-40">
Go to dashboard Go to dashboard
</Button> </Button>
{createdSite && ( {createdSite && (
<Button variant="secondary" onClick={goToSite} className="min-w-[160px]"> <Button variant="secondary" onClick={goToSite} className="min-w-40">
View {createdSite.name} View {createdSite.name}
</Button> </Button>
)} )}

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

@@ -0,0 +1,63 @@
'use client'
import { Button } from '@ciphera-net/ui'
interface ErrorDisplayProps {
title?: string
message?: string
onRetry?: () => void
onGoHome?: boolean
}
/**
* Shared error UI for route-level error.tsx boundaries.
* Matches the visual style of the 404 page.
*/
export default function ErrorDisplay({
title = 'Something went wrong',
message = 'An unexpected error occurred. Please try again or go back to the dashboard.',
onRetry,
onGoHome = true,
}: ErrorDisplayProps) {
return (
<div className="relative min-h-[80vh] flex flex-col items-center justify-center overflow-hidden">
<div className="absolute inset-0 -z-10 pointer-events-none">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[500px] bg-red-500/10 rounded-full blur-[128px] opacity-60" />
<div
className="absolute inset-0 bg-grid-pattern opacity-[0.02] dark:opacity-[0.05]"
style={{ maskImage: 'radial-gradient(ellipse at center, black 0%, transparent 70%)' }}
/>
</div>
<div className="text-center px-4 z-10">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-red-100 dark:bg-red-900/30 mb-6">
<svg className="w-8 h-8 text-red-500" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-4">
{title}
</h2>
<p className="text-lg text-neutral-600 dark:text-neutral-400 max-w-md mx-auto mb-10 leading-relaxed">
{message}
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
{onRetry && (
<Button variant="primary" onClick={onRetry} className="px-8 py-3">
Try again
</Button>
)}
{onGoHome && (
<a href="/">
<Button variant="secondary" className="px-8 py-3">
Go to dashboard
</Button>
</a>
)}
</div>
</div>
</div>
)
}

View File

@@ -2,11 +2,10 @@
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?: React.ElementType
appName?: string appName?: string
isAuthenticated?: boolean isAuthenticated?: boolean
} }
@@ -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.
@@ -96,7 +95,7 @@ export function Footer({ LinkComponent = Link, appName = 'Pulse', isAuthenticate
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4 leading-relaxed"> <p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4 leading-relaxed">
Simple analytics for privacy-conscious apps. Simple analytics for privacy-conscious apps.
</p> </p>
<div className="inline-flex items-center gap-2.5 text-sm text-neutral-600 dark:text-neutral-400 mb-4"> <div className="inline-flex items-center gap-3 text-sm text-neutral-600 dark:text-neutral-400 mb-4">
<span className="flex items-center justify-center w-8 h-8 rounded-lg bg-neutral-100 dark:bg-neutral-800 shrink-0 overflow-hidden ring-1 ring-neutral-200 dark:ring-neutral-700" aria-hidden> <span className="flex items-center justify-center w-8 h-8 rounded-lg bg-neutral-100 dark:bg-neutral-800 shrink-0 overflow-hidden ring-1 ring-neutral-200 dark:ring-neutral-700" aria-hidden>
<SwissFlagIcon className="w-5 h-5" /> <SwissFlagIcon className="w-5 h-5" />
</span> </span>

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" />
@@ -47,7 +47,7 @@ export function IntegrationGuide({ integration, children }: IntegrationGuideProp
/> />
</div> </div>
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-12 pb-10 z-10"> <div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-20 pb-10 z-10">
<Link <Link
href="/integrations" href="/integrations"
className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors" className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors"

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

@@ -6,7 +6,7 @@ export function OfflineBanner({ isOnline }: { isOnline: boolean }) {
if (isOnline) return null; if (isOnline) return null;
return ( return (
<div className="fixed top-0 left-0 right-0 z-[100] rounded-b-xl bg-yellow-500/15 dark:bg-yellow-500/25 border-b border-yellow-500/30 dark:border-yellow-500/40 text-yellow-700 dark:text-yellow-300 px-4 sm:px-8 py-2.5 text-sm flex items-center justify-center gap-2 font-medium shadow-md"> <div className="fixed top-0 left-0 right-0 z-[100] rounded-b-xl bg-yellow-500/15 dark:bg-yellow-500/25 border-b border-yellow-500/30 dark:border-yellow-500/40 text-yellow-700 dark:text-yellow-300 px-4 sm:px-8 py-2.5 text-sm flex items-center justify-center gap-2 font-medium shadow-md transition-shadow duration-300">
<FiWifiOff className="w-4 h-4 shrink-0" /> <FiWifiOff className="w-4 h-4 shrink-0" />
<span>You are currently offline. Changes may not be saved.</span> <span>You are currently offline. Changes may not be saved.</span>
</div> </div>

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { logger } from '@/lib/utils/logger'
import { useSearchParams } from 'next/navigation' import { useSearchParams } from 'next/navigation'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { Button, CheckCircleIcon } from '@ciphera-net/ui' import { Button, CheckCircleIcon } from '@ciphera-net/ui'
@@ -140,7 +141,7 @@ export default function PricingSection() {
// Clear intent // Clear intent
localStorage.removeItem('pulse_pending_checkout') localStorage.removeItem('pulse_pending_checkout')
} catch (e) { } catch (e) {
console.error('Failed to parse pending checkout', e) logger.error('Failed to parse pending checkout', e)
localStorage.removeItem('pulse_pending_checkout') localStorage.removeItem('pulse_pending_checkout')
} }
} }
@@ -150,8 +151,7 @@ export default function PricingSection() {
// Helper to get all price details // Helper to get all price details
const getPriceDetails = (planId: string) => { const getPriceDetails = (planId: string) => {
// @ts-ignore const basePrice = currentTraffic.prices[planId as keyof typeof currentTraffic.prices]
const basePrice = currentTraffic.prices[planId]
// Handle "Custom" // Handle "Custom"
if (basePrice === null || basePrice === undefined) return null if (basePrice === null || basePrice === undefined) return null
@@ -203,9 +203,9 @@ export default function PricingSection() {
throw new Error('No checkout URL returned') throw new Error('No checkout URL returned')
} }
} catch (error: any) { } catch (error: unknown) {
console.error('Checkout error:', error) logger.error('Checkout error:', error)
toast.error('Failed to start checkout. Please try again.') toast.error('Failed to start checkout — please try again')
} finally { } finally {
setLoadingPlan(null) setLoadingPlan(null)
} }
@@ -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,13 +232,13 @@ 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 dark:text-neutral-400 mb-4">
<span>10k</span> <span>10k</span>
<span className="text-brand-orange font-bold text-lg"> <span className="text-brand-orange font-bold text-lg">
Up to {currentTraffic.label} monthly pageviews Up to {currentTraffic.label} monthly pageviews
@@ -252,18 +252,22 @@ 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>
<div className="flex flex-col items-end gap-2 shrink-0"> <div className="flex flex-col items-end gap-2 shrink-0">
<span className="text-[10px] text-neutral-500 dark:text-neutral-400 font-medium uppercase tracking-wide"> <span className="text-xs 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" />
@@ -304,7 +310,7 @@ export default function PricingSection() {
<div className="mb-8"> <div className="mb-8">
<h3 className="text-lg font-bold text-neutral-900 dark:text-white mb-2">{plan.name}</h3> <h3 className="text-lg font-bold text-neutral-900 dark:text-white mb-2">{plan.name}</h3>
<p className="text-sm text-neutral-500 min-h-[40px] mb-4">{plan.description}</p> <p className="text-sm text-neutral-500 dark:text-neutral-400 min-h-[40px] mb-4">{plan.description}</p>
{priceDetails ? ( {priceDetails ? (
isYearly ? ( isYearly ? (
@@ -313,7 +319,7 @@ export default function PricingSection() {
<span className="text-4xl font-bold text-neutral-900 dark:text-white"> <span className="text-4xl font-bold text-neutral-900 dark:text-white">
{priceDetails.yearlyTotal} {priceDetails.yearlyTotal}
</span> </span>
<span className="text-neutral-500 font-medium">/year</span> <span className="text-neutral-500 dark:text-neutral-400 font-medium">/year</span>
</div> </div>
<div className="flex items-center gap-2 mt-2 text-sm font-medium"> <div className="flex items-center gap-2 mt-2 text-sm font-medium">
<span className="text-neutral-400 line-through decoration-neutral-400"> <span className="text-neutral-400 line-through decoration-neutral-400">
@@ -329,7 +335,7 @@ export default function PricingSection() {
<span className="text-4xl font-bold text-neutral-900 dark:text-white"> <span className="text-4xl font-bold text-neutral-900 dark:text-white">
{priceDetails.baseMonthly} {priceDetails.baseMonthly}
</span> </span>
<span className="text-neutral-500 font-medium">/mo</span> <span className="text-neutral-500 dark:text-neutral-400 font-medium">/mo</span>
</div> </div>
) )
) : ( ) : (
@@ -361,16 +367,20 @@ 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 dark:text-neutral-400 min-h-[40px] mb-4">For high volume sites and custom needs</p>
<div className="text-4xl font-bold text-neutral-900 dark:text-white"> <div className="text-4xl font-bold text-neutral-900 dark:text-white">
Custom Custom
</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"> <Button
variant="secondary"
className="w-full mb-8"
onClick={() => { window.location.href = 'mailto:business@ciphera.net?subject=Enterprise%20Plan%20Inquiry' }}
>
Contact us Contact us
</Button> </Button>

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

@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
import { PlusIcon, PersonIcon, CubeIcon, CheckIcon } from '@radix-ui/react-icons' import { PlusIcon, PersonIcon, CubeIcon, CheckIcon } from '@radix-ui/react-icons'
import { switchContext, OrganizationMember } from '@/lib/api/organization' import { switchContext, OrganizationMember } from '@/lib/api/organization'
import { setSessionAction } from '@/app/actions/auth' import { setSessionAction } from '@/app/actions/auth'
import { logger } from '@/lib/utils/logger'
import Link from 'next/link' import Link from 'next/link'
export default function OrganizationSwitcher({ orgs, activeOrgId }: { orgs: OrganizationMember[], activeOrgId: string | null }) { export default function OrganizationSwitcher({ orgs, activeOrgId }: { orgs: OrganizationMember[], activeOrgId: string | null }) {
@@ -12,7 +13,6 @@ export default function OrganizationSwitcher({ orgs, activeOrgId }: { orgs: Orga
const [switching, setSwitching] = useState<string | null>(null) const [switching, setSwitching] = useState<string | null>(null)
const handleSwitch = async (orgId: string | null) => { const handleSwitch = async (orgId: string | null) => {
console.log('Switching to organization:', orgId)
setSwitching(orgId || 'personal') setSwitching(orgId || 'personal')
try { try {
// * If orgId is null, we can't switch context via API in the same way if strict mode is on // * If orgId is null, we can't switch context via API in the same way if strict mode is on
@@ -34,18 +34,18 @@ export default function OrganizationSwitcher({ orgs, activeOrgId }: { orgs: Orga
// * Note: switchContext only returns access_token, we keep existing refresh token // * Note: switchContext only returns access_token, we keep existing refresh token
await setSessionAction(access_token) await setSessionAction(access_token)
// Force reload to pick up new permissions sessionStorage.setItem('pulse_switching_org', 'true')
window.location.reload() window.location.reload()
} catch (err) { } catch (err) {
console.error('Failed to switch organization', err) logger.error('Failed to switch organization', err)
setSwitching(null) setSwitching(null)
} }
} }
return ( return (
<div className="border-b border-neutral-100 dark:border-neutral-800 pb-2 mb-2"> <div className="border-b border-neutral-100 dark:border-neutral-800 pb-2 mb-2" role="group" aria-label="Organizations">
<div className="px-3 py-2 text-xs font-medium text-neutral-500 uppercase tracking-wider"> <div className="px-3 py-2 text-xs font-medium text-neutral-500 uppercase tracking-wider" aria-hidden="true">
Organizations Organizations
</div> </div>
@@ -75,21 +75,28 @@ export default function OrganizationSwitcher({ orgs, activeOrgId }: { orgs: Orga
<button <button
key={org.organization_id} key={org.organization_id}
onClick={() => handleSwitch(org.organization_id)} onClick={() => handleSwitch(org.organization_id)}
aria-current={activeOrgId === org.organization_id ? 'true' : undefined}
aria-busy={switching === org.organization_id ? 'true' : undefined}
className={`w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg transition-colors mt-1 ${ className={`w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg transition-colors mt-1 ${
activeOrgId === org.organization_id ? 'bg-neutral-100 dark:bg-neutral-800' : 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50' activeOrgId === org.organization_id ? 'bg-neutral-100 dark:bg-neutral-800' : 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50'
}`} }`}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-5 w-5 rounded bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center"> <div className="h-5 w-5 rounded bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<CubeIcon className="h-3 w-3 text-blue-600 dark:text-blue-400" /> <CubeIcon className="h-3 w-3 text-blue-600 dark:text-blue-400" aria-hidden="true" />
</div> </div>
<span className="text-neutral-700 dark:text-neutral-300 truncate max-w-[140px]"> <span className="text-neutral-700 dark:text-neutral-300 truncate max-w-[140px]">
{org.organization_name} {org.organization_name}
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{switching === org.organization_id && <span className="text-xs text-neutral-400">Loading...</span>} {switching === org.organization_id && <span className="text-xs text-neutral-400" aria-live="polite">Switching</span>}
{activeOrgId === org.organization_id && !switching && <CheckIcon className="h-4 w-4 text-neutral-600 dark:text-neutral-400" />} {activeOrgId === org.organization_id && !switching && (
<>
<CheckIcon className="h-4 w-4 text-neutral-600 dark:text-neutral-400" aria-hidden="true" />
<span className="sr-only">(current)</span>
</>
)}
</div> </div>
</button> </button>
))} ))}
@@ -99,7 +106,7 @@ export default function OrganizationSwitcher({ orgs, activeOrgId }: { orgs: Orga
href="/onboarding" href="/onboarding"
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-neutral-500 hover:text-blue-600 dark:text-neutral-400 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/10 rounded-lg transition-colors mt-1" className="w-full flex items-center gap-2 px-3 py-2 text-sm text-neutral-500 hover:text-blue-600 dark:text-neutral-400 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/10 rounded-lg transition-colors mt-1"
> >
<div className="h-5 w-5 rounded border border-dashed border-neutral-300 dark:border-neutral-600 flex items-center justify-center"> <div className="h-5 w-5 rounded border border-dashed border-neutral-300 dark:border-neutral-600 flex items-center justify-center" aria-hidden="true">
<PlusIcon className="h-3 w-3" /> <PlusIcon className="h-3 w-3" />
</div> </div>
<span>Create Organization</span> <span>Create Organization</span>

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

@@ -1,9 +1,12 @@
'use client' 'use client'
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect, useMemo } from 'react'
import { logger } from '@/lib/utils/logger'
import Link from 'next/link' import Link from 'next/link'
import { formatNumber } from '@/lib/utils/format' import Image from 'next/image'
import { formatNumber } from '@ciphera-net/ui'
import { Modal, ArrowRightIcon, Button } from '@ciphera-net/ui' import { Modal, ArrowRightIcon, Button } from '@ciphera-net/ui'
import { TableSkeleton } from '@/components/skeletons'
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'
@@ -56,7 +59,7 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
const result = await getCampaigns(siteId, dateRange.start, dateRange.end, 10) const result = await getCampaigns(siteId, dateRange.start, dateRange.end, 10)
setData(result) setData(result)
} catch (e) { } catch (e) {
console.error(e) logger.error(e)
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
@@ -72,7 +75,7 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
const result = await getCampaigns(siteId, dateRange.start, dateRange.end, 100) const result = await getCampaigns(siteId, dateRange.start, dateRange.end, 100)
setFullData(result) setFullData(result)
} catch (e) { } catch (e) {
console.error(e) logger.error(e)
} finally { } finally {
setIsLoadingFull(false) setIsLoadingFull(false)
} }
@@ -110,11 +113,14 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
const useFavicon = faviconUrl && !faviconFailed.has(source) const useFavicon = faviconUrl && !faviconFailed.has(source)
if (useFavicon) { if (useFavicon) {
return ( return (
<img <Image
src={faviconUrl} src={faviconUrl}
alt="" alt=""
width={20}
height={20}
className="w-5 h-5 flex-shrink-0 rounded object-contain" className="w-5 h-5 flex-shrink-0 rounded object-contain"
onError={() => setFaviconFailed((prev) => new Set(prev).add(source))} onError={() => setFaviconFailed((prev) => new Set(prev).add(source))}
unoptimized
/> />
) )
} }
@@ -146,7 +152,7 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
<button <button
type="button" type="button"
onClick={() => handleSort(colKey)} onClick={() => handleSort(colKey)}
className={`inline-flex items-center gap-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-inset rounded ${className}`} className={`inline-flex items-center gap-1 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-inset rounded ${className}`}
aria-label={`Sort by ${label}`} aria-label={`Sort by ${label}`}
> >
{label} {label}
@@ -170,7 +176,7 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
<Button <Button
variant="ghost" variant="ghost"
onClick={handleExportCampaigns} onClick={handleExportCampaigns}
className="h-8 px-3 text-xs gap-1.5" className="h-8 px-3 text-xs gap-2"
> >
<DownloadIcon className="w-3.5 h-3.5" /> <DownloadIcon className="w-3.5 h-3.5" />
Export Export
@@ -179,7 +185,7 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
<Button <Button
variant="ghost" variant="ghost"
onClick={() => setIsBuilderOpen(true)} onClick={() => setIsBuilderOpen(true)}
className="h-8 px-3 text-xs gap-1.5" className="h-8 px-3 text-xs gap-2"
> >
<PlusIcon className="w-3.5 h-3.5" /> <PlusIcon className="w-3.5 h-3.5" />
Build URL Build URL
@@ -276,7 +282,7 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
</p> </p>
<Link <Link
href="/installation" href="/installation"
className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-orange hover:text-brand-orange/90 hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange/20 rounded" className="inline-flex items-center gap-2 text-sm font-medium text-brand-orange hover:text-brand-orange/90 hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange/20 rounded"
> >
Read documentation Read documentation
<ArrowRightIcon className="w-4 h-4" /> <ArrowRightIcon className="w-4 h-4" />
@@ -292,9 +298,8 @@ 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-4">
<div className="animate-spin w-6 h-6 border-2 border-neutral-300 dark:border-neutral-700 border-t-brand-orange rounded-full" /> <TableSkeleton rows={10} cols={5} />
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
</div> </div>
) : ( ) : (
<> <>

View File

@@ -13,32 +13,33 @@ 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 Sparkline from './Sparkline'
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'
const COLORS = { const COLORS = {
brand: '#FD5E0F', brand: 'var(--color-brand-orange)',
success: '#10B981', // Emerald-500 success: 'var(--color-success)',
danger: '#EF4444', // Red-500 danger: 'var(--color-error)',
} }
const CHART_COLORS_LIGHT = { const CHART_COLORS_LIGHT = {
border: '#E5E5E5', border: 'var(--color-neutral-200)',
text: '#171717', text: 'var(--color-neutral-900)',
textMuted: '#737373', textMuted: 'var(--color-neutral-500)',
axis: '#A3A3A3', axis: 'var(--color-neutral-400)',
tooltipBg: '#ffffff', tooltipBg: '#ffffff',
tooltipBorder: '#E5E5E5', tooltipBorder: 'var(--color-neutral-200)',
} }
const CHART_COLORS_DARK = { const CHART_COLORS_DARK = {
border: '#404040', border: 'var(--color-neutral-700)',
text: '#fafafa', text: 'var(--color-neutral-50)',
textMuted: '#a3a3a3', textMuted: 'var(--color-neutral-400)',
axis: '#737373', axis: 'var(--color-neutral-500)',
tooltipBg: '#262626', tooltipBg: 'var(--color-neutral-800)',
tooltipBorder: '#404040', tooltipBorder: 'var(--color-neutral-700)',
} }
export interface DailyStat { export interface DailyStat {
@@ -127,7 +128,7 @@ function ChartTooltip({
return ( return (
<div <div
className="rounded-lg border px-4 py-3 shadow-lg" className="rounded-lg border px-4 py-3 shadow-lg transition-shadow duration-300"
style={{ style={{
backgroundColor: colors.tooltipBg, backgroundColor: colors.tooltipBg,
borderColor: colors.tooltipBorder, borderColor: colors.tooltipBorder,
@@ -145,7 +146,7 @@ function ChartTooltip({
</span> </span>
</div> </div>
{hasPrev && ( {hasPrev && (
<div className="mt-1.5 flex items-center gap-1.5 text-xs" style={{ color: colors.textMuted }}> <div className="mt-1.5 flex items-center gap-2 text-xs" style={{ color: colors.textMuted }}>
<span>vs {formatValue(prev as number)} {prevPeriodLabel ? `(${prevPeriodLabel})` : 'prev'}</span> <span>vs {formatValue(prev as number)} {prevPeriodLabel ? `(${prevPeriodLabel})` : 'prev'}</span>
{delta !== null && ( {delta !== null && (
<span <span
@@ -208,51 +209,6 @@ function getTrendContext(dateRange: { start: string; end: string }): string {
return `vs previous ${days} days` return `vs previous ${days} days`
} }
// * Mini sparkline SVG for KPI cards
function Sparkline({
data,
dataKey,
color,
width = 56,
height = 20,
}: {
data: Array<Record<string, unknown>>
dataKey: string
color: string
width?: number
height?: number
}) {
if (!data.length) return null
const values = data.map((d) => Number(d[dataKey] ?? 0))
const max = Math.max(...values, 1)
const min = Math.min(...values, 0)
const range = max - min || 1
const padding = 2
const w = width - padding * 2
const h = height - padding * 2
const points = values.map((v, i) => {
const x = padding + (i / Math.max(values.length - 1, 1)) * w
const y = padding + h - ((v - min) / range) * h
return `${x},${y}`
})
const pathD = points.length > 1 ? `M ${points.join(' L ')}` : `M ${points[0]} L ${points[0]}`
return (
<svg width={width} height={height} className="flex-shrink-0" aria-hidden>
<path
d={pathD}
fill="none"
stroke={color}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
export default function Chart({ export default function Chart({
data, data,
prevData, prevData,
@@ -282,7 +238,7 @@ export default function Chart({
const { toPng } = await import('html-to-image') const { toPng } = await import('html-to-image')
const dataUrl = await toPng(chartContainerRef.current, { const dataUrl = await toPng(chartContainerRef.current, {
cacheBust: true, cacheBust: true,
backgroundColor: resolvedTheme === 'dark' ? '#171717' : '#ffffff', backgroundColor: resolvedTheme === 'dark' ? 'var(--color-neutral-900)' : '#ffffff',
}) })
const link = document.createElement('a') const link = document.createElement('a')
link.download = `chart-${dateRange.start}-${dateRange.end}.png` link.download = `chart-${dateRange.start}-${dateRange.end}.png`
@@ -416,7 +372,7 @@ export default function Chart({
{/* * Subtle live/updated indicator in bottom-right corner */} {/* * Subtle live/updated indicator in bottom-right corner */}
{lastUpdatedAt != null && ( {lastUpdatedAt != null && (
<div <div
className="absolute bottom-3 right-6 flex items-center gap-1.5 text-xs text-neutral-500 dark:text-neutral-400 pointer-events-none" className="absolute bottom-3 right-6 flex items-center gap-2 text-xs text-neutral-500 dark:text-neutral-400 pointer-events-none"
title="Data refreshes every 30 seconds" title="Data refreshes every 30 seconds"
> >
<span className="relative flex h-1.5 w-1.5"> <span className="relative flex h-1.5 w-1.5">
@@ -540,7 +496,7 @@ export default function Chart({
</div> </div>
{prevData?.length ? ( {prevData?.length ? (
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-1">
<Checkbox <Checkbox
checked={showComparison} checked={showComparison}
onCheckedChange={setShowComparison} onCheckedChange={setShowComparison}
@@ -558,7 +514,7 @@ export default function Chart({
variant="ghost" variant="ghost"
onClick={handleExportChart} onClick={handleExportChart}
disabled={!hasData} disabled={!hasData}
className="gap-1.5 py-1.5 px-3 text-sm text-neutral-600 dark:text-neutral-400" className="gap-2 py-1.5 px-3 text-sm text-neutral-600 dark:text-neutral-400"
> >
<DownloadIcon className="w-4 h-4" /> <DownloadIcon className="w-4 h-4" />
Export chart Export chart
@@ -570,7 +526,7 @@ export default function Chart({
</div> </div>
{!hasData ? ( {!hasData ? (
<div className="flex h-[320px] flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-neutral-200 dark:border-neutral-700 bg-neutral-50/50 dark:bg-neutral-800/30"> <div className="flex h-80 flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-neutral-200 dark:border-neutral-700 bg-neutral-50/50 dark:bg-neutral-800/30">
<BarChartIcon className="h-12 w-12 text-neutral-300 dark:text-neutral-600" aria-hidden /> <BarChartIcon className="h-12 w-12 text-neutral-300 dark:text-neutral-600" aria-hidden />
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400"> <p className="text-sm font-medium text-neutral-500 dark:text-neutral-400">
No data for this period No data for this period
@@ -578,7 +534,7 @@ export default function Chart({
<p className="text-xs text-neutral-400 dark:text-neutral-500">Try a different date range</p> <p className="text-xs text-neutral-400 dark:text-neutral-500">Try a different date range</p>
</div> </div>
) : !hasAnyNonZero ? ( ) : !hasAnyNonZero ? (
<div className="flex h-[320px] flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-neutral-200 dark:border-neutral-700 bg-neutral-50/50 dark:bg-neutral-800/30"> <div className="flex h-80 flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-neutral-200 dark:border-neutral-700 bg-neutral-50/50 dark:bg-neutral-800/30">
<BarChartIcon className="h-12 w-12 text-neutral-300 dark:text-neutral-600" aria-hidden /> <BarChartIcon className="h-12 w-12 text-neutral-300 dark:text-neutral-600" aria-hidden />
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400"> <p className="text-sm font-medium text-neutral-500 dark:text-neutral-400">
No {metricLabel.toLowerCase()} data for this period No {metricLabel.toLowerCase()} data for this period
@@ -694,7 +650,7 @@ export default function Chart({
activeDot={{ activeDot={{
r: 5, r: 5,
strokeWidth: 2, strokeWidth: 2,
fill: resolvedTheme === 'dark' ? '#262626' : '#ffffff', fill: resolvedTheme === 'dark' ? 'var(--color-neutral-800)' : '#ffffff',
stroke: activeMetric.color, stroke: activeMetric.color,
}} }}
isAnimationActive isAnimationActive

View File

@@ -1,9 +1,12 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { formatNumber } from '@/lib/utils/format' import { logger } from '@/lib/utils/logger'
import { formatNumber } from '@ciphera-net/ui'
import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
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 } from '@ciphera-net/ui'
import { ListSkeleton } from '@/components/skeletons'
interface ContentStatsProps { interface ContentStatsProps {
topPages: TopPage[] topPages: TopPage[]
@@ -21,6 +24,7 @@ const LIMIT = 7
export default function ContentStats({ topPages, entryPages, exitPages, domain, collectPagePaths = true, siteId, dateRange }: ContentStatsProps) { export default function ContentStats({ topPages, entryPages, exitPages, domain, collectPagePaths = true, siteId, dateRange }: ContentStatsProps) {
const [activeTab, setActiveTab] = useState<Tab>('top_pages') const [activeTab, setActiveTab] = useState<Tab>('top_pages')
const handleTabKeyDown = useTabListKeyboard()
const [isModalOpen, setIsModalOpen] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false)
const [fullData, setFullData] = useState<TopPage[]>([]) const [fullData, setFullData] = useState<TopPage[]>([])
const [isLoadingFull, setIsLoadingFull] = useState(false) const [isLoadingFull, setIsLoadingFull] = useState(false)
@@ -47,7 +51,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
} }
setFullData(filterGenericPaths(data)) setFullData(filterGenericPaths(data))
} catch (e) { } catch (e) {
console.error(e) logger.error(e)
} finally { } finally {
setIsLoadingFull(false) setIsLoadingFull(false)
} }
@@ -102,7 +106,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain,
</button> </button>
)} )}
</div> </div>
<div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg" role="tablist" aria-label="Content view tabs"> <div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg" role="tablist" aria-label="Content view tabs" onKeyDown={handleTabKeyDown}>
{(['top_pages', 'entry_pages', 'exit_pages'] as Tab[]).map((tab) => ( {(['top_pages', 'entry_pages', 'exit_pages'] as Tab[]).map((tab) => (
<button <button
key={tab} key={tab}
@@ -173,9 +177,8 @@ 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-4">
<div className="animate-spin w-6 h-6 border-2 border-neutral-300 dark:border-neutral-700 border-t-brand-orange rounded-full" /> <ListSkeleton rows={10} />
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
</div> </div>
) : ( ) : (
(fullData.length > 0 ? fullData : data).map((page, index) => ( (fullData.length > 0 ? fullData : data).map((page, index) => (

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'
@@ -20,7 +20,7 @@ export default function Locations({ countries, cities }: LocationProps) {
if (!countryCode || countryCode === 'Unknown') return null if (!countryCode || countryCode === 'Unknown') return null
// * The API returns 2-letter country codes (e.g. US, DE) // * The API returns 2-letter country codes (e.g. US, DE)
// * We cast it to the flag component name // * We cast it to the flag component name
const FlagComponent = (Flags as any)[countryCode] const FlagComponent = (Flags as Record<string, React.ComponentType<{ className?: string }>>)[countryCode]
return FlagComponent ? <FlagComponent className="w-5 h-5 rounded-sm shadow-sm" /> : null return FlagComponent ? <FlagComponent className="w-5 h-5 rounded-sm shadow-sm" /> : null
} }

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'
@@ -67,9 +67,9 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
// Prepare data // Prepare data
const exportData = data.map((item) => { const exportData = data.map((item) => {
const filteredItem: Partial<DailyStat> = {} const filteredItem: Record<string, string | number> = {}
fields.forEach((field) => { fields.forEach((field) => {
(filteredItem as any)[field] = item[field] filteredItem[field] = item[field]
}) })
return filteredItem return filteredItem
}) })
@@ -212,7 +212,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
autoTable(doc, { autoTable(doc, {
startY: startY, startY: startY,
head: [fields.map(f => f.charAt(0).toUpperCase() + f.slice(1).replace('_', ' '))], head: [fields.map(f => f.charAt(0).toUpperCase() + f.slice(1).replace('_', ' '))],
body: tableData as any[][], body: tableData as (string | number)[][],
styles: { styles: {
font: 'helvetica', font: 'helvetica',
fontSize: 9, fontSize: 9,
@@ -249,7 +249,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
} }
}) })
let finalY = (doc as any).lastAutoTable.finalY + 10 let finalY = doc.lastAutoTable.finalY + 10
// Top Pages Table // Top Pages Table
if (topPages && topPages.length > 0) { if (topPages && topPages.length > 0) {
@@ -276,7 +276,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
alternateRowStyles: { fillColor: [255, 250, 245] }, alternateRowStyles: { fillColor: [255, 250, 245] },
}) })
finalY = (doc as any).lastAutoTable.finalY + 10 finalY = doc.lastAutoTable.finalY + 10
} }
// Top Referrers Table // Top Referrers Table
@@ -305,7 +305,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
alternateRowStyles: { fillColor: [255, 250, 245] }, alternateRowStyles: { fillColor: [255, 250, 245] },
}) })
finalY = (doc as any).lastAutoTable.finalY + 10 finalY = doc.lastAutoTable.finalY + 10
} }
// Campaigns Table // Campaigns Table

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'
@@ -52,7 +52,7 @@ export default function GoalStats({ goalCounts }: GoalStatsProps) {
</p> </p>
<Link <Link
href="/installation" href="/installation"
className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-orange hover:text-brand-orange/90 hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange/20 rounded" className="inline-flex items-center gap-2 text-sm font-medium text-brand-orange hover:text-brand-orange/90 hover:underline focus:outline-none focus:ring-2 focus:ring-brand-orange/20 rounded"
> >
Read documentation Read documentation
<ArrowRightIcon className="w-4 h-4" /> <ArrowRightIcon className="w-4 h-4" />

View File

@@ -1,12 +1,14 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { formatNumber } from '@/lib/utils/format' import { logger } from '@/lib/utils/logger'
import { formatNumber } from '@ciphera-net/ui'
import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
import * as Flags from 'country-flag-icons/react/3x2' import * as Flags from 'country-flag-icons/react/3x2'
// @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 } from '@ciphera-net/ui'
import { ListSkeleton } from '@/components/skeletons'
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'
@@ -26,8 +28,10 @@ const LIMIT = 7
export default function Locations({ countries, cities, regions, geoDataLevel = 'full', siteId, dateRange }: LocationProps) { export default function Locations({ countries, cities, regions, geoDataLevel = 'full', siteId, dateRange }: LocationProps) {
const [activeTab, setActiveTab] = useState<Tab>('map') const [activeTab, setActiveTab] = useState<Tab>('map')
const handleTabKeyDown = useTabListKeyboard()
const [isModalOpen, setIsModalOpen] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false)
const [fullData, setFullData] = useState<any[]>([]) type LocationItem = { country?: string; city?: string; region?: string; pageviews: number }
const [fullData, setFullData] = useState<LocationItem[]>([])
const [isLoadingFull, setIsLoadingFull] = useState(false) const [isLoadingFull, setIsLoadingFull] = useState(false)
useEffect(() => { useEffect(() => {
@@ -35,7 +39,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
const fetchData = async () => { const fetchData = async () => {
setIsLoadingFull(true) setIsLoadingFull(true)
try { try {
let data: any[] = [] let data: LocationItem[] = []
if (activeTab === 'countries') { if (activeTab === 'countries') {
data = await getCountries(siteId, dateRange.start, dateRange.end, 250) data = await getCountries(siteId, dateRange.start, dateRange.end, 250)
} else if (activeTab === 'regions') { } else if (activeTab === 'regions') {
@@ -45,7 +49,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
} }
setFullData(data) setFullData(data)
} catch (e) { } catch (e) {
console.error(e) logger.error(e)
} finally { } finally {
setIsLoadingFull(false) setIsLoadingFull(false)
} }
@@ -72,7 +76,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
return <GlobeIcon className="w-5 h-5 text-neutral-500 dark:text-neutral-400" /> return <GlobeIcon className="w-5 h-5 text-neutral-500 dark:text-neutral-400" />
} }
const FlagComponent = (Flags as any)[countryCode] const FlagComponent = (Flags as Record<string, React.ComponentType<{ className?: string }>>)[countryCode]
return FlagComponent ? <FlagComponent className="w-5 h-5 rounded-sm shadow-sm" /> : null return FlagComponent ? <FlagComponent className="w-5 h-5 rounded-sm shadow-sm" /> : null
} }
@@ -157,7 +161,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
} }
// Filter out "Unknown" entries that result from disabled collection // Filter out "Unknown" entries that result from disabled collection
const filterUnknown = (data: any[]) => { const filterUnknown = (data: LocationItem[]) => {
return data.filter(item => { return data.filter(item => {
if (activeTab === 'countries') return item.country && item.country !== 'Unknown' && item.country !== '' if (activeTab === 'countries') return item.country && item.country !== 'Unknown' && item.country !== ''
if (activeTab === 'regions') return item.region && item.region !== 'Unknown' && item.region !== '' if (activeTab === 'regions') return item.region && item.region !== 'Unknown' && item.region !== ''
@@ -171,7 +175,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
const hasData = activeTab === 'map' const hasData = activeTab === 'map'
? (countries && filterUnknown(countries).length > 0) ? (countries && filterUnknown(countries).length > 0)
: (data && data.length > 0) : (data && data.length > 0)
const displayedData = (activeTab !== 'map' && hasData) ? (data as any[]).slice(0, LIMIT) : [] const displayedData = (activeTab !== 'map' && hasData) ? data.slice(0, LIMIT) : []
const emptySlots = Math.max(0, LIMIT - displayedData.length) const emptySlots = Math.max(0, LIMIT - displayedData.length)
const showViewAll = activeTab !== 'map' && hasData && data.length > LIMIT const showViewAll = activeTab !== 'map' && hasData && data.length > LIMIT
@@ -202,7 +206,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
</button> </button>
)} )}
</div> </div>
<div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg" role="tablist" aria-label="Location view tabs"> <div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg" role="tablist" aria-label="Location view tabs" onKeyDown={handleTabKeyDown}>
{(['map', 'countries', 'regions', 'cities'] as Tab[]).map((tab) => ( {(['map', 'countries', 'regions', 'cities'] as Tab[]).map((tab) => (
<button <button
key={tab} key={tab}
@@ -227,7 +231,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
<p className="text-neutral-500 dark:text-neutral-400 text-sm">{getDisabledMessage()}</p> <p className="text-neutral-500 dark:text-neutral-400 text-sm">{getDisabledMessage()}</p>
</div> </div>
) : activeTab === 'map' ? ( ) : activeTab === 'map' ? (
hasData ? <WorldMap data={filterUnknown(countries)} /> : ( hasData ? <WorldMap data={filterUnknown(countries) as { country: string; pageviews: number }[]} /> : (
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3"> <div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4"> <div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
<GlobeIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" /> <GlobeIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
@@ -246,13 +250,13 @@ export default function Locations({ countries, cities, regions, geoDataLevel = '
{displayedData.map((item, index) => ( {displayedData.map((item, index) => (
<div key={index} className="flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors"> <div key={index} className="flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3"> <div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
{activeTab === 'countries' && <span className="shrink-0">{getFlagComponent(item.country)}</span>} {activeTab === 'countries' && <span className="shrink-0">{getFlagComponent(item.country ?? '')}</span>}
{activeTab !== 'countries' && <span className="shrink-0">{getFlagComponent(item.country)}</span>} {activeTab !== 'countries' && <span className="shrink-0">{getFlagComponent(item.country ?? '')}</span>}
<span className="truncate"> <span className="truncate">
{activeTab === 'countries' ? getCountryName(item.country) : {activeTab === 'countries' ? getCountryName(item.country ?? '') :
activeTab === 'regions' ? getRegionName(item.region, item.country) : activeTab === 'regions' ? getRegionName(item.region ?? '', item.country ?? '') :
getCityName(item.city)} getCityName(item.city ?? '')}
</span> </span>
</div> </div>
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4"> <div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
@@ -288,19 +292,18 @@ 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-4">
<div className="animate-spin w-6 h-6 border-2 border-neutral-300 dark:border-neutral-700 border-t-brand-orange rounded-full" /> <ListSkeleton rows={10} />
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
</div> </div>
) : ( ) : (
(fullData.length > 0 ? fullData : data as any[]).map((item, index) => ( (fullData.length > 0 ? fullData : data).map((item, index) => (
<div key={index} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors"> <div key={index} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3"> <div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
<span className="shrink-0">{getFlagComponent(item.country)}</span> <span className="shrink-0">{getFlagComponent(item.country ?? '')}</span>
<span className="truncate"> <span className="truncate">
{activeTab === 'countries' ? getCountryName(item.country) : {activeTab === 'countries' ? getCountryName(item.country ?? '') :
activeTab === 'regions' ? getRegionName(item.region, item.country) : activeTab === 'regions' ? getRegionName(item.region ?? '', item.country ?? '') :
getCityName(item.city)} getCityName(item.city ?? '')}
</span> </span>
</div> </div>
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4"> <div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">

View File

@@ -5,6 +5,7 @@ import { motion } from 'framer-motion'
import { ChevronDownIcon } from '@ciphera-net/ui' import { ChevronDownIcon } from '@ciphera-net/ui'
import { PerformanceStats as Stats, PerformanceByPageStat, getPerformanceByPage } from '@/lib/api/stats' import { PerformanceStats as Stats, PerformanceByPageStat, getPerformanceByPage } from '@/lib/api/stats'
import { Select } from '@ciphera-net/ui' import { Select } from '@ciphera-net/ui'
import { TableSkeleton } from '@/components/skeletons'
interface Props { interface Props {
stats: Stats stats: Stats
@@ -108,7 +109,7 @@ export default function PerformanceStats({ stats, performanceByPage, siteId, sta
const summaryText = `LCP ${Math.round(stats.lcp)} ms · CLS ${Number(stats.cls.toFixed(3))} · INP ${Math.round(stats.inp)} ms` const summaryText = `LCP ${Math.round(stats.lcp)} ms · CLS ${Number(stats.cls.toFixed(3))} · INP ${Math.round(stats.inp)} ms`
return ( return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-4"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
{/* * One-line summary: Performance score + metric summary. Click to expand. */} {/* * One-line summary: Performance score + metric summary. Click to expand. */}
<button <button
type="button" type="button"
@@ -205,7 +206,7 @@ export default function PerformanceStats({ stats, performanceByPage, siteId, sta
style={{ overflow: 'hidden' }} style={{ overflow: 'hidden' }}
> >
{loadingTable ? ( {loadingTable ? (
<div className="py-8 text-center text-neutral-500 text-sm">Loading</div> <div className="py-4"><TableSkeleton rows={5} cols={5} /></div>
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
<div className="py-6 text-center text-neutral-500 text-sm"> <div className="py-6 text-center text-neutral-500 text-sm">
No per-page metrics yet. Data appears as visitors are tracked with performance insights enabled. No per-page metrics yet. Data appears as visitors are tracked with performance insights enabled.

View File

@@ -0,0 +1,50 @@
'use client'
/**
* Mini sparkline SVG for KPI cards.
* Renders a line chart from an array of data points.
*/
export default function Sparkline({
data,
dataKey,
color,
width = 56,
height = 20,
}: {
/** Array of objects with numeric values (e.g. DailyStat with visitors, pageviews) */
data: ReadonlyArray<object>
dataKey: string
color: string
width?: number
height?: number
}) {
if (!data.length) return null
const values = data.map((d) => Number((d as Record<string, unknown>)[dataKey] ?? 0))
const max = Math.max(...values, 1)
const min = Math.min(...values, 0)
const range = max - min || 1
const padding = 2
const w = width - padding * 2
const h = height - padding * 2
const points = values.map((v, i) => {
const x = padding + (i / Math.max(values.length - 1, 1)) * w
const y = padding + h - ((v - min) / range) * h
return `${x},${y}`
})
const pathD = points.length > 1 ? `M ${points.join(' L ')}` : `M ${points[0]} L ${points[0]}`
return (
<svg width={width} height={height} className="flex-shrink-0" aria-hidden>
<path
d={pathD}
fill="none"
stroke={color}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}

View File

@@ -1,10 +1,13 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { formatNumber } from '@/lib/utils/format' import { logger } from '@/lib/utils/logger'
import { formatNumber } from '@ciphera-net/ui'
import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
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 } from '@ciphera-net/ui'
import { ListSkeleton } from '@/components/skeletons'
import { getBrowsers, getOS, getDevices, getScreenResolutions } from '@/lib/api/stats' import { getBrowsers, getOS, getDevices, getScreenResolutions } from '@/lib/api/stats'
interface TechSpecsProps { interface TechSpecsProps {
@@ -24,8 +27,10 @@ const LIMIT = 7
export default function TechSpecs({ browsers, os, devices, screenResolutions, collectDeviceInfo = true, collectScreenResolution = true, siteId, dateRange }: TechSpecsProps) { export default function TechSpecs({ browsers, os, devices, screenResolutions, collectDeviceInfo = true, collectScreenResolution = true, siteId, dateRange }: TechSpecsProps) {
const [activeTab, setActiveTab] = useState<Tab>('browsers') const [activeTab, setActiveTab] = useState<Tab>('browsers')
const handleTabKeyDown = useTabListKeyboard()
const [isModalOpen, setIsModalOpen] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false)
const [fullData, setFullData] = useState<any[]>([]) type TechItem = { name: string; pageviews: number; icon: React.ReactNode }
const [fullData, setFullData] = useState<TechItem[]>([])
const [isLoadingFull, setIsLoadingFull] = useState(false) const [isLoadingFull, setIsLoadingFull] = useState(false)
// Filter out "Unknown" entries that result from disabled collection // Filter out "Unknown" entries that result from disabled collection
@@ -38,7 +43,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
const fetchData = async () => { const fetchData = async () => {
setIsLoadingFull(true) setIsLoadingFull(true)
try { try {
let data: any[] = [] let data: TechItem[] = []
if (activeTab === 'browsers') { if (activeTab === 'browsers') {
const res = await getBrowsers(siteId, dateRange.start, dateRange.end, 100) const res = await getBrowsers(siteId, dateRange.start, dateRange.end, 100)
data = res.map(b => ({ name: b.browser, pageviews: b.pageviews, icon: getBrowserIcon(b.browser) })) data = res.map(b => ({ name: b.browser, pageviews: b.pageviews, icon: getBrowserIcon(b.browser) }))
@@ -54,7 +59,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
} }
setFullData(filterUnknown(data)) setFullData(filterUnknown(data))
} catch (e) { } catch (e) {
console.error(e) logger.error(e)
} finally { } finally {
setIsLoadingFull(false) setIsLoadingFull(false)
} }
@@ -125,7 +130,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co
</button> </button>
)} )}
</div> </div>
<div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg" role="tablist" aria-label="Technology view tabs"> <div className="flex p-1 bg-neutral-100 dark:bg-neutral-800 rounded-lg" role="tablist" aria-label="Technology view tabs" onKeyDown={handleTabKeyDown}>
{(['browsers', 'os', 'devices', 'screens'] as Tab[]).map((tab) => ( {(['browsers', 'os', 'devices', 'screens'] as Tab[]).map((tab) => (
<button <button
key={tab} key={tab}
@@ -189,9 +194,8 @@ 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-4">
<div className="animate-spin w-6 h-6 border-2 border-neutral-300 dark:border-neutral-700 border-t-brand-orange rounded-full" /> <ListSkeleton rows={10} />
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
</div> </div>
) : ( ) : (
(fullData.length > 0 ? fullData : data).map((item, index) => ( (fullData.length > 0 ? fullData : data).map((item, index) => (

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,12 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { formatNumber } from '@/lib/utils/format' import { logger } from '@/lib/utils/logger'
import Image from 'next/image'
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 } from '@ciphera-net/ui'
import { ListSkeleton } from '@/components/skeletons'
import { getTopReferrers, TopReferrer } from '@/lib/api/stats' import { getTopReferrers, TopReferrer } from '@/lib/api/stats'
interface TopReferrersProps { interface TopReferrersProps {
@@ -38,11 +41,14 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
const useFavicon = faviconUrl && !faviconFailed.has(referrer) const useFavicon = faviconUrl && !faviconFailed.has(referrer)
if (useFavicon) { if (useFavicon) {
return ( return (
<img <Image
src={faviconUrl} src={faviconUrl}
alt="" alt=""
width={20}
height={20}
className="w-5 h-5 flex-shrink-0 rounded object-contain" className="w-5 h-5 flex-shrink-0 rounded object-contain"
onError={() => setFaviconFailed((prev) => new Set(prev).add(referrer))} onError={() => setFaviconFailed((prev) => new Set(prev).add(referrer))}
unoptimized
/> />
) )
} }
@@ -61,7 +67,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
) )
setFullData(filtered) setFullData(filtered)
} catch (e) { } catch (e) {
console.error(e) logger.error(e)
} finally { } finally {
setIsLoadingFull(false) setIsLoadingFull(false)
} }
@@ -134,9 +140,8 @@ 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-4">
<div className="animate-spin w-6 h-6 border-2 border-neutral-300 dark:border-neutral-700 border-t-brand-orange rounded-full" /> <ListSkeleton rows={10} />
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
</div> </div>
) : ( ) : (
mergeReferrersByDisplayName(fullData.length > 0 ? fullData : filteredReferrers).map((ref, index) => ( mergeReferrersByDisplayName(fullData.length > 0 ? fullData : filteredReferrers).map((ref, index) => (

View File

@@ -35,9 +35,9 @@ const WorldMap = ({ data }: WorldMapProps) => {
// Plausible-like colors based on provided SVG snippet // Plausible-like colors based on provided SVG snippet
const isDark = resolvedTheme === 'dark' const isDark = resolvedTheme === 'dark'
const defaultFill = isDark ? "#262626" : "#f5f5f5" // neutral-800 / neutral-100 const defaultFill = isDark ? "var(--color-neutral-800)" : "var(--color-neutral-100)"
const defaultStroke = isDark ? "#171717" : "#ffffff" // neutral-900 / white const defaultStroke = isDark ? "var(--color-neutral-900)" : "#ffffff"
const brandOrange = "#FD5E0F" const brandOrange = "var(--color-brand-orange)"
return ( return (
<div className="relative w-full"> <div className="relative w-full">
@@ -97,7 +97,7 @@ const WorldMap = ({ data }: WorldMapProps) => {
</ComposableMap> </ComposableMap>
{tooltipContent && ( {tooltipContent && (
<div <div
className="fixed z-50 px-2 py-1 text-xs font-medium text-white bg-black/80 backdrop-blur-sm rounded shadow pointer-events-none transform -translate-x-1/2 -translate-y-full mt-[-10px]" className="fixed z-50 px-2 py-1 text-xs font-medium text-white bg-black/80 backdrop-blur-sm rounded shadow pointer-events-none transform -translate-x-1/2 -translate-y-full -mt-2.5"
style={{ left: tooltipContent.x, top: tooltipContent.y }} style={{ left: tooltipContent.x, top: tooltipContent.y }}
> >
{tooltipContent.content} {tooltipContent.content}

View File

@@ -0,0 +1,271 @@
'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'
import { SkeletonLine, SkeletonCircle } from '@/components/skeletons'
// * 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"
aria-hidden="true"
>
<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 or pressing Escape
useEffect(() => {
if (!open) return
function handleClickOutside(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleKeyDown)
}
}, [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)}
aria-expanded={open}
aria-haspopup="true"
aria-controls={open ? 'notification-dropdown' : undefined}
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 ? `Notifications, ${unreadCount} unread` : 'Notifications'}
>
<BellIcon />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 w-2 h-2 bg-brand-orange rounded-full" aria-hidden="true" />
)}
</button>
{open && (
<div
id="notification-dropdown"
role="dialog"
aria-label="Notifications"
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}
aria-label="Mark all notifications as read"
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-3 space-y-1">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex gap-3 px-4 py-3">
<SkeletonCircle className="h-8 w-8 shrink-0" />
<div className="flex-1 space-y-1.5">
<SkeletonLine className="h-3.5 w-3/4" />
<SkeletonLine className="h-3 w-1/2" />
</div>
</div>
))}
</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>
) : (
<button
type="button"
onClick={() => handleNotificationClick(n)}
className={`w-full text-left 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>
</button>
)}
</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-2 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" aria-hidden="true" />
Manage settings
</Link>
</div>
</div>
)}
</div>
)
}

View File

@@ -2,6 +2,8 @@
import { useState, useEffect, useCallback, useRef } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { setSessionAction } from '@/app/actions/auth'
import { logger } from '@/lib/utils/logger'
import { useAuth } from '@/lib/auth/context' import { useAuth } from '@/lib/auth/context'
import { import {
deleteOrganization, deleteOrganization,
@@ -16,11 +18,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,9 +36,20 @@ import {
BookOpenIcon, BookOpenIcon,
DownloadIcon, DownloadIcon,
ExternalLinkIcon, ExternalLinkIcon,
LayoutDashboardIcon LayoutDashboardIcon,
Spinner,
} from '@ciphera-net/ui' } from '@ciphera-net/ui'
// @ts-ignore import { MembersListSkeleton, InvoicesListSkeleton, AuditLogSkeleton, SettingsFormSkeleton, SkeletonCard } from '@/components/skeletons'
// * 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>
)
}
import { Button, Input } from '@ciphera-net/ui' import { Button, Input } from '@ciphera-net/ui'
export default function OrganizationSettings() { export default function OrganizationSettings() {
@@ -43,13 +57,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 +85,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 +125,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,
@@ -147,7 +171,7 @@ export default function OrganizationSettings() {
setOrgName(orgData.name) setOrgName(orgData.name)
setOrgSlug(orgData.slug) setOrgSlug(orgData.slug)
} catch (error) { } catch (error) {
console.error('Failed to load data:', error) logger.error('Failed to load data:', error)
// toast.error('Failed to load members') // toast.error('Failed to load members')
} finally { } finally {
setIsLoadingMembers(false) setIsLoadingMembers(false)
@@ -161,7 +185,7 @@ export default function OrganizationSettings() {
const sub = await getSubscription() const sub = await getSubscription()
setSubscription(sub) setSubscription(sub)
} catch (error) { } catch (error) {
console.error('Failed to load subscription:', error) logger.error('Failed to load subscription:', error)
// toast.error('Failed to load subscription details') // toast.error('Failed to load subscription details')
} finally { } finally {
setIsLoadingSubscription(false) setIsLoadingSubscription(false)
@@ -175,7 +199,7 @@ export default function OrganizationSettings() {
const invs = await getInvoices() const invs = await getInvoices()
setInvoices(invs) setInvoices(invs)
} catch (error) { } catch (error) {
console.error('Failed to load invoices:', error) logger.error('Failed to load invoices:', error)
} finally { } finally {
setIsLoadingInvoices(false) setIsLoadingInvoices(false)
} }
@@ -224,8 +248,8 @@ export default function OrganizationSettings() {
setAuditEntries(Array.isArray(entries) ? entries : []) setAuditEntries(Array.isArray(entries) ? entries : [])
setAuditTotal(typeof total === 'number' ? total : 0) setAuditTotal(typeof total === 'number' ? total : 0)
} catch (error) { } catch (error) {
console.error('Failed to load audit log', error) logger.error('Failed to load audit log', error)
toast.error(getAuthErrorMessage(error as Error) || 'Failed to load audit log') toast.error(getAuthErrorMessage(error as Error) || 'Failed to load audit log entries')
} finally { } finally {
setIsLoadingAudit(false) setIsLoadingAudit(false)
} }
@@ -248,10 +272,57 @@ 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) {
logger.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 (
<div className="p-6 text-center text-neutral-500"> <div className="p-6 text-center text-neutral-500 dark:text-neutral-400">
<p>You are in your personal context. Switch to an Organization to manage its settings.</p> <p>You are in your personal context. Switch to an Organization to manage its settings.</p>
</div> </div>
) )
@@ -262,8 +333,8 @@ export default function OrganizationSettings() {
try { try {
const { url } = await createPortalSession() const { url } = await createPortalSession()
window.location.href = url window.location.href = url
} catch (error: any) { } catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || error.message || 'Failed to redirect to billing portal') toast.error(getAuthErrorMessage(error) || (error instanceof Error ? error.message : '') || 'Failed to open billing portal')
setIsRedirectingToPortal(false) setIsRedirectingToPortal(false)
} }
} }
@@ -275,42 +346,60 @@ export default function OrganizationSettings() {
toast.success(atPeriodEnd ? 'Subscription will cancel at the end of the billing period.' : 'Subscription canceled.') toast.success(atPeriodEnd ? 'Subscription will cancel at the end of the billing period.' : 'Subscription canceled.')
setShowCancelPrompt(false) setShowCancelPrompt(false)
loadSubscription() loadSubscription()
} catch (error: any) { } catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || error.message || 'Failed to cancel subscription') toast.error(getAuthErrorMessage(error) || (error instanceof Error ? error.message : '') || 'Failed to cancel subscription')
} finally { } finally {
setCancelLoadingAction(null) setCancelLoadingAction(null)
} }
} }
const handleResumeSubscription = async () => {
setIsResuming(true)
try {
await resumeSubscription()
toast.success('Subscription will continue. Cancellation has been undone.')
loadSubscription()
} catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || (error instanceof 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')
} }
} catch (error: any) { } catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || error.message || 'Something went wrong.') toast.error(getAuthErrorMessage(error) || (error instanceof Error ? error.message : '') || 'Failed to update plan')
} finally { } finally {
setIsChangingPlan(false) setIsChangingPlan(false)
} }
@@ -330,17 +419,18 @@ export default function OrganizationSettings() {
// * Switch to personal context explicitly // * Switch to personal context explicitly
try { try {
const { access_token } = await switchContext(null) const { access_token } = await switchContext(null)
localStorage.setItem('token', access_token) await setSessionAction(access_token)
sessionStorage.setItem('pulse_switching_org', 'true')
window.location.href = '/' window.location.href = '/'
} catch (switchErr) { } catch (switchErr) {
console.error('Failed to switch to personal context after delete:', switchErr) logger.error('Failed to switch to personal context after delete:', switchErr)
// Fallback: reload and let backend handle invalid token if any sessionStorage.setItem('pulse_switching_org', 'true')
window.location.href = '/' window.location.href = '/'
} }
} catch (err: any) { } catch (err: unknown) {
console.error(err) logger.error(err)
toast.error(getAuthErrorMessage(err) || err.message || 'Failed to delete organization') toast.error(getAuthErrorMessage(err) || (err instanceof Error ? err.message : '') || 'Failed to delete organization')
setIsDeleting(false) setIsDeleting(false)
} }
} }
@@ -368,8 +458,8 @@ export default function OrganizationSettings() {
setCaptchaSolution('') setCaptchaSolution('')
setCaptchaToken('') setCaptchaToken('')
loadMembers() // Refresh list loadMembers() // Refresh list
} catch (error: any) { } catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || error.message || 'Failed to send invitation') toast.error(getAuthErrorMessage(error) || (error instanceof Error ? error.message : '') || 'Failed to send invitation')
} finally { } finally {
setIsInviting(false) setIsInviting(false)
} }
@@ -380,8 +470,8 @@ export default function OrganizationSettings() {
await revokeInvitation(currentOrgId, inviteId) await revokeInvitation(currentOrgId, inviteId)
toast.success('Invitation revoked') toast.success('Invitation revoked')
loadMembers() // Refresh list loadMembers() // Refresh list
} catch (error: any) { } catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || error.message || 'Failed to revoke invitation') toast.error(getAuthErrorMessage(error) || (error instanceof Error ? error.message : '') || 'Failed to revoke invitation')
} }
} }
@@ -395,8 +485,8 @@ export default function OrganizationSettings() {
toast.success('Organization updated successfully') toast.success('Organization updated successfully')
setIsEditing(false) setIsEditing(false)
loadMembers() loadMembers()
} catch (error: any) { } catch (error: unknown) {
toast.error(getAuthErrorMessage(error) || error.message || 'Failed to update organization') toast.error(getAuthErrorMessage(error) || (error instanceof Error ? error.message : '') || 'Failed to save organization settings')
} finally { } finally {
setIsSaving(false) setIsSaving(false)
} }
@@ -410,7 +500,7 @@ export default function OrganizationSettings() {
// handleTabChange is defined above // handleTabChange is defined above
return ( return (
<div className="max-w-4xl mx-auto space-y-8"> <div className="space-y-8">
<div> <div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">Organization Settings</h1> <h1 className="text-2xl font-bold text-neutral-900 dark:text-white">Organization Settings</h1>
<p className="mt-2 text-neutral-600 dark:text-neutral-400"> <p className="mt-2 text-neutral-600 dark:text-neutral-400">
@@ -460,6 +550,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"
@@ -482,7 +587,7 @@ export default function OrganizationSettings() {
initial={{ opacity: 0, x: 20 }} initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
className="w-full bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-6 md:p-8 shadow-sm" className="w-full bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-6 shadow-sm"
> >
{activeTab === 'general' && ( {activeTab === 'general' && (
<div className="space-y-12"> <div className="space-y-12">
@@ -499,12 +604,12 @@ export default function OrganizationSettings() {
<Input <Input
type="text" type="text"
value={orgName} value={orgName}
onChange={(e: any) => setOrgName(e.target.value)} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setOrgName(e.target.value)}
required required
minLength={2} minLength={2}
maxLength={50} maxLength={50}
disabled={!isEditing} disabled={!isEditing}
className={`bg-white dark:bg-neutral-900 ${!isEditing ? 'text-neutral-500' : ''}`} className={`bg-white dark:bg-neutral-900 ${!isEditing ? 'text-neutral-500 dark:text-neutral-400' : ''}`}
/> />
</div> </div>
@@ -513,21 +618,21 @@ export default function OrganizationSettings() {
Organization Slug Organization Slug
</label> </label>
<div className="flex rounded-xl shadow-sm"> <div className="flex rounded-xl shadow-sm">
<span className="inline-flex items-center px-3 rounded-l-xl border border-r-0 border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900 text-neutral-500 text-sm"> <span className="inline-flex items-center px-3 rounded-l-xl border border-r-0 border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900 text-neutral-500 dark:text-neutral-400 text-sm">
drop.ciphera.net/ pulse.ciphera.net/
</span> </span>
<Input <Input
type="text" type="text"
value={orgSlug} value={orgSlug}
onChange={(e: any) => setOrgSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setOrgSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
required required
minLength={3} minLength={3}
maxLength={30} maxLength={30}
disabled={!isEditing} disabled={!isEditing}
className={`rounded-l-none bg-white dark:bg-neutral-900 ${!isEditing ? 'text-neutral-500' : ''}`} className={`rounded-l-none bg-white dark:bg-neutral-900 ${!isEditing ? 'text-neutral-500 dark:text-neutral-400' : ''}`}
/> />
</div> </div>
<p className="text-xs text-neutral-500"> <p className="text-xs text-neutral-500 dark:text-neutral-400">
Changing the slug will change your organization's URL. Changing the slug will change your organization's URL.
</p> </p>
</div> </div>
@@ -568,7 +673,7 @@ export default function OrganizationSettings() {
<p className="text-sm text-neutral-500 dark:text-neutral-400">Irreversible actions for this organization.</p> <p className="text-sm text-neutral-500 dark:text-neutral-400">Irreversible actions for this organization.</p>
</div> </div>
<div className="p-4 border border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-900/10 rounded-2xl flex items-center justify-between"> <div className="p-6 border border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-900/10 rounded-2xl flex items-center justify-between">
<div> <div>
<h3 className="font-medium text-red-900 dark:text-red-200">Delete Organization</h3> <h3 className="font-medium text-red-900 dark:text-red-200">Delete Organization</h3>
<p className="text-sm text-red-700 dark:text-red-300 mt-1">Permanently delete this organization and all its data.</p> <p className="text-sm text-red-700 dark:text-red-300 mt-1">Permanently delete this organization and all its data.</p>
@@ -599,7 +704,7 @@ export default function OrganizationSettings() {
type="email" type="email"
placeholder="colleague@company.com" placeholder="colleague@company.com"
value={inviteEmail} value={inviteEmail}
onChange={(e: any) => setInviteEmail(e.target.value)} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setInviteEmail(e.target.value)}
required required
className="bg-white dark:bg-neutral-900" className="bg-white dark:bg-neutral-900"
/> />
@@ -635,14 +740,12 @@ export default function OrganizationSettings() {
{/* Members List */} {/* Members List */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-sm font-medium text-neutral-500 uppercase tracking-wider">Active Members</h3> <h3 className="text-sm font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Active Members</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">
{isLoadingMembers ? ( {isLoadingMembers ? (
<div className="flex items-center justify-center py-8"> <MembersListSkeleton />
<div className="w-6 h-6 border-2 border-brand-orange/30 border-t-brand-orange rounded-full animate-spin" />
</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 dark:text-neutral-400">No members found.</div>
) : ( ) : (
members.map((member) => ( members.map((member) => (
<div key={member.user_id} className="p-4 flex items-center justify-between hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors"> <div key={member.user_id} className="p-4 flex items-center justify-between hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors">
@@ -654,7 +757,7 @@ export default function OrganizationSettings() {
<div className="text-sm font-medium text-neutral-900 dark:text-white"> <div className="text-sm font-medium text-neutral-900 dark:text-white">
{member.user_email || 'Unknown User'} {member.user_email || 'Unknown User'}
</div> </div>
<div className="text-xs text-neutral-500"> <div className="text-xs text-neutral-500 dark:text-neutral-400">
Joined {new Date(member.joined_at).toLocaleDateString()} Joined {new Date(member.joined_at).toLocaleDateString()}
</div> </div>
</div> </div>
@@ -679,7 +782,7 @@ export default function OrganizationSettings() {
{/* Pending Invitations */} {/* Pending Invitations */}
{invitations.length > 0 && ( {invitations.length > 0 && (
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-sm font-medium text-neutral-500 uppercase tracking-wider">Pending Invitations</h3> <h3 className="text-sm font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Pending Invitations</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">
{invitations.map((invite) => ( {invitations.map((invite) => (
<div key={invite.id} className="p-4 flex items-center justify-between"> <div key={invite.id} className="p-4 flex items-center justify-between">
@@ -691,7 +794,7 @@ export default function OrganizationSettings() {
<div className="text-sm font-medium text-neutral-900 dark:text-white"> <div className="text-sm font-medium text-neutral-900 dark:text-white">
{invite.email} {invite.email}
</div> </div>
<div className="text-xs text-neutral-500"> <div className="text-xs text-neutral-500 dark:text-neutral-400">
Invited as <span className="capitalize font-medium">{invite.role}</span> • Expires {new Date(invite.expires_at).toLocaleDateString()} Invited as <span className="capitalize font-medium">{invite.role}</span> • Expires {new Date(invite.expires_at).toLocaleDateString()}
</div> </div>
</div> </div>
@@ -719,12 +822,13 @@ export default function OrganizationSettings() {
</div> </div>
{isLoadingSubscription ? ( {isLoadingSubscription ? (
<div className="flex items-center justify-center py-12"> <div className="space-y-4">
<div className="w-6 h-6 border-2 border-brand-orange/30 border-t-brand-orange rounded-full animate-spin" /> <SkeletonCard className="h-32" />
<SkeletonCard className="h-20" />
</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-6 text-center bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-200 dark:border-neutral-800">
<p className="text-neutral-500">Could not load subscription details.</p> <p className="text-neutral-500 dark:text-neutral-400">Could not load subscription details.</p>
<Button variant="ghost" onClick={loadSubscription} className="mt-4">Retry</Button> <Button variant="ghost" onClick={loadSubscription} className="mt-4">Retry</Button>
</div> </div>
) : ( ) : (
@@ -732,7 +836,7 @@ export default function OrganizationSettings() {
{/* Trial notice */} {/* Trial notice */}
{subscription.subscription_status === 'trialing' && ( {subscription.subscription_status === 'trialing' && (
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/10 border border-yellow-200 dark:border-yellow-800 rounded-2xl flex flex-col sm:flex-row sm:items-center gap-3"> <div className="p-6 bg-yellow-50 dark:bg-yellow-900/10 border border-yellow-200 dark:border-yellow-800 rounded-2xl flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex-1"> <div className="flex-1">
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200"> <p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
Your free trial ends on{' '} Your free trial ends on{' '}
@@ -750,9 +854,33 @@ export default function OrganizationSettings() {
</div> </div>
)} )}
{/* Past due notice */}
{subscription.subscription_status === 'past_due' && (
<div className="p-6 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-6 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 +894,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 +919,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 +935,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 +972,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 +995,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 +1026,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-2 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 +1036,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-2 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,14 +1045,12 @@ 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"> <InvoicesListSkeleton />
<div className="w-6 h-6 border-2 border-brand-orange/30 border-t-brand-orange rounded-full animate-spin" />
</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 dark:text-neutral-400">No invoices found.</div>
) : ( ) : (
<> <>
{invoices.map((invoice) => ( {invoices.map((invoice) => (
@@ -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-2 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-2 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,71 @@ 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 ? (
<SettingsFormSkeleton />
) : (
<div className="space-y-4">
<h3 className="text-sm font-medium text-neutral-500 dark:text-neutral-400 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 save notification preferences')
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>
@@ -989,9 +1242,7 @@ export default function OrganizationSettings() {
{/* Table */} {/* Table */}
<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"> <AuditLogSkeleton />
<div className="w-6 h-6 border-2 border-brand-orange/30 border-t-brand-orange rounded-full animate-spin" />
</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>
) : ( ) : (
@@ -1030,7 +1281,7 @@ export default function OrganizationSettings() {
{/* Pagination */} {/* Pagination */}
{auditTotal > auditPageSize && ( {auditTotal > auditPageSize && (
<div className="flex items-center justify-between px-4 py-3 border-t border-neutral-200 dark:border-neutral-800"> <div className="flex items-center justify-between px-4 py-3 border-t border-neutral-200 dark:border-neutral-800">
<span className="text-sm text-neutral-500"> <span className="text-sm text-neutral-500 dark:text-neutral-400">
{auditPage * auditPageSize + 1}{Math.min((auditPage + 1) * auditPageSize, auditTotal)} of {auditTotal} {auditPage * auditPageSize + 1}{Math.min((auditPage + 1) * auditPageSize, auditTotal)} of {auditTotal}
</span> </span>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -1210,8 +1461,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 +1472,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-xs 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 +1527,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

@@ -1,18 +1,118 @@
'use client' 'use client'
import Link from 'next/link' import Link from 'next/link'
import Image from 'next/image'
import { Site } from '@/lib/api/sites' import { Site } from '@/lib/api/sites'
import type { Stats } from '@/lib/api/stats'
import { formatNumber } from '@ciphera-net/ui'
import { BarChartIcon, SettingsIcon, BookOpenIcon, ExternalLinkIcon, Button } from '@ciphera-net/ui' import { BarChartIcon, SettingsIcon, BookOpenIcon, ExternalLinkIcon, Button } from '@ciphera-net/ui'
import { useAuth } from '@/lib/auth/context' import { useAuth } from '@/lib/auth/context'
import { FAVICON_SERVICE_URL } from '@/lib/utils/icons'
export type SiteStatsMap = Record<string, { stats: Stats }>
interface SiteListProps { interface SiteListProps {
sites: Site[] sites: Site[]
siteStats: SiteStatsMap
loading: boolean loading: boolean
onDelete: (id: string) => void onDelete: (id: string) => void
} }
export default function SiteList({ sites, loading, onDelete }: SiteListProps) { interface SiteCardProps {
site: Site
stats: Stats | null
statsLoading: boolean
onDelete: (id: string) => void
canDelete: boolean
}
function SiteCard({ site, stats, statsLoading, onDelete, canDelete }: SiteCardProps) {
const visitors24h = stats?.visitors ?? 0
const pageviews = stats?.pageviews ?? 0
return (
<div className="group relative flex flex-col rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm transition-all hover:shadow-md dark:border-neutral-800 dark:bg-neutral-900">
{/* Header: Icon + Name + Live Status */}
<div className="flex items-start justify-between mb-6">
<div className="flex items-center gap-4">
<div className="h-12 w-12 overflow-hidden rounded-lg border border-neutral-100 bg-neutral-50 p-1 dark:border-neutral-800 dark:bg-neutral-800">
<Image
src={`${FAVICON_SERVICE_URL}?domain=${site.domain}&sz=64`}
alt={site.name}
width={40}
height={40}
className="h-full w-full object-contain"
unoptimized
/>
</div>
<div>
<h3 className="font-semibold text-neutral-900 dark:text-white">{site.name}</h3>
<div className="flex items-center gap-1 text-sm text-neutral-500 dark:text-neutral-400">
{site.domain}
<a
href={`https://${site.domain}`}
target="_blank"
rel="noopener noreferrer"
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
onClick={(e) => e.stopPropagation()}
>
<ExternalLinkIcon className="h-3 w-3" />
</a>
</div>
</div>
</div>
<div className="flex items-center gap-2 rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 dark:bg-green-900/20 dark:text-green-400">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
Active
</div>
</div>
{/* Mini Stats Grid */}
<div className="mb-6 grid grid-cols-2 gap-4 rounded-lg bg-neutral-50 p-3 dark:bg-neutral-800/50">
<div>
<p className="text-xs text-neutral-500">Visitors (24h)</p>
<p className="font-mono text-lg font-medium text-neutral-900 dark:text-white">
{statsLoading ? '--' : formatNumber(visitors24h)}
</p>
</div>
<div>
<p className="text-xs text-neutral-500">Pageviews</p>
<p className="font-mono text-lg font-medium text-neutral-900 dark:text-white">
{statsLoading ? '--' : formatNumber(pageviews)}
</p>
</div>
</div>
{/* Actions */}
<div className="mt-auto flex gap-2">
<Link href={`/sites/${site.id}`} className="flex-1">
<Button variant="primary" className="w-full justify-center text-sm">
<BarChartIcon className="w-4 h-4" />
View Dashboard
</Button>
</Link>
{canDelete && (
<button
type="button"
onClick={() => onDelete(site.id)}
className="flex items-center justify-center rounded-lg border border-neutral-200 px-3 hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800 text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
title="Delete Site"
>
<SettingsIcon className="h-4 w-4" />
</button>
)}
</div>
</div>
)
}
export default function SiteList({ sites, siteStats, loading, onDelete }: SiteListProps) {
const { user } = useAuth() const { user } = useAuth()
const canDelete = user?.role === 'owner' || user?.role === 'admin'
if (loading) { if (loading) {
return ( return (
@@ -40,85 +140,19 @@ export default function SiteList({ sites, loading, onDelete }: SiteListProps) {
return ( return (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{sites.map((site) => ( {sites.map((site) => {
<div const data = siteStats[site.id]
return (
<SiteCard
key={site.id} key={site.id}
className="group relative flex flex-col rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm transition-all hover:shadow-md dark:border-neutral-800 dark:bg-neutral-900" site={site}
> stats={data?.stats ?? null}
{/* Header: Icon + Name + Live Status */} statsLoading={!data}
<div className="flex items-start justify-between mb-6"> onDelete={onDelete}
<div className="flex items-center gap-4"> canDelete={canDelete}
{/* Auto-fetch favicon */}
<div className="h-12 w-12 overflow-hidden rounded-lg border border-neutral-100 bg-neutral-50 p-1 dark:border-neutral-800 dark:bg-neutral-800">
<img
src={`https://www.google.com/s2/favicons?domain=${site.domain}&sz=64`}
alt={site.name}
className="h-full w-full object-contain"
/> />
</div> )
<div> })}
<h3 className="font-semibold text-neutral-900 dark:text-white">{site.name}</h3>
<div className="flex items-center gap-1 text-sm text-neutral-500 dark:text-neutral-400">
{site.domain}
<a
href={`https://${site.domain}`}
target="_blank"
rel="noopener noreferrer"
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
onClick={(e) => e.stopPropagation()}
>
<ExternalLinkIcon className="h-3 w-3" />
</a>
</div>
</div>
</div>
{/* "Live" Indicator */}
<div className="flex items-center gap-2 rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 dark:bg-green-900/20 dark:text-green-400">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
Active
</div>
</div>
{/* Mini Stats Grid */}
<div className="mb-6 grid grid-cols-2 gap-4 rounded-lg bg-neutral-50 p-3 dark:bg-neutral-800/50">
<div>
<p className="text-xs text-neutral-500">Visitors (24h)</p>
<p className="font-mono text-lg font-medium text-neutral-900 dark:text-white">--</p>
</div>
<div>
<p className="text-xs text-neutral-500">Pageviews</p>
<p className="font-mono text-lg font-medium text-neutral-900 dark:text-white">--</p>
</div>
</div>
{/* Actions */}
<div className="mt-auto flex gap-2">
<Link
href={`/sites/${site.id}`}
className="flex-1"
>
<Button variant="primary" className="w-full justify-center text-sm">
<BarChartIcon className="w-4 h-4" />
View Dashboard
</Button>
</Link>
{(user?.role === 'owner' || user?.role === 'admin') && (
<button
type="button"
onClick={() => onDelete(site.id)}
className="flex items-center justify-center rounded-lg border border-neutral-200 px-3 hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800 text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
title="Delete Site"
>
<SettingsIcon className="h-4 w-4" />
</button>
)}
</div>
</div>
))}
{/* Resources Card */} {/* Resources Card */}
<div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-neutral-300 bg-neutral-50 p-6 text-center dark:border-neutral-700 dark:bg-neutral-900/50"> <div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-neutral-300 bg-neutral-50 p-6 text-center dark:border-neutral-700 dark:bg-neutral-900/50">

View File

@@ -11,7 +11,7 @@ import {
} from '@ciphera-net/ui' } from '@ciphera-net/ui'
import { Site } from '@/lib/api/sites' import { Site } from '@/lib/api/sites'
import { getRealtime } from '@/lib/api/stats' import { getRealtime } from '@/lib/api/stats'
import { toast } from '@ciphera-net/ui' import { toast, Button } from '@ciphera-net/ui'
interface VerificationModalProps { interface VerificationModalProps {
isOpen: boolean isOpen: boolean
@@ -130,15 +130,12 @@ export default function VerificationModal({ isOpen, onClose, site }: Verificatio
</div> </div>
</div> </div>
<button <Button onClick={handleStartVerification} className="w-full justify-center">
onClick={handleStartVerification}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 rounded-xl font-medium hover:opacity-90 transition-opacity"
>
Open Website & Verify Open Website & Verify
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="w-4 h-4 ml-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg> </svg>
</button> </Button>
</div> </div>
)} )}
@@ -172,12 +169,9 @@ export default function VerificationModal({ isOpen, onClose, site }: Verificatio
We are successfully receiving data from your website. We are successfully receiving data from your website.
</p> </p>
</div> </div>
<button <Button onClick={onClose} className="w-full justify-center">
onClick={onClose}
className="w-full px-4 py-3 bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 rounded-xl font-medium hover:opacity-90 transition-opacity"
>
Done Done
</button> </Button>
</div> </div>
)} )}
@@ -205,18 +199,12 @@ export default function VerificationModal({ isOpen, onClose, site }: Verificatio
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<button <Button variant="secondary" onClick={onClose} className="flex-1 justify-center">
onClick={onClose}
className="flex-1 px-4 py-2.5 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 rounded-xl font-medium hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors"
>
Close Close
</button> </Button>
<button <Button onClick={handleStartVerification} className="flex-1 justify-center">
onClick={handleStartVerification}
className="flex-1 px-4 py-2.5 bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 rounded-xl font-medium hover:opacity-90 transition-opacity"
>
Try Again Try Again
</button> </Button>
</div> </div>
</div> </div>
)} )}

463
components/skeletons.tsx Normal file
View File

@@ -0,0 +1,463 @@
/**
* Reusable skeleton loading primitives and composites for Pulse.
* All skeletons follow the design-system pattern:
* animate-pulse + bg-neutral-100 dark:bg-neutral-800 + rounded
*/
const SK = 'animate-pulse bg-neutral-100 dark:bg-neutral-800'
export { useMinimumLoading } from './useMinimumLoading'
// ─── Primitives ──────────────────────────────────────────────
export function SkeletonLine({ className = '' }: { className?: string }) {
return <div className={`${SK} rounded ${className}`} />
}
export function SkeletonCircle({ className = '' }: { className?: string }) {
return <div className={`${SK} rounded-full ${className}`} />
}
export function SkeletonCard({ className = '' }: { className?: string }) {
return <div className={`${SK} rounded-2xl ${className}`} />
}
// ─── List skeleton (icon + two text lines per row) ───────────
export function ListRowSkeleton() {
return (
<div className="flex items-center justify-between h-9 px-2 -mx-2">
<div className="flex items-center gap-3 flex-1">
<SkeletonLine className="h-5 w-5 rounded shrink-0" />
<SkeletonLine className="h-4 w-3/5" />
</div>
<SkeletonLine className="h-4 w-12" />
</div>
)
}
export function ListSkeleton({ rows = 7 }: { rows?: number }) {
return (
<div className="space-y-2">
{Array.from({ length: rows }).map((_, i) => (
<ListRowSkeleton key={i} />
))}
</div>
)
}
// ─── Table skeleton (header row + data rows) ─────────────────
export function TableSkeleton({ rows = 7, cols = 5 }: { rows?: number; cols?: number }) {
return (
<div className="space-y-2">
<div className={`grid gap-2 mb-2 px-2`} style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}>
{Array.from({ length: cols }).map((_, i) => (
<SkeletonLine key={`th-${i}`} className="h-4" />
))}
</div>
{Array.from({ length: rows }).map((_, i) => (
<div key={`tr-${i}`} className="grid gap-2 h-9 px-2 -mx-2" style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}>
{Array.from({ length: cols }).map((_, j) => (
<SkeletonLine key={`td-${i}-${j}`} className="h-4" />
))}
</div>
))}
</div>
)
}
// ─── Widget panel skeleton (used inside dashboard grid) ──────
export function WidgetSkeleton() {
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
<div className="flex items-center justify-between mb-4">
<SkeletonLine className="h-6 w-32" />
<div className="flex gap-1">
<SkeletonLine className="h-7 w-16 rounded-lg" />
<SkeletonLine className="h-7 w-16 rounded-lg" />
</div>
</div>
<div className="space-y-2 flex-1 min-h-[270px]">
<ListSkeleton rows={7} />
</div>
</div>
)
}
// ─── Stat card skeleton ──────────────────────────────────────
export function StatCardSkeleton() {
return (
<div className="p-4 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900">
<SkeletonLine className="h-4 w-20 mb-2" />
<SkeletonLine className="h-8 w-28" />
</div>
)
}
// ─── Chart area skeleton ─────────────────────────────────────
export function ChartSkeleton() {
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="space-y-1">
<SkeletonLine className="h-3 w-16" />
<SkeletonLine className="h-7 w-24" />
</div>
))}
</div>
<SkeletonLine className="h-8 w-32 rounded-lg" />
</div>
<SkeletonLine className="h-64 w-full rounded-xl" />
</div>
)
}
// ─── Full dashboard skeleton ─────────────────────────────────
export function DashboardSkeleton() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4">
<div>
<SkeletonLine className="h-8 w-48 mb-2" />
<SkeletonLine className="h-4 w-32" />
</div>
<SkeletonLine className="h-8 w-40 rounded-full" />
</div>
<div className="flex items-center gap-2">
<SkeletonLine className="h-10 w-24 rounded-lg" />
<SkeletonLine className="h-10 w-36 rounded-lg" />
</div>
</div>
</div>
{/* Chart */}
<div className="mb-8">
<ChartSkeleton />
</div>
{/* Widget grid (2 cols) */}
<div className="grid gap-6 lg:grid-cols-2 mb-8">
<WidgetSkeleton />
<WidgetSkeleton />
</div>
<div className="grid gap-6 lg:grid-cols-2 mb-8">
<WidgetSkeleton />
<WidgetSkeleton />
</div>
{/* Campaigns table */}
<div className="mb-8">
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<SkeletonLine className="h-6 w-32 mb-4" />
<TableSkeleton rows={7} cols={5} />
</div>
</div>
</div>
)
}
// ─── Realtime page skeleton ──────────────────────────────────
export function RealtimeSkeleton() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8 h-[calc(100vh-64px)] flex flex-col">
<div className="mb-6">
<SkeletonLine className="h-4 w-32 mb-2" />
<SkeletonLine className="h-8 w-64" />
</div>
<div className="flex flex-col md:flex-row flex-1 gap-6 min-h-0">
{/* Visitors list */}
<div className="w-full md:w-1/3 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden flex flex-col bg-white dark:bg-neutral-900">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800">
<SkeletonLine className="h-6 w-32" />
</div>
<div className="p-2 space-y-1">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="p-4 space-y-2">
<div className="flex justify-between">
<SkeletonLine className="h-4 w-32" />
<SkeletonLine className="h-4 w-16" />
</div>
<SkeletonLine className="h-3 w-48" />
<div className="flex gap-2">
<SkeletonLine className="h-3 w-16" />
<SkeletonLine className="h-3 w-16" />
<SkeletonLine className="h-3 w-16" />
</div>
</div>
))}
</div>
</div>
{/* Session details */}
<div className="flex-1 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden bg-white dark:bg-neutral-900">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-800">
<SkeletonLine className="h-6 w-40" />
</div>
<div className="p-6 space-y-6">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex gap-4 pl-6">
<SkeletonCircle className="h-3 w-3 shrink-0 mt-1" />
<div className="space-y-1 flex-1">
<SkeletonLine className="h-4 w-48" />
<SkeletonLine className="h-3 w-32" />
</div>
</div>
))}
</div>
</div>
</div>
</div>
)
}
// ─── Session events skeleton (for loading events panel) ──────
export function SessionEventsSkeleton() {
return (
<div className="relative pl-6 border-l-2 border-neutral-100 dark:border-neutral-800 space-y-8">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="relative">
<span className={`absolute -left-[29px] top-1 h-3 w-3 rounded-full ${SK}`} />
<div className="space-y-1">
<SkeletonLine className="h-4 w-48" />
<SkeletonLine className="h-3 w-32" />
</div>
</div>
))}
</div>
)
}
// ─── Uptime page skeleton ────────────────────────────────────
export function UptimeSkeleton() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="mb-8">
<SkeletonLine className="h-4 w-32 mb-2" />
<SkeletonLine className="h-8 w-24 mb-1" />
<SkeletonLine className="h-4 w-64" />
</div>
{/* Overall status */}
<SkeletonCard className="h-20 mb-6" />
{/* Monitor cards */}
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-5 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<SkeletonCircle className="w-3 h-3" />
<SkeletonLine className="h-5 w-32" />
<SkeletonLine className="h-4 w-48 hidden sm:block" />
</div>
<SkeletonLine className="h-4 w-28" />
</div>
<SkeletonLine className="h-8 w-full rounded-sm" />
</div>
))}
</div>
</div>
)
}
// ─── Checks / Response time skeleton ─────────────────────────
export function ChecksSkeleton() {
return (
<div className="space-y-4">
<SkeletonLine className="h-40 w-full rounded-xl" />
<div className="space-y-1.5">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center justify-between py-1.5 px-2">
<div className="flex items-center gap-2">
<SkeletonCircle className="w-2 h-2" />
<SkeletonLine className="h-3 w-32" />
</div>
<SkeletonLine className="h-3 w-16" />
</div>
))}
</div>
</div>
)
}
// ─── Funnels list skeleton ───────────────────────────────────
export function FunnelsListSkeleton() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="mb-8">
<div className="flex items-center gap-4 mb-6">
<SkeletonLine className="h-10 w-10 rounded-xl" />
<div>
<SkeletonLine className="h-8 w-24 mb-1" />
<SkeletonLine className="h-4 w-64" />
</div>
</div>
<div className="grid gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6">
<SkeletonLine className="h-6 w-40 mb-2" />
<SkeletonLine className="h-4 w-64 mb-4" />
<div className="flex items-center gap-2">
{Array.from({ length: 3 }).map((_, j) => (
<div key={j} className="flex items-center">
<SkeletonLine className="h-7 w-20 rounded-lg" />
{j < 2 && <SkeletonLine className="h-4 w-4 mx-2 rounded" />}
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
)
}
// ─── Funnel detail skeleton ──────────────────────────────────
export function FunnelDetailSkeleton() {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<div className="mb-8">
<SkeletonLine className="h-4 w-32 mb-2" />
<SkeletonLine className="h-8 w-48 mb-1" />
<SkeletonLine className="h-4 w-64" />
</div>
<SkeletonCard className="h-80 mb-8" />
<div className="grid gap-4 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<SkeletonCard key={i} className="h-28" />
))}
</div>
</div>
)
}
// ─── Notifications list skeleton ─────────────────────────────
export function NotificationsListSkeleton() {
return (
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-start gap-3 p-4 rounded-xl border border-neutral-200 dark:border-neutral-800">
<SkeletonCircle className="h-10 w-10 shrink-0" />
<div className="flex-1 space-y-2">
<SkeletonLine className="h-4 w-3/4" />
<SkeletonLine className="h-3 w-1/2" />
</div>
<SkeletonLine className="h-3 w-16 shrink-0" />
</div>
))}
</div>
)
}
// ─── Settings form skeleton ──────────────────────────────────
export function SettingsFormSkeleton() {
return (
<div className="space-y-6">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="space-y-2">
<SkeletonLine className="h-4 w-24" />
<SkeletonLine className="h-10 w-full rounded-lg" />
</div>
))}
<SkeletonLine className="h-10 w-28 rounded-lg" />
</div>
)
}
// ─── Goals list skeleton ─────────────────────────────────────
export function GoalsListSkeleton() {
return (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center justify-between py-3 px-4 rounded-2xl border border-neutral-200 dark:border-neutral-800">
<div className="flex items-center gap-2">
<SkeletonLine className="h-4 w-24" />
<SkeletonLine className="h-3 w-20" />
</div>
<div className="flex items-center gap-2">
<SkeletonLine className="h-4 w-10" />
<SkeletonLine className="h-4 w-12" />
</div>
</div>
))}
</div>
)
}
// ─── Pricing cards skeleton ──────────────────────────────────
export function PricingCardsSkeleton() {
return (
<div className="grid gap-6 md:grid-cols-3 max-w-5xl mx-auto">
{Array.from({ length: 3 }).map((_, i) => (
<SkeletonCard key={i} className="h-96" />
))}
</div>
)
}
// ─── Organization settings skeleton (members, billing, etc) ─
export function MembersListSkeleton() {
return (
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 p-3 rounded-xl">
<SkeletonCircle className="h-10 w-10 shrink-0" />
<div className="flex-1 space-y-1">
<SkeletonLine className="h-4 w-32" />
<SkeletonLine className="h-3 w-48" />
</div>
<SkeletonLine className="h-6 w-16 rounded-full" />
</div>
))}
</div>
)
}
export function InvoicesListSkeleton() {
return (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center justify-between py-3 px-4 rounded-lg">
<div className="flex items-center gap-3">
<SkeletonLine className="h-4 w-24" />
<SkeletonLine className="h-4 w-16" />
</div>
<SkeletonLine className="h-4 w-20" />
</div>
))}
</div>
)
}
export function AuditLogSkeleton() {
return (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 py-2 px-4">
<SkeletonLine className="h-3 w-28" />
<SkeletonLine className="h-3 w-16" />
<SkeletonLine className="h-3 w-48 flex-1" />
</div>
))}
</div>
)
}

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { logger } from '@/lib/utils/logger'
import { CopyIcon, CheckIcon } from '@radix-ui/react-icons' import { CopyIcon, CheckIcon } from '@radix-ui/react-icons'
import { listSites, Site } from '@/lib/api/sites' import { listSites, Site } from '@/lib/api/sites'
import { Select, Input, Button } from '@ciphera-net/ui' import { Select, Input, Button } from '@ciphera-net/ui'
@@ -30,7 +31,7 @@ export default function UtmBuilder({ initialSiteId }: UtmBuilderProps) {
const data = await listSites() const data = await listSites()
setSites(data) setSites(data)
} catch (e) { } catch (e) {
console.error('Failed to load sites for UTM builder', e) logger.error('Failed to load sites for UTM builder', e)
} }
} }
fetchSites() fetchSites()

View File

@@ -0,0 +1,34 @@
'use client'
import { useState, useEffect, useRef } from 'react'
/**
* Prevents skeleton flicker on fast loads by keeping it visible
* for at least `minMs` once it appears.
*
* @param loading - The raw loading state from data fetching
* @param minMs - Minimum milliseconds the skeleton stays visible (default 300)
* @returns Whether the skeleton should be shown
*/
export function useMinimumLoading(loading: boolean, minMs = 300): boolean {
const [show, setShow] = useState(loading)
const startRef = useRef<number>(loading ? Date.now() : 0)
useEffect(() => {
if (loading) {
startRef.current = Date.now()
setShow(true)
} else {
const elapsed = Date.now() - startRef.current
const remaining = minMs - elapsed
if (remaining > 0) {
const timer = setTimeout(() => setShow(false), remaining)
return () => clearTimeout(timer)
} else {
setShow(false)
}
}
}, [loading, minMs])
return show
}

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;
} }
``` ```
@@ -400,8 +405,8 @@ toast.success('Site created successfully')
**Error Toast:** **Error Toast:**
```tsx ```tsx
toast.error('Failed to load data') toast.error('Failed to load uptime monitors')
// Red toast with X icon // Red toast with X icon — always mention what failed
``` ```
**Error Display:** **Error Display:**
@@ -812,9 +817,9 @@ Always test both light and dark modes:
### VS Code-Style Syntax Highlighting ### VS Code-Style Syntax Highlighting
```tsx ```tsx
<div className="bg-[#1e1e1e] rounded-xl overflow-hidden shadow-2xl border border-neutral-800"> <div className="bg-neutral-900 rounded-xl overflow-hidden shadow-2xl border border-neutral-800">
{/* Header bar */} {/* Header bar */}
<div className="flex items-center px-4 py-3 bg-[#252526] border-b border-neutral-800"> <div className="flex items-center px-4 py-3 bg-neutral-800 border-b border-neutral-800">
<div className="flex gap-2"> <div className="flex gap-2">
<div className="w-3 h-3 rounded-full bg-red-500/20" /> <div className="w-3 h-3 rounded-full bg-red-500/20" />
<div className="w-3 h-3 rounded-full bg-yellow-500/20" /> <div className="w-3 h-3 rounded-full bg-yellow-500/20" />
@@ -968,7 +973,6 @@ presets: [
**Dashboard:** Chart, TopPages, TopReferrers, Locations, TechSpecs, Campaigns, Goals, Performance **Dashboard:** Chart, TopPages, TopReferrers, Locations, TechSpecs, Campaigns, Goals, Performance
**Settings:** OrganizationSettings, ProfileSettings **Settings:** OrganizationSettings, ProfileSettings
**Sites:** SiteList, VerificationModal **Sites:** SiteList, VerificationModal
**Tools:** UtmBuilder
--- ---

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
@@ -24,9 +24,9 @@ export function getSignupUrl(redirectPath = '/auth/callback') {
export class ApiError extends Error { export class ApiError extends Error {
status: number status: number
data?: any data?: Record<string, unknown>
constructor(message: string, status: number, data?: any) { constructor(message: string, status: number, data?: Record<string, unknown>) {
super(message) super(message)
this.status = status this.status = status
this.data = data this.data = data
@@ -184,46 +184,5 @@ async function apiRequest<T>(
return response.json() return response.json()
} }
// * Legacy axios-style client for compatibility
export function getClient() {
return {
post: async (endpoint: string, body: any) => {
// Handle the case where endpoint might start with /api (remove it if our base client adds it, OR adjust usage)
// Our apiRequest adds /api/v1 prefix.
// If we pass /api/billing/checkout, apiRequest makes it /api/v1/api/billing/checkout -> Wrong.
// We should probably just expose apiRequest directly or wrap it properly.
// Let's adapt the endpoint:
// If endpoint starts with /api/, strip it because apiRequest adds /api/v1
// BUT WAIT: The backend billing endpoint is likely at /api/billing/checkout (not /api/v1/billing/checkout) if I registered it at root group?
// Let's check backend routing.
// In main.go: billingGroup := router.Group("/api/billing") -> so it is at /api/billing/... NOT /api/v1/billing...
// So we need a raw fetch for this, or modify apiRequest to support non-v1 routes.
// For now, let's just implement a simple fetch wrapper that mimics axios
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null
const headers: any = { 'Content-Type': 'application/json' }
// Although we use cookies, sometimes we might fallback to token if cookies fail?
// Pulse uses cookies primarily now.
const url = `${API_URL}${endpoint}`
const res = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
credentials: 'include'
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.error || 'Request failed')
}
return { data: await res.json() }
}
}
}
export const authFetch = apiRequest export const authFetch = apiRequest
export default apiRequest export default apiRequest

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

@@ -47,7 +47,6 @@ export async function getUserOrganizations(): Promise<OrganizationMember[]> {
// Switch Context (Get token for specific org) // Switch Context (Get token for specific org)
export async function switchContext(organizationId: string | null): Promise<{ access_token: string; expires_in: number }> { export async function switchContext(organizationId: string | null): Promise<{ access_token: string; expires_in: number }> {
const payload = { organization_id: organizationId || '' } const payload = { organization_id: organizationId || '' }
console.log('Sending switch context request:', payload)
return await authFetch<{ access_token: string; expires_in: number }>('/auth/switch-context', { return await authFetch<{ access_token: string; expires_in: number }>('/auth/switch-context', {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify(payload),
@@ -87,10 +86,7 @@ export async function sendInvitation(
role: string = 'member', role: string = 'member',
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string } captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
): Promise<OrganizationInvitation> { ): Promise<OrganizationInvitation> {
const body: any = { const body: Record<string, string> = { email, role }
email,
role
}
if (captcha?.captcha_id) body.captcha_id = captcha.captcha_id if (captcha?.captcha_id) body.captcha_id = captcha.captcha_id
if (captcha?.captcha_solution) body.captcha_solution = captcha.captcha_solution if (captcha?.captcha_solution) body.captcha_solution = captcha.captcha_solution

View File

@@ -21,6 +21,8 @@ export interface Site {
enable_performance_insights?: boolean enable_performance_insights?: boolean
// Bot and noise filtering // Bot and noise filtering
filter_bots?: boolean filter_bots?: boolean
// Data retention (months); 0 = keep forever
data_retention_months?: number
created_at: string created_at: string
updated_at: string updated_at: string
} }
@@ -47,6 +49,8 @@ export interface UpdateSiteRequest {
enable_performance_insights?: boolean enable_performance_insights?: boolean
// Bot and noise filtering // Bot and noise filtering
filter_bots?: boolean filter_bots?: boolean
// Data retention (months); 0 = keep forever
data_retention_months?: number
} }
export async function listSites(): Promise<Site[]> { export async function listSites(): Promise<Site[]> {

View File

@@ -6,6 +6,7 @@ import apiRequest from '@/lib/api/client'
import { LoadingOverlay } from '@ciphera-net/ui' import { LoadingOverlay } from '@ciphera-net/ui'
import { logoutAction, getSessionAction, setSessionAction } from '@/app/actions/auth' import { logoutAction, getSessionAction, setSessionAction } from '@/app/actions/auth'
import { getUserOrganizations, switchContext } from '@/lib/api/organization' import { getUserOrganizations, switchContext } from '@/lib/api/organization'
import { logger } from '@/lib/utils/logger'
interface User { interface User {
id: string id: string
@@ -66,7 +67,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
return merged return merged
}) })
}) })
.catch((e) => console.error('Failed to fetch full profile after login', e)) .catch((e) => logger.error('Failed to fetch full profile after login', e))
} }
const logout = useCallback(async () => { const logout = useCallback(async () => {
@@ -96,7 +97,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
return merged return merged
}) })
} catch (e) { } catch (e) {
console.error('Failed to refresh user data', e) logger.error('Failed to refresh user data', e)
} }
router.refresh() router.refresh()
}, [router]) }, [router])
@@ -121,7 +122,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setUser(merged) setUser(merged)
localStorage.setItem('user', JSON.stringify(merged)) localStorage.setItem('user', JSON.stringify(merged))
} catch (e) { } catch (e) {
console.error('Failed to fetch full profile', e) logger.error('Failed to fetch full profile', e)
} }
} else { } else {
// * Session invalid/expired // * Session invalid/expired
@@ -159,7 +160,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
// * If user has organizations but no context (org_id), switch to the first one // * If user has organizations but no context (org_id), switch to the first one
if (!user.org_id && organizations.length > 0) { if (!user.org_id && organizations.length > 0) {
const firstOrg = organizations[0] const firstOrg = organizations[0]
console.log('Auto-switching to organization:', firstOrg.organization_name)
try { try {
const { access_token } = await switchContext(firstOrg.organization_id) const { access_token } = await switchContext(firstOrg.organization_id)
@@ -179,11 +179,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
router.refresh() router.refresh()
} }
} catch (e) { } catch (e) {
console.error('Failed to auto-switch context', e) logger.error('Failed to auto-switch context', e)
} }
} }
} catch (e) { } catch (e) {
console.error("Failed to fetch organizations", e) logger.error("Failed to fetch organizations", e)
} }
} }
} }

View File

@@ -0,0 +1,28 @@
'use client'
import { useCallback } from 'react'
/**
* Provides an onKeyDown handler for WAI-ARIA tab lists.
* Moves focus between sibling `[role="tab"]` buttons with Left/Right arrow keys.
*/
export function useTabListKeyboard() {
return useCallback((e: React.KeyboardEvent<HTMLElement>) => {
const target = e.currentTarget
const tabs = Array.from(target.querySelectorAll<HTMLElement>('[role="tab"]'))
const index = tabs.indexOf(e.target as HTMLElement)
if (index < 0) return
let next: number | null = null
if (e.key === 'ArrowRight') next = (index + 1) % tabs.length
else if (e.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length
else if (e.key === 'Home') next = 0
else if (e.key === 'End') next = tabs.length - 1
if (next !== null) {
e.preventDefault()
tabs[next].focus()
tabs[next].click()
}
}, [])
}

View File

@@ -0,0 +1,24 @@
'use client'
import { useEffect, useCallback } from 'react'
/**
* Warns users with a browser prompt when they try to navigate away
* or close the tab while there are unsaved form changes.
*
* @param isDirty - Whether the form has unsaved changes
*/
export function useUnsavedChanges(isDirty: boolean) {
const handleBeforeUnload = useCallback(
(e: BeforeUnloadEvent) => {
if (!isDirty) return
e.preventDefault()
},
[isDirty]
)
useEffect(() => {
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [handleBeforeUnload])
}

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 = [
@@ -27,3 +40,42 @@ export function getLimitForTierIndex(index: number): number {
if (index < 0 || index >= TRAFFIC_TIERS.length) return 100000 if (index < 0 || index >= TRAFFIC_TIERS.length) return 100000
return TRAFFIC_TIERS[index].value return TRAFFIC_TIERS[index].value
} }
/** Maximum data retention (months) allowed per plan. */
export function getMaxRetentionMonthsForPlan(planId: string | null | undefined): number {
switch (planId) {
case 'business': return 36
case 'team': return 24
case 'solo': return 12
default: return 6
}
}
/** Selectable retention options (months) for the given plan. */
export function getRetentionOptionsForPlan(planId: string | null | undefined): { label: string; value: number }[] {
const base = [
{ label: '1 month', value: 1 },
{ label: '3 months', value: 3 },
{ label: '6 months', value: 6 },
]
const solo = [...base, { label: '1 year', value: 12 }]
const team = [...solo, { label: '2 years', value: 24 }]
const business = [...team, { label: '3 years', value: 36 }]
switch (planId) {
case 'business': return business
case 'team': return team
case 'solo': return solo
default: return base
}
}
/** Human-readable label for a retention value in months. */
export function formatRetentionMonths(months: number): string {
if (months === 0) return 'Forever'
if (months === 1) return '1 month'
if (months < 12) return `${months} months`
const years = months / 12
if (Number.isInteger(years)) return years === 1 ? '1 year' : `${years} years`
return `${months} months`
}

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

@@ -1,4 +1,10 @@
import React from 'react' import React from 'react'
/**
* Google's public favicon service base URL.
* Append `?domain=<host>&sz=<px>` to get a favicon.
*/
export const FAVICON_SERVICE_URL = 'https://www.google.com/s2/favicons'
import { import {
FaChrome, FaChrome,
FaFirefox, FaFirefox,
@@ -197,7 +203,7 @@ export function getReferrerFavicon(referrer: string): string | null {
try { try {
const url = new URL(referrer.startsWith('http') ? referrer : `https://${referrer}`) const url = new URL(referrer.startsWith('http') ? referrer : `https://${referrer}`)
if (REFERRER_USE_X_ICON.has(url.hostname.toLowerCase())) return null if (REFERRER_USE_X_ICON.has(url.hostname.toLowerCase())) return null
return `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=32` return `${FAVICON_SERVICE_URL}?domain=${url.hostname}&sz=32`
} catch { } catch {
return null return null
} }

16
lib/utils/logger.ts Normal file
View File

@@ -0,0 +1,16 @@
/**
* Dev-only logger that suppresses client-side output in production.
* Server-side logs always pass through (they go to server logs, not the browser).
*/
const isServer = typeof window === 'undefined'
const isDev = process.env.NODE_ENV === 'development'
export const logger = {
error(...args: unknown[]) {
if (isServer || isDev) console.error(...args)
},
warn(...args: unknown[]) {
if (isServer || isDev) console.warn(...args)
},
}

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" aria-hidden="true" />
}
return <CheckCircleIcon className="w-4 h-4 shrink-0 text-emerald-500" aria-hidden="true" />
}

View File

@@ -1,4 +1,5 @@
import type { Site } from '@/lib/api/sites' import type { Site } from '@/lib/api/sites'
import { formatRetentionMonths } from '@/lib/plans'
const DOCS_URL = const DOCS_URL =
(typeof process !== 'undefined' && process.env?.NEXT_PUBLIC_APP_URL) (typeof process !== 'undefined' && process.env?.NEXT_PUBLIC_APP_URL)
@@ -22,6 +23,7 @@ export function generatePrivacySnippet(site: Site): string {
const screen = site.collect_screen_resolution ?? true const screen = site.collect_screen_resolution ?? true
const perf = site.enable_performance_insights ?? false const perf = site.enable_performance_insights ?? false
const filterBots = site.filter_bots ?? true const filterBots = site.filter_bots ?? true
const retentionMonths = site.data_retention_months ?? 6
const parts: string[] = [] const parts: string[] = []
if (paths) parts.push('which pages are viewed') if (paths) parts.push('which pages are viewed')
@@ -44,6 +46,9 @@ export function generatePrivacySnippet(site: Site): string {
if (filterBots) { if (filterBots) {
p2 += 'Known bots and referrer spam are excluded from our analytics. ' p2 += 'Known bots and referrer spam are excluded from our analytics. '
} }
if (retentionMonths > 0) {
p2 += `Raw event data is automatically deleted after ${formatRetentionMonths(retentionMonths)}. `
}
p2 += `Data is processed in a privacy-preserving way and is not used to identify individuals. For more information, see Pulse's documentation: ${DOCS_URL}` p2 += `Data is processed in a privacy-preserving way and is not used to identify individuals. For more information, see Pulse's documentation: ${DOCS_URL}`
return `${p1}\n\n${p2}` return `${p1}\n\n${p2}`

66
middleware.ts Normal file
View File

@@ -0,0 +1,66 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const PUBLIC_ROUTES = new Set([
'/',
'/login',
'/signup',
'/auth/callback',
'/pricing',
'/features',
'/about',
'/faq',
'/changelog',
'/installation',
])
const PUBLIC_PREFIXES = [
'/share/',
'/integrations',
'/docs',
]
function isPublicRoute(pathname: string): boolean {
if (PUBLIC_ROUTES.has(pathname)) return true
return PUBLIC_PREFIXES.some((prefix) => pathname.startsWith(prefix))
}
const AUTH_ONLY_ROUTES = new Set(['/login', '/signup'])
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
const hasAccess = request.cookies.has('access_token')
const hasRefresh = request.cookies.has('refresh_token')
const hasSession = hasAccess || hasRefresh
// * Authenticated user hitting /login or /signup → send them home
if (hasSession && AUTH_ONLY_ROUTES.has(pathname)) {
return NextResponse.redirect(new URL('/', request.url))
}
// * Public route → allow through
if (isPublicRoute(pathname)) {
return NextResponse.next()
}
// * Protected route without a session → redirect to login
if (!hasSession) {
const loginUrl = new URL('/login', request.url)
return NextResponse.redirect(loginUrl)
}
return NextResponse.next()
}
export const config = {
matcher: [
/*
* Match all routes except:
* - _next/static, _next/image (Next.js internals)
* - favicon.ico, manifest.json, icons, images (static assets)
* - api routes (handled by their own auth)
*/
'/((?!_next/static|_next/image|favicon\\.ico|manifest\\.json|.*\\.png$|.*\\.svg$|.*\\.ico$|api/).*)',
],
}

View File

@@ -12,6 +12,39 @@ const nextConfig: NextConfig = {
output: 'standalone', output: 'standalone',
// * Privacy-first: Disable analytics and telemetry // * Privacy-first: Disable analytics and telemetry
productionBrowserSourceMaps: false, productionBrowserSourceMaps: false,
experimental: {
optimizePackageImports: ['react-icons'],
},
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'www.google.com',
pathname: '/s2/favicons**',
},
],
},
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()',
},
{ key: 'X-XSS-Protection', value: '1; mode=block' },
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
},
],
},
]
},
async redirects() { async redirects() {
return [ return [
{ {
@@ -21,6 +54,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)

Some files were not shown because too many files have changed in this diff Show More