Cleared 4 rule buckets (37 violations, including 5 real bugs) and
silenced 1 informational bucket from the Next 16 / react-hooks v7
upgrade. Cleared rules promoted from `warn` back to `error` so new
regressions block CI.
Real bug fixes:
- `interest-contact-log-tab.tsx`: `useMemo` used for side effects
(5 setState calls inside a memo body); converted to `useEffect`.
- `PieChart.tsx`: cumulative `let angle` mutation in a render-phase
`map`; converted to `reduce` so the slice array is built without
re-assignment.
- `documents-hub.tsx`: `useMemo(() => ({ count: 0 }))` used as a
mutable drag counter; converted to `useRef`.
- `notes-list.tsx`: `Date.now()` read during render for note-edit
countdown (impure) → pinned to a `now` state ticked every 30s.
- `onboarding-checklist.tsx` / `user-profile.tsx` /
`user-settings.tsx`: `useEffect(() => void load(), [])` with the
`load` function declared AFTER the effect — relied on hoisting,
trips Compiler's "access before declared" rule. Declared inside
the effect.
Pattern fixes (intentional cache-via-ref → state or layout-effect):
- 6 `ref.current = x` writes during render moved into layout
effects (`use-realtime-invalidation`, `settings-form-card`,
`inbox`).
- 3 `ref.current` reads during render (search totals cache,
scanner file ref) rewritten to backed-by-state.
- `use-is-mobile.ts` rewritten on `useSyncExternalStore` to avoid
the SSR-then-rehydrate setState dance.
- `use-notifications.ts` rewritten to write socket pushes directly
into the React Query cache via `setQueryData`, removing a local
state mirror.
Rule config (`eslint.config.mjs`):
- `react-hooks/purity` → error (was warn, cleared)
- `react-hooks/set-state-in-render` → error (was warn, cleared)
- `react-hooks/immutability` → error (was warn, cleared)
- `react-hooks/refs` → error (was warn, cleared)
- `react-hooks/incompatible-library` → off (informational only)
- `react-hooks/set-state-in-effect` → warn (51 remaining, all the
useEffect→fetch→setState data-fetch pattern; migration to
useQuery tracked in BACKLOG)
Verified: tsc clean, eslint 0 errors / 69 warnings (down from 105),
vitest 1315/1315, next build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
261 lines
9.4 KiB
TypeScript
261 lines
9.4 KiB
TypeScript
'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;
|
||
/** 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: 'sales_email_smtp_host',
|
||
},
|
||
{
|
||
id: 'documenso',
|
||
href: 'documenso',
|
||
label: 'Connect Documenso for EOIs',
|
||
description:
|
||
'API credentials and the EOI template id, plus the in-app vs Documenso pathway choice.',
|
||
autoCheckSettingKey: 'documenso_api_url',
|
||
},
|
||
{
|
||
id: 'settings',
|
||
href: 'settings',
|
||
label: 'Tune business rules + recommender weights',
|
||
description:
|
||
'Pipeline weights, net-10 discount, berth recommender knobs (heat weights, fall-through policy).',
|
||
autoCheckSettingKey: 'recommender_top_n_default',
|
||
},
|
||
{
|
||
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: '../',
|
||
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 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<Record<string, boolean>>({});
|
||
const [manualChecks, setManualChecks] = useState<Record<string, boolean>>({});
|
||
const [loading, setLoading] = useState(true);
|
||
const [saving, setSaving] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
async function load() {
|
||
setLoading(true);
|
||
try {
|
||
const settings = await apiFetch<SettingsResp>('/api/v1/admin/settings');
|
||
const all = [...settings.data.portSettings, ...settings.data.globalSettings];
|
||
const byKey = new Map(all.map((r) => [r.key, r.value]));
|
||
|
||
const checks: Record<string, boolean> = {};
|
||
const listChecks = await Promise.all(
|
||
STEPS.map(async (s) => {
|
||
if (s.autoCheckSettingKey) {
|
||
const v = byKey.get(s.autoCheckSettingKey);
|
||
return [s.id, v !== undefined && v !== null && v !== '' && v !== false] 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);
|
||
|
||
// Pull the manual-checkbox state from system_settings.
|
||
const manual = (byKey.get('onboarding_manual_status') ?? {}) as Record<string, boolean>;
|
||
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 (
|
||
<div className="mt-6 space-y-6">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Setup checklist</CardTitle>
|
||
<CardDescription>
|
||
{completed} of {STEPS.length} complete. Auto-checked steps update when you save the
|
||
underlying setting; manual ones (like website-form integration) need the checkbox.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-5">
|
||
<Progress value={percent} className="h-2" />
|
||
|
||
<ol className="space-y-3">
|
||
{STEPS.map((step, idx) => {
|
||
const auto = Boolean(autoChecks[step.id]);
|
||
const manual = Boolean(manualChecks[step.id]);
|
||
const done = auto || manual;
|
||
return (
|
||
<li
|
||
key={step.id}
|
||
className={
|
||
done
|
||
? 'flex items-start gap-3 rounded-md border border-emerald-200 bg-emerald-50/50 p-3'
|
||
: 'flex items-start gap-3 rounded-md border p-3'
|
||
}
|
||
>
|
||
<span
|
||
className={
|
||
done
|
||
? 'mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-emerald-500 text-white'
|
||
: 'mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground'
|
||
}
|
||
>
|
||
{done ? (
|
||
<Check className="h-4 w-4" />
|
||
) : loading ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Circle className="h-4 w-4" />
|
||
)}
|
||
</span>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div>
|
||
<Link
|
||
href={`/${portSlug}/admin/${step.href}` as never}
|
||
className="text-sm font-medium hover:underline inline-flex items-center gap-1"
|
||
>
|
||
{idx + 1}. {step.label}
|
||
<ExternalLink className="h-3 w-3 opacity-50" />
|
||
</Link>
|
||
<p className="mt-0.5 text-xs text-muted-foreground">{step.description}</p>
|
||
{auto && (
|
||
<p className="mt-1 text-[11px] text-emerald-700">
|
||
Auto-detected complete via{' '}
|
||
<code className="text-[10px]">
|
||
{step.autoCheckSettingKey ?? step.autoCheckListEndpoint}
|
||
</code>
|
||
</p>
|
||
)}
|
||
</div>
|
||
{!auto && (
|
||
<Button
|
||
size="sm"
|
||
variant={manual ? 'secondary' : 'outline'}
|
||
disabled={saving === step.id}
|
||
onClick={() => toggleManual(step.id)}
|
||
>
|
||
{saving === step.id ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
) : manual ? (
|
||
'Mark incomplete'
|
||
) : (
|
||
'Mark done'
|
||
)}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</li>
|
||
);
|
||
})}
|
||
</ol>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|