bd85395cd6
* 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>
97 lines
2.9 KiB
TypeScript
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>
|
|
)
|
|
}
|