'use client'
import { useEffect, useState } from 'react'
import { useLocale, useTranslations } from 'next-intl'
import { Check } from 'lucide-react'
import { AttnLine } from '@/components/ui/attn-line'
import { HelpPopover } from '@/components/ui/help-popover'
import { Skeleton } from '@/components/ui/skeleton'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import {
SettingsGroup,
SettingsRow,
SettingsRowEnd,
SettingsRowNote,
SettingsSectionHeader,
SettingsSeg,
} from '@/components/settings/SettingsRows'
import { cn, formatCurrency } from '@/lib/utils'
import { useFormat } from '@/lib/hooks/use-format'
import { BillingActions } from '@/components/settings/BillingActions'
import { PLAN_PRICES } from '@/components/settings/billing-plans'
import type { BillingPlan } from '@/lib/stripe/client'
// What the paid tier unlocks: the external connections. One item per PAID
// capability in lib/entitlements/keys.ts (ai, bank_sync, skatteverket,
// email_send, stripe_payments, woocommerce_sync + shopify_sync as one
// "webshop" item). Keep in step with PAID_CAPABILITIES when a key is added.
const UNLOCK_KEYS = ['unlock_ai', 'unlock_bank', 'unlock_skv', 'unlock_email', 'unlock_payments', 'unlock_webshop'] as const
// Mirrors the checkout route's deferred-first-charge condition (Stripe's 48h
// trial_end floor plus clock margin). Above this, checkout collects the card
// but the first charge lands when the trial ends.
const DEFER_THRESHOLD_MS = 49 * 3600 * 1000
interface BillingView {
isPaying: boolean
configured: boolean
trialEndsAt: string | null
daysLeft: number | null
chargeDeferred: boolean
paidJustNow: boolean
isDemo: boolean
}
function UnlockList({ className }: { className?: string }) {
const t = useTranslations('settings_billing')
return (
{UNLOCK_KEYS.map((key) => (
-
{t(key)}
{t(`${key}_gloss`)}
))}
)
}
/**
* Settings → Abonnemang. Rendered both as the full page (thin wrapper) and
* inside the settings modal (via SETTINGS_SECTIONS), so it's a client component
* that reads its state from GET /api/billing/status.
*
* The sell view is deliberately an order summary in the Fönster row grammar:
* what you unlock, then price / first charge / how to cancel as flat rows,
* then one CTA. The money terms are stated once each, where the decision is
* made, instead of repeated as reassurance copy around the page.
*/
export function BillingSettingsContent() {
const tNav = useTranslations('settings_nav')
const tIntro = useTranslations('settings_intro')
const t = useTranslations('settings_billing')
const errorLocale = useLocale() as ErrorLocale
const { formatDateLong } = useFormat()
// null = the billing state is not known: still loading, or the read failed
// (loadError). A failed GET must never render a fabricated non-paying,
// unconfigured panel to a paying customer.
const [view, setView] = useState(null)
// detail === null: transient, so the line carries a retry. A detail sentence
// means the user has to act (an expired session) and a retry cannot help.
const [loadError, setLoadError] = useState<{ detail: string | null } | null>(null)
const [reloadKey, setReloadKey] = useState(0)
const [plan, setPlan] = useState('yearly')
useEffect(() => {
let active = true
async function load() {
setLoadError(null)
try {
const res = await fetch('/api/billing/status')
if (!res.ok) {
// Not-JSON bodies (an HTML error page, an empty 502) leave null, and
// getErrorMessage falls back to the status map.
const body = await res.json().catch(() => null)
if (!active) return
const sessionGone = res.status === 401 || res.status === 403
setView(null)
setLoadError({
detail: sessionGone
? getErrorMessage(body, { statusCode: res.status, locale: errorLocale })
: null,
})
return
}
// A 200 whose body will not parse throws into the catch below; a 200
// without the expected booleans is a failed read too. Neither may
// become a fabricated view.
const d = (await res.json()) as {
isPaying?: unknown
configured?: unknown
trialEndsAt?: unknown
isDemo?: unknown
}
if (!active) return
if (typeof d?.isPaying !== 'boolean' || typeof d?.configured !== 'boolean') {
setView(null)
setLoadError({ detail: null })
return
}
const trialEndsAt = typeof d.trialEndsAt === 'string' ? d.trialEndsAt : null
// Compute time-derived state here (effect), not during render, to keep render pure.
const msLeft = trialEndsAt ? new Date(trialEndsAt).getTime() - Date.now() : null
const daysLeft = msLeft !== null ? Math.max(0, Math.ceil(msLeft / 86_400_000)) : null
const chargeDeferred = msLeft !== null && msLeft > DEFER_THRESHOLD_MS
// Set by the checkout success redirect. Provisioning happens via the
// Stripe webhook, so isPaying can lag the redirect by a few seconds.
const paidJustNow = new URLSearchParams(window.location.search).get('success') === '1'
setView({
isPaying: d.isPaying,
configured: d.configured,
trialEndsAt,
daysLeft,
chargeDeferred,
paidJustNow,
isDemo: d.isDemo === true,
})
} catch {
if (active) {
setView(null)
setLoadError({ detail: null })
}
}
}
void load()
return () => { active = false }
}, [reloadKey, errorLocale])
const header =
if (!view) {
return (
{header}
{/* Live region always mounted so the failure is announced when it
appears, not merely inserted. */}
{loadError && (
setReloadKey((k) => k + 1) }
}
>
{loadError.detail ? `${t('load_failed')} ${loadError.detail}` : t('load_failed')}
)}
{!loadError && (
)}
)
}
// Demo / sandbox account → can't check out. Show the value prop but point
// them to creating a real account instead of a pay button that would 403.
if (view.isDemo) {
return (
{header}
{t('status_demo')}
{t('status_demo_note')}
)
}
// Paying company → manage view.
if (view.isPaying) {
return (
{header}
{t('status_active')}
{t('status_active_note')}
{t('manage_note')}
)
}
// Just returned from checkout but the webhook hasn't flipped isPaying yet →
// confirm instead of re-showing the sell pitch to someone who already paid.
if (view.paidJustNow) {
return (
{header}
{t('paid_just_now')}
{t('paid_just_now_note')}
)
}
// Trialing / expired → sell view: one number, four short lines, one
// button, two quiet sentences. Everything else lives behind the "?".
const { trialEndsAt, daysLeft, chargeDeferred } = view
const price = PLAN_PRICES[plan]
const priceNote =
plan === 'yearly'
? t('price_note_yearly', { inc: formatCurrency(price.incVat), perMonth: formatCurrency(price.perMonthEquivalent) })
: t('price_note_monthly', { inc: formatCurrency(price.incVat) })
const termsLine =
chargeDeferred && trialEndsAt
? t('terms_deferred', { date: formatDateLong(trialEndsAt) })
: t('terms_now')
return (
{header}
{/* Consequential trial countdown: one attn-tone sentence, not a banner. */}
{daysLeft !== null && (
{daysLeft > 0
? t('trial_ends_in', { days: daysLeft, date: trialEndsAt ? formatDateLong(trialEndsAt) : '' })
: t('trial_ended')}
)}
{formatCurrency(price.exVat)}
/ {t(`period_${plan}`)}
{priceNote}
{t('interval_yearly')}
{t('interval_yearly_save')}
>
),
},
]}
/>
{termsLine}
{t('without_note')}{' '}
{t('unlock_help')}
)
}