a0dc94ce79
- Redesign onboarding into a 5-step wizard: welcome → phone → consent → code → done - Progress bar, human-readable consent scopes (INGEST/ARCHIVE/REPLICATE/DISPLAY) with descriptions - Resend-code action, back navigation, indigo theme matching member portal - "Done" step introduces portal features (digests, events, privacy) with CTA - Onboard page shell: rounded card, policy footer - Restore Thread model + Message.expiresAt/quotedPlatformMsgId/threadId + RAW/DNC statuses to schema.prisma (wiped by prior reset; matches existing add_hybrid_ai_architecture migration) — unblocks ThreadsModule registration Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
322 lines
12 KiB
TypeScript
322 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
|
|
interface Props {
|
|
token: string;
|
|
groupName: string;
|
|
tenantName: string;
|
|
defaultScopes: string[];
|
|
defaultRetentionDays: number;
|
|
}
|
|
|
|
type Step = 'welcome' | 'phone' | 'consent' | 'code' | 'done';
|
|
|
|
const STEP_ORDER: Step[] = ['welcome', 'phone', 'consent', 'code', 'done'];
|
|
|
|
interface ScopeMeta {
|
|
key: string;
|
|
label: string;
|
|
description: string;
|
|
}
|
|
|
|
const SCOPE_META: ScopeMeta[] = [
|
|
{
|
|
key: 'INGEST',
|
|
label: 'Read group messages',
|
|
description: 'Let TOWER read messages you send here so they can be organised and surfaced.',
|
|
},
|
|
{
|
|
key: 'ARCHIVE',
|
|
label: 'Save to searchable archive',
|
|
description: 'Keep a record of relevant messages so members can find them later.',
|
|
},
|
|
{
|
|
key: 'REPLICATE',
|
|
label: 'Share across connected groups',
|
|
description: 'Allow approved messages to be forwarded to connected sister groups.',
|
|
},
|
|
{
|
|
key: 'DISPLAY',
|
|
label: 'Show in portal & digests',
|
|
description: 'Let your contributions appear in the member portal and daily digests.',
|
|
},
|
|
];
|
|
|
|
const API_BASE = process.env['NEXT_PUBLIC_API_URL'] ?? 'http://localhost:3001';
|
|
|
|
export function OnboardingForm({ token, groupName, tenantName, defaultScopes, defaultRetentionDays }: Props) {
|
|
const [step, setStep] = useState<Step>('welcome');
|
|
const [phone, setPhone] = useState('');
|
|
const [challengeId, setChallengeId] = useState<string | null>(null);
|
|
const [code, setCode] = useState('');
|
|
const [retentionDays, setRetentionDays] = useState(defaultRetentionDays);
|
|
const [scopes, setScopes] = useState<string[]>(defaultScopes);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const stepIndex = STEP_ORDER.indexOf(step);
|
|
const progressPct = (stepIndex / (STEP_ORDER.length - 1)) * 100;
|
|
|
|
async function requestOtp() {
|
|
setError(null);
|
|
setBusy(true);
|
|
try {
|
|
const res = await fetch(`${API_BASE}/public/auth/request-otp`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
body: JSON.stringify({ onboardingToken: token, phone }),
|
|
});
|
|
if (!res.ok) {
|
|
const body = (await res.json().catch(() => ({}))) as { message?: string };
|
|
setError(body.message ?? 'Failed to send code');
|
|
return;
|
|
}
|
|
const data = (await res.json()) as { challengeId: string };
|
|
setChallengeId(data.challengeId);
|
|
setStep('consent');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function verifyOtp() {
|
|
setError(null);
|
|
setBusy(true);
|
|
try {
|
|
const res = await fetch('/api/onboard/verify-otp', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
body: JSON.stringify({ onboardingToken: token, challengeId, phone, code, scopes, retentionDays }),
|
|
});
|
|
if (!res.ok) {
|
|
const body = (await res.json().catch(() => ({}))) as { message?: string };
|
|
setError(body.message ?? 'Verification failed');
|
|
return;
|
|
}
|
|
setStep('done');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
function toggleScope(key: string, checked: boolean) {
|
|
setScopes((prev) => (checked ? [...prev, key] : prev.filter((s) => s !== key)));
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
{/* Progress bar */}
|
|
{step !== 'welcome' && step !== 'done' && (
|
|
<div className="mb-6">
|
|
<div className="h-1.5 w-full bg-gray-100 rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-indigo-600 transition-all duration-300"
|
|
style={{ width: `${progressPct}%` }}
|
|
/>
|
|
</div>
|
|
<p className="text-xs text-gray-400 mt-2">Step {stepIndex} of 3</p>
|
|
</div>
|
|
)}
|
|
|
|
{step === 'welcome' && (
|
|
<div className="space-y-5">
|
|
<div className="w-12 h-12 rounded-xl bg-indigo-100 flex items-center justify-center text-2xl">
|
|
👋
|
|
</div>
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-gray-900">Welcome to {groupName}</h2>
|
|
<p className="text-sm text-gray-500 mt-1">Managed by {tenantName}</p>
|
|
</div>
|
|
<p className="text-sm text-gray-600 leading-relaxed">
|
|
TOWER keeps your community's important messages organised, searchable, and shareable —
|
|
while keeping you in control of your data. Let's get you set up. It takes about a minute.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => setStep('phone')}
|
|
className="w-full rounded-lg bg-indigo-600 text-white text-sm font-medium py-2.5 hover:bg-indigo-700 transition-colors"
|
|
>
|
|
Get started
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{step === 'phone' && (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<h2 className="text-base font-semibold text-gray-900">Your WhatsApp number</h2>
|
|
<p className="text-sm text-gray-500 mt-1">We'll send a 6-digit code to this number on WhatsApp.</p>
|
|
</div>
|
|
<input
|
|
type="tel"
|
|
value={phone}
|
|
onChange={(e) => setPhone(e.target.value)}
|
|
placeholder="+91 98765 43210"
|
|
className="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
|
autoFocus
|
|
/>
|
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setStep('welcome')}
|
|
className="rounded-lg border border-gray-300 text-gray-600 text-sm font-medium px-4 py-2.5 hover:bg-gray-50"
|
|
>
|
|
Back
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={requestOtp}
|
|
disabled={busy || phone.replace(/\D/g, '').length < 8}
|
|
className="flex-1 rounded-lg bg-indigo-600 text-white text-sm font-medium py-2.5 hover:bg-indigo-700 disabled:opacity-50 transition-colors"
|
|
>
|
|
{busy ? 'Sending…' : 'Send code'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{step === 'consent' && (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<h2 className="text-base font-semibold text-gray-900">What you're agreeing to</h2>
|
|
<p className="text-sm text-gray-500 mt-1">
|
|
You stay in control — change or revoke any of these anytime from your portal.
|
|
</p>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{SCOPE_META.map((s) => {
|
|
const checked = scopes.includes(s.key);
|
|
return (
|
|
<label
|
|
key={s.key}
|
|
className={`flex gap-3 rounded-lg border p-3 cursor-pointer transition-colors ${
|
|
checked ? 'border-indigo-300 bg-indigo-50/50' : 'border-gray-200 hover:bg-gray-50'
|
|
}`}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={checked}
|
|
onChange={(e) => toggleScope(s.key, e.target.checked)}
|
|
className="mt-0.5 w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 shrink-0"
|
|
/>
|
|
<span>
|
|
<span className="block text-sm font-medium text-gray-900">{s.label}</span>
|
|
<span className="block text-xs text-gray-500 mt-0.5">{s.description}</span>
|
|
</span>
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-gray-700">Keep my messages for</span>
|
|
<div className="flex items-center gap-2 mt-1">
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={3650}
|
|
value={retentionDays}
|
|
onChange={(e) => setRetentionDays(Number(e.target.value))}
|
|
className="w-24 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
|
/>
|
|
<span className="text-sm text-gray-500">days</span>
|
|
</div>
|
|
</label>
|
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setStep('phone')}
|
|
className="rounded-lg border border-gray-300 text-gray-600 text-sm font-medium px-4 py-2.5 hover:bg-gray-50"
|
|
>
|
|
Back
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => { setError(null); setStep('code'); }}
|
|
className="flex-1 rounded-lg bg-indigo-600 text-white text-sm font-medium py-2.5 hover:bg-indigo-700 transition-colors"
|
|
>
|
|
Continue
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{step === 'code' && (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<h2 className="text-base font-semibold text-gray-900">Enter your code</h2>
|
|
<p className="text-sm text-gray-500 mt-1">
|
|
We sent a 6-digit code to <span className="font-medium text-gray-700">{phone}</span> on WhatsApp.
|
|
</p>
|
|
</div>
|
|
<input
|
|
type="text"
|
|
inputMode="numeric"
|
|
pattern="[0-9]{6}"
|
|
maxLength={6}
|
|
value={code}
|
|
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
|
|
placeholder="000000"
|
|
className="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-center text-lg tracking-[0.3em] font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
|
autoFocus
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={requestOtp}
|
|
disabled={busy}
|
|
className="text-xs text-indigo-600 hover:underline disabled:opacity-50"
|
|
>
|
|
Didn't get it? Resend code
|
|
</button>
|
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setStep('consent')}
|
|
className="rounded-lg border border-gray-300 text-gray-600 text-sm font-medium px-4 py-2.5 hover:bg-gray-50"
|
|
>
|
|
Back
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={verifyOtp}
|
|
disabled={busy || code.length < 6}
|
|
className="flex-1 rounded-lg bg-indigo-600 text-white text-sm font-medium py-2.5 hover:bg-indigo-700 disabled:opacity-50 transition-colors"
|
|
>
|
|
{busy ? 'Verifying…' : 'Verify & join'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{step === 'done' && (
|
|
<div className="space-y-5 text-center">
|
|
<div className="w-14 h-14 rounded-full bg-green-100 flex items-center justify-center text-2xl mx-auto">
|
|
✓
|
|
</div>
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-gray-900">You're all set!</h2>
|
|
<p className="text-sm text-gray-500 mt-1">Welcome to {groupName}.</p>
|
|
</div>
|
|
<div className="text-left bg-gray-50 rounded-lg p-4 space-y-2">
|
|
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide">In your portal you can</p>
|
|
<ul className="text-sm text-gray-600 space-y-1.5">
|
|
<li>📋 Read daily digests of what matters</li>
|
|
<li>📅 RSVP to community events</li>
|
|
<li>🔒 Manage your privacy & consent anytime</li>
|
|
</ul>
|
|
</div>
|
|
<a
|
|
href="/my"
|
|
className="block w-full rounded-lg bg-indigo-600 text-white text-sm font-medium py-2.5 hover:bg-indigo-700 transition-colors"
|
|
>
|
|
Go to my portal →
|
|
</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|