Files
accounted/components/settings/BillingActions.tsx
T
Jakob Wennberg bd85395cd6 feat(billing): rebuild the Abonnemang page as a clean order summary (#1696)
* feat(billing): rebuild the Abonnemang page as a clean order summary

The sell view is now four outcome lines (one per paid capability), one
freeze-and-retain sentence, and price / first charge / cancellation as flat
Fönster rows above a single CTA. The decorative skyline banner and the
repeated reassurance copy are gone; each money term is stated once, where
the decision is made. Copy moves from hardcoded Swedish into the
settings_billing namespace (sv+en). BillingActions shrinks to the CTA; plan
choice lives in the price row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(billing): sell view as one number, four short benefits, one button

Second pass on the Abonnemang page: the row version still read as
cluttered. The price is now the headline (display serif, interval toggle
beside it, one exkl./inkl. line), the benefits are noun + gloss in a 2x2
grid, and the money terms are one sentence under the CTA. Legal text stays
behind the ?.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(billing): list every paid capability, framed as the external connections

PAID_CAPABILITIES has seven keys, the page listed four. Add Betalningar
(stripe_payments) and Webshop (woocommerce_sync + shopify_sync) and phrase
the no-subscription line as the tier model actually works: only the external
connections pause, everything else stays.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 19:51:31 +02:00

97 lines
2.9 KiB
TypeScript

'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { ChevronRight } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import type { BillingPlan } from '@/lib/stripe/client'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { formatCurrency } from '@/lib/utils'
import { PLAN_PRICES } from '@/components/settings/billing-plans'
/**
* The billing call to action. Paying companies get the Stripe Customer
* Portal (manage/cancel) as a quiet row action; everyone else gets the
* Checkout button for the plan chosen in the offer rows above it. Both POST
* to a route that returns a hosted Stripe URL we redirect to.
*
* `firstChargeDeferred`: the checkout route will defer the first charge to
* the trial's end (see billing/checkout), so the CTA can truthfully say
* "0 kr idag" instead of quoting a price.
*/
export function BillingActions({
isPaying,
configured,
plan = 'yearly',
firstChargeDeferred = false,
}: {
isPaying: boolean
configured: boolean
plan?: BillingPlan
firstChargeDeferred?: boolean
}) {
const t = useTranslations('settings_billing')
const { toast } = useToast()
const [loading, setLoading] = useState(false)
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 | { message?: string }
}
const errorMessage = typeof data.error === 'string' ? data.error : data.error?.message
if (!res.ok || !data.url) throw new Error(errorMessage || t('checkout_failed'))
window.location.href = data.url
} catch (e) {
toast({
title: t('checkout_failed'),
description: e instanceof Error ? getUserErrorMessage(e) : undefined,
variant: 'destructive',
})
setLoading(false)
}
}
if (isPaying) {
return (
<Button variant="outline" size="sm" onClick={() => go('/api/billing/portal')} disabled={loading}>
{t('manage_cta')}
</Button>
)
}
if (!configured) {
return (
<Button size="lg" disabled className="w-full sm:w-auto">
{t('cta_coming_soon')}
</Button>
)
}
const label = loading
? t('cta_opening')
: firstChargeDeferred
? t('cta_start_deferred')
: t('cta_start_now', { price: formatCurrency(PLAN_PRICES[plan].exVat), period: t(`period_${plan}`) })
return (
<Button
size="lg"
onClick={() => go('/api/billing/checkout', { plan })}
disabled={loading}
className="w-full sm:w-auto"
>
{label}
{!loading && <ChevronRight className="h-4 w-4" />}
</Button>
)
}