Skip to content

Commit faf154b

Browse files
feat: execute Batch 3 of pre-launch web UX and form fixes
- W1: Web GDPR profile deletion modal and API wiring - W4: Standardize terms/conditions checkbox on waitlist signup form - W5: Verified dedicated privacy policy page - W8 & W9: Implement proper input validation and clear toast notifications for split errors on Web ExpenseForm - W11: Add strict character limits (255) for all description inputs
1 parent 869ba27 commit faf154b

5 files changed

Lines changed: 178 additions & 13 deletions

File tree

apps/web/app/waitlist/page.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,13 @@ export default function WaitlistPage() {
173173
</div>
174174
</div>
175175

176+
<div className="flex items-start gap-3 py-1">
177+
<input type="checkbox" className="mt-1 h-3.5 w-3.5 rounded border-white/10 bg-white/5 checked:bg-purple-600 transition-all cursor-pointer" id="terms" required />
178+
<label htmlFor="terms" className="text-[11px] font-medium text-zinc-500 leading-normal cursor-pointer hover:text-zinc-300 transition-colors text-left">
179+
By joining the queue, you agree to our <a href="/terms" className="text-white hover:underline">Terms of Service</a> and <a href="/privacy" className="text-white hover:underline">Privacy Policy</a>.
180+
</label>
181+
</div>
182+
176183
<div className="pt-4">
177184
<AccentButton
178185
type="submit"

apps/web/src/components/groups/CreateExpenseModal.tsx

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -204,23 +204,43 @@ export function CreateExpenseModal({
204204
setError('');
205205
const totalCents = Math.round(Number(amount || 0) * 100);
206206

207-
if (!description.trim() || totalCents <= 0 || participants.length === 0) {
208-
setError('Add a description, amount, and at least one participant.');
207+
if (!description.trim()) {
208+
setError('Description is required.');
209209
return;
210210
}
211211

212-
let shares: Record<string, number>;
213-
if (splitType === 'equal') {
214-
shares = equalShares(totalCents, participants);
215-
} else if (splitType === 'exact') {
216-
shares = exactShares(participants, exactByUser);
217-
} else {
218-
shares = percentageShares(totalCents, participants, percentagesByUser);
212+
if (description.length > 255) {
213+
setError('Description cannot exceed 255 characters.');
214+
return;
215+
}
216+
217+
if (totalCents <= 0) {
218+
setError('Amount must be greater than 0.');
219+
return;
220+
}
221+
222+
if (participants.length === 0) {
223+
setError('Select at least one participant.');
224+
return;
219225
}
220226

221-
const diff = totalCents - sumShares(shares);
222-
if (diff !== 0 && participants.length > 0) {
223-
shares[participants[0]] = (shares[participants[0]] ?? 0) + diff;
227+
let shares: Record<string, number>;
228+
try {
229+
if (splitType === 'equal') {
230+
shares = equalShares(totalCents, participants);
231+
} else if (splitType === 'exact') {
232+
shares = exactShares(participants, exactByUser);
233+
} else {
234+
shares = percentageShares(totalCents, participants, percentagesByUser);
235+
}
236+
237+
const diff = totalCents - sumShares(shares);
238+
if (diff !== 0 && participants.length > 0) {
239+
shares[participants[0]] = (shares[participants[0]] ?? 0) + diff;
240+
}
241+
} catch (err) {
242+
setError((err as Error).message || 'Invalid split configuration.');
243+
return;
224244
}
225245

226246
const splits = participants.map((userId) => ({
@@ -321,6 +341,7 @@ export function CreateExpenseModal({
321341
placeholder="Team dinner, rideshare..."
322342
value={description}
323343
onChange={(event) => setDescription(event.target.value)}
344+
maxLength={255}
324345
required
325346
/>
326347
</div>

apps/web/src/components/groups/ExpenseDetailCard.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ export function ExpenseDetailCard({ expense, receiptUrl }: { expense: ExpenseDto
4545
toast('Description must be at least 2 characters', 'error');
4646
return;
4747
}
48+
if (description.length > 255) {
49+
toast('Description cannot exceed 255 characters', 'error');
50+
return;
51+
}
4852

4953
try {
5054
setSaving(true);
@@ -112,6 +116,7 @@ export function ExpenseDetailCard({ expense, receiptUrl }: { expense: ExpenseDto
112116
<input
113117
value={description}
114118
onChange={(event) => setDescription(event.target.value)}
119+
maxLength={255}
115120
className="w-full rounded-xl border border-[var(--fs-border)] bg-[var(--fs-background)] p-3 text-sm text-[var(--fs-text-primary)] outline-none focus:border-[var(--fs-primary)]"
116121
/>
117122
</div>

apps/web/src/components/profile/ProfilePanel.tsx

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import Link from 'next/link';
44
import { AuthUserDto } from '@fairshare/shared-types';
55
import { LogOut, ShieldCheck, LifeBuoy, Settings, ChevronRight, User, Command } from 'lucide-react';
66
import { useAuth } from '../auth/AuthProvider';
7-
import { motion } from 'framer-motion';
7+
import { useToast } from '../ui/Toaster';
8+
import { useState } from 'react';
9+
import { deleteAccountAction } from '../../lib/actions';
10+
import { motion, AnimatePresence } from 'framer-motion';
811
import { GlassCard } from '../../../components/ui/GlassCard';
912
import { fadeUp, staggerContainer } from '../../../components/home/motion-variants';
1013

@@ -18,6 +21,35 @@ function initials(name?: string | null) {
1821
export function ProfilePanel({ fallbackUser }: { fallbackUser: AuthUserDto | null }) {
1922
const { user, logout, loading } = useAuth();
2023
const currentUser = user ?? fallbackUser;
24+
const { toast } = useToast();
25+
26+
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
27+
const [isDeleting, setIsDeleting] = useState(false);
28+
const [deleteConfirmation, setDeleteConfirmation] = useState('');
29+
30+
const handleDeleteAccount = async () => {
31+
if (deleteConfirmation.toLowerCase() !== 'delete my account') {
32+
toast('Please type "delete my account" to confirm', 'error');
33+
return;
34+
}
35+
36+
setIsDeleting(true);
37+
try {
38+
const result = await deleteAccountAction();
39+
if (result.success) {
40+
toast('Account successfully scheduled for deletion');
41+
await logout();
42+
} else {
43+
toast(result.message || 'Failed to delete account', 'error');
44+
}
45+
} catch (err) {
46+
toast('An unexpected error occurred', 'error');
47+
} finally {
48+
setIsDeleting(false);
49+
setIsDeleteModalOpen(false);
50+
setDeleteConfirmation('');
51+
}
52+
};
2153

2254
const actionItems = [
2355
{
@@ -128,6 +160,86 @@ export function ProfilePanel({ fallbackUser }: { fallbackUser: AuthUserDto | nul
128160
</GlassCard>
129161
</button>
130162
</div>
163+
{/* ── Danger Zone ── */}
164+
<div className="pt-8">
165+
<GlassCard className="p-6 border-rose-500/20 bg-rose-500/5 shadow-[var(--fs-shadow-soft)]">
166+
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
167+
<div className="space-y-1 text-center sm:text-left">
168+
<h2 className="text-lg font-black italic tracking-tighter text-rose-500 uppercase">
169+
Danger Zone
170+
</h2>
171+
<p className="text-[12px] font-medium text-[var(--fs-text-muted)] max-w-xl">
172+
Permanently delete your account and anonymize all associated personal data. This action cannot be undone.
173+
</p>
174+
</div>
175+
<button
176+
onClick={() => setIsDeleteModalOpen(true)}
177+
disabled={loading || isDeleting}
178+
className="px-6 py-3 rounded-xl bg-rose-500 hover:bg-rose-600 text-white font-bold tracking-widest uppercase text-xs transition-colors"
179+
>
180+
Delete Account
181+
</button>
182+
</div>
183+
</GlassCard>
184+
</div>
185+
186+
{/* ── Delete Account Modal ── */}
187+
<AnimatePresence>
188+
{isDeleteModalOpen && (
189+
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
190+
<motion.div
191+
initial={{ opacity: 0 }}
192+
animate={{ opacity: 1 }}
193+
exit={{ opacity: 0 }}
194+
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
195+
onClick={() => !isDeleting && setIsDeleteModalOpen(false)}
196+
/>
197+
<motion.div
198+
initial={{ opacity: 0, scale: 0.95, y: 20 }}
199+
animate={{ opacity: 1, scale: 1, y: 0 }}
200+
exit={{ opacity: 0, scale: 0.95, y: 20 }}
201+
className="relative w-full max-w-md overflow-hidden rounded-3xl border border-rose-500/20 bg-[var(--fs-surface)] p-6 shadow-2xl"
202+
>
203+
<div className="mb-6 flex flex-col items-center text-center">
204+
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-rose-500/10 text-rose-500">
205+
<ShieldCheck size={32} />
206+
</div>
207+
<h3 className="text-xl font-black italic tracking-tighter text-[var(--fs-text-primary)] uppercase">
208+
Delete Account
209+
</h3>
210+
<p className="mt-2 text-sm text-[var(--fs-text-muted)]">
211+
This will permanently anonymize your data and remove your access to all groups. To proceed, please type <span className="font-bold text-rose-500">delete my account</span> below.
212+
</p>
213+
</div>
214+
215+
<input
216+
type="text"
217+
placeholder="delete my account"
218+
value={deleteConfirmation}
219+
onChange={(e) => setDeleteConfirmation(e.target.value)}
220+
className="w-full rounded-xl border border-[var(--fs-border)] bg-[var(--fs-bg)] px-4 py-3 text-center font-bold tracking-widest uppercase text-[var(--fs-text-primary)] outline-none focus:border-rose-500 focus:ring-1 focus:ring-rose-500 placeholder:text-[var(--fs-text-muted)]/50"
221+
/>
222+
223+
<div className="mt-8 flex gap-3">
224+
<button
225+
onClick={() => setIsDeleteModalOpen(false)}
226+
disabled={isDeleting}
227+
className="flex-1 rounded-xl bg-[var(--fs-bg)] py-3 font-bold tracking-widest text-[var(--fs-text-primary)] transition-colors hover:bg-[var(--fs-border)] uppercase text-xs"
228+
>
229+
Cancel
230+
</button>
231+
<button
232+
onClick={handleDeleteAccount}
233+
disabled={isDeleting || deleteConfirmation.toLowerCase() !== 'delete my account'}
234+
className="flex-1 rounded-xl bg-rose-500 py-3 font-bold tracking-widest text-white transition-colors hover:bg-rose-600 disabled:opacity-50 uppercase text-xs"
235+
>
236+
{isDeleting ? 'Deleting...' : 'Confirm'}
237+
</button>
238+
</div>
239+
</motion.div>
240+
</div>
241+
)}
242+
</AnimatePresence>
131243
</motion.div>
132244
);
133245
}

apps/web/src/lib/actions.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,3 +390,23 @@ export async function deleteGroupAction(groupId: string) {
390390

391391
return { success: true };
392392
}
393+
394+
export async function deleteAccountAction() {
395+
const token = (await cookies()).get(authCookies.accessToken)?.value;
396+
397+
const response = await fetch(`${getBackendBaseUrl()}/users/me`, {
398+
method: 'DELETE',
399+
headers: {
400+
...(token ? { Authorization: `Bearer ${token}` } : {}),
401+
},
402+
cache: 'no-store',
403+
});
404+
405+
const data = await response.json().catch(() => null);
406+
407+
if (!response.ok) {
408+
return { success: false, message: data?.message ?? 'Failed to delete account' };
409+
}
410+
411+
return { success: true };
412+
}

0 commit comments

Comments
 (0)