Files
maaz519 5a0f7aa58f fix: route browser→API calls through BFF to avoid CORS
- Create /api/onboard/request-otp and /api/auth/member/login BFF routes
- Update OnboardingForm and member-login page to call BFF instead of API directly
- Add vercel.json with monorepo build config and rootDirectory hint
- Add .vercelignore to exclude .turbo, node_modules, .next (was uploading 2.3GB)
- Add apps/web/.gitignore and update root .gitignore for tsbuildinfo files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 21:01:05 +05:30

320 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.',
},
];
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/onboard/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&apos;s important messages organised, searchable, and shareable
while keeping you in control of your data. Let&apos;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&apos;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&apos;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&apos;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&apos;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 &amp; 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>
);
}