wacrm/src/components/settings/profile-form.tsx
Arnas Donauskas 32022c13fe
feat(theme): sweep remaining violet-* → primary across all surfaces
PR #132 (color themes) only tokenized the high-visibility chrome.
Inbox / contacts / pipelines / broadcasts / automations / auth /
settings sub-forms still had ~150 hard-coded `violet-*` classes
across ~32 files, so picking a non-violet theme felt half-broken —
sidebar and header changed colour, but message bubbles, deal cards,
and the broadcast wizard all stayed violet.

This is the mechanical follow-up: replace every `violet-N(/N)?`
with `primary(/N)?` so the theme picker themes the whole app.

Two non-trivial swaps inside the pass:
- `text-white` on a `bg-primary` button → `text-primary-foreground`.
  Emerald and Amber define a *dark* `--primary-foreground` because
  their primaries are bright; `text-white` over those would tank
  contrast.
- `bg-primary hover:bg-primary` (hover identical to base — original
  code had this latent bug from `hover:bg-violet-500`) → adds `/90`
  on hover so the lift is visible.

Test update:
- `broadcast-status.test.ts` asserted shade numbers (`bg-X-N/10`).
  Relaxed the regex to also accept token form (`bg-X/10`) since
  brand-accent status badges now ride the active theme.

Files touched: 49. Lint clean (existing warnings only), 173/173
tests, typecheck + build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 17:00:32 +03:00

360 lines
11 KiB
TypeScript

'use client';
import { useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import { Loader2, Upload, Trash2, Mail, CircleAlert } from 'lucide-react';
import { createClient } from '@/lib/supabase/client';
import { useAuth } from '@/hooks/use-auth';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Avatar,
AvatarFallback,
AvatarImage,
} from '@/components/ui/avatar';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card';
const MAX_AVATAR_BYTES = 2 * 1024 * 1024;
const ALLOWED_MIME = new Set([
'image/png',
'image/jpeg',
'image/webp',
'image/gif',
]);
// Rough email shape check — the real validator is Supabase Auth, which
// rejects anything malformed when we call updateUser({ email }). We
// just want to stop obvious typos before making a network call.
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function ProfileForm() {
const { user, profile, refreshProfile } = useAuth();
const supabase = createClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const [fullName, setFullName] = useState('');
const [email, setEmail] = useState('');
const [pendingAvatar, setPendingAvatar] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [removeAvatar, setRemoveAvatar] = useState(false);
const [saving, setSaving] = useState(false);
const [emailChangePending, setEmailChangePending] = useState(false);
// Seed form state once the profile loads.
useEffect(() => {
if (!profile) return;
setFullName(profile.full_name ?? '');
setEmail(profile.email ?? '');
}, [profile]);
// Cleanup object URLs to avoid leaks.
useEffect(() => {
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
};
}, [previewUrl]);
const currentAvatar =
previewUrl ?? (!removeAvatar ? profile?.avatar_url ?? null : null);
const initial = (fullName || profile?.full_name || profile?.email || 'U')
.charAt(0)
.toUpperCase();
const onPickFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = ''; // reset so the same file can be re-picked
if (!file) return;
if (!ALLOWED_MIME.has(file.type)) {
toast.error('Unsupported image type', {
description: 'Use PNG, JPG, WebP, or GIF.',
});
return;
}
if (file.size > MAX_AVATAR_BYTES) {
toast.error('Image is too large', {
description: 'Maximum 2 MB.',
});
return;
}
if (previewUrl) URL.revokeObjectURL(previewUrl);
setPendingAvatar(file);
setPreviewUrl(URL.createObjectURL(file));
setRemoveAvatar(false);
};
const onRemoveAvatar = () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
setPendingAvatar(null);
setPreviewUrl(null);
setRemoveAvatar(true);
};
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!user || !profile) return;
const trimmedName = fullName.trim();
if (!trimmedName) {
toast.error('Display name is required');
return;
}
const trimmedEmail = email.trim();
if (!EMAIL_RE.test(trimmedEmail)) {
toast.error('Enter a valid email address');
return;
}
setSaving(true);
try {
let nextAvatarUrl: string | null = profile.avatar_url ?? null;
// Upload a newly-staged image, if any.
if (pendingAvatar) {
const ext =
pendingAvatar.name.split('.').pop()?.toLowerCase() || 'png';
const path = `${user.id}/avatar-${Date.now()}.${ext}`;
const { error: uploadError } = await supabase.storage
.from('avatars')
.upload(path, pendingAvatar, {
cacheControl: '3600',
upsert: true,
contentType: pendingAvatar.type,
});
if (uploadError) {
throw new Error(`Upload failed: ${uploadError.message}`);
}
const {
data: { publicUrl },
} = supabase.storage.from('avatars').getPublicUrl(path);
nextAvatarUrl = publicUrl;
} else if (removeAvatar) {
nextAvatarUrl = null;
}
// Persist name + avatar to profiles.
const { error: updateError } = await supabase
.from('profiles')
.update({
full_name: trimmedName,
avatar_url: nextAvatarUrl,
})
.eq('user_id', user.id);
if (updateError) {
throw new Error(`Save failed: ${updateError.message}`);
}
// Email change goes through Supabase Auth, which emails a
// confirmation to both the old and new addresses. We don't
// touch profiles.email — Supabase will push the change there
// after the user clicks the link (handled by the handle_new_user
// trigger pattern in production deployments).
let emailSent = false;
if (trimmedEmail.toLowerCase() !== profile.email.toLowerCase()) {
const { error: emailError } = await supabase.auth.updateUser({
email: trimmedEmail,
});
if (emailError) {
// Partial success: name/avatar saved but email didn't.
toast.success('Profile saved');
toast.error(`Email change failed: ${emailError.message}`);
setSaving(false);
await refreshProfile();
return;
}
emailSent = true;
}
setEmailChangePending(emailSent);
setPendingAvatar(null);
setPreviewUrl(null);
setRemoveAvatar(false);
await refreshProfile();
toast.success(
emailSent
? 'Profile saved — check your email to confirm the address change'
: 'Profile saved',
);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
toast.error(msg);
} finally {
setSaving(false);
}
};
const dirty =
!!profile &&
(fullName.trim() !== (profile.full_name ?? '') ||
email.trim().toLowerCase() !== (profile.email ?? '').toLowerCase() ||
pendingAvatar !== null ||
removeAvatar);
const joined = user?.created_at
? new Date(user.created_at).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
})
: '—';
return (
<Card className="bg-slate-900/40 border-slate-800">
<CardHeader>
<CardTitle className="text-white">Profile</CardTitle>
<CardDescription className="text-slate-400">
How you show up across the app. Your avatar and name appear in the
header, sidebar, and anywhere your teammates see you.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={onSubmit} className="space-y-6">
{/* Avatar row */}
<div className="flex flex-wrap items-center gap-5">
<Avatar size="lg" className="size-16">
{currentAvatar ? (
<AvatarImage src={currentAvatar} alt={fullName || 'Avatar'} />
) : null}
<AvatarFallback className="bg-primary/10 text-base text-primary">
{initial}
</AvatarFallback>
</Avatar>
<div className="flex flex-wrap gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="hidden"
onChange={onPickFile}
/>
<Button
type="button"
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={saving}
>
<Upload className="size-4" />
{currentAvatar ? 'Change photo' : 'Upload photo'}
</Button>
{currentAvatar && (
<Button
type="button"
variant="ghost"
onClick={onRemoveAvatar}
disabled={saving}
className="text-slate-400 hover:text-white"
>
<Trash2 className="size-4" />
Remove
</Button>
)}
<p className="w-full text-xs text-slate-500">
PNG, JPG, WebP, or GIF. Up to 2 MB.
</p>
</div>
</div>
{/* Name */}
<div className="space-y-2">
<Label htmlFor="profile-full-name" className="text-slate-200">
Display name
</Label>
<Input
id="profile-full-name"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
placeholder="Ada Lovelace"
maxLength={120}
disabled={saving}
required
/>
</div>
{/* Email */}
<div className="space-y-2">
<Label htmlFor="profile-email" className="text-slate-200">
Email
</Label>
<Input
id="profile-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={saving}
required
/>
{emailChangePending && (
<p className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-300">
<Mail className="mt-0.5 size-3.5 shrink-0" />
<span>
Check the inbox for <strong>{profile?.email}</strong> and{' '}
<strong>{email}</strong> — both need to confirm before the
change takes effect.
</span>
</p>
)}
</div>
{/* Read-only block */}
<div className="rounded-lg border border-slate-800 bg-slate-900/60 p-4">
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-slate-500">
Account details
</p>
<dl className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<div>
<dt className="text-slate-500">Role</dt>
<dd className="mt-0.5 font-mono text-slate-200">
{profile?.role ?? 'user'}
</dd>
</div>
<div>
<dt className="text-slate-500">Joined</dt>
<dd className="mt-0.5 text-slate-200">{joined}</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-slate-500">User ID</dt>
<dd className="mt-0.5 break-all font-mono text-xs text-slate-400">
{user?.id ?? '—'}
</dd>
</div>
</dl>
</div>
{!profile && (
<p className="flex items-center gap-2 text-sm text-slate-400">
<CircleAlert className="size-4" />
Loading your profile…
</p>
)}
<div className="flex justify-end">
<Button type="submit" disabled={saving || !dirty || !profile}>
{saving ? (
<>
<Loader2 className="size-4 animate-spin" />
Saving…
</>
) : (
'Save changes'
)}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}