feat(sandbox,branding): prod-parity demo with AI gating + accounted rebrand (#585)
* feat(sandbox,branding): prod-parity demo with AI gating + accounted rebrand Sandbox now ships with seeded suppliers, supplier invoices, an asset, a verified agent_profile, and pending operations so the demo company exercises every prod surface. Server-side `guardSandbox()` short- circuits any AI or paid-external API call (Bedrock chat/composer, Resend invoice send, Riksbanken FX, VIES, etc.) and the AgentSheet swaps in a SandboxAgentPreview that explains what's gated and offers a register CTA. DashboardContent no longer mounts the NewUserChecklist when the agent is already built, fixing the path that let sandbox users still trigger /onboarding/agent. Visible branding flips from Gnubok to Accounted: new BrandWordmark component (Hedvig Letters Serif 700), new app/icon.png + PWA icons generated from the accounted icon, default appName updated. URLs, header names, API key prefixes, hostnames, and event/cookie/ localStorage keys keep `gnubok` — the rebrand is visual only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sandbox): hardcode supplier-invoice arrival numbers in seed get_next_arrival_number is MAX(arrival_number) + 1 against the same table we're about to insert into. Calling it twice before either row lands made both calls return 1, which then violated the (company_id, arrival_number) unique index — POST /api/sandbox/seed 500'd on first sandbox start. The seeded company is brand new in this branch so 1 and 2 are guaranteed unused; hardcoding side-steps the race entirely. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sandbox): set paid_amount=0 on unpaid supplier invoice PostgREST normalizes the column set across rows in a bulk insert, so the second supplier invoice (Espresso House, status=registered) was being sent with paid_amount=null because the first row (Telia, paid) set it. supplier_invoices.paid_amount is NOT NULL DEFAULT 0; the default only kicks in when the column is *absent* from the payload, not when it's explicitly null. Set it inline to side-step the normalization. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sandbox): set actor_type=agent_chat on seeded pending_operations pending_operations only allows user-scoped INSERTs via the `pending_operations_chat_insert` policy, which requires actor_type='agent_chat' alongside auth.uid()=user_id + company membership. The seed was inserting with the default actor_type='user', tripping the RLS check. Also lift risk_level from preview_data (where it was unused) onto the row itself, matching the column added in 20260430120000_pending_operations_actor_and_risk. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pr-review): address PR #585 review feedback Fixes called out by the core-only CI check, Greptile, and the compliance + Swedish-accounting bots: - AGI Programnamn pinned back to 'gnubok' (CI blocker). The XML Skatteverket receives must keep the stable software identifier regardless of the visual rebrand — same rule as the v1 health endpoint's `service: 'gnubok'` literal. - handleCreateAccount in SandboxAgentPreview + ChatEmptyState now wraps signOut() in try/catch so a transient Supabase failure doesn't strand the user on a dead button (greptile P2 × 2). - /api/currency/rate hard-fails on missing companyId instead of conditionally skipping the sandbox guard (greptile P2 / compliance V8.2.1). - topUpSandboxAdditions now delegates to ensureSandboxAgentProfile; the assistant persona lives in exactly one place across the seed, layout backfills, and top-up path (greptile P2 outside-diff / compliance SOC2 CC6.1). - ensureSandboxAgentProfile drops the userId param and sets verified_by_user_id to NULL — synthetic seed data should not attribute verification to a real user (compliance V8.2.1 / GDPR Art. 25(2)). Errors now logged via the structured logger instead of being silently swallowed (V16). - Sandbox seed swaps real-world company names (Telia, Espresso House) for clearly-synthetic Demo-prefixed brands using the 5559... documentation org-number range (compliance A.8.33). Asset cost bumped 24 000 → 35 000 SEK so the demo clears the förbrukningsinventarier threshold and illustrates capitalization unambiguously (swedish-asset-accounting). - Representation pending-operation preview corrected: VAT label fixed from 6% → 12%, and input VAT split between the avdragsgill (2641) and ej-avdragsgill (5811) portions to match swedish-vat / ML 8 kap rules (swedish-vat). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pr-review): seed preview consistency + AGI Programnamn constant Two last review-bot items before merge: - Sandbox seed: the representation pending-operation preview was splitting the 240 SEK café meal 60/180 between 5810 and 5811, which is wrong for a single attendee under the 300 SEK / person avdragsgill cap (ML 8 kap) — the entire amount is fully avdragsgill in that case. Collapse the preview to a single 5810 + 2641 + 2440 entry so it matches the supplier_invoice_items row 1:1 and stops teaching demo users an incorrect bookkeeping pattern. - Hoist the AGI Programnamn 'gnubok' literal into a named constant with a comment pointing to potential future Skatteverket vendor registration (per the swedish-compliance bot's nit). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sandbox): avoid BFL duplicate-verification on pending op + fix VAT cap comment Swedish-compliance bot caught two final nits: - The pending operation for the Demokafé representation was using the same supplier_invoice_number as the already-seeded supplier_invoices row (88245). If the sandbox user approved the staged operation, the insert would have created (or attempted) a duplicate verification — BFL 5 kap. requires each affärshändelse be recorded exactly once. Swap the staged operation's invoice number to a distinct value (INKOMMANDE-2026-001) so approval cleanly creates a new row. - The preview comment described the 300 SEK threshold as an "avdragsgill cap". The actual rule (ML 8 kap. 9 §) caps the deductible VAT at 25 % × 300 SEK × antal_personer = 75 SEK per person — the 300 SEK is the tax base, not the total. Math here is correct either way, but the comment now states the correct formula so future seed edits don't propagate the wrong understanding. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
f53725b20a
commit
20989379bb
@@ -725,7 +725,7 @@ function MessageBubble({
|
||||
key={s.tool_use_id}
|
||||
className="rounded-lg border border-border bg-card px-4 py-3 text-sm text-muted-foreground"
|
||||
>
|
||||
Förslag stageat men ingen operation-id mottagen. Granska i gnubok under <em>Förslag</em>.
|
||||
Förslag stageat men ingen operation-id mottagen. Granska i accounted under <em>Förslag</em>.
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -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<string | null>(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({
|
||||
<AgentAvatar avatarId={identity.avatarId} size="sm" alt={agentName ?? 'Assistent'} />
|
||||
<h2 className="font-display text-lg tracking-tight truncate">{sheetTitle}</h2>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{conversationId && (
|
||||
{conversationId && !isSandbox && (
|
||||
<Link
|
||||
href={`/chat/${conversationId}`}
|
||||
onClick={onClose}
|
||||
@@ -81,13 +85,17 @@ export default function AgentSheet({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<AgentChat
|
||||
intentId={intentId}
|
||||
intentArgs={intentArgs}
|
||||
contextRef={contextRef}
|
||||
seedUserMessage={seedUserMessage}
|
||||
onConversationIdChange={(id) => setConversationId(id)}
|
||||
/>
|
||||
{isSandbox ? (
|
||||
<SandboxAgentPreview agentName={agentName} />
|
||||
) : (
|
||||
<AgentChat
|
||||
intentId={intentId}
|
||||
intentArgs={intentArgs}
|
||||
contextRef={contextRef}
|
||||
seedUserMessage={seedUserMessage}
|
||||
onConversationIdChange={(id) => setConversationId(id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
<AgentChat
|
||||
intentId={intentId}
|
||||
contextRef={contextRef ?? undefined}
|
||||
initialConversationId={conversationId}
|
||||
initialMessages={initialMessages}
|
||||
scrollerClassName="px-6 py-8"
|
||||
/>
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{isSandbox ? (
|
||||
<SandboxAgentPreview agentName={agentName} />
|
||||
) : (
|
||||
<AgentChat
|
||||
intentId={intentId}
|
||||
contextRef={contextRef ?? undefined}
|
||||
initialConversationId={conversationId}
|
||||
initialMessages={initialMessages}
|
||||
scrollerClassName="px-6 py-8"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
<div className="hidden md:flex flex-1 flex-col items-center justify-center px-6 py-12 text-center">
|
||||
<AgentAvatar avatarId={identity.avatarId} size="lg" alt={name} className="mb-5" />
|
||||
<h1 className="font-display text-2xl tracking-tight mb-2">Fråga {name}</h1>
|
||||
<div className="rounded-lg border border-border bg-secondary/40 px-5 py-4 max-w-md mb-6 text-left">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Avstängd i sandlådan
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-2 leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="lg" onClick={handleCreateAccount}>
|
||||
Skapa konto för att använda {name}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Hidden on mobile — the sidebar IS the page when no conversation is open.
|
||||
// On desktop, fills the right pane with a centered prompt.
|
||||
return (
|
||||
|
||||
@@ -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() {
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-display text-lg tracking-tight truncate">{agentName} är redo</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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.'}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
<AgentChat
|
||||
intentId="onboarding.intake"
|
||||
initialMessages={[]}
|
||||
initialConversationId={null}
|
||||
onFirstTurnComplete={(id) => {
|
||||
// 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"
|
||||
/>
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{isSandbox ? (
|
||||
<SandboxAgentPreview agentName={agentName} />
|
||||
) : (
|
||||
<AgentChat
|
||||
intentId="onboarding.intake"
|
||||
initialMessages={[]}
|
||||
initialConversationId={null}
|
||||
onFirstTurnComplete={(id) => {
|
||||
// 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"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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({
|
||||
<AgentAvatar avatarId={identity.avatarId} size="sm" alt={agentName} />
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-display text-lg tracking-tight truncate">{agentName}</h1>
|
||||
<p className="text-xs text-muted-foreground truncate">Ny konversation</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{isSandbox ? 'Förhandsvisning — avstängd i sandlådan' : 'Ny konversation'}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
<AgentChat
|
||||
intentId={intentId}
|
||||
seedUserMessage={seedUserMessage}
|
||||
initialMessages={[]}
|
||||
initialConversationId={null}
|
||||
onFirstTurnComplete={(id) => {
|
||||
// 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"
|
||||
/>
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{isSandbox ? (
|
||||
<SandboxAgentPreview agentName={agentName} />
|
||||
) : (
|
||||
<AgentChat
|
||||
intentId={intentId}
|
||||
seedUserMessage={seedUserMessage}
|
||||
initialMessages={[]}
|
||||
initialConversationId={null}
|
||||
onFirstTurnComplete={(id) => {
|
||||
// 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"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex-1 overflow-y-auto px-5 py-8">
|
||||
<div className="mx-auto max-w-md space-y-6">
|
||||
<div className="rounded-lg border border-border bg-secondary/40 p-5">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Förhandsvisning i sandlådan
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-2 leading-relaxed">
|
||||
{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.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-3 leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-foreground mt-0.5">·</span>
|
||||
<span>
|
||||
<span className="text-foreground">Föreslår bokföring</span>{' '}
|
||||
för oklassificerade transaktioner — du godkänner i ett klick.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-foreground mt-0.5">·</span>
|
||||
<span>
|
||||
<span className="text-foreground">Förklarar momsrutor</span>,
|
||||
årets resultat och vad som driver KPI:erna.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-foreground mt-0.5">·</span>
|
||||
<span>
|
||||
<span className="text-foreground">Granskar verifikat</span>{' '}
|
||||
och föreslår rättningar enligt BFL och K2.
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border px-5 pt-4 pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1"
|
||||
onClick={handleCreateAccount}
|
||||
>
|
||||
Skapa konto för att använda {name}
|
||||
<ArrowRight className="ml-1.5 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] text-muted-foreground">
|
||||
Sandlådedata raderas efter 24 timmar.{' '}
|
||||
<Link href="/register" className="underline underline-offset-2 hover:text-foreground">
|
||||
Skapa konto
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<span
|
||||
className={cn(
|
||||
'font-display tracking-tight inline-block',
|
||||
size === 'hero' ? 'text-5xl md:text-6xl' : 'text-base',
|
||||
className,
|
||||
)}
|
||||
style={{ fontWeight: 700 }}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<NewUserChecklist
|
||||
hasBookkeepingImported={!!onboardingProgress?.hasSIEImport}
|
||||
hasBankConnected={!!onboardingProgress?.hasBankConnected}
|
||||
hasSkatteverketConnected={!!onboardingProgress?.hasSkatteverketConnected}
|
||||
hasAgentBuilt={agentBuilt}
|
||||
onFreshStart={() => {
|
||||
localStorage.setItem(setupFreshStartKey(companyId), 'true')
|
||||
setSetupGateActive(false)
|
||||
|
||||
@@ -20,7 +20,7 @@ export function SandboxBanner() {
|
||||
return (
|
||||
<div className="relative z-50 flex items-center justify-center gap-x-3 gap-y-1 bg-warning px-10 py-2 text-sm text-warning-foreground sm:px-4 flex-wrap">
|
||||
<span className="font-medium text-center text-xs sm:text-sm">
|
||||
Sandlådemiljö — data raderas efter 24h
|
||||
Sandlådemiljö — AI och externa tjänster är avstängda. Data raderas efter 24h.
|
||||
</span>
|
||||
<button
|
||||
onClick={handleCreateAccount}
|
||||
|
||||
@@ -30,8 +30,8 @@ export function DocsLayout({ currentPath, children }: DocsLayoutProps) {
|
||||
<div className="min-h-screen bg-background">
|
||||
<header className="sticky top-0 z-40 bg-background/95 backdrop-blur border-b border-border">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<Link href="/docs/api" className="font-display text-xl tracking-tight">
|
||||
gnubok <span className="text-muted-foreground">/ docs</span>
|
||||
<Link href="/docs/api" className="font-display text-xl tracking-tight" style={{ fontWeight: 700 }}>
|
||||
accounted <span className="text-muted-foreground" style={{ fontWeight: 400 }}>/ docs</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-6 text-sm">
|
||||
<Link href="/docs/api/reference" className="text-foreground/80 hover:text-foreground transition-colors">
|
||||
@@ -96,7 +96,7 @@ export function DocsLayout({ currentPath, children }: DocsLayoutProps) {
|
||||
|
||||
<footer className="border-t border-border mt-20">
|
||||
<div className="max-w-7xl mx-auto px-6 py-8 text-sm text-muted-foreground flex items-center justify-between">
|
||||
<span>gnubok REST API · Swedish bookkeeping for agents</span>
|
||||
<span>accounted REST API · Swedish bookkeeping for agents</span>
|
||||
<span className="font-mono text-xs">AGPL-3.0-or-later</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1156,7 +1156,7 @@ function OnboardingCard({
|
||||
{
|
||||
done: hasAnyItem,
|
||||
title: 'Ladda upp eller maila in ett underlag',
|
||||
hint: 'gnubok tolkar fakturan eller kvittot åt dig och fyller i fält automatiskt.',
|
||||
hint: 'accounted tolkar fakturan eller kvittot åt dig och fyller i fält automatiskt.',
|
||||
},
|
||||
{
|
||||
done: hasResolvedItem,
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function SendInvoiceDialog({
|
||||
}: SendInvoiceDialogProps) {
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
const { company } = useCompany()
|
||||
const { company, isSandbox } = useCompany()
|
||||
const t = useTranslations('invoice_send_dialog')
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
@@ -209,6 +209,13 @@ export default function SendInvoiceDialog({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{isSandbox && mode === 'email' && (
|
||||
<div className="rounded-lg border border-border bg-secondary/40 px-3 py-2.5 text-sm text-muted-foreground">
|
||||
E-postutskick är avstängt i sandlådan. Använd istället
|
||||
«Markera som skickad» för att testa det resterande
|
||||
flödet.
|
||||
</div>
|
||||
)}
|
||||
{showJournalPreview ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -251,8 +258,9 @@ export default function SendInvoiceDialog({
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={isSubmitting || !isInitialized}
|
||||
disabled={isSubmitting || !isInitialized || (isSandbox && mode === 'email')}
|
||||
className="w-full sm:w-auto min-h-11"
|
||||
title={isSandbox && mode === 'email' ? 'E-postutskick är avstängt i sandlådan' : undefined}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
|
||||
@@ -550,7 +550,7 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Anslut till Skatteverket med BankID för att skicka AGI direkt från {`gnubok`}.
|
||||
Anslut till Skatteverket med BankID för att skicka AGI direkt från accounted.
|
||||
</p>
|
||||
{!readOnly && (
|
||||
<Button onClick={handleConnect}>
|
||||
|
||||
Reference in New Issue
Block a user