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>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
bb5fafe87b
commit
bd85395cd6
@@ -1,51 +1,39 @@
|
||||
'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 { SettingsSeg } from '@/components/settings/SettingsRows'
|
||||
import { formatDateLong } from '@/lib/utils'
|
||||
import type { BillingPlan } from '@/lib/stripe/client'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
const PRICE: Record<BillingPlan, { amount: string; suffix: string; sub: string; cta: string }> = {
|
||||
monthly: {
|
||||
amount: '199 kr',
|
||||
suffix: '/ mån exkl. moms',
|
||||
sub: '248,75 kr/mån inkl. moms. Faktureras månadsvis.',
|
||||
cta: 'Aktivera abonnemang: 199 kr/mån',
|
||||
},
|
||||
yearly: {
|
||||
amount: '166 kr',
|
||||
suffix: '/ mån exkl. moms',
|
||||
sub: '1 999 kr/år exkl. moms (2 498,75 kr inkl.). Du betalar för 10 månader.',
|
||||
cta: 'Aktivera årsabonnemang: 1 999 kr/år',
|
||||
},
|
||||
}
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { PLAN_PRICES } from '@/components/settings/billing-plans'
|
||||
|
||||
/**
|
||||
* Interactive billing CTA. Paying companies get the Stripe Customer Portal
|
||||
* (manage/cancel) as a quiet row action; everyone else gets a plan picker
|
||||
* (SettingsSeg) + Checkout. Both POST to a route that returns a hosted Stripe
|
||||
* URL we redirect to.
|
||||
* 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.
|
||||
*
|
||||
* `firstChargeAt`: when the checkout route will defer the first charge to the
|
||||
* trial's end (see billing/checkout), the date it lands. Shifts the CTA from
|
||||
* "pay now" to "0 kr idag": the strongest risk-reversal we can make truthfully.
|
||||
* `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,
|
||||
firstChargeAt = null,
|
||||
plan = 'yearly',
|
||||
firstChargeDeferred = false,
|
||||
}: {
|
||||
isPaying: boolean
|
||||
configured: boolean
|
||||
firstChargeAt?: string | null
|
||||
plan?: BillingPlan
|
||||
firstChargeDeferred?: boolean
|
||||
}) {
|
||||
const t = useTranslations('settings_billing')
|
||||
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)
|
||||
@@ -60,11 +48,11 @@ export function BillingActions({
|
||||
error?: string | { message?: string }
|
||||
}
|
||||
const errorMessage = typeof data.error === 'string' ? data.error : data.error?.message
|
||||
if (!res.ok || !data.url) throw new Error(errorMessage || 'Något gick fel')
|
||||
if (!res.ok || !data.url) throw new Error(errorMessage || t('checkout_failed'))
|
||||
window.location.href = data.url
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Kunde inte öppna betalningen',
|
||||
title: t('checkout_failed'),
|
||||
description: e instanceof Error ? getUserErrorMessage(e) : undefined,
|
||||
variant: 'destructive',
|
||||
})
|
||||
@@ -75,7 +63,7 @@ export function BillingActions({
|
||||
if (isPaying) {
|
||||
return (
|
||||
<Button variant="outline" size="sm" onClick={() => go('/api/billing/portal')} disabled={loading}>
|
||||
Hantera abonnemang
|
||||
{t('manage_cta')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -83,54 +71,26 @@ export function BillingActions({
|
||||
if (!configured) {
|
||||
return (
|
||||
<Button size="lg" disabled className="w-full sm:w-auto">
|
||||
Uppgradering öppnar snart
|
||||
{t('cta_coming_soon')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<SettingsSeg
|
||||
value={plan}
|
||||
onChange={setPlan}
|
||||
aria-label="Betalningsintervall"
|
||||
options={[
|
||||
{ value: 'monthly', label: 'Månadsvis' },
|
||||
{
|
||||
value: 'yearly',
|
||||
label: (
|
||||
<>
|
||||
Årsvis
|
||||
<span className="ml-2 text-muted-foreground">Spara 2 mån</span>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="tabular-nums text-foreground">{PRICE[plan].amount}</span> {PRICE[plan].suffix}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground tabular-nums">{PRICE[plan].sub}</p>
|
||||
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}`) })
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => go('/api/billing/checkout', { plan })}
|
||||
disabled={loading}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{loading ? 'Öppnar…' : firstChargeAt ? 'Starta abonnemanget: 0 kr idag' : PRICE[plan].cta}
|
||||
{!loading && <ChevronRight className="h-4 w-4" />}
|
||||
</Button>
|
||||
{firstChargeAt && (
|
||||
// The one deferred-charge line that stays visible (the rest of the
|
||||
// legal/marketing copy lives behind the group-level "?").
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Första debiteringen sker {formatDateLong(firstChargeAt)}, när provperioden slutar. Avslutar du innan dess
|
||||
kostar det ingenting.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { BillingPlan } from '@/lib/stripe/client'
|
||||
|
||||
/**
|
||||
* List prices in SEK, exkl. moms, mirroring the Stripe Prices behind
|
||||
* STRIPE_PRICE_MONTHLY / STRIPE_PRICE_YEARLY (tax_behavior=exclusive, 25 %
|
||||
* added at checkout for Swedish customers). Displayed on the billing page
|
||||
* only; the charge itself is whatever Stripe computes.
|
||||
*/
|
||||
export const PLAN_PRICES: Record<BillingPlan, { exVat: number; incVat: number; perMonthEquivalent: number }> = {
|
||||
monthly: { exVat: 199, incVat: 248.75, perMonthEquivalent: 199 },
|
||||
yearly: { exVat: 1999, incVat: 2498.75, perMonthEquivalent: 166 },
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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 {
|
||||
@@ -12,28 +13,19 @@ import {
|
||||
SettingsRowEnd,
|
||||
SettingsRowNote,
|
||||
SettingsSectionHeader,
|
||||
SettingsSeg,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { formatDateLong } from '@/lib/utils'
|
||||
import { cn, formatCurrency } from '@/lib/utils'
|
||||
import { useFormat } from '@/lib/hooks/use-format'
|
||||
import { BillingActions } from '@/components/settings/BillingActions'
|
||||
import { ILLUSTRATIONS, illustrationSrc } from '@/components/onboarding/onboarding-illustrations'
|
||||
import { PLAN_PRICES } from '@/components/settings/billing-plans'
|
||||
import type { BillingPlan } from '@/lib/stripe/client'
|
||||
|
||||
// What the paid tier unlocks (mirrors lib/entitlements PAID_CAPABILITIES).
|
||||
const INCLUDED = [
|
||||
'AI-assistent: chatt, kategorisering och dokumenttolkning',
|
||||
'Bankkoppling och automatisk synk (PSD2)',
|
||||
'Skatteverket: moms- och AGI-inlämning',
|
||||
'E-postutskick av fakturor, påminnelser och lönebesked',
|
||||
]
|
||||
|
||||
// What stays free forever (freeze-and-retain, nothing is taken away). Shown
|
||||
// as the second column of the flat feature list so the paid tier reads
|
||||
// strongest right next to it. Mirrors the old ALWAYS_FREE copy.
|
||||
const ALWAYS_FREE = [
|
||||
'Bokföring och rapporter',
|
||||
'Fakturering',
|
||||
'SIE-export',
|
||||
'Org.nr-uppslag och momsnummerkontroll',
|
||||
]
|
||||
// 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
|
||||
@@ -50,49 +42,17 @@ interface BillingView {
|
||||
isDemo: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorative masthead: the marketing site's halftone Stockholm skyline as a
|
||||
* quiet wide banner, echoing the onboarding backdrop so the paid surface
|
||||
* feels like the same product. Purely decorative (aria-hidden, empty alt),
|
||||
* no text, one gesture.
|
||||
*/
|
||||
function BillingHero() {
|
||||
const art = ILLUSTRATIONS['about-stockholm']
|
||||
function UnlockList({ className }: { className?: string }) {
|
||||
const t = useTranslations('settings_billing')
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className="relative mt-6 h-40 overflow-hidden rounded-lg border border-border bg-frame"
|
||||
>
|
||||
{/* Anchored so the skyline's waterline sits on the strip's bottom edge
|
||||
(the water reflection falls below it), spires reaching up into the
|
||||
strip: same physics as the onboarding backdrop. */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={illustrationSrc('about-stockholm')}
|
||||
width={art.w}
|
||||
height={art.h}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="absolute bottom-0 left-1/2 w-full min-w-[640px] -translate-x-1/2 translate-y-[51%] opacity-70 dark:opacity-40 dark:invert"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FeatureList({ heading, items }: { heading: string; items: string[] }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">{heading}</p>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{items.map((item) => (
|
||||
<li key={item} className="flex items-start gap-2 text-sm">
|
||||
<Check aria-hidden="true" className="mt-1 h-3.5 w-3.5 shrink-0 text-foreground" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<ul className={cn('grid gap-x-6 gap-y-3 px-1 sm:grid-cols-2', className)}>
|
||||
{UNLOCK_KEYS.map((key) => (
|
||||
<li key={key} className="text-sm">
|
||||
<span className="font-medium">{t(key)}</span>
|
||||
<span className="block text-xs text-muted-foreground">{t(`${key}_gloss`)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -100,12 +60,18 @@ function FeatureList({ heading, items }: { heading: string; items: string[] }) {
|
||||
* 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 tBilling = useTranslations('settings_billing')
|
||||
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.
|
||||
@@ -114,6 +80,7 @@ export function BillingSettingsContent() {
|
||||
// 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<BillingPlan>('yearly')
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
@@ -180,12 +147,7 @@ export function BillingSettingsContent() {
|
||||
return () => { active = false }
|
||||
}, [reloadKey, errorLocale])
|
||||
|
||||
const header = (
|
||||
<>
|
||||
<SettingsSectionHeader title={tNav('billing')} intro={tIntro('billing')} />
|
||||
<BillingHero />
|
||||
</>
|
||||
)
|
||||
const header = <SettingsSectionHeader title={tNav('billing')} intro={tIntro('billing')} />
|
||||
|
||||
if (!view) {
|
||||
return (
|
||||
@@ -199,12 +161,10 @@ export function BillingSettingsContent() {
|
||||
action={
|
||||
loadError.detail
|
||||
? undefined
|
||||
: { label: tBilling('load_retry'), onClick: () => setReloadKey((k) => k + 1) }
|
||||
: { label: t('load_retry'), onClick: () => setReloadKey((k) => k + 1) }
|
||||
}
|
||||
>
|
||||
{loadError.detail
|
||||
? `${tBilling('load_failed')} ${loadError.detail}`
|
||||
: tBilling('load_failed')}
|
||||
{loadError.detail ? `${t('load_failed')} ${loadError.detail}` : t('load_failed')}
|
||||
</AttnLine>
|
||||
)}
|
||||
</div>
|
||||
@@ -224,24 +184,14 @@ export function BillingSettingsContent() {
|
||||
return (
|
||||
<div>
|
||||
{header}
|
||||
<SettingsGroup label="Ditt abonnemang">
|
||||
<SettingsRow label="Status" borderless>
|
||||
<span>Demo</span>
|
||||
<SettingsRowNote>
|
||||
Du provkör Accounted i en demo. Skapa ett riktigt konto för att aktivera
|
||||
abonnemang, AI-assistent, bankkoppling och inlämning till Skatteverket.
|
||||
</SettingsRowNote>
|
||||
<SettingsGroup label={t('group_yours')}>
|
||||
<SettingsRow label={t('row_status')} borderless>
|
||||
<span>{t('status_demo')}</span>
|
||||
<SettingsRowNote>{t('status_demo_note')}</SettingsRowNote>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
<SettingsGroup label="I abonnemanget">
|
||||
<ul className="space-y-2 px-1 pt-3">
|
||||
{INCLUDED.map((item) => (
|
||||
<li key={item} className="flex items-start gap-2 text-sm">
|
||||
<Check aria-hidden="true" className="mt-1 h-3.5 w-3.5 shrink-0 text-foreground" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<SettingsGroup label={t('group_included')}>
|
||||
<UnlockList className="pt-3" />
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
)
|
||||
@@ -252,27 +202,20 @@ export function BillingSettingsContent() {
|
||||
return (
|
||||
<div>
|
||||
{header}
|
||||
<SettingsGroup label="Ditt abonnemang">
|
||||
<SettingsRow label="Status">
|
||||
<span className="font-medium">Aktivt</span>
|
||||
<SettingsRowNote>Du kan hantera eller avsluta det när som helst.</SettingsRowNote>
|
||||
<SettingsGroup label={t('group_yours')}>
|
||||
<SettingsRow label={t('row_status')}>
|
||||
<span className="font-medium">{t('status_active')}</span>
|
||||
<SettingsRowNote>{t('status_active_note')}</SettingsRowNote>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Hantera" borderless>
|
||||
<SettingsRowNote>Byt kort, ändra plan eller säg upp via Stripes kundportal.</SettingsRowNote>
|
||||
<SettingsRow label={t('row_manage')} borderless>
|
||||
<SettingsRowNote>{t('manage_note')}</SettingsRowNote>
|
||||
<SettingsRowEnd>
|
||||
<BillingActions isPaying configured={view.configured} />
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
<SettingsGroup label="I abonnemanget">
|
||||
<ul className="space-y-2 px-1 pt-3">
|
||||
{INCLUDED.map((item) => (
|
||||
<li key={item} className="flex items-start gap-2 text-sm">
|
||||
<Check aria-hidden="true" className="mt-1 h-3.5 w-3.5 shrink-0 text-foreground" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<SettingsGroup label={t('group_included')}>
|
||||
<UnlockList className="pt-3" />
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
)
|
||||
@@ -284,22 +227,31 @@ export function BillingSettingsContent() {
|
||||
return (
|
||||
<div>
|
||||
{header}
|
||||
<SettingsGroup label="Ditt abonnemang">
|
||||
<SettingsRow label="Status" borderless>
|
||||
<SettingsGroup label={t('group_yours')}>
|
||||
<SettingsRow label={t('row_status')} borderless>
|
||||
<span className="flex items-start gap-2">
|
||||
<Check aria-hidden="true" className="mt-1 h-3.5 w-3.5 shrink-0 text-foreground" />
|
||||
<span>Klart! Ditt abonnemang är aktiverat och alla funktioner låses upp inom någon minut.</span>
|
||||
<span>{t('paid_just_now')}</span>
|
||||
</span>
|
||||
<SettingsRowNote>Ladda om sidan om du inte ser ändringen.</SettingsRowNote>
|
||||
<SettingsRowNote>{t('paid_just_now_note')}</SettingsRowNote>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Trialing / expired → sell view.
|
||||
const { trialEndsAt, daysLeft } = view
|
||||
const deferredTo = view.chargeDeferred ? trialEndsAt : null
|
||||
// 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 (
|
||||
<div>
|
||||
@@ -309,53 +261,48 @@ export function BillingSettingsContent() {
|
||||
{daysLeft !== null && (
|
||||
<AttnLine className="mt-3">
|
||||
{daysLeft > 0
|
||||
? `Din provperiod löper ut om ${daysLeft} ${daysLeft === 1 ? 'dag' : 'dagar'}${
|
||||
trialEndsAt ? ` (${formatDateLong(trialEndsAt)})` : ''
|
||||
}. ${
|
||||
deferredTo
|
||||
? 'Lägg till ditt kort nu: inget dras förrän provperioden är slut.'
|
||||
: 'Lägg till betalning nu så fortsätter allt utan avbrott.'
|
||||
}`
|
||||
: 'Din provperiod har löpt ut. Aktivera abonnemanget för att få tillbaka AI, bankkoppling och inlämning.'}
|
||||
? t('trial_ends_in', { days: daysLeft, date: trialEndsAt ? formatDateLong(trialEndsAt) : '' })
|
||||
: t('trial_ended')}
|
||||
</AttnLine>
|
||||
)}
|
||||
|
||||
<SettingsGroup
|
||||
label="Allt du behöver för att sköta bokföringen själv"
|
||||
help="Ingen bindningstid · Avsluta när du vill · Säker betalning via Stripe"
|
||||
>
|
||||
<div className="px-1 pt-3">
|
||||
<BillingActions isPaying={false} configured={view.configured} firstChargeAt={deferredTo} />
|
||||
<div className="mt-8 flex flex-wrap items-start justify-between gap-x-6 gap-y-3 px-1">
|
||||
<div>
|
||||
<p className="font-display text-xl tabular-nums leading-8">
|
||||
{formatCurrency(price.exVat)}
|
||||
<span className="text-muted-foreground"> / {t(`period_${plan}`)}</span>
|
||||
</p>
|
||||
<p className="text-xs tabular-nums text-muted-foreground">{priceNote}</p>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
<SettingsSeg
|
||||
value={plan}
|
||||
onChange={setPlan}
|
||||
aria-label={t('row_interval')}
|
||||
options={[
|
||||
{ value: 'monthly', label: t('interval_monthly') },
|
||||
{
|
||||
value: 'yearly',
|
||||
label: (
|
||||
<>
|
||||
{t('interval_yearly')}
|
||||
<span className="ml-2 text-muted-foreground">{t('interval_yearly_save')}</span>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{deferredTo && (
|
||||
<SettingsGroup label="Så funkar det">
|
||||
<SettingsRow label="Idag">
|
||||
<span>Du lägger till ditt kort. Inget dras nu.</span>
|
||||
</SettingsRow>
|
||||
<SettingsRow label={<span className="tabular-nums">{formatDateLong(deferredTo)}</span>}>
|
||||
<span>Provperioden slutar och den första debiteringen sker.</span>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="När som helst" borderless>
|
||||
<span>
|
||||
Avsluta direkt via Stripe. Före {formatDateLong(deferredTo)} kostar det ingenting.
|
||||
</span>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
)}
|
||||
<UnlockList className="mt-6 border-t border-border pt-6" />
|
||||
|
||||
<SettingsGroup
|
||||
label="Funktioner"
|
||||
// The 7-year retention reassurance moved behind the "?": long legal
|
||||
// copy stays out of the page flow.
|
||||
help="Utan abonnemang behåller du bokföringen, fakturorna, rapporterna och all din data utan kostnad. Ingenting raderas: räkenskapsinformation bevaras i sju år enligt bokföringslagen, oavsett abonnemang."
|
||||
>
|
||||
<div className="grid gap-6 px-1 pt-3 sm:grid-cols-2">
|
||||
<FeatureList heading="I abonnemanget" items={INCLUDED} />
|
||||
<FeatureList heading="Alltid gratis" items={ALWAYS_FREE} />
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
<div className="mt-8 border-t border-border px-1 pt-6">
|
||||
<BillingActions isPaying={false} configured={view.configured} plan={plan} firstChargeDeferred={chargeDeferred} />
|
||||
<p className="pt-3 text-xs text-muted-foreground">{termsLine}</p>
|
||||
<p className="pt-1 text-xs text-muted-foreground">
|
||||
{t('without_note')}{' '}
|
||||
<HelpPopover className="ml-1 align-middle">{t('unlock_help')}</HelpPopover>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user