'use client'; import { useEffect, useState } from 'react'; import Link from 'next/link'; import { useParams } from 'next/navigation'; import { Check, Circle, Loader2, ExternalLink } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Progress } from '@/components/ui/progress'; import { apiFetch } from '@/lib/api/client'; interface OnboardingStep { id: string; href: string; label: string; description: string; /** Setting key whose presence proves the step is done. When set, the * checkmark auto-fills from the settings list. When undefined, the * step relies on the manual checkbox in `onboarding_status`. */ autoCheckSettingKey?: string; /** Multi-key gate: all listed setting keys must be present (non-empty) * for the step to auto-complete. Useful for compound checks where a * single key would falsely mark "done" — e.g. Documenso needs a URL * plus signer identity plus a template id, not just the URL. */ autoCheckSettingKeysAll?: readonly string[]; /** Override: read this many users / tags / roles from a list endpoint * and consider the step done when count > 0. */ autoCheckListEndpoint?: string; } const STEPS: OnboardingStep[] = [ { id: 'branding', href: 'branding', label: 'Set port name, logo, primary colour', description: 'Branding flows into the navbar, emails, EOI PDFs, and the public auth shell.', autoCheckSettingKey: 'branding_logo_url', }, { id: 'email', href: 'email', label: 'Configure outgoing email', description: 'From-address, signature, footer, plus per-port SMTP overrides if you don’t use the global account.', autoCheckSettingKey: 'smtp_host_override', }, { id: 'documenso', href: 'documenso', label: 'Connect Documenso for EOIs', description: 'API credentials, the EOI template id, plus the developer + approver identity that signs every EOI.', // Compound gate: an EOI cannot be sent without ALL of these. A // port-admin who saves only the URL would otherwise see the step go // green and discover the gap on first EOI attempt (Documenso 404s // on a missing template, or sends recipients with empty names). autoCheckSettingKeysAll: [ 'documenso_api_url_override', 'documenso_developer_email', 'documenso_approver_email', 'documenso_eoi_template_id', ], }, { id: 'settings', href: 'settings', label: 'Tune business rules + recommender weights', description: 'Pipeline weights, net-10 discount, berth recommender knobs (heat weights, fall-through policy).', // Recommender defaults are layered (port > global > built-in), so a // port that uses the built-ins never writes a row. Use a tuned // heat-weight as the "admin actually saw + chose" sentinel instead. autoCheckSettingKey: 'heat_weight_recency', }, { id: 'roles', href: 'roles', label: 'Create roles & assign users', description: 'Per-port roles inherit from system roles; override permissions here.', autoCheckListEndpoint: '/api/v1/admin/roles', }, { id: 'users', href: 'users', label: 'Invite the rest of the team', description: 'Invite users, assign roles, optionally grant residential access. Track pending vs accepted.', autoCheckListEndpoint: '/api/v1/admin/users', }, { id: 'tags', href: 'tags', label: 'Define starter tags', description: 'Color-coded labels used across clients, yachts, companies, and interests.', autoCheckListEndpoint: '/api/v1/tags/options', }, { id: 'storage', href: 'storage', label: 'Configure storage backend', description: 'Verify S3/filesystem and run a test connection before going live so PDFs and avatars persist correctly.', autoCheckSettingKey: 'storage_backend', }, { id: 'forms', href: 'forms', label: 'Wire the website intake forms', description: 'Inquiry forms on the marketing site dual-write into the CRM via /api/public/website-inquiries. Manually mark complete when verified.', }, ]; interface ResolvedValue { isSet: boolean; source?: 'port' | 'global' | 'env' | 'default' | 'none'; value?: unknown; } interface ResolvedResp { data: { entries: Array<{ key: string }>; values: Record }; } interface SettingRow { key: string; value: unknown; portId: string | null; } interface SettingsResp { data: { portSettings: SettingRow[]; globalSettings: SettingRow[] }; } export function OnboardingChecklist() { const params = useParams<{ portSlug: string }>(); const portSlug = params?.portSlug ?? ''; const [autoChecks, setAutoChecks] = useState>({}); // Per-step source flags — populated for steps whose auto-check resolved // via the env / default fallback rather than a port / global override. // Surfaces "Resolving from env" copy so super admins see what's // backing each green tick without digging into the settings page. const [autoSources, setAutoSources] = useState>({}); const [manualChecks, setManualChecks] = useState>({}); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(null); useEffect(() => { async function load() { setLoading(true); try { // Collect every setting key referenced by the checklist so we can // batch-resolve them through the full chain in one round-trip. // The `resolved` endpoint reads port→global→env→default, so a // port using env-only credentials still auto-ticks (the old // raw `/admin/settings` query missed env fallback entirely). const keys = new Set(); for (const s of STEPS) { if (s.autoCheckSettingKey) keys.add(s.autoCheckSettingKey); if (s.autoCheckSettingKeysAll) for (const k of s.autoCheckSettingKeysAll) keys.add(k); } // Manual-checkbox state still lives in the raw system_settings // row (it's a JSON blob, not a per-key registry entry) — keep // fetching it the old way. const [resolved, settings] = await Promise.all([ keys.size > 0 ? apiFetch( `/api/v1/admin/settings/resolved?keys=${Array.from(keys) .map(encodeURIComponent) .join(',')}`, ) : Promise.resolve({ data: { entries: [], values: {} } } as ResolvedResp), apiFetch('/api/v1/admin/settings'), ]); const values = resolved.data.values; const isPresent = (key: string): boolean => Boolean(values[key]?.isSet); const checks: Record = {}; const listChecks = await Promise.all( STEPS.map(async (s) => { if (s.autoCheckSettingKey) { return [s.id, isPresent(s.autoCheckSettingKey)] as const; } if (s.autoCheckSettingKeysAll) { return [s.id, s.autoCheckSettingKeysAll.every((k) => isPresent(k))] as const; } if (s.autoCheckListEndpoint) { try { const res = await apiFetch<{ data: unknown[] }>(s.autoCheckListEndpoint); return [s.id, Array.isArray(res.data) && res.data.length > 0] as const; } catch { return [s.id, false] as const; } } return [s.id, false] as const; }), ); for (const [id, done] of listChecks) checks[id] = done; setAutoChecks(checks); // Capture the dominant source per step. For single-key checks this // is the key's source; for multi-key checks we report the // "weakest" source so the rep sees env if any sub-key is env. const sourcesByStep: Record = {}; const PRIORITY: Record = { port: 4, global: 3, env: 2, default: 1, none: 0, }; for (const s of STEPS) { if (s.autoCheckSettingKey) { sourcesByStep[s.id] = values[s.autoCheckSettingKey]?.source; } else if (s.autoCheckSettingKeysAll) { const sources = s.autoCheckSettingKeysAll .map((k) => values[k]?.source) .filter((x): x is NonNullable => Boolean(x)); // Pick the lowest-priority (weakest) source so the rep sees // "env" if the compound has any env-only sub-key. sources.sort((a, b) => (PRIORITY[a] ?? 0) - (PRIORITY[b] ?? 0)); sourcesByStep[s.id] = sources[0]; } } setAutoSources(sourcesByStep); // Pull the manual-checkbox state from system_settings. const allSettings = [...settings.data.portSettings, ...settings.data.globalSettings]; const byKey = new Map(allSettings.map((r) => [r.key, r.value])); const manual = (byKey.get('onboarding_manual_status') ?? {}) as Record; setManualChecks(manual); } finally { setLoading(false); } } void load(); }, []); async function toggleManual(id: string) { const next = { ...manualChecks, [id]: !manualChecks[id] }; setManualChecks(next); setSaving(id); try { await apiFetch('/api/v1/admin/settings', { method: 'PUT', body: { key: 'onboarding_manual_status', value: next }, }); } finally { setSaving(null); } } const stepDone = (id: string) => Boolean(autoChecks[id]) || Boolean(manualChecks[id]); const completed = STEPS.filter((s) => stepDone(s.id)).length; const percent = Math.round((completed / STEPS.length) * 100); return (
Setup checklist {completed} of {STEPS.length} complete. Auto-checked steps update when you save the underlying setting; manual ones (like website-form integration) need the checkbox.
    {STEPS.map((step, idx) => { const auto = Boolean(autoChecks[step.id]); const manual = Boolean(manualChecks[step.id]); const done = auto || manual; return (
  1. {done ? ( ) : loading ? ( ) : ( )}
    {idx + 1}. {step.label}

    {step.description}

    {auto && (

    Auto-detected complete via{' '} {step.autoCheckSettingKey ?? step.autoCheckSettingKeysAll?.join(' + ') ?? step.autoCheckListEndpoint} {autoSources[step.id] && autoSources[step.id] !== 'port' ? ( · resolving from{' '} {autoSources[step.id] === 'env' ? 'env fallback' : autoSources[step.id] === 'global' ? 'global default' : autoSources[step.id] === 'default' ? 'built-in default' : autoSources[step.id]} ) : null}

    )}
    {!auto && ( )}
  2. ); })}
); }