profileShare

rasmusjy / tasteprint

Read-only snapshot

No repository description.

main default branch 169 files Expires Sep 13, 2026, 9:06 AM
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