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() {
- {branding.appName} +

{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() {
- {branding.appName} +

{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} - {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() {
- {branding.appName} - {branding.appName.toLowerCase()} + + {branding.appName.toLowerCase()} +

diff --git a/app/layout.tsx b/app/layout.tsx index 650bb18c..159e8a52 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -29,6 +29,9 @@ const hedvigSerif = Hedvig_Letters_Serif({ variable: "--font-hedvig-serif", subsets: ["latin"], display: "swap", + // Hedvig Letters Serif on Google Fonts only ships 400; the brand wordmark + // requests 700, which browsers synthesize from this file. Keep the load + // single-file to stay within the existing display-font budget. weight: "400", }); diff --git a/app/sandbox/page.tsx b/app/sandbox/page.tsx index cb5b9024..5af5160e 100644 --- a/app/sandbox/page.tsx +++ b/app/sandbox/page.tsx @@ -3,12 +3,12 @@ import { useState, useEffect } from 'react' import { useRouter } from 'next/navigation' import Link from 'next/link' -import Image from 'next/image' import { createClient } from '@/lib/supabase/client' import { Button } from '@/components/ui/button' import { useToast } from '@/components/ui/use-toast' -import { Loader2, Receipt, ArrowLeftRight, BookOpen, BarChart3 } from 'lucide-react' +import { Loader2 } from 'lucide-react' import { getBranding } from '@/lib/branding/service' +import { BrandWordmark } from '@/components/branding/BrandWordmark' const branding = getBranding() @@ -81,14 +81,7 @@ export default function SandboxPage() {
- {branding.appName} +
@@ -116,14 +109,7 @@ export default function SandboxPage() {
- {branding.appName} +

Testa {branding.appName.toLowerCase()} utan att registrera dig

@@ -133,23 +119,11 @@ export default function SandboxPage() {
- {/* Feature highlights */} -
- {[ - { icon: Receipt, label: 'Fakturor' }, - { icon: ArrowLeftRight, label: 'Transaktioner' }, - { icon: BookOpen, label: 'Bokföring' }, - { icon: BarChart3, label: 'Rapporter' }, - ].map(({ icon: Icon, label }) => ( -
- - {label} -
- ))} -
+

+ 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({

{sheetTitle}

- {conversationId && ( + {conversationId && !isSandbox && ( - setConversationId(id)} - /> + {isSandbox ? ( + + ) : ( + setConversationId(id)} + /> + )}
) } diff --git a/components/agent/ChatConversationView.tsx b/components/agent/ChatConversationView.tsx index 6338ceba..c84b67f5 100644 --- a/components/agent/ChatConversationView.tsx +++ b/components/agent/ChatConversationView.tsx @@ -5,7 +5,9 @@ import Link from 'next/link' import { ArrowLeft } from 'lucide-react' import AgentChat, { normalizeStoredMessages } from './AgentChat' import AgentAvatar from './AgentAvatar' +import SandboxAgentPreview from './SandboxAgentPreview' import { useAgentSheet } from './AgentSheetProvider' +import { useCompanyOptional } from '@/contexts/CompanyContext' interface Props { conversationId: string @@ -26,6 +28,9 @@ export default function ChatConversationView({ }: Props) { const initialMessages = useMemo(() => normalizeStoredMessages(rawMessages), [rawMessages]) const { identity } = useAgentSheet() + const companyCtx = useCompanyOptional() + const isSandbox = companyCtx?.isSandbox ?? false + const agentName = identity.displayName?.trim() || null return ( <> @@ -52,14 +57,18 @@ export default function ChatConversationView({
-
- +
+ {isSandbox ? ( + + ) : ( + + )}
) 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({

{agentName}

-

Ny konversation

+

+ {isSandbox ? 'Förhandsvisning — avstängd i sandlådan' : 'Ny konversation'} +

-
- { - // 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.