feat(settings/profile): collapsible profile card + share sheet
Collapsible profile card:
- Starts collapsed if profile already exists (shows name + URL slug)
- Expands/collapses via chevron toggle row
- Auto-collapses after Save Profile succeeds
Share sheet on Product Recommendations:
- ↗ Share button appears once a valid slug is set
- Options: Copy link (clipboard, shows ✅ feedback), WhatsApp deep link,
and native Web Share API ("More options") when browser supports it
- Backdrop click closes the sheet
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3b62841bd4
commit
8598259fb1
1 changed files with 161 additions and 62 deletions
|
|
@ -33,6 +33,9 @@ export default function ProfileSettingsPage() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saveMsg, setSaveMsg] = useState("");
|
const [saveMsg, setSaveMsg] = useState("");
|
||||||
|
const [profileExpanded, setProfileExpanded] = useState(true);
|
||||||
|
const [showShareSheet, setShowShareSheet] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const [slug, setSlug] = useState("");
|
const [slug, setSlug] = useState("");
|
||||||
const [displayName, setDisplayName] = useState("");
|
const [displayName, setDisplayName] = useState("");
|
||||||
|
|
@ -58,12 +61,15 @@ export default function ProfileSettingsPage() {
|
||||||
setDisplayName(pd.profile.display_name);
|
setDisplayName(pd.profile.display_name);
|
||||||
setBio(pd.profile.bio ?? "");
|
setBio(pd.profile.bio ?? "");
|
||||||
setIsPublic(pd.profile.is_public);
|
setIsPublic(pd.profile.is_public);
|
||||||
|
setProfileExpanded(false); // already set up — start collapsed
|
||||||
}
|
}
|
||||||
setProducts(prods.items || []);
|
setProducts(prods.items || []);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}).catch(() => setLoading(false));
|
}).catch(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const profileUrl = typeof window !== "undefined" && slug ? `${window.location.origin}/m/${slug}` : "";
|
||||||
|
|
||||||
async function saveProfile() {
|
async function saveProfile() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSaveMsg("");
|
setSaveMsg("");
|
||||||
|
|
@ -75,13 +81,32 @@ export default function ProfileSettingsPage() {
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
setProfile(d.profile);
|
setProfile(d.profile);
|
||||||
setSaveMsg(`Saved! View at /m/${d.profile.slug}`);
|
setSaveMsg("Saved!");
|
||||||
|
setProfileExpanded(false); // collapse after save
|
||||||
} else {
|
} else {
|
||||||
setSaveMsg(d.error || "Save failed");
|
setSaveMsg(d.error || "Save failed");
|
||||||
}
|
}
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyLink() {
|
||||||
|
if (!profileUrl) return;
|
||||||
|
await navigator.clipboard.writeText(profileUrl);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shareViaWhatsApp() {
|
||||||
|
const text = encodeURIComponent(`Check out my baby product recommendations! ${profileUrl}`);
|
||||||
|
window.open(`https://wa.me/?text=${text}`, "_blank");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shareNative() {
|
||||||
|
if (navigator.share) {
|
||||||
|
await navigator.share({ title: `${displayName}'s baby picks`, url: profileUrl });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resetProductForm() {
|
function resetProductForm() {
|
||||||
setPTitle(""); setPUrl(""); setPDesc(""); setPImageUrl(""); setPCategory("general");
|
setPTitle(""); setPUrl(""); setPDesc(""); setPImageUrl(""); setPCategory("general");
|
||||||
setShowAddProduct(false); setEditingProduct(null);
|
setShowAddProduct(false); setEditingProduct(null);
|
||||||
|
|
@ -162,65 +187,93 @@ export default function ProfileSettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-4 space-y-4">
|
<div className="px-4 space-y-4">
|
||||||
{/* Profile section */}
|
{/* Profile section — collapsible */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
|
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm overflow-hidden">
|
||||||
<h2 className="font-semibold text-gray-800 dark:text-white mb-4">Profile</h2>
|
{/* Always-visible header row */}
|
||||||
|
|
||||||
<label className="block text-sm text-gray-500 dark:text-gray-400 mb-1">Display Name</label>
|
|
||||||
<input
|
|
||||||
value={displayName}
|
|
||||||
onChange={e => setDisplayName(e.target.value)}
|
|
||||||
className={`${inputClass} mb-3`}
|
|
||||||
placeholder="Priya Sharma"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<label className="block text-sm text-gray-500 dark:text-gray-400 mb-1">Your Page URL</label>
|
|
||||||
<div className="flex items-center gap-1 border border-gray-200 dark:border-gray-600 rounded-xl px-3 py-2.5 text-sm mb-1 bg-white dark:bg-gray-700 focus-within:ring-2 focus-within:ring-rose-300">
|
|
||||||
<span className="text-gray-400 text-xs whitespace-nowrap">{baseUrl}/m/</span>
|
|
||||||
<input
|
|
||||||
value={slug}
|
|
||||||
onChange={e => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""))}
|
|
||||||
className="flex-1 bg-transparent outline-none dark:text-white text-sm"
|
|
||||||
placeholder="priya-sharma"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{slug && !slugValid && (
|
|
||||||
<p className="text-xs text-red-500 mb-2">3–40 chars, lowercase letters, numbers, hyphens only</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<label className="block text-sm text-gray-500 dark:text-gray-400 mb-1 mt-3">
|
|
||||||
Bio <span className="text-gray-400">({bio.length}/200)</span>
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={bio}
|
|
||||||
onChange={e => setBio(e.target.value.slice(0, 200))}
|
|
||||||
rows={3}
|
|
||||||
className={`${inputClass} mb-3 resize-none`}
|
|
||||||
placeholder="New mama sharing what works for us 💕"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<label className="flex items-center gap-3 cursor-pointer mb-4">
|
|
||||||
<div
|
|
||||||
onClick={() => setIsPublic(v => !v)}
|
|
||||||
className={`w-10 h-6 rounded-full transition-colors relative cursor-pointer ${isPublic ? "bg-rose-400" : "bg-gray-300 dark:bg-gray-600"}`}
|
|
||||||
>
|
|
||||||
<div className={`absolute top-1 w-4 h-4 bg-white rounded-full shadow transition-transform ${isPublic ? "translate-x-5" : "translate-x-1"}`} />
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-gray-700 dark:text-gray-300">Make my page public</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={saveProfile}
|
onClick={() => setProfileExpanded(v => !v)}
|
||||||
disabled={saving || !displayName || !slugValid}
|
className="w-full flex items-center justify-between p-4"
|
||||||
className="w-full bg-rose-400 text-white rounded-xl py-2.5 font-medium text-sm disabled:opacity-50 active:scale-95 transition-transform"
|
|
||||||
>
|
>
|
||||||
{saving ? "Saving…" : "Save Profile"}
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xl">👤</span>
|
||||||
|
<div className="text-left">
|
||||||
|
<p className="font-semibold text-gray-800 dark:text-white text-sm">
|
||||||
|
{displayName || "Set up your profile"}
|
||||||
|
</p>
|
||||||
|
{!profileExpanded && slug && (
|
||||||
|
<p className="text-xs text-gray-400">{baseUrl}/m/{slug}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={`text-gray-400 text-sm transition-transform ${profileExpanded ? "rotate-180" : ""}`}>▼</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{saveMsg && (
|
{/* Expanded form */}
|
||||||
<p className={`text-xs mt-2 text-center ${saveMsg.startsWith("Saved") ? "text-green-600 dark:text-green-400" : "text-red-500"}`}>
|
{profileExpanded && (
|
||||||
{saveMsg}
|
<div className="px-4 pb-4 space-y-3 border-t border-gray-100 dark:border-gray-700 pt-3">
|
||||||
</p>
|
<div>
|
||||||
|
<label className="block text-sm text-gray-500 dark:text-gray-400 mb-1">Display Name</label>
|
||||||
|
<input
|
||||||
|
value={displayName}
|
||||||
|
onChange={e => setDisplayName(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="Priya Sharma"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-500 dark:text-gray-400 mb-1">Your Page URL</label>
|
||||||
|
<div className="flex items-center gap-1 border border-gray-200 dark:border-gray-600 rounded-xl px-3 py-2.5 text-sm bg-white dark:bg-gray-700 focus-within:ring-2 focus-within:ring-rose-300">
|
||||||
|
<span className="text-gray-400 text-xs whitespace-nowrap">{baseUrl}/m/</span>
|
||||||
|
<input
|
||||||
|
value={slug}
|
||||||
|
onChange={e => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""))}
|
||||||
|
className="flex-1 bg-transparent outline-none dark:text-white text-sm"
|
||||||
|
placeholder="priya-sharma"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{slug && !slugValid && (
|
||||||
|
<p className="text-xs text-red-500 mt-1">3–40 chars, lowercase letters, numbers, hyphens only</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
Bio <span className="text-gray-400">({bio.length}/200)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={bio}
|
||||||
|
onChange={e => setBio(e.target.value.slice(0, 200))}
|
||||||
|
rows={3}
|
||||||
|
className={`${inputClass} resize-none`}
|
||||||
|
placeholder="New mama sharing what works for us 💕"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
|
<div
|
||||||
|
onClick={() => setIsPublic(v => !v)}
|
||||||
|
className={`w-10 h-6 rounded-full transition-colors relative cursor-pointer ${isPublic ? "bg-rose-400" : "bg-gray-300 dark:bg-gray-600"}`}
|
||||||
|
>
|
||||||
|
<div className={`absolute top-1 w-4 h-4 bg-white rounded-full shadow transition-transform ${isPublic ? "translate-x-5" : "translate-x-1"}`} />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Make my page public</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={saveProfile}
|
||||||
|
disabled={saving || !displayName || !slugValid}
|
||||||
|
className="w-full bg-rose-400 text-white rounded-xl py-2.5 font-medium text-sm disabled:opacity-50 active:scale-95 transition-transform"
|
||||||
|
>
|
||||||
|
{saving ? "Saving…" : "Save Profile"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{saveMsg && (
|
||||||
|
<p className={`text-xs text-center ${saveMsg.startsWith("Saved") ? "text-green-600 dark:text-green-400" : "text-red-500"}`}>
|
||||||
|
{saveMsg}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -228,12 +281,58 @@ export default function ProfileSettingsPage() {
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
|
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="font-semibold text-gray-800 dark:text-white">Product Recommendations</h2>
|
<h2 className="font-semibold text-gray-800 dark:text-white">Product Recommendations</h2>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => { resetProductForm(); setShowAddProduct(true); }}
|
{/* Share button — only visible when profile has a slug */}
|
||||||
className="text-sm text-rose-500 font-medium"
|
{slug && slugValid && (
|
||||||
>
|
<div className="relative">
|
||||||
+ Add
|
<button
|
||||||
</button>
|
onClick={() => setShowShareSheet(v => !v)}
|
||||||
|
className="flex items-center gap-1 px-2.5 py-1.5 rounded-xl bg-rose-50 dark:bg-rose-900/20 text-rose-500 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<span>↗</span>
|
||||||
|
<span>Share</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showShareSheet && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-30" onClick={() => setShowShareSheet(false)} />
|
||||||
|
<div className="absolute right-0 top-9 z-40 bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-100 dark:border-gray-700 p-3 w-52 space-y-1">
|
||||||
|
<p className="text-xs text-gray-400 px-2 pb-1">Share your page</p>
|
||||||
|
<button
|
||||||
|
onClick={() => { copyLink(); setShowShareSheet(false); }}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 text-sm text-gray-700 dark:text-gray-200"
|
||||||
|
>
|
||||||
|
<span className="text-lg">{copied ? "✅" : "📋"}</span>
|
||||||
|
{copied ? "Copied!" : "Copy link"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { shareViaWhatsApp(); setShowShareSheet(false); }}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 text-sm text-gray-700 dark:text-gray-200"
|
||||||
|
>
|
||||||
|
<span className="text-lg">💬</span>
|
||||||
|
WhatsApp
|
||||||
|
</button>
|
||||||
|
{typeof navigator !== "undefined" && "share" in navigator && (
|
||||||
|
<button
|
||||||
|
onClick={() => { shareNative(); setShowShareSheet(false); }}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 text-sm text-gray-700 dark:text-gray-200"
|
||||||
|
>
|
||||||
|
<span className="text-lg">📤</span>
|
||||||
|
More options
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => { resetProductForm(); setShowAddProduct(true); }}
|
||||||
|
className="text-sm text-rose-500 font-medium px-2.5 py-1.5"
|
||||||
|
>
|
||||||
|
+ Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add/Edit form */}
|
{/* Add/Edit form */}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue