ProfilePage.tsx
8,219 bytes
| 1 | import { useState, type FormEvent } from "react"; |
|---|---|
| 2 | import { useMutation } from "@tanstack/react-query"; |
| 3 | import { Check, Copy, ExternalLink, LockKeyhole, Save, Search, Share2, Trash2 } from "lucide-react"; |
| 4 | import { Link, useNavigate } from "react-router-dom"; |
| 5 | import { Avatar, PageHeader } from "../components/Ui"; |
| 6 | import { useAuth } from "../lib/auth"; |
| 7 | import { ApiError, api, jsonBody } from "../lib/api"; |
| 8 | import type { Account } from "../types"; |
| 9 | |
| 10 | export function ProfilePage() { |
| 11 | const { user, refresh, deleteAccount } = useAuth(); |
| 12 | const navigate = useNavigate(); |
| 13 | const [displayName, setDisplayName] = useState(user?.displayName ?? ""); |
| 14 | const [homeCity, setHomeCity] = useState(user?.homeCity ?? ""); |
| 15 | const [homeCountryCode, setHomeCountryCode] = useState(user?.homeCountryCode ?? ""); |
| 16 | const [bio, setBio] = useState(user?.bio ?? ""); |
| 17 | const [avatarUrl, setAvatarUrl] = useState(user?.avatarUrl ?? ""); |
| 18 | const [profilePublic, setProfilePublic] = useState(user?.profilePublic ?? false); |
| 19 | const [compareSlug, setCompareSlug] = useState(""); |
| 20 | const [error, setError] = useState(""); |
| 21 | const [saved, setSaved] = useState(false); |
| 22 | const [copied, setCopied] = useState(false); |
| 23 | const [showDelete, setShowDelete] = useState(false); |
| 24 | const [deletePassword, setDeletePassword] = useState(""); |
| 25 | const [deleteError, setDeleteError] = useState(""); |
| 26 | |
| 27 | const update = useMutation({ |
| 28 | mutationFn: () => api<Account>("/api/v1/auth/me", { |
| 29 | method: "PATCH", |
| 30 | ...jsonBody({ |
| 31 | displayName: displayName.trim(), |
| 32 | homeCity: homeCity.trim() || null, |
| 33 | homeCountryCode: homeCountryCode.trim().toUpperCase(), |
| 34 | bio: bio.trim() || null, |
| 35 | avatarUrl: avatarUrl.trim() || null, |
| 36 | profilePublic |
| 37 | }) |
| 38 | }), |
| 39 | onSuccess: async () => { |
| 40 | await refresh(); |
| 41 | setSaved(true); |
| 42 | window.setTimeout(() => setSaved(false), 2000); |
| 43 | }, |
| 44 | onError: caught => setError(caught instanceof ApiError ? caught.message : "Your profile could not be saved.") |
| 45 | }); |
| 46 | const removeAccount = useMutation({ |
| 47 | mutationFn: () => deleteAccount(deletePassword), |
| 48 | onSuccess: () => navigate("/", { replace: true }), |
| 49 | onError: caught => setDeleteError(caught instanceof ApiError ? caught.message : "The account could not be deleted.") |
| 50 | }); |
| 51 | |
| 52 | if (!user) return null; |
| 53 | const publicUrl = `${window.location.origin}/t/${user.shareSlug}`; |
| 54 | |
| 55 | function submit(event: FormEvent) { |
| 56 | event.preventDefault(); |
| 57 | setError(""); |
| 58 | setSaved(false); |
| 59 | update.mutate(); |
| 60 | } |
| 61 | |
| 62 | async function copyLink() { |
| 63 | await navigator.clipboard.writeText(publicUrl); |
| 64 | setCopied(true); |
| 65 | window.setTimeout(() => setCopied(false), 2000); |
| 66 | } |
| 67 | |
| 68 | function compare(event: FormEvent) { |
| 69 | event.preventDefault(); |
| 70 | const slug = compareSlug.trim().replace(/^.*\/t\//, ""); |
| 71 | if (slug) navigate(`/app/compare/${encodeURIComponent(slug)}`); |
| 72 | } |
| 73 | |
| 74 | return ( |
| 75 | <div className="page profile-page"> |
| 76 | <PageHeader eyebrow="Your passport" title="Profile and sharing" |
| 77 | copy="Choose what appears on your public Tasteprint, then send one link instead of a photo dump." /> |
| 78 | |
| 79 | <div className="profile-layout"> |
| 80 | <form className="panel profile-form" onSubmit={submit}> |
| 81 | <div className="profile-identity"> |
| 82 | <Avatar name={displayName || user.displayName} url={avatarUrl} size="large" /> |
| 83 | <div><span className="eyebrow">Traveler profile</span><h2>{displayName || user.displayName}</h2><p>{user.email}</p></div> |
| 84 | </div> |
| 85 | <div className="form-grid form-grid-two"> |
| 86 | <label><span>Display name</span><input value={displayName} onChange={event => setDisplayName(event.target.value)} maxLength={80} required /></label> |
| 87 | <label><span>Avatar image URL</span><input type="url" value={avatarUrl} onChange={event => setAvatarUrl(event.target.value)} placeholder="https://..." maxLength={500} /></label> |
| 88 | <label><span>Home city</span><input value={homeCity} onChange={event => setHomeCity(event.target.value)} placeholder="Tallinn" maxLength={100} /></label> |
| 89 | <label><span>Home country code</span><input value={homeCountryCode} onChange={event => setHomeCountryCode(event.target.value.toUpperCase())} placeholder="EE" maxLength={2} pattern="[A-Za-z]{2}" /></label> |
| 90 | </div> |
| 91 | <label><span>Short bio</span><textarea value={bio} onChange={event => setBio(event.target.value)} rows={3} maxLength={280} placeholder="I travel for the table." /></label> |
| 92 | <label className="privacy-toggle"> |
| 93 | <input type="checkbox" checked={profilePublic} onChange={event => setProfilePublic(event.target.checked)} /> |
| 94 | <span><strong>Public Tasteprint</strong><small>Anyone with your link can see your map, stats, bio, and recent tastings.</small></span> |
| 95 | </label> |
| 96 | {error && <p className="form-error" role="alert">{error}</p>} |
| 97 | <button className="button button-primary" type="submit" disabled={update.isPending}> |
| 98 | {saved ? <Check size={17} /> : <Save size={17} />} {saved ? "Saved" : "Save profile"} |
| 99 | </button> |
| 100 | </form> |
| 101 | |
| 102 | <aside className="profile-side"> |
| 103 | <section className={`panel share-panel ${profilePublic ? "is-public" : ""}`}> |
| 104 | {profilePublic ? <Share2 size={24} /> : <LockKeyhole size={24} />} |
| 105 | <span className="eyebrow">Share link</span> |
| 106 | <h2>{profilePublic ? "Your Tasteprint is visible" : "Your Tasteprint is private"}</h2> |
| 107 | <div className="share-url"><span>{publicUrl}</span><button type="button" onClick={() => void copyLink()} aria-label="Copy public link">{copied ? <Check size={17} /> : <Copy size={17} />}</button></div> |
| 108 | <div className="share-actions"> |
| 109 | <button className="button button-outline" type="button" onClick={() => void copyLink()} disabled={!profilePublic}>{copied ? "Copied" : "Copy link"}</button> |
| 110 | {profilePublic && <Link className="button button-ghost" to={`/t/${user.shareSlug}`} target="_blank">Preview <ExternalLink size={16} /></Link>} |
| 111 | </div> |
| 112 | {!profilePublic && <p>Turn on public sharing and save before sending the link.</p>} |
| 113 | </section> |
| 114 | |
| 115 | <section className="panel compare-start"> |
| 116 | <Search size={23} /> |
| 117 | <span className="eyebrow">Compare taste maps</span> |
| 118 | <h2>What should you eat together?</h2> |
| 119 | <p>Paste a friend's Tasteprint link or enter their handle.</p> |
| 120 | <form onSubmit={compare}> |
| 121 | <input value={compareSlug} onChange={event => setCompareSlug(event.target.value)} placeholder="friend-handle" required /> |
| 122 | <button className="button button-ink" type="submit">Compare</button> |
| 123 | </form> |
| 124 | </section> |
| 125 | <section className="panel danger-panel"> |
| 126 | <Trash2 size={22} /> |
| 127 | <span className="eyebrow">Account data</span> |
| 128 | <h2>Delete your Tasteprint</h2> |
| 129 | <p>This permanently removes your profile, sessions, tastings, trips, and owned challenges.</p> |
| 130 | {!showDelete ? ( |
| 131 | <button className="danger-link" type="button" onClick={() => setShowDelete(true)}>Delete account</button> |
| 132 | ) : ( |
| 133 | <form onSubmit={event => { |
| 134 | event.preventDefault(); |
| 135 | setDeleteError(""); |
| 136 | removeAccount.mutate(); |
| 137 | }}> |
| 138 | <label> |
| 139 | <span>Confirm with your password</span> |
| 140 | <input type="password" value={deletePassword} onChange={event => setDeletePassword(event.target.value)} |
| 141 | maxLength={72} required autoComplete="current-password" /> |
| 142 | </label> |
| 143 | {deleteError && <p className="form-error" role="alert">{deleteError}</p>} |
| 144 | <div> |
| 145 | <button className="button button-ghost" type="button" onClick={() => setShowDelete(false)}>Cancel</button> |
| 146 | <button className="button button-danger" type="submit" disabled={removeAccount.isPending}>Delete permanently</button> |
| 147 | </div> |
| 148 | </form> |
| 149 | )} |
| 150 | </section> |
| 151 | </aside> |
| 152 | </div> |
| 153 | </div> |
| 154 | ); |
| 155 | } |
| 156 | |