- Background: flat gray → rose-to-amber gradient (matches all other pages) - Header: add back button with rounded-xl pill style + xl font title - Buttons: bg-pink-500 → bg-rose-400 throughout - Loading: spinner → branded emoji bounce (consistent with home screen) - Toggle: checkbox → styled toggle switch matching app UI language - Layout: remove desktop max-w-lg wrapper; full-width mobile layout - Input styling: unified inputClass with focus ring + consistent padding - Empty state: plain text → emoji + two-line message - Product rows: border div → bg-gray-50 pill card (matches wardrobe style) - Add pb-24 so content clears the bottom nav bar Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
298 lines
13 KiB
TypeScript
298 lines
13 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
|
||
interface Profile {
|
||
id: string;
|
||
slug: string;
|
||
display_name: string;
|
||
bio: string | null;
|
||
avatar_url: string | null;
|
||
is_public: boolean;
|
||
}
|
||
|
||
interface Product {
|
||
id: string;
|
||
title: string;
|
||
description: string | null;
|
||
url: string;
|
||
image_url: string | null;
|
||
category: string;
|
||
display_order: number;
|
||
click_count: number;
|
||
}
|
||
|
||
const CATEGORIES = ["general", "feeding", "sleep", "play", "clothing"] as const;
|
||
const CATEGORY_EMOJI: Record<string, string> = { feeding: "🍼", sleep: "💤", play: "🎮", clothing: "👗", general: "🛍️" };
|
||
|
||
export default function ProfileSettingsPage() {
|
||
const router = useRouter();
|
||
const [profile, setProfile] = useState<Profile | null>(null);
|
||
const [products, setProducts] = useState<Product[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [saving, setSaving] = useState(false);
|
||
const [saveMsg, setSaveMsg] = useState("");
|
||
|
||
const [slug, setSlug] = useState("");
|
||
const [displayName, setDisplayName] = useState("");
|
||
const [bio, setBio] = useState("");
|
||
const [isPublic, setIsPublic] = useState(false);
|
||
|
||
const [showAddProduct, setShowAddProduct] = useState(false);
|
||
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
||
const [pTitle, setPTitle] = useState("");
|
||
const [pUrl, setPUrl] = useState("");
|
||
const [pDesc, setPDesc] = useState("");
|
||
const [pImageUrl, setPImageUrl] = useState("");
|
||
const [pCategory, setPCategory] = useState("general");
|
||
|
||
useEffect(() => {
|
||
Promise.all([
|
||
fetch("/api/profile").then(r => r.json()),
|
||
fetch("/api/profile/products").then(r => r.json()),
|
||
]).then(([pd, prods]) => {
|
||
if (pd.profile) {
|
||
setProfile(pd.profile);
|
||
setSlug(pd.profile.slug);
|
||
setDisplayName(pd.profile.display_name);
|
||
setBio(pd.profile.bio ?? "");
|
||
setIsPublic(pd.profile.is_public);
|
||
}
|
||
setProducts(prods.items || []);
|
||
setLoading(false);
|
||
}).catch(() => setLoading(false));
|
||
}, []);
|
||
|
||
async function saveProfile() {
|
||
setSaving(true);
|
||
setSaveMsg("");
|
||
const r = await fetch("/api/profile", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ slug, display_name: displayName, bio: bio || null, is_public: isPublic }),
|
||
});
|
||
const d = await r.json();
|
||
if (r.ok) {
|
||
setProfile(d.profile);
|
||
setSaveMsg(`Saved! View at /m/${d.profile.slug}`);
|
||
} else {
|
||
setSaveMsg(d.error || "Save failed");
|
||
}
|
||
setSaving(false);
|
||
}
|
||
|
||
function resetProductForm() {
|
||
setPTitle(""); setPUrl(""); setPDesc(""); setPImageUrl(""); setPCategory("general");
|
||
setShowAddProduct(false); setEditingProduct(null);
|
||
}
|
||
|
||
async function saveProduct() {
|
||
const body = { title: pTitle, url: pUrl, description: pDesc || null, image_url: pImageUrl || null, category: pCategory };
|
||
if (editingProduct) {
|
||
await fetch(`/api/profile/products/${editingProduct.id}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body),
|
||
});
|
||
} else {
|
||
await fetch("/api/profile/products", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ ...body, display_order: products.length }),
|
||
});
|
||
}
|
||
const r = await fetch("/api/profile/products");
|
||
const d = await r.json();
|
||
setProducts(d.items || []);
|
||
resetProductForm();
|
||
}
|
||
|
||
async function deleteProduct(id: string) {
|
||
if (!confirm("Remove this product?")) return;
|
||
await fetch(`/api/profile/products/${id}`, { method: "DELETE" });
|
||
setProducts(p => p.filter(x => x.id !== id));
|
||
}
|
||
|
||
async function moveProduct(id: string, dir: "up" | "down") {
|
||
const idx = products.findIndex(p => p.id === id);
|
||
if ((dir === "up" && idx === 0) || (dir === "down" && idx === products.length - 1)) return;
|
||
const swapIdx = dir === "up" ? idx - 1 : idx + 1;
|
||
await fetch(`/api/profile/products/${id}`, {
|
||
method: "PATCH", headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ display_order: products[swapIdx].display_order }),
|
||
});
|
||
await fetch(`/api/profile/products/${products[swapIdx].id}`, {
|
||
method: "PATCH", headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ display_order: products[idx].display_order }),
|
||
});
|
||
const r = await fetch("/api/profile/products");
|
||
const d = await r.json();
|
||
setProducts(d.items || []);
|
||
}
|
||
|
||
function startEdit(p: Product) {
|
||
setEditingProduct(p); setPTitle(p.title); setPUrl(p.url);
|
||
setPDesc(p.description ?? ""); setPImageUrl(p.image_url ?? ""); setPCategory(p.category);
|
||
setShowAddProduct(true);
|
||
}
|
||
|
||
const slugValid = /^[a-z0-9-]{3,40}$/.test(slug);
|
||
const baseUrl = typeof window !== "undefined" ? window.location.origin : "";
|
||
|
||
const inputClass = "w-full border border-gray-200 dark:border-gray-600 rounded-xl px-3 py-2.5 text-sm bg-white dark:bg-gray-700 dark:text-white focus:outline-none focus:ring-2 focus:ring-rose-300";
|
||
|
||
if (loading) return (
|
||
<div className="min-h-screen flex flex-col items-center justify-center gap-4 bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
|
||
<div className="flex gap-3 text-4xl">
|
||
{["🍼", "😴", "🚼", "👶"].map((e, i) => (
|
||
<span key={i} className="animate-bounce" style={{ animationDelay: `${i * 120}ms` }}>{e}</span>
|
||
))}
|
||
</div>
|
||
<p className="text-sm text-gray-400">Loading profile…</p>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 pb-24">
|
||
{/* Header */}
|
||
<div className="flex items-center gap-3 p-4">
|
||
<button onClick={() => router.back()} className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-xl">←</button>
|
||
<h1 className="text-xl font-bold">My Profile Page</h1>
|
||
</div>
|
||
|
||
<div className="px-4 space-y-4">
|
||
{/* Profile section */}
|
||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
|
||
<h2 className="font-semibold text-gray-800 dark:text-white mb-4">Profile</h2>
|
||
|
||
<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
|
||
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 mt-2 text-center ${saveMsg.startsWith("Saved") ? "text-green-600 dark:text-green-400" : "text-red-500"}`}>
|
||
{saveMsg}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Products section */}
|
||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="font-semibold text-gray-800 dark:text-white">Product Recommendations</h2>
|
||
<button
|
||
onClick={() => { resetProductForm(); setShowAddProduct(true); }}
|
||
className="text-sm text-rose-500 font-medium"
|
||
>
|
||
+ Add
|
||
</button>
|
||
</div>
|
||
|
||
{/* Add/Edit form */}
|
||
{showAddProduct && (
|
||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-xl p-4 mb-4 space-y-2">
|
||
<input value={pTitle} onChange={e => setPTitle(e.target.value)}
|
||
placeholder="Product title *" className={inputClass} />
|
||
<input value={pUrl} onChange={e => setPUrl(e.target.value)}
|
||
placeholder="Product URL *" className={inputClass} />
|
||
<textarea value={pDesc} onChange={e => setPDesc(e.target.value)} rows={2}
|
||
placeholder="Description (optional)" className={`${inputClass} resize-none`} />
|
||
<input value={pImageUrl} onChange={e => setPImageUrl(e.target.value)}
|
||
placeholder="Image URL (optional)" className={inputClass} />
|
||
<select value={pCategory} onChange={e => setPCategory(e.target.value)}
|
||
className={inputClass}>
|
||
{CATEGORIES.map(c => <option key={c} value={c}>{CATEGORY_EMOJI[c]} {c.charAt(0).toUpperCase() + c.slice(1)}</option>)}
|
||
</select>
|
||
<div className="flex gap-2 pt-1">
|
||
<button onClick={saveProduct} disabled={!pTitle || !pUrl}
|
||
className="flex-1 bg-rose-400 text-white rounded-xl py-2.5 text-sm font-medium disabled:opacity-50">
|
||
{editingProduct ? "Update" : "Add Product"}
|
||
</button>
|
||
<button onClick={resetProductForm}
|
||
className="flex-1 border border-gray-200 dark:border-gray-600 rounded-xl py-2.5 text-sm text-gray-600 dark:text-gray-300">
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{products.length === 0 && !showAddProduct && (
|
||
<div className="text-center py-8">
|
||
<span className="text-4xl">🛍️</span>
|
||
<p className="text-sm text-gray-400 mt-2">No products yet.</p>
|
||
<p className="text-xs text-gray-400">Add your first recommendation!</p>
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-2">
|
||
{products.map((p, i) => (
|
||
<div key={p.id} className="flex items-center gap-3 p-3 rounded-xl bg-gray-50 dark:bg-gray-700/50">
|
||
<div className="text-2xl">{CATEGORY_EMOJI[p.category] || "🛍️"}</div>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium text-gray-800 dark:text-white truncate">{p.title}</p>
|
||
<p className="text-xs text-gray-400">{p.category} · {p.click_count} clicks</p>
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<button onClick={() => moveProduct(p.id, "up")} disabled={i === 0}
|
||
className="text-gray-400 disabled:opacity-30 text-xs px-1.5 py-1">↑</button>
|
||
<button onClick={() => moveProduct(p.id, "down")} disabled={i === products.length - 1}
|
||
className="text-gray-400 disabled:opacity-30 text-xs px-1.5 py-1">↓</button>
|
||
<button onClick={() => startEdit(p)} className="text-rose-400 text-xs px-1.5 py-1">Edit</button>
|
||
<button onClick={() => deleteProduct(p.id)} className="text-gray-400 text-xs px-1.5 py-1">✕</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|