tia/src/app/settings/profile/page.tsx
Mannu 3b62841bd4 fix(settings/profile): align My Profile page with app theme
- 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>
2026-05-23 19:26:38 +05:30

298 lines
13 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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">340 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>
);
}