feat(empty-states): startkort on six pages with strata imagery (#1603)

Replace the true-empty states on Kundfakturor, Transaktioner, Underlag,
Loner, Bokforing and Skattekonto with StartCard: a self-contained dark
hero (image-derived ground baked into the strata render, white primary
CTA) that says what the page can do instead of what is missing. Primary
CTAs lead with the connect/setup action per page (bank via PSD2 deep
link, mailboxes, Skatteverket, migration import); filtered/search empty
states and viewer fallbacks keep the old compact states. Design signed
off in the Startkort prototype iterations 2026-08-13.

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:
Jakob Wennberg
2026-08-13 23:27:24 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 08440fed94
commit 57d6651cfc
18 changed files with 556 additions and 248 deletions
+5 -2
View File
@@ -1,6 +1,6 @@
'use client'
import { Fragment, useState, useEffect, useCallback, useRef } from 'react'
import { Fragment, useState, useEffect, useCallback, useRef, type ReactNode } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
@@ -189,7 +189,7 @@ const PAGE_SIZE_VALUES = new Set<PageSizeChoice>(['20', '50', '100', 'all'])
// Sentinel limit sent for "Alla". The route clamps this to its own MAX_LIMIT.
const ALL_PAGE_SIZE = 100000
export default function JournalEntryList() {
export default function JournalEntryList({ pristineSlot }: { pristineSlot?: ReactNode } = {}) {
const router = useRouter()
const { toast } = useToast()
const { canWrite } = useCanWrite()
@@ -932,6 +932,9 @@ export default function JournalEntryList() {
// drafts view) must fall through to the main render below so the
// Verifikat/Utkast toggle stays reachable.
if (!loading && entries.length === 0 && !loadFailed && !hasActiveFilters && listMode === 'committed' && draftCount === 0) {
if (pristineSlot) {
return <>{pristineSlot}</>
}
return (
<DataList className="stagger-enter">
<DataListEmpty
+253
View File
@@ -0,0 +1,253 @@
'use client'
import type { CSSProperties, ReactNode } from 'react'
import Link from 'next/link'
import { X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { START_CARDS, type StartCardName } from './startkort-assets'
// The start card is a self-contained dark surface: like LedgerGraph it carries
// its own palette in both themes, because the strata image bakes its ground
// color into the pixels. Warm white type and a white primary pill sit on top.
// Gradients here are image scrims inside a hero surface, not card chrome
// (see DECISIONS.md 2026-08-13), and rounded-xl is the hero-surface allowance.
const TITLE_COLOR = '#FAF8F1'
const BODY_COLOR = 'rgba(247, 244, 236, 0.72)'
const EYEBROW_COLOR = 'rgba(247, 244, 236, 0.52)'
interface StartCardAction {
label: string
href?: string
onClick?: () => void
}
interface StartCardProps {
card: StartCardName
/** 'side-right': image on the right half. 'bleed-left': full-bleed image,
* text over a scrim on the left. 'center': full-bleed, centered text. */
layout: 'side-right' | 'bleed-left' | 'center'
title: string
body: string
eyebrow?: string
primary: StartCardAction
secondary?: StartCardAction
/** Floating channel badges over the right half (Underlag). */
floatIcons?: boolean
/** Tighter type for narrow containers (the inbox preview pane). */
dense?: boolean
onDismiss?: () => void
dismissLabel?: string
className?: string
}
function ActionButton({ action, kind }: { action: StartCardAction; kind: 'primary' | 'secondary' }) {
const styles =
kind === 'primary'
? 'bg-white text-[#1A1410] hover:bg-white/90 active:bg-white/90 focus-visible:ring-white/60 focus-visible:ring-offset-transparent'
: 'border border-white/30 bg-transparent text-[#F7F4EC] hover:bg-white/10 active:bg-white/10 focus-visible:ring-white/60 focus-visible:ring-offset-transparent'
if (action.href) {
return (
<Button asChild className={styles}>
<Link href={action.href}>{action.label}</Link>
</Button>
)
}
return (
<Button className={styles} onClick={action.onClick}>
{action.label}
</Button>
)
}
// Channel badges for the Underlag card, Wispr-style: translucent circles over
// the image, one deliberately cropped by the card edge. Brand marks inline so
// the card stays self-contained (Gmail's M, WhatsApp's glyph).
const BADGE_STROKE = '#F4F1E8'
function badgeSvg(paths: string) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke={BADGE_STROKE}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
dangerouslySetInnerHTML={{ __html: paths }}
/>
)
}
const FLOAT_BADGES: Array<{ style: CSSProperties; icon: ReactNode }> = [
{
style: { right: '34%', top: '8%', width: 38, height: 38 },
icon: badgeSvg(
'<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
),
},
{
style: { right: '6%', top: '14%', width: 46, height: 46 },
icon: (
<svg viewBox="0 0 48 48" aria-hidden="true">
<path fill="#4caf50" d="M45 16.2l-5 2.75-5 4.75V40h7c1.657 0 3-1.343 3-3V16.2z" />
<path fill="#1e88e5" d="M3 16.2l3.614 1.71L11 22.7V40H4c-1.657 0-3-1.343-3-3V16.2z" />
<polygon fill="#e53935" points="35,11.2 24,19.45 13,11.2 12,17 13,23.7 24,31.95 35,23.7 36,17" />
<path
fill="#c62828"
d="M3 12.298V16.2l8 6.5V11.2L7.4 8.504A1.61 1.61 0 0 0 6.41 8.174C4.527 8.174 3 9.7 3 11.583v.715z"
/>
<path
fill="#fbc02d"
d="M45 12.298V16.2l-8 6.5V11.2l3.6-2.696c.286-.214.633-.33.99-.33C43.473 8.174 45 9.7 45 11.583v.715z"
/>
</svg>
),
},
{
style: { right: '22%', top: '46%', width: 54, height: 54 },
icon: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
fill="#25D366"
d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 0 1-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 0 1-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 0 1 2.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0 0 12.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 0 0 5.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 0 0-3.48-8.413z"
/>
</svg>
),
},
{
style: { right: '5%', top: '64%', width: 42, height: 42 },
icon: badgeSvg(
'<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
),
},
{
style: { right: '26%', bottom: '-8%', width: 50, height: 50 },
icon: badgeSvg(
'<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/>',
),
},
]
export function StartCard({
card,
layout,
title,
body,
eyebrow,
primary,
secondary,
floatIcons = false,
dense = false,
onDismiss,
dismissLabel,
className,
}: StartCardProps) {
const asset = START_CARDS[card]
const g = asset.groundRgb
const scrim =
layout === 'center'
? `radial-gradient(ellipse 90% 130% at 50% 55%, rgba(${g}, 0.7) 0%, rgba(${g}, 0.36) 55%, rgba(${g}, 0.06) 100%)`
: `linear-gradient(90deg, rgba(${g}, 0.92) 0%, rgba(${g}, 0.55) 45%, rgba(${g}, 0.08) 75%)`
return (
<div
className={cn('relative h-[216px] overflow-hidden rounded-xl', className)}
style={{ backgroundColor: asset.ground }}
>
{layout === 'side-right' ? (
<div className="absolute inset-y-0 right-0 w-1/2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={asset.src}
width={asset.w}
height={asset.h}
alt=""
decoding="async"
loading="lazy"
className="h-full w-full object-cover"
style={{ objectPosition: asset.objectPosition }}
/>
<div
className="absolute inset-0"
style={{ background: `linear-gradient(90deg, ${asset.ground} 0%, rgba(${g}, 0) 42%)` }}
/>
</div>
) : (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={asset.src}
width={asset.w}
height={asset.h}
alt=""
decoding="async"
loading="lazy"
className="absolute inset-0 h-full w-full object-cover"
style={{ objectPosition: asset.objectPosition }}
/>
<div className="absolute inset-0" style={{ background: scrim }} />
</>
)}
{floatIcons &&
FLOAT_BADGES.map((badge, i) => (
<div
key={i}
aria-hidden="true"
className="absolute z-[2] flex items-center justify-center rounded-full border border-white/15 backdrop-blur-[3px] [&>svg]:h-[46%] [&>svg]:w-[46%]"
style={{ ...badge.style, backgroundColor: 'rgba(30, 36, 52, 0.55)' }}
>
{badge.icon}
</div>
))}
{onDismiss && (
<button
type="button"
onClick={onDismiss}
aria-label={dismissLabel}
className="absolute right-4 top-3 z-[3] p-1 text-[#EBE5D3]/50 transition-colors duration-150 hover:text-[#EBE5D3]"
>
<X className="h-4 w-4" />
</button>
)}
<div
className={cn(
'relative z-[2] flex h-full flex-col justify-center',
dense ? 'px-6 py-4' : 'px-7 py-5',
layout === 'center'
? 'mx-auto max-w-[70%] items-center text-center'
: floatIcons
? 'max-w-[66%] items-start'
: 'max-w-[58%] items-start',
)}
>
{eyebrow && (
<div
className="mb-2 text-[10.5px] font-medium uppercase tracking-[0.12em]"
style={{ color: EYEBROW_COLOR }}
>
{eyebrow}
</div>
)}
<h2
className={cn('font-display text-balance', dense ? 'text-[19px] leading-snug' : 'text-2xl leading-tight')}
style={{ color: TITLE_COLOR }}
>
{title}
</h2>
<p className="mb-4 mt-2 text-[13px] leading-relaxed" style={{ color: BODY_COLOR }}>
{body}
</p>
<div className={cn('flex flex-wrap gap-2', layout === 'center' && 'justify-center')}>
<ActionButton action={primary} kind="primary" />
{secondary && <ActionButton action={secondary} kind="secondary" />}
</div>
</div>
</div>
)
}
+70
View File
@@ -0,0 +1,70 @@
// Strata renders for the start cards (public/startkort/*.webp). Generated
// deterministically by strata-engine in the CRM workspace (studio/strata):
//
// node stratify.mjs <source> linen-clear --bg <ground> [flags] --name <out>
//
// Per-file source + flags, so any card can be regenerated or re-grounded:
// venice.webp imports/venice-canal-painting/04-commons-giovanni-antonio-canal...jpg --bg #3A2118
// sthlm.webp imports/stockholm-gamla-stan-riddarholmen-waterfront-skyline/05-web...jpg --bg #16303B
// geese.webp imports/byt-hero/02-commons-canadiangeeseflyinginvformation-jpg.jpg --bg #2E3C58
// stopwatch.webp imports/konsult-hero/04-flickr-stopwatch.jpg (night-clear) --bg #322416
// ledger.webp imports/ledger/01-commons-pittsboro-merchant-business-ledger-dpla-.jpg --bg #1F2B20 --tone 0.35
// abacus.webp imports/abacus/04-commons-abacus-of-state-department-store-jpg.jpg (right unit crop) --bg #1B2136 --tone 0.5
//
// The ground color is baked into the render (the gaps between bars carry it),
// so the card background and scrims below must stay in the same hue: change
// them together or the image edge shows a seam. Intrinsic dimensions declared
// up front so the <img> gets width/height and avoids layout shift.
export const START_CARDS = {
venice: {
src: '/startkort/venice.webp',
w: 2400,
h: 1466,
ground: '#3A2118',
groundRgb: '58, 33, 24',
objectPosition: 'center 42%',
},
sthlm: {
src: '/startkort/sthlm.webp',
w: 2400,
h: 1500,
ground: '#16303B',
groundRgb: '22, 48, 59',
objectPosition: 'center 32%',
},
geese: {
src: '/startkort/geese.webp',
w: 2400,
h: 1600,
ground: '#2E3C58',
groundRgb: '46, 60, 88',
objectPosition: '0 26%',
},
stopwatch: {
src: '/startkort/stopwatch.webp',
w: 2400,
h: 1800,
ground: '#322416',
groundRgb: '50, 36, 22',
objectPosition: 'center 30%',
},
ledger: {
src: '/startkort/ledger.webp',
w: 2400,
h: 3602,
ground: '#1F2B20',
groundRgb: '31, 43, 32',
objectPosition: 'center 30%',
},
abacus: {
src: '/startkort/abacus.webp',
w: 2400,
h: 2176,
ground: '#1B2136',
groundRgb: '27, 33, 54',
objectPosition: 'center 35%',
},
} as const
export type StartCardName = keyof typeof START_CARDS
@@ -39,18 +39,16 @@ import {
ExternalLink,
FileQuestion,
Search,
Circle,
X,
ChevronDown,
ChevronRight,
Sparkles,
MessageCircle,
Maximize2,
} from 'lucide-react'
import Link from 'next/link'
import { cn, formatCurrency, formatDate, formatDateLong } from '@/lib/utils'
import { QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import { GoogleMark, MicrosoftMark } from '@/components/ui/provider-marks'
import { StartCard } from '@/components/dashboard/StartCard'
import EditKonteringDialog from '@/components/extensions/general/EditKonteringDialog'
import InvoiceInboxSkeleton from '@/components/extensions/general/InvoiceInboxSkeleton'
import { WhatsAppMark } from '@/components/extensions/general/WhatsAppMark'
@@ -313,6 +311,7 @@ const WorkspaceSkeleton = InvoiceInboxSkeleton
export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
const { toast } = useToast()
const t = useTranslations('inbox_workspace')
const tStart = useTranslations('start_cards')
const fileInputRef = useRef<HTMLInputElement | null>(null)
// Its own input: sharing the header's would upload without the purchase.
const purchaseFileInputRef = useRef<HTMLInputElement | null>(null)
@@ -1572,16 +1571,20 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
// So: compact card on mobile only, quiet empty state on desktop.
showOnboarding ? (
<>
<div className="xl:hidden">
<OnboardingCard
hasInboxAddress={hasInboxAddress}
hasAnyItem={hasAnyItem}
hasResolvedItem={hasResolvedItem}
onActivateInbox={handleRotateAddress}
onUploadClick={() => fileInputRef.current?.click()}
<div className="xl:hidden p-3">
<StartCard
card="geese"
layout="bleed-left"
dense
title={tStart('inbox_title')}
body={tStart('inbox_body')}
primary={{ label: tStart('inbox_primary'), href: '/settings/mail' }}
secondary={{
label: tStart('inbox_secondary'),
onClick: () => fileInputRef.current?.click(),
}}
onDismiss={handleDismissOnboarding}
isActivating={isRotating}
compact
dismissLabel={tStart('inbox_dismiss')}
/>
</div>
<div className="hidden xl:block p-6 text-center text-sm text-muted-foreground">
@@ -1721,16 +1724,27 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
onRetry={() => { void loadDocument(selected.id, selected.document_id) }}
/>
) : showOnboarding ? (
<div className="h-full flex items-center justify-center px-4 py-6">
<OnboardingCard
hasInboxAddress={hasInboxAddress}
hasAnyItem={hasAnyItem}
hasResolvedItem={hasResolvedItem}
onActivateInbox={handleRotateAddress}
onUploadClick={() => fileInputRef.current?.click()}
onDismiss={handleDismissOnboarding}
isActivating={isRotating}
/>
<div className="h-full flex flex-col justify-center px-4 py-6">
<div className="animate-fade-in">
<StartCard
card="geese"
layout="bleed-left"
dense
floatIcons
title={tStart('inbox_title')}
body={tStart('inbox_body')}
primary={{ label: tStart('inbox_primary'), href: '/settings/mail' }}
secondary={{
label: tStart('inbox_secondary'),
onClick: () => fileInputRef.current?.click(),
}}
onDismiss={handleDismissOnboarding}
dismissLabel={tStart('inbox_dismiss')}
/>
<p className="mt-4 text-center text-xs text-muted-foreground">
{t('drop_anywhere_hint')}
</p>
</div>
</div>
) : (
<EmptyPreview
@@ -2206,186 +2220,6 @@ export function DocumentPreview({
// ── Empty preview state ──────────────────────────────────────
// ── Onboarding card ──────────────────────────────────────────
interface OnboardingCardProps {
hasInboxAddress: boolean
hasAnyItem: boolean
hasResolvedItem: boolean
onActivateInbox: () => void
onUploadClick: () => void
onDismiss: () => void
isActivating: boolean
compact?: boolean
}
function OnboardingCard({
hasInboxAddress,
hasAnyItem,
hasResolvedItem,
onActivateInbox,
onUploadClick,
onDismiss,
isActivating,
compact = false,
}: OnboardingCardProps) {
// The card described the page as it was before the underlag rebuild: three
// steps ending at "matcha eller bokför", with no mention that the page now
// searches the mailboxes itself, lists the purchases that are missing a
// receipt, or proposes the kontering. It also carried a Beta badge it had
// outgrown.
const steps = [
{
done: hasInboxAddress,
title: 'Aktivera din inkorgsadress',
hint: 'En egen adress som leverantörer kan fakturera direkt, och som du kan vidarebefordra kvitton till.',
},
{
done: hasAnyItem,
title: 'Koppla en brevlåda, maila in, eller ladda upp',
hint: 'Med en kopplad brevlåda letar Kvittojakten själv upp kvitton till köp som saknar underlag. Utan den fyller du på för hand.',
},
{
done: hasResolvedItem,
title: 'Godkänn konteringen',
hint: 'Matchade underlag får ett förslag på hur de bokförs, utifrån hur du bokfört samma leverantör förut. Du granskar och bokför.',
},
]
// First incomplete step drives the active CTA. Falls back to -1 if all done
// (the parent should have hidden the card by then, but guard anyway).
const currentStep = steps.findIndex((s) => !s.done)
return (
<div
className={cn(
'relative rounded-lg border bg-card',
compact ? 'mx-3 my-3 p-4 text-xs' : 'max-w-md mx-auto p-6'
)}
>
<button
type="button"
onClick={onDismiss}
aria-label="Dölj guide"
className="absolute top-2 right-2 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
<div className={cn('space-y-1', compact ? 'pr-6' : 'pr-8')}>
<div className="flex items-center gap-2 flex-wrap">
<h2
className={cn(
'font-display tracking-tight',
compact ? 'text-sm' : 'text-lg'
)}
>
Så funkar Underlag
</h2>
</div>
<p className={cn('text-muted-foreground', compact ? 'text-[11px]' : 'text-xs')}>
Här samlas underlagen, och här syns köpen som saknar ett. Kvittojakten
söker igenom kopplade brevlådor efter kvitton du inte fått in, och
matchade underlag får ett förslag på kontering att godkänna. Att samla
underlag är alltid gratis; AI-tolkning och kvittojakt ingår i
abonnemanget.
</p>
</div>
<ol className={cn('space-y-2.5', compact ? 'mt-3' : 'mt-5')}>
{steps.map((step, i) => {
const isDone = step.done
const isCurrent = !isDone && i === currentStep
return (
<li
key={step.title}
className={cn('flex items-start gap-2.5', compact && 'gap-2')}
>
<span className="shrink-0 mt-0.5">
{isDone ? (
<Check className={cn('text-success', compact ? 'h-3.5 w-3.5' : 'h-4 w-4')} />
) : isCurrent ? (
<span
className={cn(
'inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground font-medium',
compact ? 'h-3.5 w-3.5 text-[9px]' : 'h-4 w-4 text-[10px]'
)}
>
{i + 1}
</span>
) : (
<Circle className={cn('text-muted-foreground/40', compact ? 'h-3.5 w-3.5' : 'h-4 w-4')} />
)}
</span>
<div className="min-w-0">
<p
className={cn(
'font-medium',
isDone ? 'text-muted-foreground line-through decoration-muted-foreground/40' : 'text-foreground',
compact ? 'text-xs' : 'text-sm'
)}
>
{step.title}
</p>
{!isDone && (
<p
className={cn(
'text-muted-foreground mt-0.5',
compact ? 'text-[11px]' : 'text-xs'
)}
>
{step.hint}
</p>
)}
</div>
</li>
)
})}
</ol>
{/* CTA matches the current step. No CTA for step 3: it requires the user to pick a row. */}
{currentStep === 0 && (
<Button
size={compact ? 'sm' : 'default'}
className={cn('w-full mt-4', compact && 'h-8 text-xs')}
onClick={onActivateInbox}
disabled={isActivating}
>
{isActivating ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<Mail className="h-3.5 w-3.5 mr-1.5" />
)}
Aktivera inkorgsadress
</Button>
)}
{currentStep === 1 && (
<div className={cn('mt-4 space-y-2', compact && 'mt-3')}>
<Button
size={compact ? 'sm' : 'default'}
className={cn('w-full', compact && 'h-8 text-xs')}
onClick={onUploadClick}
>
<Upload className="h-3.5 w-3.5 mr-1.5" />
Ladda upp en fil
</Button>
<p className={cn('text-center text-muted-foreground', compact ? 'text-[10px]' : 'text-[11px]')}>
…eller maila underlagen till din inkorgsadress.
</p>
</div>
)}
{currentStep === 2 && (
<p
className={cn(
'mt-4 text-center italic text-muted-foreground',
compact ? 'text-[11px]' : 'text-xs'
)}
>
Välj en post i listan för att matcha eller bokföra.
</p>
)}
</div>
)
}
function EmptyPreview({
onUploadClick,
+16 -13
View File
@@ -3,6 +3,7 @@
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { EmptyState } from '@/components/ui/empty-state'
import { StartCard } from '@/components/dashboard/StartCard'
import { Check, Upload, Plus } from 'lucide-react'
import Link from 'next/link'
@@ -13,22 +14,24 @@ interface InboxZeroStateProps {
export default function InboxZeroState({ hasTransactions, onCreateTransaction }: InboxZeroStateProps) {
const t = useTranslations('tx_inbox_zero')
const tStart = useTranslations('start_cards')
if (!hasTransactions) {
// No transactions at all
// No transactions at all: the start card leads with the bank connection
// (the import page's own "Rekommenderat" path) and keeps the bank-file
// import as the alternative. Manual creation stays reachable from the
// header split button.
return (
<EmptyState icon={Upload} title={t('empty_title')} description={t('empty_description')}>
<Button asChild>
<Link href="/import">
<Upload className="mr-2 h-4 w-4" />
{t('import_btn')}
</Link>
</Button>
<Button variant="outline" onClick={onCreateTransaction}>
<Plus className="mr-2 h-4 w-4" />
{t('add_manual_btn')}
</Button>
</EmptyState>
<div className="animate-fade-in">
<StartCard
card="sthlm"
layout="bleed-left"
title={tStart('transactions_title')}
body={tStart('transactions_body')}
primary={{ label: tStart('transactions_primary'), href: '/import?mode=psd2' }}
secondary={{ label: tStart('transactions_secondary'), href: '/import?mode=bank' }}
/>
</div>
)
}