Files
accounted/components/settings/BillingActions.tsx
T
Jakob Wennberg 0e8698f538 fix(entitlements): billing reachable in settings modal + conversion redesign (#821)
- Register BillingSettingsContent in SETTINGS_SECTIONS so the settings MODAL renders Abonnemang (it was falling back to Företag — billing was only a standalone page, never a registered section). Page is now a thin wrapper over the same component.
- Add GET /api/billing/status (isPaying / configured / trialEndsAt) so the client section gets state without server-only reads.
- Redesign for conversion: trial days-left urgency banner, reactive monthly/yearly price with a 'Spara 2 mån' badge, full-width price-bearing CTA, Stripe trust line, design-system-compliant chrome (flat Card, no shadow/rounded-xl, on-scale spacing, serif headline). Trialing companies now see the upgrade path (not the manage button).

The reported 'peach band' was not reproduced in code — no peach/salmon color exists in the app CSS and the only bottom drag-handle is in a md:hidden mobile sheet; most likely a macOS screenshot/desktop artifact.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 16:51:31 +02:00

111 lines
3.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState, type ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import type { BillingPlan } from '@/lib/stripe/client'
const PRICE: Record<BillingPlan, { amount: string; suffix: string; sub: string; cta: string }> = {
monthly: {
amount: '199 kr',
suffix: '/ mån',
sub: 'Faktureras månadsvis.',
cta: 'Aktivera abonnemang 199 kr/mån',
},
yearly: {
amount: '166 kr',
suffix: '/ mån',
sub: '1 999 kr/år — du betalar för 10 månader.',
cta: 'Aktivera årsabonnemang 1 999 kr/år',
},
}
/**
* Interactive billing CTA. Paying companies get the Stripe Customer Portal
* (manage/cancel); everyone else gets a reactive plan picker + Checkout. Both
* POST to a route that returns a hosted Stripe URL we redirect to.
*/
export function BillingActions({ isPaying, configured }: { isPaying: boolean; configured: boolean }) {
const { toast } = useToast()
const [loading, setLoading] = useState(false)
const [plan, setPlan] = useState<BillingPlan>('yearly')
async function go(endpoint: string, payload?: Record<string, unknown>) {
setLoading(true)
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload ?? {}),
})
const data = (await res.json().catch(() => ({}))) as { url?: string; error?: string }
if (!res.ok || !data.url) throw new Error(data.error || 'Något gick fel')
window.location.href = data.url
} catch (e) {
toast({
title: 'Kunde inte öppna betalningen',
description: e instanceof Error ? e.message : undefined,
variant: 'destructive',
})
setLoading(false)
}
}
if (isPaying) {
return (
<Button size="lg" onClick={() => go('/api/billing/portal')} disabled={loading} className="w-full sm:w-auto">
Hantera abonnemang
</Button>
)
}
if (!configured) {
return (
<Button size="lg" disabled className="w-full">
Uppgradering öppnar snart
</Button>
)
}
const segment = (p: BillingPlan, label: ReactNode) => (
<button
type="button"
onClick={() => setPlan(p)}
className={`flex items-center rounded-md px-3 py-2 transition-colors ${
plan === p ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:text-foreground'
}`}
>
{label}
</button>
)
return (
<div className="space-y-4">
<div>
<div className="flex items-baseline gap-2">
<span className="font-display text-3xl tracking-tight tabular-nums">{PRICE[plan].amount}</span>
<span className="text-muted-foreground">{PRICE[plan].suffix}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">{PRICE[plan].sub}</p>
</div>
<div className="inline-flex rounded-lg border border-border p-1 text-sm">
{segment('monthly', 'Månadsvis')}
{segment(
'yearly',
<>
Årsvis
<Badge variant="success" className="ml-2">Spara 2 mån</Badge>
</>,
)}
</div>
<Button size="lg" onClick={() => go('/api/billing/checkout', { plan })} disabled={loading} className="w-full">
{loading ? 'Öppnar…' : PRICE[plan].cta}
</Button>
<p className="text-xs text-muted-foreground text-center">Säker betalning via Stripe · Avsluta när du vill</p>
</div>
)
}