24 Commits

Author SHA1 Message Date
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
14 changed files with 534 additions and 95 deletions

View File

@@ -6,6 +6,36 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased] ## [Unreleased]
## [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 ## [0.7.0-alpha] - 2026-02-17
### Changed ### Changed
@@ -82,7 +112,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
--- ---
[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.7.0-alpha...HEAD [Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.9.0-alpha...HEAD
[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.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.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

View File

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

View File

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

View File

@@ -13,6 +13,8 @@ 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 { 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 +70,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)
@@ -83,6 +89,7 @@ export default function SiteSettingsPage() {
useEffect(() => { useEffect(() => {
loadSite() loadSite()
loadSubscription()
}, [siteId]) }, [siteId])
useEffect(() => { useEffect(() => {
@@ -91,6 +98,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,7 +142,9 @@ 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
}) })
if (data.has_password) { if (data.has_password) {
setIsPasswordEnabled(true) setIsPasswordEnabled(true)
@@ -226,7 +259,9 @@ 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')
loadSite() loadSite()
@@ -821,6 +856,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-4 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>

View File

@@ -5,6 +5,7 @@ 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 '@ciphera-net/ui' import { getAuthErrorMessage } from '@ciphera-net/ui'
@@ -57,9 +58,10 @@ export default function NewSitePage() {
getSubscription() getSubscription()
]) ])
if (subscription?.plan_id === 'solo' && sites.length >= 1) { const siteLimit = subscription?.plan_id ? getSitesLimitForPlan(subscription.plan_id) : null
if (siteLimit != null && sites.length >= siteLimit) {
setAtLimit(true) setAtLimit(true)
toast.error('Solo plan limit reached (1 site). Please upgrade to add more sites.') toast.error(`${subscription.plan_id} plan limit reached (${siteLimit} site${siteLimit === 1 ? '' : 's'}). Please upgrade to add more sites.`)
router.replace('/') router.replace('/')
} }
} catch (error) { } catch (error) {

View File

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

View File

@@ -1,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

@@ -16,8 +16,8 @@ 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 { getNotificationSettings, updateNotificationSettings } from '@/lib/api/notification-settings'
import { toast } from '@ciphera-net/ui' import { toast } from '@ciphera-net/ui'
@@ -83,9 +83,13 @@ export default function OrganizationSettings() {
const [isRedirectingToPortal, setIsRedirectingToPortal] = useState(false) const [isRedirectingToPortal, setIsRedirectingToPortal] = useState(false)
const [cancelLoadingAction, setCancelLoadingAction] = useState<'period_end' | 'immediate' | null>(null) const [cancelLoadingAction, setCancelLoadingAction] = useState<'period_end' | 'immediate' | null>(null)
const [showCancelPrompt, setShowCancelPrompt] = useState(false) const [showCancelPrompt, setShowCancelPrompt] = useState(false)
const [isResuming, setIsResuming] = useState(false)
const [showChangePlanModal, setShowChangePlanModal] = useState(false) const [showChangePlanModal, setShowChangePlanModal] = useState(false)
const [changePlanId, setChangePlanId] = useState<string>(PLAN_ID_SOLO)
const [changePlanTierIndex, setChangePlanTierIndex] = useState(2) const [changePlanTierIndex, setChangePlanTierIndex] = useState(2)
const [changePlanYearly, setChangePlanYearly] = useState(false) const [changePlanYearly, setChangePlanYearly] = useState(false)
const [invoicePreview, setInvoicePreview] = useState<PreviewInvoiceResult | null>(null)
const [isLoadingPreview, setIsLoadingPreview] = useState(false)
const [isChangingPlan, setIsChangingPlan] = useState(false) const [isChangingPlan, setIsChangingPlan] = useState(false)
const [invoices, setInvoices] = useState<Invoice[]>([]) const [invoices, setInvoices] = useState<Invoice[]>([])
const [isLoadingInvoices, setIsLoadingInvoices] = useState(false) const [isLoadingInvoices, setIsLoadingInvoices] = useState(false)
@@ -294,6 +298,25 @@ export default function OrganizationSettings() {
} }
}, [activeTab, user?.role, handleTabChange]) }, [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 (
@@ -328,30 +351,48 @@ export default function OrganizationSettings() {
} }
} }
const handleResumeSubscription = async () => {
setIsResuming(true)
try {
await resumeSubscription()
toast.success('Subscription will continue. Cancellation has been undone.')
loadSubscription()
} catch (error: any) {
toast.error(getAuthErrorMessage(error) || error.message || 'Failed to resume subscription')
} finally {
setIsResuming(false)
}
}
const openChangePlanModal = () => { const openChangePlanModal = () => {
const currentPlan = subscription?.plan_id
if (currentPlan === PLAN_ID_TEAM || currentPlan === PLAN_ID_BUSINESS) {
setChangePlanId(currentPlan)
} else {
setChangePlanId(PLAN_ID_SOLO)
}
if (subscription?.pageview_limit != null && subscription.pageview_limit > 0) { if (subscription?.pageview_limit != null && subscription.pageview_limit > 0) {
setChangePlanTierIndex(getTierIndexForLimit(subscription.pageview_limit)) setChangePlanTierIndex(getTierIndexForLimit(subscription.pageview_limit))
} else { } else {
setChangePlanTierIndex(2) setChangePlanTierIndex(2)
} }
setChangePlanYearly(subscription?.billing_interval === 'year') setChangePlanYearly(subscription?.billing_interval === 'year')
setInvoicePreview(null)
setShowChangePlanModal(true) setShowChangePlanModal(true)
} }
const hasActiveSubscription = subscription?.subscription_status === 'active' || subscription?.subscription_status === 'trialing'
const handleChangePlanSubmit = async () => { const handleChangePlanSubmit = async () => {
const interval = changePlanYearly ? 'year' : 'month' const interval = changePlanYearly ? 'year' : 'month'
const limit = getLimitForTierIndex(changePlanTierIndex) const limit = getLimitForTierIndex(changePlanTierIndex)
setIsChangingPlan(true) setIsChangingPlan(true)
try { try {
if (hasActiveSubscription) { if (hasActiveSubscription) {
await changePlan({ plan_id: PLAN_ID_SOLO, interval, limit }) await changePlan({ plan_id: changePlanId, interval, limit })
toast.success('Plan updated. Changes may take a moment to reflect.') toast.success('Plan updated. Changes may take a moment to reflect.')
setShowChangePlanModal(false) setShowChangePlanModal(false)
loadSubscription() loadSubscription()
} else { } else {
const { url } = await createCheckoutSession({ plan_id: PLAN_ID_SOLO, interval, limit }) const { url } = await createCheckoutSession({ plan_id: changePlanId, interval, limit })
if (url) window.location.href = url if (url) window.location.href = url
else throw new Error('No checkout URL') else throw new Error('No checkout URL')
} }
@@ -811,9 +852,33 @@ export default function OrganizationSettings() {
</div> </div>
)} )}
{/* Past due notice */}
{subscription.subscription_status === 'past_due' && (
<div className="p-4 bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-800 rounded-2xl flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex-1">
<p className="text-sm font-medium text-red-800 dark:text-red-200">
Payment past due
</p>
<p className="text-xs text-red-700 dark:text-red-300 mt-0.5">
We couldn't charge your payment method. Please update your billing info to avoid service interruption.
</p>
</div>
<Button
variant="secondary"
onClick={handleManageSubscription}
disabled={isRedirectingToPortal}
isLoading={isRedirectingToPortal}
className="shrink-0"
>
Update payment method
</Button>
</div>
)}
{/* Cancel-at-period-end notice */} {/* Cancel-at-period-end notice */}
{subscription.cancel_at_period_end && ( {subscription.cancel_at_period_end && (
<div className="p-4 bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-800 rounded-2xl"> <div className="p-4 bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-800 rounded-2xl flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<p className="text-sm font-medium text-amber-800 dark:text-amber-200"> <p className="text-sm font-medium text-amber-800 dark:text-amber-200">
Your subscription will end on{' '} Your subscription will end on{' '}
<span className="font-semibold"> <span className="font-semibold">
@@ -827,6 +892,16 @@ export default function OrganizationSettings() {
You keep full access until then. Your data is retained for 30 days after. Use "Change plan" to resubscribe. You keep full access until then. Your data is retained for 30 days after. Use "Change plan" to resubscribe.
</p> </p>
</div> </div>
<Button
variant="secondary"
onClick={handleResumeSubscription}
disabled={isResuming}
isLoading={isResuming}
className="shrink-0"
>
Keep my subscription
</Button>
</div>
)} )}
{/* Plan & Usage card */} {/* Plan & Usage card */}
@@ -842,9 +917,11 @@ export default function OrganizationSettings() {
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
: subscription.subscription_status === 'trialing' : subscription.subscription_status === 'trialing'
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300'
: subscription.subscription_status === 'past_due'
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
: 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300' : 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300'
}`}> }`}>
{subscription.subscription_status === 'trialing' ? 'Trial' : (subscription.subscription_status || 'Free')} {subscription.subscription_status === 'trialing' ? 'Trial' : subscription.subscription_status === 'past_due' ? 'Past Due' : (subscription.subscription_status || 'Free')}
</span> </span>
{subscription.billing_interval && ( {subscription.billing_interval && (
<span className="text-xs text-neutral-500 capitalize"> <span className="text-xs text-neutral-500 capitalize">
@@ -856,16 +933,33 @@ export default function OrganizationSettings() {
Change plan Change plan
</Button> </Button>
</div> </div>
{(subscription.business_name || (subscription.tax_ids && subscription.tax_ids.length > 0)) && (
<div className="px-6 pb-2 -mt-2 space-y-1 text-sm text-neutral-500 dark:text-neutral-400">
{subscription.business_name && (
<div>Billing for: {subscription.business_name}</div>
)}
{subscription.tax_ids && subscription.tax_ids.length > 0 && (
<div>
Tax ID{subscription.tax_ids.length > 1 ? 's' : ''}:{' '}
{subscription.tax_ids.map((t) => {
const label = t.type === 'eu_vat' ? 'VAT' : t.type === 'us_ein' ? 'EIN' : t.type.replace(/_/g, ' ').toUpperCase()
return `${label} ${t.value}${t.country ? ` (${t.country})` : ''}`
}).join(', ')}
</div>
)}
</div>
)}
{/* Usage stats */} {/* Usage stats */}
<div className="border-t border-neutral-200 dark:border-neutral-800 px-6 py-5 grid grid-cols-2 md:grid-cols-4 gap-y-4 gap-x-6"> <div className="border-t border-neutral-200 dark:border-neutral-800 p-6 grid grid-cols-2 md:grid-cols-4 gap-y-4 gap-x-6">
<div> <div>
<div className="text-xs text-neutral-500 uppercase tracking-wider mb-1">Sites</div> <div className="text-xs text-neutral-500 uppercase tracking-wider mb-1">Sites</div>
<div className="text-lg font-semibold text-neutral-900 dark:text-white"> <div className="text-lg font-semibold text-neutral-900 dark:text-white">
{typeof subscription.sites_count === 'number' {typeof subscription.sites_count === 'number'
? subscription.plan_id === 'solo' ? (() => {
? `${subscription.sites_count} / 1` const limit = getSitesLimitForPlan(subscription.plan_id)
: `${subscription.sites_count}` return limit != null ? `${subscription.sites_count} / ${limit}` : `${subscription.sites_count}`
})()
: ''} : ''}
</div> </div>
</div> </div>
@@ -876,6 +970,22 @@ export default function OrganizationSettings() {
? `${subscription.pageview_usage.toLocaleString()} / ${subscription.pageview_limit.toLocaleString()}` ? `${subscription.pageview_usage.toLocaleString()} / ${subscription.pageview_limit.toLocaleString()}`
: ''} : ''}
</div> </div>
{subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number' && (
<div className="mt-2 h-1.5 w-full rounded-full bg-neutral-200 dark:bg-neutral-700 overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
subscription.pageview_usage / subscription.pageview_limit >= 1
? 'bg-red-500'
: subscription.pageview_usage / subscription.pageview_limit >= 0.9
? 'bg-red-400'
: subscription.pageview_usage / subscription.pageview_limit >= 0.8
? 'bg-amber-400'
: 'bg-green-500'
}`}
style={{ width: `${Math.min(100, (subscription.pageview_usage / subscription.pageview_limit) * 100)}%` }}
/>
</div>
)}
</div> </div>
<div> <div>
<div className="text-xs text-neutral-500 uppercase tracking-wider mb-1"> <div className="text-xs text-neutral-500 uppercase tracking-wider mb-1">
@@ -883,8 +993,18 @@ export default function OrganizationSettings() {
</div> </div>
<div className="text-lg font-semibold text-neutral-900 dark:text-white"> <div className="text-lg font-semibold text-neutral-900 dark:text-white">
{(() => { {(() => {
const d = subscription.current_period_end ? new Date(subscription.current_period_end as string) : null const ts = subscription.next_invoice_period_end ?? subscription.current_period_end
return d && !Number.isNaN(d.getTime()) && d.getTime() !== 0 ? d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—' const d = ts ? new Date(typeof ts === 'number' ? ts * 1000 : ts) : null
const dateStr = d && !Number.isNaN(d.getTime()) && d.getTime() !== 0
? d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
: ''
const amount = subscription.next_invoice_amount_due != null && subscription.next_invoice_currency
? (subscription.next_invoice_amount_due / 100).toLocaleString('en-US', {
style: 'currency',
currency: subscription.next_invoice_currency.toUpperCase(),
})
: null
return amount && dateStr !== '' ? `${dateStr} for ${amount}` : dateStr
})()} })()}
</div> </div>
</div> </div>
@@ -904,7 +1024,7 @@ export default function OrganizationSettings() {
type="button" type="button"
onClick={handleManageSubscription} onClick={handleManageSubscription}
disabled={isRedirectingToPortal} disabled={isRedirectingToPortal}
className="inline-flex items-center gap-1.5 text-sm text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors disabled:opacity-50" className="inline-flex items-center gap-1.5 text-sm text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded"
> >
<ExternalLinkIcon className="w-4 h-4" /> <ExternalLinkIcon className="w-4 h-4" />
Payment method & invoices Payment method & invoices
@@ -914,7 +1034,7 @@ export default function OrganizationSettings() {
<button <button
type="button" type="button"
onClick={() => setShowCancelPrompt(true)} onClick={() => setShowCancelPrompt(true)}
className="inline-flex items-center gap-1.5 rounded-lg border border-neutral-200 dark:border-neutral-700 px-3.5 py-1.5 text-sm text-neutral-600 dark:text-neutral-400 hover:border-red-300 hover:text-red-600 dark:hover:border-red-800 dark:hover:text-red-400 transition-colors" className="inline-flex items-center gap-1.5 rounded-xl border border-neutral-200 dark:border-neutral-700 px-3.5 py-1.5 text-sm text-neutral-600 dark:text-neutral-400 hover:border-red-300 hover:text-red-600 dark:hover:border-red-800 dark:hover:text-red-400 transition-colors focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
> >
Cancel subscription Cancel subscription
</button> </button>
@@ -923,7 +1043,7 @@ export default function OrganizationSettings() {
{/* Invoice History */} {/* Invoice History */}
<div> <div>
<h3 className="text-sm font-medium text-neutral-500 uppercase tracking-wider mb-3">Recent invoices</h3> <h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-3">Recent invoices</h3>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden divide-y divide-neutral-200 dark:divide-neutral-800"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden divide-y divide-neutral-200 dark:divide-neutral-800">
{isLoadingInvoices ? ( {isLoadingInvoices ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
@@ -957,14 +1077,21 @@ export default function OrganizationSettings() {
</span> </span>
{invoice.invoice_pdf && ( {invoice.invoice_pdf && (
<a href={invoice.invoice_pdf} target="_blank" rel="noopener noreferrer" <a href={invoice.invoice_pdf} target="_blank" rel="noopener noreferrer"
className="p-1.5 text-neutral-400 hover:text-neutral-900 dark:hover:text-white rounded-lg transition-colors" title="Download PDF"> className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange" title="Download PDF">
<DownloadIcon className="w-4 h-4" /> <DownloadIcon className="w-3.5 h-3.5" />
Download PDF
</a> </a>
)} )}
{invoice.hosted_invoice_url && ( {invoice.hosted_invoice_url && (
<a href={invoice.hosted_invoice_url} target="_blank" rel="noopener noreferrer" <a href={invoice.hosted_invoice_url} target="_blank" rel="noopener noreferrer"
className="p-1.5 text-neutral-400 hover:text-neutral-900 dark:hover:text-white rounded-lg transition-colors" title="View invoice"> className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange ${
<ExternalLinkIcon className="w-4 h-4" /> invoice.status === 'open'
? 'bg-brand-orange text-white hover:bg-brand-orange-hover'
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800'
}`}
title={invoice.status === 'open' ? 'Pay now' : 'View invoice'}>
<ExternalLinkIcon className="w-3.5 h-3.5" />
{invoice.status === 'open' ? 'Pay now' : 'View invoice'}
</a> </a>
)} )}
</div> </div>
@@ -1338,8 +1465,9 @@ export default function OrganizationSettings() {
<button <button
type="button" type="button"
onClick={() => setShowChangePlanModal(false)} onClick={() => setShowChangePlanModal(false)}
className="text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-400" className="text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-400 focus:outline-none focus:ring-2 focus:ring-brand-orange rounded-lg p-1"
disabled={isChangingPlan} disabled={isChangingPlan}
aria-label="Close dialog"
> >
<XIcon className="w-5 h-5" /> <XIcon className="w-5 h-5" />
</button> </button>
@@ -1348,6 +1476,41 @@ export default function OrganizationSettings() {
Choose your pageview limit and billing interval. {hasActiveSubscription ? 'Your next invoice will reflect prorations.' : 'Youll start a new subscription.'} Choose your pageview limit and billing interval. {hasActiveSubscription ? 'Your next invoice will reflect prorations.' : 'Youll start a new subscription.'}
</p> </p>
<div className="space-y-4"> <div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Plan</label>
<div className="grid grid-cols-3 gap-2">
{([
{ id: PLAN_ID_SOLO, name: 'Solo', sites: '1 site' },
{ id: PLAN_ID_TEAM, name: 'Team', sites: 'Up to 5 sites' },
{ id: PLAN_ID_BUSINESS, name: 'Business', sites: 'Up to 10 sites' },
] as const).map((plan) => {
const isCurrentPlan = subscription?.plan_id === plan.id
const isSelected = changePlanId === plan.id
return (
<button
key={plan.id}
type="button"
onClick={() => setChangePlanId(plan.id)}
className={`relative p-3 rounded-xl border text-left transition-all focus:outline-none focus:ring-2 focus:ring-brand-orange ${
isSelected
? 'border-brand-orange bg-brand-orange/5 dark:bg-brand-orange/10'
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
}`}
>
<span className={`block text-sm font-semibold ${isSelected ? 'text-brand-orange' : 'text-neutral-900 dark:text-white'}`}>
{plan.name}
</span>
<span className="block text-xs text-neutral-500 mt-0.5">{plan.sites}</span>
{isCurrentPlan && (
<span className="absolute -top-2 right-2 px-1.5 py-0.5 text-[10px] font-medium bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 rounded-full border border-neutral-200 dark:border-neutral-700">
Current
</span>
)}
</button>
)
})}
</div>
</div>
<div> <div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Pageviews per month</label> <label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Pageviews per month</label>
<select <select
@@ -1368,20 +1531,44 @@ export default function OrganizationSettings() {
<button <button
type="button" type="button"
onClick={() => setChangePlanYearly(false)} onClick={() => setChangePlanYearly(false)}
className={`flex-1 py-2 text-sm font-medium rounded-md transition-colors ${!changePlanYearly ? 'bg-brand-orange text-white' : 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'}`} className={`flex-1 py-2 text-sm font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange ${!changePlanYearly ? 'bg-brand-orange text-white' : 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'}`}
> >
Monthly Monthly
</button> </button>
<button <button
type="button" type="button"
onClick={() => setChangePlanYearly(true)} onClick={() => setChangePlanYearly(true)}
className={`flex-1 py-2 text-sm font-medium rounded-md transition-colors ${changePlanYearly ? 'bg-brand-orange text-white' : 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'}`} className={`flex-1 py-2 text-sm font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange ${changePlanYearly ? 'bg-brand-orange text-white' : 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'}`}
> >
Yearly Yearly
</button> </button>
</div> </div>
</div> </div>
</div> </div>
{hasActiveSubscription && (
<div className="mt-4 p-4 rounded-lg bg-neutral-100 dark:bg-neutral-800/50 border border-neutral-200 dark:border-neutral-700">
{isLoadingPreview ? (
<div className="flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400">
<Spinner className="w-4 h-4" />
Calculating next invoice…
</div>
) : invoicePreview ? (
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Next invoice:{' '}
{(invoicePreview.amount_due / 100).toLocaleString('en-US', {
style: 'currency',
currency: invoicePreview.currency.toUpperCase(),
})}{' '}
on {new Date(invoicePreview.period_end * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}{' '}
<span className="text-neutral-500">(prorated)</span>
</p>
) : (
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Unable to calculate preview. Your next invoice will reflect prorations.
</p>
)}
</div>
)}
<div className="flex gap-2 mt-6"> <div className="flex gap-2 mt-6">
<Button <Button
onClick={handleChangePlanSubmit} onClick={handleChangePlanSubmit}

View File

@@ -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

@@ -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

@@ -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,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}`

30
package-lock.json generated
View File

@@ -1,16 +1,18 @@
{ {
"name": "pulse-frontend", "name": "pulse-frontend",
"version": "0.6.0-alpha", "version": "0.7.0-alpha",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pulse-frontend", "name": "pulse-frontend",
"version": "0.6.0-alpha", "version": "0.7.0-alpha",
"dependencies": { "dependencies": {
"@ciphera-net/ui": "^0.0.57", "@ciphera-net/ui": "^0.0.57",
"@ducanh2912/next-pwa": "^10.2.9", "@ducanh2912/next-pwa": "^10.2.9",
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
"@stripe/react-stripe-js": "^5.6.0",
"@stripe/stripe-js": "^8.7.0",
"axios": "^1.13.2", "axios": "^1.13.2",
"country-flag-icons": "^1.6.4", "country-flag-icons": "^1.6.4",
"d3-scale": "^4.0.2", "d3-scale": "^4.0.2",
@@ -2715,6 +2717,30 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@stripe/react-stripe-js": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-5.6.0.tgz",
"integrity": "sha512-tucu/vTGc+5NXbo2pUiaVjA4ENdRBET8qGS00BM4BAU8J4Pi3eY6BHollsP2+VSuzzlvXwMg0it3ZLhbCj2fPg==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.7.2"
},
"peerDependencies": {
"@stripe/stripe-js": ">=8.0.0 <9.0.0",
"react": ">=16.8.0 <20.0.0",
"react-dom": ">=16.8.0 <20.0.0"
}
},
"node_modules/@stripe/stripe-js": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.7.0.tgz",
"integrity": "sha512-tNUerSstwNC1KuHgX4CASGO0Md3CB26IJzSXmVlSuFvhsBP4ZaEPpY4jxWOn9tfdDscuVT4Kqb8cZ2o9nLCgRQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12.16"
}
},
"node_modules/@surma/rollup-plugin-off-main-thread": { "node_modules/@surma/rollup-plugin-off-main-thread": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "pulse-frontend", "name": "pulse-frontend",
"version": "0.7.0-alpha", "version": "0.9.0-alpha",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -13,6 +13,8 @@
"@ciphera-net/ui": "^0.0.57", "@ciphera-net/ui": "^0.0.57",
"@ducanh2912/next-pwa": "^10.2.9", "@ducanh2912/next-pwa": "^10.2.9",
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
"@stripe/react-stripe-js": "^5.6.0",
"@stripe/stripe-js": "^8.7.0",
"axios": "^1.13.2", "axios": "^1.13.2",
"country-flag-icons": "^1.6.4", "country-flag-icons": "^1.6.4",
"d3-scale": "^4.0.2", "d3-scale": "^4.0.2",