603 lines
35 KiB
TypeScript
603 lines
35 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import {
|
|
Store, Globe, Image as ImageIcon, Phone, Mail, MapPin, Link,
|
|
Settings, Menu, SlidersHorizontal, ShieldCheck, RefreshCw,
|
|
Save, Plus, Trash2, ChevronDown, CreditCard, Star, MessageSquare, Upload,
|
|
} from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { adminService } from '@/services/api/adminService';
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Tabs
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
type Tab = 'branding' | 'footer' | 'megamenu' | 'filters';
|
|
|
|
const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
|
{ id: 'branding', label: 'Branding & Settings', icon: <Store className="w-4 h-4" /> },
|
|
{ id: 'footer', label: 'Footer', icon: <Globe className="w-4 h-4" /> },
|
|
{ id: 'megamenu', label: 'Mega Menu', icon: <Menu className="w-4 h-4" /> },
|
|
{ id: 'filters', label: 'Catalog Filters', icon: <SlidersHorizontal className="w-4 h-4" /> },
|
|
];
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Branding Tab
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
function BrandingTab() {
|
|
const [form, setForm] = useState<Record<string, string | number>>({
|
|
store_name: '', logo_url: '', primary_wordmark_url: '', secondary_wordmark_url: '',
|
|
favicon_url: '', support_phone: '', currency_code: 'INR', advance_percent: 20, theme_color: '#6D28D9',
|
|
});
|
|
const [sizeChart, setSizeChart] = useState<{ size: string; width: string; height: string; depth: string }[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
useEffect(() => {
|
|
adminService.getCmsSettings().then((data: Record<string, any>) => {
|
|
const { size_chart, ...rest } = data;
|
|
setForm((prev) => ({ ...prev, ...rest }));
|
|
if (Array.isArray(size_chart)) setSizeChart(size_chart);
|
|
setLoading(false);
|
|
}).catch(() => { setLoading(false); toast.error('Failed to load settings'); });
|
|
}, []);
|
|
|
|
const set = (k: string, v: string | number) => setForm((p) => ({ ...p, [k]: v }));
|
|
|
|
const addSizeRow = () => setSizeChart((rows) => [...rows, { size: '', width: '', height: '', depth: '' }]);
|
|
const updateSizeRow = (i: number, field: string, v: string) =>
|
|
setSizeChart((rows) => rows.map((r, j) => j === i ? { ...r, [field]: v } : r));
|
|
const deleteSizeRow = (i: number) => setSizeChart((rows) => rows.filter((_, j) => j !== i));
|
|
|
|
const save = async () => {
|
|
setSaving(true);
|
|
try {
|
|
await adminService.updateCmsSettings({ ...form, size_chart: sizeChart } as Record<string, unknown>);
|
|
toast.success('Store settings saved');
|
|
} catch { toast.error('Failed to save'); } finally { setSaving(false); }
|
|
};
|
|
|
|
if (loading) return <Spinner />;
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
<SectionCard title="Store Identity" icon={<Store className="w-4 h-4" />}>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<Field label="Store Name" value={form.store_name as string} onChange={(v) => set('store_name', v)} />
|
|
<Field label="Support Phone" value={form.support_phone as string} onChange={(v) => set('support_phone', v)} icon={<Phone className="w-3.5 h-3.5" />} />
|
|
<Field label="Currency Code" value={form.currency_code as string} onChange={(v) => set('currency_code', v)} />
|
|
<div>
|
|
<label className="block text-[12px] font-medium text-muted-foreground mb-1.5">Repair Advance %</label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="number" min={0} max={100} step={1}
|
|
value={form.advance_percent as number}
|
|
onChange={(e) => set('advance_percent', Number(e.target.value))}
|
|
className="w-24 h-9 px-3 crm-radius-control border border-border bg-card text-[13px] text-foreground focus:outline-none focus:border-primary"
|
|
/>
|
|
<span className="text-[13px] text-muted-foreground">% of order total collected as deposit</span>
|
|
</div>
|
|
</div>
|
|
<Field label="Theme Color (hex)" value={form.theme_color as string} onChange={(v) => set('theme_color', v)} />
|
|
</div>
|
|
</SectionCard>
|
|
|
|
{/* ── Size Chart ── */}
|
|
<SectionCard title="Size Chart (Global — applies to all products)" icon={<SlidersHorizontal className="w-4 h-4" />}>
|
|
<p className="text-[12px] text-muted-foreground mb-3">
|
|
Configure the global size chart shown on every product detail page under the "Size Chart" tab.
|
|
</p>
|
|
<div className="flex flex-col gap-2">
|
|
{/* Header */}
|
|
{sizeChart.length > 0 && (
|
|
<div className="grid grid-cols-[1fr_1fr_1fr_1fr_auto] gap-2 mb-1">
|
|
{['Size', 'Width', 'Height', 'Depth', ''].map((h) => (
|
|
<span key={h} className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wide px-1">{h}</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
{/* Rows */}
|
|
{sizeChart.map((row, i) => (
|
|
<div key={i} className="grid grid-cols-[1fr_1fr_1fr_1fr_auto] gap-2 items-center">
|
|
<Field label="" value={row.size} onChange={(v) => updateSizeRow(i, 'size', v)} placeholder="e.g. S / M / L" />
|
|
<Field label="" value={row.width} onChange={(v) => updateSizeRow(i, 'width', v)} placeholder="e.g. 70 mm" />
|
|
<Field label="" value={row.height} onChange={(v) => updateSizeRow(i, 'height', v)} placeholder="e.g. 140 mm" />
|
|
<Field label="" value={row.depth} onChange={(v) => updateSizeRow(i, 'depth', v)} placeholder="e.g. 20 mm" />
|
|
<button type="button" onClick={() => deleteSizeRow(i)}
|
|
className="w-8 h-9 flex items-center justify-center text-destructive hover:bg-destructive/10 crm-radius-control border border-border">
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
{sizeChart.length === 0 && (
|
|
<p className="text-[12px] text-muted-foreground py-3 text-center border border-dashed border-border crm-radius-section">
|
|
No size chart rows yet. Add a row to get started.
|
|
</p>
|
|
)}
|
|
<button type="button" onClick={addSizeRow}
|
|
className="inline-flex items-center gap-1.5 text-[12px] text-primary font-medium cursor-pointer mt-1 self-start">
|
|
<Plus className="w-3.5 h-3.5" /> Add Row
|
|
</button>
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<div className="flex justify-end">
|
|
<SaveBtn saving={saving} onClick={save} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Footer Tab
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
interface SocialLink { platform: string; url: string; icon: string; }
|
|
interface NavLink { label: string; href: string; }
|
|
interface FooterColumn { title: string; links: NavLink[]; }
|
|
interface PaymentMethod { name: string; icon_url: string; }
|
|
|
|
function FooterTab() {
|
|
const [phone, setPhone] = useState('');
|
|
const [email, setEmail] = useState('');
|
|
const [address, setAddress] = useState('');
|
|
const [copyright, setCopyright] = useState('');
|
|
const [socials, setSocials] = useState<SocialLink[]>([]);
|
|
const [columns, setColumns] = useState<FooterColumn[]>([]);
|
|
const [payments, setPayments] = useState<PaymentMethod[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
useEffect(() => {
|
|
adminService.getCmsFooterInfo().then((d: any) => {
|
|
setPhone(d.phone || ''); setEmail(d.email || '');
|
|
setAddress(d.address || ''); setCopyright(d.copyright || '');
|
|
setSocials(d.social_links || []); setColumns(d.columns || []);
|
|
setPayments(d.payment_methods || []); setLoading(false);
|
|
}).catch(() => { setLoading(false); toast.error('Failed to load footer info'); });
|
|
}, []);
|
|
|
|
const save = async () => {
|
|
setSaving(true);
|
|
try {
|
|
await adminService.updateCmsFooterInfo({ phone, email, address, copyright, social_links: socials, columns, payment_methods: payments });
|
|
toast.success('Footer info saved');
|
|
} catch { toast.error('Failed to save'); } finally { setSaving(false); }
|
|
};
|
|
|
|
if (loading) return <Spinner />;
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
<SectionCard title="Contact Details" icon={<Phone className="w-4 h-4" />}>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<Field label="Phone" value={phone} onChange={setPhone} icon={<Phone className="w-3.5 h-3.5" />} />
|
|
<Field label="Email" value={email} onChange={setEmail} icon={<Mail className="w-3.5 h-3.5" />} />
|
|
<div className="sm:col-span-2">
|
|
<label className="block text-[12px] font-medium text-muted-foreground mb-1.5">Address</label>
|
|
<textarea rows={2} value={address} onChange={(e) => setAddress(e.target.value)}
|
|
className="w-full px-3 py-2 crm-radius-control border border-border bg-card text-[13px] text-foreground focus:outline-none focus:border-primary resize-none" />
|
|
</div>
|
|
<div className="sm:col-span-2">
|
|
<Field label="Copyright Text" value={copyright} onChange={setCopyright} />
|
|
</div>
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<SectionCard title="Social Links" icon={<Link className="w-4 h-4" />}>
|
|
<div className="flex flex-col gap-2">
|
|
{socials.map((s, i) => (
|
|
<div key={i} className="grid grid-cols-[1fr_1fr_1fr_auto] gap-2 items-center">
|
|
<Field label="" value={s.platform} onChange={(v) => setSocials(socials.map((x, j) => j === i ? { ...x, platform: v } : x))} placeholder="Platform" />
|
|
<Field label="" value={s.url} onChange={(v) => setSocials(socials.map((x, j) => j === i ? { ...x, url: v } : x))} placeholder="URL" />
|
|
<Field label="" value={s.icon} onChange={(v) => setSocials(socials.map((x, j) => j === i ? { ...x, icon: v } : x))} placeholder="Icon key" />
|
|
<button type="button" onClick={() => setSocials(socials.filter((_, j) => j !== i))} className="w-8 h-9 flex items-center justify-center text-destructive hover:bg-destructive/10 crm-radius-control border border-border"><Trash2 className="w-3.5 h-3.5" /></button>
|
|
</div>
|
|
))}
|
|
<button type="button" onClick={() => setSocials([...socials, { platform: '', url: '', icon: '' }])}
|
|
className="inline-flex items-center gap-1.5 text-[12px] text-primary font-medium cursor-pointer">
|
|
<Plus className="w-3.5 h-3.5" /> Add Social Link
|
|
</button>
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<SectionCard title="Navigation Columns" icon={<Menu className="w-4 h-4" />}>
|
|
<div className="flex flex-col gap-4">
|
|
{columns.map((col, ci) => (
|
|
<div key={ci} className="border border-border crm-radius-section p-4 flex flex-col gap-2">
|
|
<div className="flex items-center gap-2 justify-between">
|
|
<Field label="Column Title" value={col.title} onChange={(v) => setColumns(columns.map((c, j) => j === ci ? { ...c, title: v } : c))} />
|
|
<button type="button" onClick={() => setColumns(columns.filter((_, j) => j !== ci))} className="mt-5 w-8 h-8 flex items-center justify-center text-destructive hover:bg-destructive/10 crm-radius-control border border-border"><Trash2 className="w-3.5 h-3.5" /></button>
|
|
</div>
|
|
{col.links.map((lnk, li) => (
|
|
<div key={li} className="grid grid-cols-[1fr_1fr_auto] gap-2">
|
|
<Field label="" value={lnk.label} onChange={(v) => setColumns(columns.map((c, j) => j !== ci ? c : { ...c, links: c.links.map((l, k) => k === li ? { ...l, label: v } : l) }))} placeholder="Label" />
|
|
<Field label="" value={lnk.href} onChange={(v) => setColumns(columns.map((c, j) => j !== ci ? c : { ...c, links: c.links.map((l, k) => k === li ? { ...l, href: v } : l) }))} placeholder="/path" />
|
|
<button type="button" onClick={() => setColumns(columns.map((c, j) => j !== ci ? c : { ...c, links: c.links.filter((_, k) => k !== li) }))} className="w-8 h-9 flex items-center justify-center text-destructive hover:bg-destructive/10 crm-radius-control border border-border"><Trash2 className="w-3 h-3" /></button>
|
|
</div>
|
|
))}
|
|
<button type="button" onClick={() => setColumns(columns.map((c, j) => j === ci ? { ...c, links: [...c.links, { label: '', href: '' }] } : c))}
|
|
className="inline-flex items-center gap-1 text-[11px] text-primary font-medium cursor-pointer"><Plus className="w-3 h-3" /> Add Link</button>
|
|
</div>
|
|
))}
|
|
<button type="button" onClick={() => setColumns([...columns, { title: '', links: [] }])}
|
|
className="inline-flex items-center gap-1.5 text-[12px] text-primary font-medium cursor-pointer">
|
|
<Plus className="w-3.5 h-3.5" /> Add Column
|
|
</button>
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<SectionCard title="Payment Methods" icon={<CreditCard className="w-4 h-4" />}>
|
|
<div className="flex flex-col gap-3">
|
|
{payments.map((p, i) => (
|
|
<div key={i} className="border border-border crm-radius-section p-3 flex flex-col sm:flex-row items-start sm:items-center gap-3">
|
|
<div className="w-full sm:w-48 shrink-0">
|
|
<Field label="Payment Name" value={p.name} onChange={(v) => setPayments(payments.map((x, j) => j === i ? { ...x, name: v } : x))} placeholder="Visa / MasterCard / UPI" />
|
|
</div>
|
|
<div className="flex-1 w-full min-w-0">
|
|
<ImageUploadField label="Icon Image" value={p.icon_url} onChange={(v) => setPayments(payments.map((x, j) => j === i ? { ...x, icon_url: v } : x))} placeholder="/images/payments/visa.svg" />
|
|
</div>
|
|
<button type="button" onClick={() => setPayments(payments.filter((_, j) => j !== i))} className="mt-2 sm:mt-5 w-8 h-9 flex items-center justify-center text-destructive hover:bg-destructive/10 crm-radius-control border border-border shrink-0 self-end sm:self-center"><Trash2 className="w-3.5 h-3.5" /></button>
|
|
</div>
|
|
))}
|
|
<button type="button" onClick={() => setPayments([...payments, { name: '', icon_url: '' }])}
|
|
className="inline-flex items-center gap-1.5 text-[12px] text-primary font-medium cursor-pointer self-start">
|
|
<Plus className="w-3.5 h-3.5" /> Add Payment Method
|
|
</button>
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<div className="flex justify-end">
|
|
<SaveBtn saving={saving} onClick={save} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Mega Menu Tab
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
const MENU_KEYS = ['shop', 'deals', 'products'] as const;
|
|
type MenuKey = typeof MENU_KEYS[number];
|
|
|
|
interface MenuGroup { title: string; links: NavLink[]; }
|
|
interface MenuPromo { image_url: string; badge: string; title: string; href: string; }
|
|
interface MenuData { nav_key: MenuKey; groups: MenuGroup[]; promo: MenuPromo | null; featured_category_ids: string[]; }
|
|
|
|
function MegaMenuTab() {
|
|
const [activeKey, setActiveKey] = useState<MenuKey>('shop');
|
|
const [menus, setMenus] = useState<Record<MenuKey, MenuData>>({
|
|
shop: { nav_key: 'shop', groups: [], promo: null, featured_category_ids: [] },
|
|
deals: { nav_key: 'deals', groups: [], promo: null, featured_category_ids: [] },
|
|
products: { nav_key: 'products', groups: [], promo: null, featured_category_ids: [] },
|
|
});
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
useEffect(() => {
|
|
adminService.getCmsMegaMenu().then((data: any) => {
|
|
setMenus({
|
|
shop: data.shop || menus.shop,
|
|
deals: data.deals || menus.deals,
|
|
products: data.products || menus.products,
|
|
});
|
|
setLoading(false);
|
|
}).catch(() => { setLoading(false); toast.error('Failed to load mega menu'); });
|
|
}, []);
|
|
|
|
const menu = menus[activeKey];
|
|
const setMenu = (patch: Partial<MenuData>) => setMenus((p) => ({ ...p, [activeKey]: { ...p[activeKey], ...patch } }));
|
|
|
|
const save = async () => {
|
|
setSaving(true);
|
|
try {
|
|
await adminService.updateCmsMegaMenu({
|
|
nav_key: activeKey,
|
|
groups: menu.groups,
|
|
promo: menu.promo ?? undefined,
|
|
featured_category_ids: menu.featured_category_ids,
|
|
});
|
|
toast.success(`"${activeKey}" menu saved`);
|
|
} catch { toast.error('Failed to save'); } finally { setSaving(false); }
|
|
};
|
|
|
|
if (loading) return <Spinner />;
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
{/* Nav key selector */}
|
|
<div className="crm-radius-toggle inline-flex items-center gap-1 border border-border bg-card p-1 self-start">
|
|
{MENU_KEYS.map((k) => (
|
|
<button key={k} type="button" onClick={() => setActiveKey(k)}
|
|
className={`h-8 px-4 crm-radius-toggle text-[13px] font-medium cursor-pointer transition-colors capitalize ${activeKey === k ? 'bg-primary text-white' : 'text-foreground hover:bg-muted'}`}>
|
|
{k}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<SectionCard title={`"${activeKey.charAt(0).toUpperCase() + activeKey.slice(1)}" Menu Groups`} icon={<Menu className="w-4 h-4" />}>
|
|
<div className="flex flex-col gap-3">
|
|
{menu.groups.map((grp, gi) => (
|
|
<div key={gi} className="border border-border crm-radius-section p-4 flex flex-col gap-2">
|
|
<div className="flex items-center gap-2 justify-between">
|
|
<Field label="Group Title" value={grp.title} onChange={(v) => setMenu({ groups: menu.groups.map((g, j) => j === gi ? { ...g, title: v } : g) })} />
|
|
<button type="button" onClick={() => setMenu({ groups: menu.groups.filter((_, j) => j !== gi) })} className="mt-5 w-8 h-8 flex items-center justify-center text-destructive hover:bg-destructive/10 crm-radius-control border border-border"><Trash2 className="w-3.5 h-3.5" /></button>
|
|
</div>
|
|
{grp.links.map((lnk, li) => (
|
|
<div key={li} className="grid grid-cols-[1fr_1fr_auto] gap-2">
|
|
<Field label="" value={lnk.label} onChange={(v) => setMenu({ groups: menu.groups.map((g, j) => j !== gi ? g : { ...g, links: g.links.map((l, k) => k === li ? { ...l, label: v } : l) }) })} placeholder="Label" />
|
|
<Field label="" value={lnk.href} onChange={(v) => setMenu({ groups: menu.groups.map((g, j) => j !== gi ? g : { ...g, links: g.links.map((l, k) => k === li ? { ...l, href: v } : l) }) })} placeholder="/path" />
|
|
<button type="button" onClick={() => setMenu({ groups: menu.groups.map((g, j) => j !== gi ? g : { ...g, links: g.links.filter((_, k) => k !== li) }) })} className="w-8 h-9 flex items-center justify-center text-destructive hover:bg-destructive/10 crm-radius-control border border-border"><Trash2 className="w-3 h-3" /></button>
|
|
</div>
|
|
))}
|
|
<button type="button" onClick={() => setMenu({ groups: menu.groups.map((g, j) => j === gi ? { ...g, links: [...g.links, { label: '', href: '' }] } : g) })}
|
|
className="inline-flex items-center gap-1 text-[11px] text-primary font-medium cursor-pointer"><Plus className="w-3 h-3" /> Add Link</button>
|
|
</div>
|
|
))}
|
|
<button type="button" onClick={() => setMenu({ groups: [...menu.groups, { title: '', links: [] }] })}
|
|
className="inline-flex items-center gap-1.5 text-[12px] text-primary font-medium cursor-pointer">
|
|
<Plus className="w-3.5 h-3.5" /> Add Group
|
|
</button>
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<SectionCard title="Promo Card (Optional)" icon={<ImageIcon className="w-4 h-4" />}>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div className="sm:col-span-2">
|
|
<ImageUploadField label="Promo Image URL" value={menu.promo?.image_url || ''} onChange={(v) => setMenu({ promo: { ...(menu.promo || { badge: '', title: '', href: '' }), image_url: v } })} placeholder="/uploads/storefront/promo.webp" />
|
|
</div>
|
|
<Field label="Badge Text" value={menu.promo?.badge || ''} onChange={(v) => setMenu({ promo: { ...(menu.promo || { image_url: '', title: '', href: '' }), badge: v } })} placeholder="e.g. HOT SALE" />
|
|
<Field label="Promo Title" value={menu.promo?.title || ''} onChange={(v) => setMenu({ promo: { ...(menu.promo || { image_url: '', badge: '', href: '' }), title: v } })} />
|
|
<Field label="Promo Link" value={menu.promo?.href || ''} onChange={(v) => setMenu({ promo: { ...(menu.promo || { image_url: '', badge: '', title: '' }), href: v } })} placeholder="/deals" />
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<div className="flex justify-end">
|
|
<SaveBtn saving={saving} onClick={save} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Catalog Filters Tab
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
interface HighlightFilter { label: string; value: string; icon: string; }
|
|
interface PriceRange { label: string; min: number | null; max: number | null; }
|
|
|
|
function CatalogFiltersTab() {
|
|
const [highlights, setHighlights] = useState<HighlightFilter[]>([]);
|
|
const [priceRanges, setPriceRanges] = useState<PriceRange[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
useEffect(() => {
|
|
adminService.getCmsCatalogFilters().then((d: any) => {
|
|
setHighlights(d.highlights || []); setPriceRanges(d.price_ranges || []);
|
|
setLoading(false);
|
|
}).catch(() => { setLoading(false); toast.error('Failed to load filters'); });
|
|
}, []);
|
|
|
|
const save = async () => {
|
|
setSaving(true);
|
|
try {
|
|
await adminService.updateCmsCatalogFilters({ highlights, price_ranges: priceRanges });
|
|
toast.success('Catalog filters saved');
|
|
} catch { toast.error('Failed to save'); } finally { setSaving(false); }
|
|
};
|
|
|
|
if (loading) return <Spinner />;
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
<SectionCard title="Highlight Filter Tabs" icon={<SlidersHorizontal className="w-4 h-4" />}>
|
|
<div className="flex flex-col gap-2">
|
|
{highlights.map((h, i) => (
|
|
<div key={i} className="grid grid-cols-[1fr_1fr_1fr_auto] gap-2 items-center">
|
|
<Field label="" value={h.label} onChange={(v) => setHighlights(highlights.map((x, j) => j === i ? { ...x, label: v } : x))} placeholder="Label" />
|
|
<Field label="" value={h.value} onChange={(v) => setHighlights(highlights.map((x, j) => j === i ? { ...x, value: v } : x))} placeholder="Value key" />
|
|
<Field label="" value={h.icon} onChange={(v) => setHighlights(highlights.map((x, j) => j === i ? { ...x, icon: v } : x))} placeholder="Icon key" />
|
|
<button type="button" onClick={() => setHighlights(highlights.filter((_, j) => j !== i))} className="w-8 h-9 flex items-center justify-center text-destructive hover:bg-destructive/10 crm-radius-control border border-border"><Trash2 className="w-3.5 h-3.5" /></button>
|
|
</div>
|
|
))}
|
|
<button type="button" onClick={() => setHighlights([...highlights, { label: '', value: '', icon: '' }])}
|
|
className="inline-flex items-center gap-1.5 text-[12px] text-primary font-medium cursor-pointer">
|
|
<Plus className="w-3.5 h-3.5" /> Add Highlight Tab
|
|
</button>
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<SectionCard title="Price Range Buckets" icon={<Settings className="w-4 h-4" />}>
|
|
<div className="flex flex-col gap-2">
|
|
{priceRanges.map((r, i) => (
|
|
<div key={i} className="grid grid-cols-[2fr_1fr_1fr_auto] gap-2 items-center">
|
|
<Field label="" value={r.label} onChange={(v) => setPriceRanges(priceRanges.map((x, j) => j === i ? { ...x, label: v } : x))} placeholder="Label (e.g. Under ₹500)" />
|
|
<div>
|
|
<input type="number" placeholder="Min (₹)" value={r.min ?? ''} onChange={(e) => setPriceRanges(priceRanges.map((x, j) => j === i ? { ...x, min: e.target.value ? Number(e.target.value) : null } : x))}
|
|
className="w-full h-9 px-3 crm-radius-control border border-border bg-card text-[13px] text-foreground focus:outline-none focus:border-primary" />
|
|
</div>
|
|
<div>
|
|
<input type="number" placeholder="Max (₹)" value={r.max ?? ''} onChange={(e) => setPriceRanges(priceRanges.map((x, j) => j === i ? { ...x, max: e.target.value ? Number(e.target.value) : null } : x))}
|
|
className="w-full h-9 px-3 crm-radius-control border border-border bg-card text-[13px] text-foreground focus:outline-none focus:border-primary" />
|
|
</div>
|
|
<button type="button" onClick={() => setPriceRanges(priceRanges.filter((_, j) => j !== i))} className="w-8 h-9 flex items-center justify-center text-destructive hover:bg-destructive/10 crm-radius-control border border-border"><Trash2 className="w-3.5 h-3.5" /></button>
|
|
</div>
|
|
))}
|
|
<button type="button" onClick={() => setPriceRanges([...priceRanges, { label: '', min: null, max: null }])}
|
|
className="inline-flex items-center gap-1.5 text-[12px] text-primary font-medium cursor-pointer">
|
|
<Plus className="w-3.5 h-3.5" /> Add Price Range
|
|
</button>
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<div className="flex justify-end">
|
|
<SaveBtn saving={saving} onClick={save} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Shared UI primitives
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
function ImageUploadField({ label, value, onChange, placeholder }: {
|
|
label: string; value: string; onChange: (v: string) => void; placeholder?: string;
|
|
}) {
|
|
const [uploading, setUploading] = useState(false);
|
|
|
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
if (!file.type.startsWith('image/')) {
|
|
toast.error('Please select an image file (PNG, JPG, WEBP, SVG)');
|
|
return;
|
|
}
|
|
|
|
setUploading(true);
|
|
try {
|
|
const res = await adminService.uploadStorefrontImage(file);
|
|
onChange(res.image_url);
|
|
toast.success('Image uploaded successfully');
|
|
} catch (err: any) {
|
|
toast.error(err?.message || 'Failed to upload image');
|
|
} finally {
|
|
setUploading(false);
|
|
e.target.value = '';
|
|
}
|
|
};
|
|
|
|
const previewUrl = value
|
|
? (value.startsWith('http') ? value : `https://ifixkartdev.trionixsolution.com${value.startsWith('/') ? '' : '/'}${value}`)
|
|
: '';
|
|
|
|
return (
|
|
<div>
|
|
{label && <label className="block text-[12px] font-medium text-muted-foreground mb-1.5">{label}</label>}
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 shrink-0 rounded-md border border-border bg-muted/40 overflow-hidden flex items-center justify-center relative">
|
|
{value ? (
|
|
/* eslint-disable-next-line @next/next/no-img-element */
|
|
<img src={previewUrl} alt="Preview" className="w-full h-full object-contain p-1" onError={(e) => { (e.target as HTMLElement).style.display = 'none'; }} />
|
|
) : (
|
|
<ImageIcon className="w-4 h-4 text-muted-foreground/50" />
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-1 flex items-center gap-2 min-w-0">
|
|
<input
|
|
type="text"
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={placeholder || '/uploads/storefront/logo.webp or click Upload'}
|
|
className="flex-1 h-9 px-3 crm-radius-control border border-border bg-card text-[13px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition-colors min-w-0"
|
|
/>
|
|
<label className="inline-flex items-center gap-1.5 h-9 px-3.5 crm-radius-control bg-secondary text-secondary-foreground text-[12px] font-semibold hover:bg-secondary/80 border border-border cursor-pointer transition-colors shrink-0">
|
|
{uploading ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5 text-primary" />}
|
|
<span>{uploading ? 'Uploading...' : 'Upload'}</span>
|
|
<input type="file" accept="image/*" className="hidden" onChange={handleFileChange} disabled={uploading} />
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Field({ label, value, onChange, placeholder, icon }: {
|
|
label: string; value: string; onChange: (v: string) => void; placeholder?: string; icon?: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div>
|
|
{label && <label className="block text-[12px] font-medium text-muted-foreground mb-1.5">{label}</label>}
|
|
<div className="relative">
|
|
{icon && <span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none">{icon}</span>}
|
|
<input
|
|
type="text"
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={placeholder}
|
|
className={`w-full h-9 ${icon ? 'pl-9' : 'px-3'} pr-3 crm-radius-control border border-border bg-card text-[13px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition-colors`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SectionCard({ title, icon, children }: { title: string; icon: React.ReactNode; children: React.ReactNode }) {
|
|
return (
|
|
<div className="crm-radius-section border border-border bg-card shadow-[0_1px_3px_rgba(16,24,40,0.06)] overflow-hidden">
|
|
<div className="px-5 py-4 border-b border-border flex items-center gap-2">
|
|
<span className="text-primary">{icon}</span>
|
|
<h3 className="text-[13px] font-semibold text-foreground">{title}</h3>
|
|
</div>
|
|
<div className="p-5">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SaveBtn({ saving, onClick }: { saving: boolean; onClick: () => void }) {
|
|
return (
|
|
<button type="button" onClick={onClick} disabled={saving}
|
|
className="inline-flex items-center gap-2 h-9 px-5 crm-radius-control bg-primary text-white text-[13px] font-semibold hover:bg-primary/90 disabled:opacity-60 transition-colors cursor-pointer">
|
|
{saving ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
|
{saving ? 'Saving...' : 'Save Changes'}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function Spinner() {
|
|
return (
|
|
<div className="flex items-center justify-center py-16">
|
|
<RefreshCw className="w-5 h-5 text-primary animate-spin" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Page
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
export default function StorefrontCmsPage() {
|
|
const [tab, setTab] = useState<Tab>('branding');
|
|
|
|
return (
|
|
<div className="flex flex-col gap-5 min-w-0">
|
|
{/* Header */}
|
|
<div className="flex flex-wrap items-start justify-between gap-3 min-w-0">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<h1 className="text-lg font-semibold text-foreground">Storefront CMS</h1>
|
|
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 crm-radius-toggle bg-primary/10 text-primary text-[11px] font-semibold leading-none">Live</span>
|
|
</div>
|
|
<p className="text-[12px] text-muted-foreground mt-0.5">Configure store branding, footer, mega-menu, and catalog filters from one place.</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tab Bar */}
|
|
<div className="crm-radius-section border border-border bg-card shadow-[0_1px_3px_rgba(16,24,40,0.06)] overflow-hidden">
|
|
<div className="flex border-b border-border overflow-x-auto">
|
|
{TABS.map((t) => (
|
|
<button
|
|
key={t.id}
|
|
type="button"
|
|
onClick={() => setTab(t.id)}
|
|
className={`flex items-center gap-2 h-12 px-5 text-[13px] font-medium whitespace-nowrap cursor-pointer border-b-2 transition-colors ${
|
|
tab === t.id
|
|
? 'border-primary text-primary'
|
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
|
}`}
|
|
>
|
|
{t.icon}
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="p-5">
|
|
{tab === 'branding' && <BrandingTab />}
|
|
{tab === 'footer' && <FooterTab />}
|
|
{tab === 'megamenu' && <MegaMenuTab />}
|
|
{tab === 'filters' && <CatalogFiltersTab />}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|