diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx
index 629a1b76..2582262d 100644
--- a/app/(auth)/login/page.tsx
+++ b/app/(auth)/login/page.tsx
@@ -10,7 +10,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
import { Loader2, Mail, ArrowLeft, KeyRound, ExternalLink } from 'lucide-react'
-import Image from 'next/image'
+import { BrandWordmark } from '@/components/branding/BrandWordmark'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { isBankIdEnabled } from '@/lib/auth/bankid'
import { BankIdAuth } from '@/components/auth/BankIdAuth'
@@ -376,14 +376,7 @@ function LoginPageContent() {
-
+
{tAuth('login_subtitle')}
diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx
index c1473f0d..0fe8221f 100644
--- a/app/(auth)/register/page.tsx
+++ b/app/(auth)/register/page.tsx
@@ -10,7 +10,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
import { Loader2, Mail, ArrowLeft, ExternalLink } from 'lucide-react'
-import Image from 'next/image'
+import { BrandWordmark } from '@/components/branding/BrandWordmark'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { isBankIdEnabled } from '@/lib/auth/bankid'
import { BankIdAuth } from '@/components/auth/BankIdAuth'
@@ -410,14 +410,7 @@ function RegisterPageContent() {
-
+
{t('subtitle')}
diff --git a/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx b/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx
index 2e1e6049..a0a4dc4b 100644
--- a/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx
+++ b/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx
@@ -700,7 +700,7 @@ export default function ArsredovisningPage() {
Notis om digital inlämning: Bolagsverket har föreslagit att
digital inlämning (iXBRL) av årsredovisning för aktiebolag ska bli
obligatorisk — beslut och ikraftträdande är ännu inte fastställda. Idag är
- PDF-inlämning fortfarande godkänd. Gnubok stödjer för närvarande endast
+ PDF-inlämning fortfarande godkänd. Accounted stödjer för närvarande endast
PDF-utkast; iXBRL-generering är planerad till en kommande version.
diff --git a/app/(dashboard)/chat/layout.tsx b/app/(dashboard)/chat/layout.tsx
index 1883ec0b..232fc8ba 100644
--- a/app/(dashboard)/chat/layout.tsx
+++ b/app/(dashboard)/chat/layout.tsx
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { getActiveCompanyId } from '@/lib/company/context'
+import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent'
import ChatSidebar from '@/components/agent/ChatSidebar'
export const dynamic = 'force-dynamic'
@@ -21,11 +22,31 @@ export default async function ChatLayout({ children }: { children: React.ReactNo
// empty conversations list with no Anna to talk to. The home route at /
// renders NewUserChecklist for the same state, so we forward there
// instead of duplicating the welcome screen here.
- const { data: agent } = await supabase
+ let { data: agent } = await supabase
.from('agent_profiles')
.select('verified_at')
.eq('company_id', companyId)
.maybeSingle()
+
+ // Sandbox sessions get a pre-built assistant — backfill if a pre-seed
+ // session is missing it so /chat doesn't bounce back to / in a loop.
+ if (!agent?.verified_at) {
+ const { data: settings } = await supabase
+ .from('company_settings')
+ .select('is_sandbox')
+ .eq('company_id', companyId)
+ .maybeSingle()
+ if (settings?.is_sandbox) {
+ await ensureSandboxAgentProfile(supabase, companyId)
+ const refresh = await supabase
+ .from('agent_profiles')
+ .select('verified_at')
+ .eq('company_id', companyId)
+ .maybeSingle()
+ agent = refresh.data
+ }
+ }
+
if (!agent?.verified_at) redirect('/')
const { data: conversations } = await supabase
diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx
index c6494210..a5ac505f 100644
--- a/app/(dashboard)/layout.tsx
+++ b/app/(dashboard)/layout.tsx
@@ -12,6 +12,7 @@ import { getExtensionNavItems } from '@/lib/extensions/sectors'
import { CompanyProvider } from '@/contexts/CompanyContext'
import { getActiveCompanyId } from '@/lib/company/context'
import { getBranding } from '@/lib/branding/service'
+import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent'
import type { EntityType, CompanyRole, Team } from '@/types'
/**
@@ -209,6 +210,23 @@ export default async function DashboardLayout({
const isSandbox = settings?.is_sandbox === true
+ // Backfill a verified agent_profile for sandbox sessions that pre-date the
+ // seed change. Without this an old anonymous session shows the "Bygg din
+ // bokföringsassistent" CTA in three places (dashboard hero, NewUserChecklist
+ // step 4, /chat layout redirect) and the user can still kick off a build
+ // flow that the server now 403s. Best-effort; doesn't block the layout
+ // even if the insert fails.
+ let resolvedAgentIdentity = agentProfileIdentity
+ if (isSandbox && !agentProfileIdentity?.verified_at) {
+ await ensureSandboxAgentProfile(supabase, companyId)
+ const { data: refreshed } = await supabase
+ .from('agent_profiles')
+ .select('display_name, avatar_id, verified_at')
+ .eq('company_id', companyId)
+ .maybeSingle()
+ resolvedAgentIdentity = refreshed ?? agentProfileIdentity
+ }
+
const companyContextValue = {
company: companyWithName,
role: memberRow.role as CompanyRole,
@@ -229,9 +247,9 @@ export default async function DashboardLayout({
diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx
index 5b6af597..0c027382 100644
--- a/app/(dashboard)/page.tsx
+++ b/app/(dashboard)/page.tsx
@@ -5,6 +5,7 @@ import DashboardContent from '@/components/dashboard/DashboardContent'
import WelcomeGate from '@/components/onboarding/WelcomeGate'
import { getActiveCompanyId } from '@/lib/company/context'
import { getDisplayTotal } from '@/lib/invoices/rounding'
+import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent'
import type { Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
export const dynamic = 'force-dynamic'
@@ -122,7 +123,22 @@ export default async function DashboardPage() {
redirect('/onboarding')
}
- const agentBuilt = Boolean(agentProfile?.verified_at)
+ // Sandbox sessions that pre-date the agent_profile seeding step would
+ // otherwise still see the "Bygg din bokföringsassistent" hero + the
+ // NewUserChecklist's agent step lit up. Backfill here so the next render
+ // sees a verified profile and treats the sandbox as fully set up.
+ let effectiveAgentVerified = agentProfile?.verified_at ?? null
+ if (settings?.is_sandbox === true && !effectiveAgentVerified) {
+ await ensureSandboxAgentProfile(supabase, companyId)
+ const { data: refreshed } = await supabase
+ .from('agent_profiles')
+ .select('verified_at')
+ .eq('company_id', companyId)
+ .maybeSingle()
+ effectiveAgentVerified = refreshed?.verified_at ?? null
+ }
+
+ const agentBuilt = Boolean(effectiveAgentVerified)
// "Has the company already been used?" Any real business data means we must
// NOT hijack the dashboard with the full-screen onboarding gate — existing
diff --git a/app/(onboarding)/onboarding/agent/page.tsx b/app/(onboarding)/onboarding/agent/page.tsx
index 552846dd..941ae7d5 100644
--- a/app/(onboarding)/onboarding/agent/page.tsx
+++ b/app/(onboarding)/onboarding/agent/page.tsx
@@ -21,6 +21,17 @@ export default async function AgentOnboardingPage() {
const companyId = await getActiveCompanyId(supabase, user.id)
if (!companyId) redirect('/onboarding')
+ // Sandbox companies ship with a pre-built verified agent_profile — the
+ // build flow on this page would call TIC and the gated composer stream,
+ // both of which 403. Send them back to the dashboard where the demo
+ // assistant is already visible via the sheet preview.
+ const { data: settingsForSandbox } = await supabase
+ .from('company_settings')
+ .select('is_sandbox')
+ .eq('company_id', companyId)
+ .maybeSingle()
+ if (settingsForSandbox?.is_sandbox) redirect('/')
+
// Trigger the TIC live-fetch + cache before the field-resolving query
// below. ensureTicSnapshot is fast on cache-hit (single SELECT) and
// best-effort on miss — it never throws. Phase A still runs through the
diff --git a/app/api/agent/composer/route.ts b/app/api/agent/composer/route.ts
index 7c5c1de9..e1bd3ba3 100644
--- a/app/api/agent/composer/route.ts
+++ b/app/api/agent/composer/route.ts
@@ -4,6 +4,7 @@ import { z } from 'zod'
import { getActiveCompanyId } from '@/lib/company/context'
import { checkAgentRateLimit, agentRateLimitResponseBody } from '@/lib/rate-limits/agent'
import { composeAgentProfile } from '@/lib/agent/composer'
+import { guardSandbox } from '@/lib/sandbox/guard'
const BodySchema = z.object({
// Optional override; if absent we use the user's active_company_id.
@@ -63,6 +64,9 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Not a member of this company' }, { status: 403 })
}
+ const blocked = await guardSandbox(supabase, companyId)
+ if (blocked) return blocked
+
try {
const composed = await composeAgentProfile(supabase, companyId, { dryRun: body.dry_run })
return NextResponse.json({ data: composed })
diff --git a/app/api/agent/invoke/route.ts b/app/api/agent/invoke/route.ts
index 061c35b0..d2fb0e80 100644
--- a/app/api/agent/invoke/route.ts
+++ b/app/api/agent/invoke/route.ts
@@ -6,6 +6,7 @@ import { getActiveCompanyId } from '@/lib/company/context'
import { getIntent } from '@/lib/agent/intents/registry'
import { checkAgentRateLimit, agentRateLimitResponseBody } from '@/lib/rate-limits/agent'
import { runChatTurn, friendlyModelError } from '@/lib/agent/chat/run-turn'
+import { guardSandbox } from '@/lib/sandbox/guard'
// Make sure extensions are loaded — the chat loop dispatches against the
// agent tool registry which is populated by the mcp-server extension at load.
@@ -105,6 +106,11 @@ export async function POST(request: Request) {
.maybeSingle()
if (!membership) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
+ // No Anthropic Bedrock calls in the sandbox — the demo runs entirely on
+ // seed data and the assistant is gated to a "look, don't touch" preview.
+ const blocked = await guardSandbox(supabase, companyId)
+ if (blocked) return blocked
+
// onboarding.intake completion signal — once the user has actually
// engaged (typed a real reply, not the auto-fired greeting prompt that
// mounts the chat), stamp intake_completed_at on the profile so re-entry
diff --git a/app/api/agent/onboarding/stream/route.ts b/app/api/agent/onboarding/stream/route.ts
index 5d119796..913d8b8d 100644
--- a/app/api/agent/onboarding/stream/route.ts
+++ b/app/api/agent/onboarding/stream/route.ts
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { getActiveCompanyId } from '@/lib/company/context'
+import { guardSandbox } from '@/lib/sandbox/guard'
import { checkAgentRateLimit, agentRateLimitResponseBody } from '@/lib/rate-limits/agent'
import { gatherComposerInputs, inputsToSourceSignals } from '@/lib/agent/composer/inputs'
import { selectAtoms } from '@/lib/agent/composer/atom-selection'
@@ -100,6 +101,11 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Not a member of this company' }, { status: 403 })
}
+ // No live composer run for sandbox companies — they ship with a pre-built
+ // verified agent_profile so the chrome is visible without burning Bedrock.
+ const blocked = await guardSandbox(supabase, companyId)
+ if (blocked) return blocked
+
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder()
diff --git a/app/api/currency/rate/route.ts b/app/api/currency/rate/route.ts
index c7208e42..16997579 100644
--- a/app/api/currency/rate/route.ts
+++ b/app/api/currency/rate/route.ts
@@ -1,6 +1,8 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
+import { getActiveCompanyId } from '@/lib/company/context'
+import { guardSandbox } from '@/lib/sandbox/guard'
import type { Currency } from '@/types'
const VALID_CURRENCIES: Currency[] = ['EUR', 'USD', 'GBP', 'NOK', 'DKK']
@@ -12,6 +14,16 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
+ const companyId = await getActiveCompanyId(supabase, user.id)
+ // Refuse the request when no active company resolves rather than letting
+ // a session without one slip past the sandbox guard. Riksbanken's open
+ // API is IP rate-limited; we don't want demo traffic eating that budget.
+ if (!companyId) {
+ return NextResponse.json({ error: 'No active company' }, { status: 400 })
+ }
+ const blocked = await guardSandbox(supabase, companyId)
+ if (blocked) return blocked
+
const { searchParams } = new URL(request.url)
const currency = searchParams.get('currency') as Currency | null
const dateStr = searchParams.get('date')
diff --git a/app/api/invoices/[id]/send/__tests__/route.test.ts b/app/api/invoices/[id]/send/__tests__/route.test.ts
index adac2750..e84131d4 100644
--- a/app/api/invoices/[id]/send/__tests__/route.test.ts
+++ b/app/api/invoices/[id]/send/__tests__/route.test.ts
@@ -65,6 +65,15 @@ vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
mockCreateInvoiceJournalEntry(...args),
}))
+// The sandbox guard issues a company_settings query at the top of the route;
+// short-circuit it in tests since the queued mock-supabase is shaped for the
+// route's existing fetch chain, not an extra pre-flight read.
+vi.mock('@/lib/sandbox/guard', () => ({
+ guardSandbox: vi.fn().mockResolvedValue(null),
+ isSandboxCompany: vi.fn().mockResolvedValue(false),
+ sandboxBlockedResponse: vi.fn(),
+}))
+
import { POST } from '../route'
describe('POST /api/invoices/[id]/send', () => {
diff --git a/app/api/invoices/[id]/send/route.ts b/app/api/invoices/[id]/send/route.ts
index 28b6a70f..11002936 100644
--- a/app/api/invoices/[id]/send/route.ts
+++ b/app/api/invoices/[id]/send/route.ts
@@ -15,6 +15,7 @@ import { uploadDocument } from '@/lib/core/documents/document-service'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
+import { guardSandbox } from '@/lib/sandbox/guard'
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
ensureInitialized()
@@ -26,6 +27,11 @@ export const POST = withRouteContext(
const { user, supabase, companyId, log, requestId } = ctx
const opLog = log.child({ invoiceId: id })
+ // The sandbox must never deliver a real email to a real customer — block
+ // the entire send pipeline (PDF render + Resend send + status flip).
+ const blocked = await guardSandbox(supabase, companyId)
+ if (blocked) return blocked
+
const emailService = getEmailService()
if (!emailService.isConfigured()) {
return errorResponseFromCode('INVOICE_SEND_EMAIL_NOT_CONFIGURED', opLog, { requestId })
diff --git a/app/api/sandbox/seed/route.ts b/app/api/sandbox/seed/route.ts
index cc47eef1..42d46024 100644
--- a/app/api/sandbox/seed/route.ts
+++ b/app/api/sandbox/seed/route.ts
@@ -1,10 +1,12 @@
import crypto from 'crypto'
+import type { SupabaseClient } from '@supabase/supabase-js'
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getActiveCompanyId } from '@/lib/company/context'
import { createLogger } from '@/lib/logger'
import { checkRateLimit } from '@/lib/auth/rate-limit-http'
import { truncateIp } from '@/lib/api/v1/with-api-v1'
+import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent'
// Anonymous sign-in is enabled in all environments so visitors can try the
// product; a per-/24 cap on the seed endpoint keeps a single network from
@@ -80,7 +82,11 @@ export async function POST(request: Request) {
companyId = newCompanyId as string
}
- // Idempotency: if already seeded, return early
+ // Idempotency: if the core seed already ran (company_settings exists), skip
+ // the bulk insert path. We still TOP UP the newer surfaces (agent_profile,
+ // suppliers, asset, pending operations) afterwards so an old sandbox session
+ // — created before those were added to the seed — picks them up on the next
+ // call instead of being stuck without a verified assistant.
const { data: existing } = await supabase
.from('company_settings')
.select('id')
@@ -88,7 +94,13 @@ export async function POST(request: Request) {
.maybeSingle()
if (existing) {
- return NextResponse.json({ seeded: false })
+ try {
+ await topUpSandboxAdditions(supabase, companyId)
+ return NextResponse.json({ seeded: false, topped_up: true })
+ } catch (err) {
+ log.error('failed to top up sandbox additions', { error: err, userId: user.id, companyId })
+ return NextResponse.json({ seeded: false, topped_up: false })
+ }
}
try {
@@ -581,6 +593,246 @@ export async function POST(request: Request) {
if (dlError) throw dlError
+ // 13. Seed suppliers + one registered supplier invoice + one paid one.
+ // Supplier invoices are arguably the second-most-used surface after
+ // bank transactions; without them the /suppliers and /supplier-invoices
+ // pages render the empty state and the demo loses a big chunk of the
+ // accounts-payable story.
+ // Supplier names use the "Demo" prefix and the documentation-reserved
+ // 5559... org-number range so the seeded rows cannot be confused with
+ // production data should they ever leak into a real environment.
+ const { data: suppliers, error: supError } = await supabase
+ .from('suppliers')
+ .insert([
+ {
+ user_id: userId,
+ company_id: companyId,
+ name: 'Demo Telekom AB',
+ supplier_type: 'swedish_business',
+ org_number: '5559000001',
+ vat_number: 'SE555900000101',
+ email: 'demo+telekom@example.com',
+ bankgiro: '5559-0001',
+ address_line1: 'Demovägen 10',
+ postal_code: '111 22',
+ city: 'Stockholm',
+ country: 'SE',
+ default_payment_terms: 30,
+ },
+ {
+ user_id: userId,
+ company_id: companyId,
+ name: 'Demokafé AB',
+ supplier_type: 'swedish_business',
+ org_number: '5559000002',
+ vat_number: 'SE555900000201',
+ bankgiro: '5559-0002',
+ address_line1: 'Demovägen 11',
+ postal_code: '111 22',
+ city: 'Stockholm',
+ country: 'SE',
+ default_payment_terms: 15,
+ },
+ ])
+ .select('id, name')
+
+ if (supError) throw supError
+ const supplierMap = Object.fromEntries(suppliers.map(s => [s.name, s.id]))
+
+ // Supplier invoice #1 — Telia, paid 15 days ago (mobile + bredband, 25% VAT).
+ const sevenDaysFromNow = new Date(today)
+ sevenDaysFromNow.setDate(today.getDate() + 7)
+
+ // Hardcode 1 and 2 — get_next_arrival_number is MAX+1 against the same
+ // table we're about to insert into, so calling it twice before the first
+ // insert lands gives the same value for both rows and violates the
+ // (company_id, arrival_number) unique index. The company is brand new
+ // here, so 1 and 2 are guaranteed to be free.
+ const { data: supInvoices, error: supInvError } = await supabase
+ .from('supplier_invoices')
+ .insert([
+ {
+ user_id: userId,
+ company_id: companyId,
+ supplier_id: supplierMap['Demo Telekom AB'],
+ arrival_number: 1,
+ supplier_invoice_number: '4711-2026-03',
+ invoice_date: toDateStr(thirtyDaysAgo),
+ due_date: toDateStr(today),
+ received_date: toDateStr(thirtyDaysAgo),
+ status: 'paid',
+ currency: 'SEK',
+ subtotal: 480,
+ vat_amount: 120,
+ total: 600,
+ payment_reference: '47112026031',
+ paid_at: toDateStr(fifteenDaysAgo),
+ paid_amount: 600,
+ },
+ {
+ user_id: userId,
+ company_id: companyId,
+ supplier_id: supplierMap['Demokafé AB'],
+ arrival_number: 2,
+ supplier_invoice_number: '88245',
+ invoice_date: toDateStr(fiveDaysAgo),
+ due_date: toDateStr(sevenDaysFromNow),
+ received_date: toDateStr(fiveDaysAgo),
+ status: 'registered',
+ currency: 'SEK',
+ subtotal: 240,
+ vat_amount: 28.80,
+ total: 268.80,
+ // Must be set explicitly: PostgREST normalizes columns across
+ // rows in a bulk insert, so omitting paid_amount here while the
+ // first row sets it sends null instead of falling through to the
+ // schema default (0), violating the NOT NULL constraint.
+ paid_amount: 0,
+ },
+ ])
+ .select('id, supplier_invoice_number')
+
+ if (supInvError) throw supInvError
+ const supInvoiceMap = Object.fromEntries(
+ supInvoices.map(s => [s.supplier_invoice_number, s.id])
+ )
+
+ // Supplier invoice line items. Note: supplier_invoice_items.vat_rate is
+ // stored as a decimal (0.25 = 25%); invoice_items.vat_rate above uses
+ // integer percent (25). Two different conventions inherited from earlier
+ // migrations — don't try to "fix" it here.
+ const { error: supItemsError } = await supabase
+ .from('supplier_invoice_items')
+ .insert([
+ {
+ supplier_invoice_id: supInvoiceMap['4711-2026-03'],
+ description: 'Mobil + bredband — mars',
+ quantity: 1,
+ unit_price: 480,
+ line_total: 480,
+ vat_rate: 0.25,
+ vat_amount: 120,
+ account_number: '6212',
+ },
+ {
+ supplier_invoice_id: supInvoiceMap['88245'],
+ description: 'Kundmöte Demokafé (representation)',
+ quantity: 1,
+ unit_price: 240,
+ line_total: 240,
+ vat_rate: 0.12,
+ vat_amount: 28.80,
+ account_number: '5810',
+ },
+ ])
+
+ if (supItemsError) throw supItemsError
+
+ // 14. Add one fully-depreciable asset (laptop) so /assets shows
+ // something other than a Package empty state. Acquired 18 months ago,
+ // 60-month linear depreciation. Cost set above the 2026
+ // förbrukningsinventarier threshold (half prisbasbelopp ≈ 29 600 SEK)
+ // so the demo unambiguously illustrates capitalization rather than
+ // direct expensing.
+ const eighteenMonthsAgo = new Date(today)
+ eighteenMonthsAgo.setMonth(today.getMonth() - 18)
+ const { error: assetError } = await supabase
+ .from('assets')
+ .insert({
+ user_id: userId,
+ company_id: companyId,
+ name: 'Demo-laptop',
+ category: 'computer',
+ acquisition_date: toDateStr(eighteenMonthsAgo),
+ acquisition_cost: 35000,
+ salvage_value: 0,
+ useful_life_months: 60,
+ depreciation_method: 'linear',
+ bas_asset_account: '1250',
+ bas_accumulated_account: '1259',
+ bas_expense_account: '7831',
+ notes: 'Demo-tillgång — visar planenlig avskrivning över 5 år.',
+ })
+
+ if (assetError) throw assetError
+
+ // 15. Pre-built, verified agent_profile so the assistant chrome (FAB,
+ // /chat surface, agent identity in nav) renders without firing a
+ // composer run. The chat itself is server-gated by guardSandbox().
+ // Delegated to ensureSandboxAgentProfile so the persona lives in one
+ // place (this seed, the dashboard/chat layout backfill, and the seed
+ // top-up path all use the same helper).
+ await ensureSandboxAgentProfile(supabase, companyId)
+
+ // 16. Pre-staged pending_operations so /pending isn't empty.
+ // These are the kind of operation the AI agent would stage; pre-seeded
+ // here so the user can see the approval queue UI (preview, period
+ // status, risk level) without having to invoke the disabled AI.
+ // actor_type='agent_chat' + risk_level on the row itself is required by
+ // pending_operations_chat_insert (the only RLS policy that lets a
+ // user-scoped client INSERT into this table).
+ const { error: pendOpsError } = await supabase
+ .from('pending_operations')
+ .insert([
+ {
+ user_id: userId,
+ company_id: companyId,
+ operation_type: 'create_supplier_invoice_from_inbox',
+ status: 'pending',
+ actor_type: 'agent_chat',
+ risk_level: 'low',
+ // Uses a distinct supplier_invoice_number so approving this
+ // pending operation creates a NEW supplier_invoices row instead
+ // of colliding with the Demokafé '88245' already booked above
+ // (BFL 5 kap — each affärshändelse must be recorded exactly once).
+ title: 'Registrera leverantörsfaktura — Demokafé (representation, nytt underlag)',
+ params: {
+ supplier_id: supplierMap['Demokafé AB'],
+ supplier_invoice_number: 'INKOMMANDE-2026-001',
+ invoice_date: toDateStr(fiveDaysAgo),
+ due_date: toDateStr(sevenDaysFromNow),
+ total: 268.80,
+ vat_amount: 28.80,
+ account_number: '5810',
+ },
+ preview_data: {
+ // Representation @ 12% VAT (café meal), 240 SEK excl. VAT for
+ // a single attendee. The avdragsrätt cap is 25% × 300 SEK ×
+ // antal_personer = 75 SEK / person (ML 8 kap. 9 §); since the
+ // VAT here is 28.80 SEK the full amount is deductible and the
+ // cost lands in 5810 — no split needed.
+ preview_lines: [
+ { account: '5810', description: 'Representation (12% moms, ≤ 75 SEK moms/pers)', debit: 240, credit: 0 },
+ { account: '2641', description: 'Ingående moms', debit: 28.80, credit: 0 },
+ { account: '2440', description: 'Leverantörsskulder', debit: 0, credit: 268.80 },
+ ],
+ },
+ },
+ {
+ user_id: userId,
+ company_id: companyId,
+ operation_type: 'categorize_transaction',
+ status: 'pending',
+ actor_type: 'agent_chat',
+ risk_level: 'low',
+ title: 'Bokför insättning — bankgiro',
+ params: {
+ account_number: '3001',
+ is_business: true,
+ vat_treatment: 'standard_25',
+ },
+ preview_data: {
+ preview_lines: [
+ { account: '1930', description: 'Företagskonto', debit: 1200, credit: 0 },
+ { account: '2611', description: 'Utgående moms 25%', debit: 0, credit: 240 },
+ { account: '3001', description: 'Försäljning 25% moms', debit: 0, credit: 960 },
+ ],
+ },
+ },
+ ])
+
+ if (pendOpsError) throw pendOpsError
+
return NextResponse.json({ seeded: true })
} catch (err) {
log.error('failed to seed sandbox data', { error: err, userId: user.id, companyId })
@@ -590,3 +842,17 @@ export async function POST(request: Request) {
)
}
}
+
+/**
+ * Idempotent top-up for sandboxes that pre-date the agent_profile addition
+ * to the seed. Re-running the seed on those older sandboxes short-circuits
+ * at the company_settings idempotency check above, so they never get the
+ * agent_profile without this hook. Delegates to ensureSandboxAgentProfile
+ * so the profile data stays in exactly one place.
+ */
+async function topUpSandboxAdditions(
+ supabase: SupabaseClient,
+ companyId: string,
+): Promise {
+ await ensureSandboxAgentProfile(supabase, companyId)
+}
diff --git a/app/api/transactions/[id]/refresh-exchange-rate/route.ts b/app/api/transactions/[id]/refresh-exchange-rate/route.ts
index b685752a..d61d3e5a 100644
--- a/app/api/transactions/[id]/refresh-exchange-rate/route.ts
+++ b/app/api/transactions/[id]/refresh-exchange-rate/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
+import { guardSandbox } from '@/lib/sandbox/guard'
import type { Currency, Transaction } from '@/types'
export const POST = withRouteContext(
@@ -10,6 +11,9 @@ export const POST = withRouteContext(
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
+ const blocked = await guardSandbox(supabase, companyId)
+ if (blocked) return blocked
+
const { data: transaction, error: fetchError } = await supabase
.from('transactions')
.select('*')
diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts
index 364d0013..d3c7857b 100644
--- a/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts
+++ b/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts
@@ -73,6 +73,15 @@ vi.mock('@/lib/invoices/pdf-template', () => ({
InvoicePDF: vi.fn().mockReturnValue({}),
brandingFromCompanySettings: vi.fn().mockReturnValue({}),
}))
+
+// The sandbox guard reads company_settings.is_sandbox at the top of the
+// route; the per-table mock supabase below has no row for that lookup so
+// short-circuit the guard in tests.
+vi.mock('@/lib/sandbox/guard', () => ({
+ guardSandbox: vi.fn().mockResolvedValue(null),
+ isSandboxCompany: vi.fn().mockResolvedValue(false),
+ sandboxBlockedResponse: vi.fn(),
+}))
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts
index eb58fa4b..536e11bd 100644
--- a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts
+++ b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts
@@ -54,6 +54,7 @@ import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { eventBus } from '@/lib/events'
+import { guardSandbox } from '@/lib/sandbox/guard'
import type { CompanySettings, Customer, EntityType, Invoice, InvoiceItem } from '@/types'
const INVOICE_SEND_RESPONSE_COLUMNS =
@@ -136,6 +137,11 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
+ // Sandbox demo never sends a real email — guard the whole pipeline
+ // before any number is allocated or PDF is rendered.
+ const blocked = await guardSandbox(ctx.supabase, ctx.companyId!)
+ if (blocked) return blocked
+
// Step 1: email service configured?
const emailService = getEmailService()
if (!emailService.isConfigured()) {
diff --git a/app/api/vat/validate/route.ts b/app/api/vat/validate/route.ts
index 82d87a8d..6e3e323d 100644
--- a/app/api/vat/validate/route.ts
+++ b/app/api/vat/validate/route.ts
@@ -4,6 +4,7 @@ import { validateBody } from '@/lib/api/validate'
import { ValidateVatNumberSchema } from '@/lib/api/schemas'
import { validateVatNumber } from '@/lib/vat/vies-client'
import { requireCompanyId } from '@/lib/company/context'
+import { guardSandbox } from '@/lib/sandbox/guard'
export async function POST(request: Request) {
const supabase = await createClient()
@@ -16,6 +17,11 @@ export async function POST(request: Request) {
const companyId = await requireCompanyId(supabase, user.id)
+ // VIES is a live external call to the EU Commission — block in the sandbox
+ // so the demo can't generate background traffic against it.
+ const blocked = await guardSandbox(supabase, companyId)
+ if (blocked) return blocked
+
const result = await validateBody(request, ValidateVatNumberSchema)
if (!result.success) return result.response
const { vat_number, customer_id } = result.data
diff --git a/app/companies/new/page.tsx b/app/companies/new/page.tsx
index faf27bba..d17b96db 100644
--- a/app/companies/new/page.tsx
+++ b/app/companies/new/page.tsx
@@ -3,7 +3,6 @@
import { useState, useEffect, Suspense } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
-import Image from 'next/image'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
import { createCompanyFromOnboarding } from '@/lib/company/actions'
@@ -238,14 +237,9 @@ function NewCompanyContent() {
>
-
- {branding.appName.toLowerCase()}
+
+ {branding.appName.toLowerCase()}
+
{STEP_INFO.map((_, i) => {
diff --git a/app/docs/api/changelog/page.tsx b/app/docs/api/changelog/page.tsx
index 9510c14a..cbffd740 100644
--- a/app/docs/api/changelog/page.tsx
+++ b/app/docs/api/changelog/page.tsx
@@ -4,8 +4,8 @@ import { DocsMarkdown } from '@/lib/docs/markdown'
import { CHANGELOG_MD } from '@/lib/docs/content/changelog'
export const metadata: Metadata = {
- title: 'Changelog · gnubok API',
- description: 'Reverse-chronological release notes for the gnubok REST API.',
+ title: 'Changelog · accounted API',
+ description: 'Reverse-chronological release notes for the accounted REST API.',
}
export default function DocsApiChangelogPage() {
diff --git a/app/docs/api/cookbook/[slug]/page.tsx b/app/docs/api/cookbook/[slug]/page.tsx
index cf07cad9..a400a7d9 100644
--- a/app/docs/api/cookbook/[slug]/page.tsx
+++ b/app/docs/api/cookbook/[slug]/page.tsx
@@ -13,7 +13,7 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
const entry = findRecipe(slug)
if (!entry) return { title: 'Not found' }
return {
- title: `${entry.title} · gnubok API cookbook`,
+ title: `${entry.title} · accounted API cookbook`,
description: entry.description,
}
}
diff --git a/app/docs/api/errors/page.tsx b/app/docs/api/errors/page.tsx
index 31e0061c..d253e0e2 100644
--- a/app/docs/api/errors/page.tsx
+++ b/app/docs/api/errors/page.tsx
@@ -4,8 +4,8 @@ import { DocsMarkdown } from '@/lib/docs/markdown'
import { buildErrorReferenceMd } from '@/lib/docs/content/errors'
export const metadata: Metadata = {
- title: 'Errors · gnubok API',
- description: 'Every stable error code returned by the gnubok REST API, with HTTP status, description, and remediation.',
+ title: 'Errors · accounted API',
+ description: 'Every stable error code returned by the accounted REST API, with HTTP status, description, and remediation.',
}
export default function DocsApiErrorsPage() {
diff --git a/app/docs/api/page.tsx b/app/docs/api/page.tsx
index c6d9ac1b..647c12e8 100644
--- a/app/docs/api/page.tsx
+++ b/app/docs/api/page.tsx
@@ -6,7 +6,7 @@ import { LANDING_MD } from '@/lib/docs/content/landing'
import Link from 'next/link'
export const metadata: Metadata = {
- title: 'gnubok API · Documentation',
+ title: 'accounted API · Documentation',
description: 'Swedish double-entry bookkeeping as a public REST API for agents and integrations.',
}
diff --git a/app/docs/api/reference/[slug]/page.tsx b/app/docs/api/reference/[slug]/page.tsx
index d9af6baa..0b835393 100644
--- a/app/docs/api/reference/[slug]/page.tsx
+++ b/app/docs/api/reference/[slug]/page.tsx
@@ -13,7 +13,7 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
const page = buildResourcePages().find((p) => p.slug === slug)
if (!page) return { title: 'Not found' }
return {
- title: `${page.label} · gnubok API`,
+ title: `${page.label} · accounted API`,
description: page.description,
}
}
diff --git a/app/docs/api/reference/page.tsx b/app/docs/api/reference/page.tsx
index 54ab6297..013ec82f 100644
--- a/app/docs/api/reference/page.tsx
+++ b/app/docs/api/reference/page.tsx
@@ -4,8 +4,8 @@ import { DocsMarkdown } from '@/lib/docs/markdown'
import { buildReferenceOverviewMd } from '@/lib/docs/content/reference'
export const metadata: Metadata = {
- title: 'API reference · gnubok API',
- description: 'Every endpoint exposed by the gnubok REST API, grouped by resource.',
+ title: 'API reference · accounted API',
+ description: 'Every endpoint exposed by the accounted REST API, grouped by resource.',
}
export default function DocsApiReferencePage() {
diff --git a/app/docs/api/versioning/page.tsx b/app/docs/api/versioning/page.tsx
index 618f383d..bf5680b2 100644
--- a/app/docs/api/versioning/page.tsx
+++ b/app/docs/api/versioning/page.tsx
@@ -4,7 +4,7 @@ import { DocsMarkdown } from '@/lib/docs/markdown'
import { VERSIONING_MD } from '@/lib/docs/content/versioning'
export const metadata: Metadata = {
- title: 'Versioning · gnubok API',
+ title: 'Versioning · accounted API',
description: 'How API versions are pinned, upgraded, and deprecated. Plus idempotency, dry-run, and strict-mode write semantics.',
}
diff --git a/app/docs/api/webhooks/page.tsx b/app/docs/api/webhooks/page.tsx
index 7ec047bd..29fa6a8e 100644
--- a/app/docs/api/webhooks/page.tsx
+++ b/app/docs/api/webhooks/page.tsx
@@ -4,8 +4,8 @@ import { DocsMarkdown } from '@/lib/docs/markdown'
import { WEBHOOKS_MD } from '@/lib/docs/content/webhooks'
export const metadata: Metadata = {
- title: 'Webhooks · gnubok API',
- description: 'Receive HMAC-signed POST notifications when state changes in gnubok. Includes signature verification samples in Node.js and Python.',
+ title: 'Webhooks · accounted API',
+ description: 'Receive HMAC-signed POST notifications when state changes in accounted. Includes signature verification samples in Node.js and Python.',
}
export default function DocsApiWebhooksPage() {
diff --git a/app/favicon.ico b/app/favicon.ico
deleted file mode 100644
index 2e6728b3..00000000
Binary files a/app/favicon.ico and /dev/null differ
diff --git a/app/icon.png b/app/icon.png
new file mode 100644
index 00000000..d6702e4e
Binary files /dev/null and b/app/icon.png differ
diff --git a/app/invite/[token]/page.tsx b/app/invite/[token]/page.tsx
index 0fff66ec..eed5b761 100644
--- a/app/invite/[token]/page.tsx
+++ b/app/invite/[token]/page.tsx
@@ -3,7 +3,6 @@
import { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
-import Image from 'next/image'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
@@ -168,14 +167,9 @@ export default function InvitePage() {
+ AI-assistenten och externa tjänster (e-post, bankuppkoppling,
+ valutakurser, Skatteverket) är avstängda i sandlådan — de
+ kräver ett riktigt konto.
+
),
)}
diff --git a/components/agent/AgentSheet.tsx b/components/agent/AgentSheet.tsx
index d9b9f69c..6c8da3b8 100644
--- a/components/agent/AgentSheet.tsx
+++ b/components/agent/AgentSheet.tsx
@@ -5,7 +5,9 @@ import { X, Expand } from 'lucide-react'
import Link from 'next/link'
import AgentChat from './AgentChat'
import AgentAvatar from './AgentAvatar'
+import SandboxAgentPreview from './SandboxAgentPreview'
import { useAgentSheet } from './AgentSheetProvider'
+import { useCompanyOptional } from '@/contexts/CompanyContext'
// Undimmed non-modal side sheet — sits above the page on a hairline border +
// shadow, but the page underneath stays fully interactive. Plan §3b.
@@ -31,6 +33,8 @@ export default function AgentSheet({
}: Props) {
const [conversationId, setConversationId] = useState(null)
const { identity } = useAgentSheet()
+ const companyCtx = useCompanyOptional()
+ const isSandbox = companyCtx?.isSandbox ?? false
const agentName = identity.displayName?.trim() || null
const sheetTitle = intentToTitle(intentId, agentName)
@@ -60,7 +64,7 @@ export default function AgentSheet({
>
)
diff --git a/components/agent/ChatEmptyState.tsx b/components/agent/ChatEmptyState.tsx
index 4594baa0..5918ad69 100644
--- a/components/agent/ChatEmptyState.tsx
+++ b/components/agent/ChatEmptyState.tsx
@@ -1,10 +1,13 @@
'use client'
-import { ArrowUpRight } from 'lucide-react'
+import { ArrowUpRight, Sparkles } from 'lucide-react'
import Link from 'next/link'
+import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { useAgentSheet } from './AgentSheetProvider'
import AgentAvatar from './AgentAvatar'
+import { useCompanyOptional } from '@/contexts/CompanyContext'
+import { createClient } from '@/lib/supabase/client'
// Tiny client component for /chat empty state. Reads the agent identity from
// the provider so it can show the user's chosen avatar + name above the
@@ -31,8 +34,46 @@ const SUGGESTIONS: { label: string; prompt: string }[] = [
export default function ChatEmptyState() {
const { identity } = useAgentSheet()
+ const companyCtx = useCompanyOptional()
+ const router = useRouter()
+ const isSandbox = companyCtx?.isSandbox ?? false
const name = identity.displayName?.trim() || 'din assistent'
+ if (isSandbox) {
+ const handleCreateAccount = async () => {
+ const supabase = createClient()
+ // Sign-out is best-effort — navigate even if Supabase is unreachable
+ // so the button never looks dead.
+ try {
+ await supabase.auth.signOut()
+ } catch {
+ // Intentionally swallowed.
+ }
+ router.push('/register')
+ }
+ return (
+
+
+
Fråga {name}
+
+
+
+ Avstängd i sandlådan
+
+
+ AI-assistenten använder en betald molntjänst och är därför
+ inaktiverad här. I den fullständiga produkten kan {name} kategorisera
+ transaktioner, granska leverantörsfakturor och svara på frågor om
+ din bokföring.
+
+
+
+
+ )
+ }
+
// Hidden on mobile — the sidebar IS the page when no conversation is open.
// On desktop, fills the right pane with a centered prompt.
return (
diff --git a/components/agent/ChatIntakeStarter.tsx b/components/agent/ChatIntakeStarter.tsx
index d8050bfe..75151e3a 100644
--- a/components/agent/ChatIntakeStarter.tsx
+++ b/components/agent/ChatIntakeStarter.tsx
@@ -4,7 +4,9 @@ import { useRouter } from 'next/navigation'
import { useState } from 'react'
import AgentChat from './AgentChat'
import AgentAvatar from './AgentAvatar'
+import SandboxAgentPreview from './SandboxAgentPreview'
import { useAgentSheet } from './AgentSheetProvider'
+import { useCompanyOptional } from '@/contexts/CompanyContext'
// Phase C entry surface. Lands here from ReviewCard's "kör" after Phase B
// verify succeeds. Renders AgentChat in fresh-start mode — no
@@ -17,6 +19,8 @@ import { useAgentSheet } from './AgentSheetProvider'
export default function ChatIntakeStarter() {
const router = useRouter()
const { identity } = useAgentSheet()
+ const companyCtx = useCompanyOptional()
+ const isSandbox = companyCtx?.isSandbox ?? false
const agentName = identity.displayName?.trim() || 'Din assistent'
// Lock the swap to the first id we see — defensive guard against the
// AgentChat callback firing twice during React 19 Strict Mode reruns.
@@ -29,28 +33,34 @@ export default function ChatIntakeStarter() {
{agentName} är redo
- Några frågor för att lära känna din verksamhet — svara i din egen takt, du kan avsluta när du vill.
+ {isSandbox
+ ? 'Förhandsvisning — den verkliga konversationen kräver ett konto.'
+ : 'Några frågor för att lära känna din verksamhet — svara i din egen takt, du kan avsluta när du vill.'}
-
- {
- // Wait for the greeting to finish streaming AND persist before
- // swapping the URL. Swapping on the early `conversation` event
- // unmounts AgentChat mid-stream, so the greeting is never saved
- // and /chat/[id] hydrates empty — the bug where the chat lands
- // blank and only shows the intro on a later visit.
- if (swapped) return
- setSwapped(true)
- router.replace(`/chat/${id}`)
- }}
- scrollerClassName="px-6 py-8"
- />
+
+ {isSandbox ? (
+
+ ) : (
+ {
+ // Wait for the greeting to finish streaming AND persist before
+ // swapping the URL. Swapping on the early `conversation` event
+ // unmounts AgentChat mid-stream, so the greeting is never saved
+ // and /chat/[id] hydrates empty — the bug where the chat lands
+ // blank and only shows the intro on a later visit.
+ if (swapped) return
+ setSwapped(true)
+ router.replace(`/chat/${id}`)
+ }}
+ scrollerClassName="px-6 py-8"
+ />
+ )}
>
)
diff --git a/components/agent/ChatNewStarter.tsx b/components/agent/ChatNewStarter.tsx
index 739ee656..7d598fb4 100644
--- a/components/agent/ChatNewStarter.tsx
+++ b/components/agent/ChatNewStarter.tsx
@@ -4,7 +4,9 @@ import { useRouter } from 'next/navigation'
import { useState } from 'react'
import AgentChat from './AgentChat'
import AgentAvatar from './AgentAvatar'
+import SandboxAgentPreview from './SandboxAgentPreview'
import { useAgentSheet } from './AgentSheetProvider'
+import { useCompanyOptional } from '@/contexts/CompanyContext'
// Inline starter used by suggestion chips and ⌘K. Mirrors ChatIntakeStarter
// but accepts any intent + seed so we don't fork the intake-specific
@@ -19,6 +21,8 @@ export default function ChatNewStarter({
}) {
const router = useRouter()
const { identity } = useAgentSheet()
+ const companyCtx = useCompanyOptional()
+ const isSandbox = companyCtx?.isSandbox ?? false
const agentName = identity.displayName?.trim() || 'Din assistent'
const [swapped, setSwapped] = useState(false)
@@ -28,26 +32,32 @@ export default function ChatNewStarter({
- {
- // Wait for the first turn to finish before swapping the URL —
- // otherwise the unmount aborts the in-flight stream and
- // /chat/[id] hydrates with only the user message.
- if (swapped) return
- setSwapped(true)
- router.replace(`/chat/${id}`)
- }}
- scrollerClassName="px-6 py-8"
- />
+
+ {isSandbox ? (
+
+ ) : (
+ {
+ // Wait for the first turn to finish before swapping the URL —
+ // otherwise the unmount aborts the in-flight stream and
+ // /chat/[id] hydrates with only the user message.
+ if (swapped) return
+ setSwapped(true)
+ router.replace(`/chat/${id}`)
+ }}
+ scrollerClassName="px-6 py-8"
+ />
+ )}
>
)
diff --git a/components/agent/SandboxAgentPreview.tsx b/components/agent/SandboxAgentPreview.tsx
new file mode 100644
index 00000000..1bf8e6f5
--- /dev/null
+++ b/components/agent/SandboxAgentPreview.tsx
@@ -0,0 +1,111 @@
+'use client'
+
+import Link from 'next/link'
+import { useRouter } from 'next/navigation'
+import { Sparkles, ArrowRight } from 'lucide-react'
+import { createClient } from '@/lib/supabase/client'
+import { Button } from '@/components/ui/button'
+
+/**
+ * Stand-in for AgentChat in the sandbox. The real chat surface POSTs to
+ * /api/agent/invoke which is server-gated by guardSandbox(), so the input
+ * would just produce a 403. Instead of showing that as a raw error, we
+ * render a brief description of what the assistant does in prod and a
+ * single "Skapa konto" CTA. Same chrome (header) as the real chat — only
+ * the body swaps out.
+ *
+ * Mirrors the look of the empty-state but with an explanation block so the
+ * sandbox user understands what they're seeing without typing into a
+ * dead-end input.
+ */
+export default function SandboxAgentPreview({
+ agentName,
+}: {
+ agentName: string | null
+}) {
+ const router = useRouter()
+ const name = agentName?.trim() || 'din assistent'
+
+ async function handleCreateAccount() {
+ const supabase = createClient()
+ // Sign-out is best-effort — a transient Supabase failure shouldn't
+ // strand the user on a dead button; navigate to /register either way
+ // and let the registration flow re-init auth state.
+ try {
+ await supabase.auth.signOut()
+ } catch {
+ // Intentionally swallowed — see comment above.
+ }
+ router.push('/register')
+ }
+
+ return (
+
+
+
+
+
+
+ Förhandsvisning i sandlådan
+
+
+ {name} är en specialiserad bokföringsassistent som kan
+ kategorisera transaktioner, granska leverantörsfakturor och
+ svara på frågor om din bokföring — kalibrerad mot dina
+ kontoplaner, verksamhet och svensk skattelagstiftning.
+
+
+ I sandlådan är AI-funktionerna avstängda eftersom de använder
+ externa AI-tjänster som kostar pengar att köra. Skapa ett
+ konto för att aktivera assistenten på riktigt.
+
+
+
+
+
+ ·
+
+ Föreslår bokföring{' '}
+ för oklassificerade transaktioner — du godkänner i ett klick.
+
+
+
+ ·
+
+ Förklarar momsrutor,
+ årets resultat och vad som driver KPI:erna.
+
+
+
+ ·
+
+ Granskar verifikat{' '}
+ och föreslår rättningar enligt BFL och K2.
+
+
+
+
+
+
+
+
+
+
+
+ Sandlådedata raderas efter 24 timmar.{' '}
+
+ Skapa konto
+
+ .
+
+
+
+ )
+}
diff --git a/components/branding/BrandWordmark.tsx b/components/branding/BrandWordmark.tsx
new file mode 100644
index 00000000..539e4f28
--- /dev/null
+++ b/components/branding/BrandWordmark.tsx
@@ -0,0 +1,45 @@
+import { cn } from '@/lib/utils'
+import { getBranding } from '@/lib/branding/service'
+
+interface BrandWordmarkProps {
+ /**
+ * Visual size. `'hero'` is for landing/auth/onboarding hero slots (~the
+ * same vertical weight as the old 240px logo image). `'inline'` matches
+ * the old 30px image used in top-left nav contexts.
+ */
+ size?: 'hero' | 'inline'
+ /**
+ * Force lowercase rendering. Defaults to true to match the existing
+ * font-display + `.toLowerCase()` pattern used elsewhere in the app.
+ */
+ lowercase?: boolean
+ className?: string
+}
+
+/**
+ * Text-only wordmark used in place of the legacy logo image on auth /
+ * onboarding / sandbox / invite surfaces. Renders the active brand's
+ * `appName` in Hedvig Letters Serif at weight 700 — the display font is
+ * single-weight on Google Fonts so 700 ends up synthetically bolded, but
+ * that matches the requested aesthetic.
+ */
+export function BrandWordmark({
+ size = 'hero',
+ lowercase = true,
+ className,
+}: BrandWordmarkProps) {
+ const branding = getBranding()
+ const name = lowercase ? branding.appName.toLowerCase() : branding.appName
+ return (
+
+ {name}
+
+ )
+}
diff --git a/components/dashboard/DashboardContent.tsx b/components/dashboard/DashboardContent.tsx
index 1346108d..2807a966 100644
--- a/components/dashboard/DashboardContent.tsx
+++ b/components/dashboard/DashboardContent.tsx
@@ -59,7 +59,18 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
const [showAllAlerts, setShowAllAlerts] = useState(false)
const t = useTranslations('dashboard')
- const needsSetup = onboardingProgress && !onboardingProgress.hasBankConnected && !onboardingProgress.hasSIEImport
+ // The setup gate exists to nudge brand-new users into a data-import step
+ // before they hit the dashboard. Once the assistant is built we treat the
+ // user as past that phase — they've already committed to using the tool —
+ // and let the dashboard render normally. This also keeps the sandbox
+ // (which ships with a pre-built assistant + seeded data but no bank
+ // connection / SIE import) from showing a checklist that re-links to
+ // /onboarding/agent.
+ const needsSetup =
+ !agentBuilt &&
+ onboardingProgress &&
+ !onboardingProgress.hasBankConnected &&
+ !onboardingProgress.hasSIEImport
const [setupGateActive, setSetupGateActive] = useState(!!needsSetup)
useEffect(() => {
@@ -82,7 +93,10 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
if (setupGateActive) {
return (
{
localStorage.setItem(setupFreshStartKey(companyId), 'true')
setSetupGateActive(false)
diff --git a/components/dashboard/SandboxBanner.tsx b/components/dashboard/SandboxBanner.tsx
index 016f1f56..f8d828c0 100644
--- a/components/dashboard/SandboxBanner.tsx
+++ b/components/dashboard/SandboxBanner.tsx
@@ -20,7 +20,7 @@ export function SandboxBanner() {
return (
- Sandlådemiljö — data raderas efter 24h
+ Sandlådemiljö — AI och externa tjänster är avstängda. Data raderas efter 24h.