2678 lines
120 KiB
TypeScript
2678 lines
120 KiB
TypeScript
/**
|
||
* @page Homepage & Services Layout Section Manager (`app/(admin)/storefront-sections/page.tsx`)
|
||
* @purpose Admin CRM console to edit, reorder, upload images, configure nested arrays, and persist dynamic storefront configurations.
|
||
*/
|
||
'use client';
|
||
|
||
import { useState, useEffect, useRef } from 'react';
|
||
import {
|
||
Save,
|
||
ChevronDown,
|
||
ArrowUp,
|
||
ArrowDown,
|
||
Eye,
|
||
EyeOff,
|
||
RefreshCw,
|
||
Plus,
|
||
Trash2,
|
||
Upload,
|
||
X,
|
||
Calendar,
|
||
} from 'lucide-react';
|
||
import { toast } from 'sonner';
|
||
import { getAccessToken } from '@/services/api/client';
|
||
import { CustomSelect } from '@/components/ui/CustomSelect';
|
||
import ConfirmDeleteModal from '@/components/ui/ConfirmDeleteModal';
|
||
|
||
interface HomepageSection {
|
||
id: string;
|
||
section_key: string;
|
||
name: string;
|
||
type: string;
|
||
page: 'home' | 'services';
|
||
is_active: boolean;
|
||
display_order: number;
|
||
title: string;
|
||
subtitle: string;
|
||
metadata_json: Record<string, any>;
|
||
}
|
||
|
||
const INITIAL_SECTIONS: HomepageSection[] = [
|
||
// Storefront Homepage Sections
|
||
{
|
||
id: 'sec_hero',
|
||
section_key: 'hero_slider',
|
||
name: 'Hero Carousel Slider',
|
||
type: 'hero',
|
||
page: 'home',
|
||
is_active: true,
|
||
display_order: 1,
|
||
title: 'Next-Gen Electronics & OEM Repair Hub',
|
||
subtitle: 'Up to 40% Off on Top Smartphones, Laptops & Certified Spare Parts',
|
||
metadata_json: {
|
||
slides: [
|
||
{
|
||
id: '1',
|
||
badge: 'Limited Time Offer',
|
||
title: 'Ultimate Virtual Reality\nExperience Box',
|
||
subtitle: 'Experience the Future of Entertainment Today',
|
||
image: '',
|
||
buttonText: 'Shop Now',
|
||
buttonUrl: '/products',
|
||
gradient: 'from-blue-600 via-indigo-600 to-purple-650'
|
||
},
|
||
{
|
||
id: '2',
|
||
badge: 'Exclusive Launch',
|
||
title: 'Next-Gen Performance\nPro Gaming Gear',
|
||
subtitle: 'Unleash Your Full Creative Power Everywhere',
|
||
image: '',
|
||
buttonText: 'Shop Now',
|
||
buttonUrl: '/products',
|
||
gradient: 'from-slate-900 via-blue-900 to-indigo-950'
|
||
}
|
||
]
|
||
}
|
||
},
|
||
{
|
||
id: 'sec_promo_cards',
|
||
section_key: 'promo_cards',
|
||
name: 'Promotional Feature Cards',
|
||
type: 'promo_cards',
|
||
page: 'home',
|
||
is_active: true,
|
||
display_order: 2,
|
||
title: 'Top Category Promos',
|
||
subtitle: 'Featured discounts on VR boxes, audio gear, and accessories',
|
||
metadata_json: {
|
||
cards: [
|
||
{
|
||
id: 'promo-1',
|
||
badge: 'Weekend Discount',
|
||
title: 'iPhone Vision',
|
||
subtitle: 'Only for this week...',
|
||
priceText: 'from $ 239.99',
|
||
image: '',
|
||
bgColor: 'bg-[#F2EDFD]',
|
||
textColor: 'text-[#4A154B]',
|
||
badgeBg: 'bg-[#7B2CBF] text-white',
|
||
link: '/products'
|
||
},
|
||
{
|
||
id: 'promo-2',
|
||
badge: 'Amazing Deal',
|
||
title: 'Vibe Your Sound',
|
||
subtitle: 'This Week Exclusively...',
|
||
priceText: 'from $ 399.99',
|
||
image: '',
|
||
bgColor: 'bg-[#E3F2FD]',
|
||
textColor: 'text-primary-dark',
|
||
badgeBg: 'bg-primary text-white',
|
||
link: '/products'
|
||
},
|
||
{
|
||
id: 'promo-3',
|
||
badge: 'Flat 30% OFF',
|
||
title: 'True Harmony',
|
||
subtitle: 'Limited Time Only...',
|
||
priceText: 'from $ 189.99',
|
||
image: '',
|
||
bgColor: 'bg-[#FCE4EC]',
|
||
textColor: 'text-[#880E4F]',
|
||
badgeBg: 'bg-[#EC407A] text-white',
|
||
link: '/products'
|
||
}
|
||
]
|
||
}
|
||
},
|
||
{
|
||
id: 'sec_categories',
|
||
section_key: 'featured_categories',
|
||
name: 'Shop By Categories Grid',
|
||
type: 'categories',
|
||
page: 'home',
|
||
is_active: true,
|
||
display_order: 3,
|
||
title: 'Shop By Categories',
|
||
subtitle: 'Explore high-performance electronics and replacement hardware',
|
||
metadata_json: {
|
||
categories: [
|
||
{ id: 'cat-1', name: 'TV & Speaker', slug: 'tv-speaker', iconName: 'Speaker', image: '', link: '/shop?category=tv-speaker' },
|
||
{ id: 'cat-2', name: 'Party Speakers', slug: 'party-speakers', iconName: 'Volume2', image: '', link: '/shop?category=party-speakers' },
|
||
{ id: 'cat-3', name: 'Cameras', slug: 'cameras', iconName: 'Camera', image: '', link: '/shop?category=cameras' },
|
||
{ id: 'cat-4', name: 'Phones', slug: 'phones', iconName: 'Smartphone', image: '', link: '/shop?category=phones' },
|
||
{ id: 'cat-5', name: 'Chargers & Cables', slug: 'chargers-cables', iconName: 'Cable', image: '', link: '/shop?category=chargers-cables' },
|
||
{ id: 'cat-6', name: 'Smart Watches', slug: 'smart-watches', iconName: 'Watch', image: '', link: '/shop?category=smart-watches' }
|
||
]
|
||
}
|
||
},
|
||
{
|
||
id: 'sec_editorial',
|
||
section_key: 'editorial_story',
|
||
name: 'New Arrivals Products',
|
||
type: 'editorial',
|
||
page: 'home',
|
||
is_active: true,
|
||
display_order: 4,
|
||
title: 'New Arrivals Products',
|
||
subtitle: 'Explore the latest smartphones, accessories, and certified OEM spare parts',
|
||
metadata_json: {
|
||
items: []
|
||
}
|
||
},
|
||
{
|
||
id: 'sec_promo_banners',
|
||
section_key: 'promo_banners',
|
||
name: 'Dual Promotional Banner Row',
|
||
type: 'promo_banners',
|
||
page: 'home',
|
||
is_active: true,
|
||
display_order: 5,
|
||
title: 'Featured Banners',
|
||
subtitle: 'Spotlight on sound systems and power banks',
|
||
metadata_json: {
|
||
banners: [
|
||
{
|
||
title: 'Premium Sound Systems',
|
||
subtitle: 'Up to 50% off on premium bluetooth speakers & audio decks',
|
||
image: '',
|
||
buttonText: 'Browse Audio',
|
||
buttonUrl: '/shop?category=tv-speaker'
|
||
},
|
||
{
|
||
title: 'Super-Charged Battery Packs',
|
||
subtitle: 'Stay connected with 20000mAh Power Delivery power banks',
|
||
image: '',
|
||
buttonText: 'Shop Chargers',
|
||
buttonUrl: '/shop?category=chargers-cables'
|
||
}
|
||
]
|
||
}
|
||
},
|
||
{
|
||
id: 'sec_deals',
|
||
section_key: 'deal_of_the_day',
|
||
name: 'Deals Of The Day & Countdown',
|
||
type: 'deal',
|
||
page: 'home',
|
||
is_active: true,
|
||
display_order: 6,
|
||
title: 'Deals Of The Day',
|
||
subtitle: 'Limited-time discounts on certified OEM screens & batteries',
|
||
metadata_json: {
|
||
countdown_end: '2026-12-31T23:59:59Z',
|
||
products: [
|
||
{
|
||
id: 'deal-1',
|
||
title: 'HP LaserJet Mono Single Function Laser Printer',
|
||
image: '',
|
||
originalPrice: 89,
|
||
dealPrice: 85,
|
||
link: '/products/hp-laserjet'
|
||
},
|
||
{
|
||
id: 'deal-2',
|
||
title: 'Jajot J2 - Feature Phone, Keypad Mobile phone',
|
||
image: '',
|
||
originalPrice: 2000,
|
||
dealPrice: 1500,
|
||
link: '/products/jajot-j2'
|
||
}
|
||
]
|
||
}
|
||
},
|
||
{
|
||
id: 'sec_recently',
|
||
section_key: 'recently_launched',
|
||
name: 'Recently Launched Hardware',
|
||
type: 'launched',
|
||
page: 'home',
|
||
is_active: true,
|
||
display_order: 7,
|
||
title: 'Recently Launched',
|
||
subtitle: 'Latest smartphone accessories & travel gear',
|
||
metadata_json: {
|
||
items: [
|
||
{ id: 'rl-1', title: 'Apple Watch Series 9 GPS + Cellular', price: 669, image: '', link: '/products/apple-watch-s9' },
|
||
{ id: 'rl-2', title: 'Ceptics Multi Travel Adapter Plug', price: 300, image: '', link: '/products/ceptics-adapter' },
|
||
{ id: 'rl-3', title: "D'Wild USB Type C 100W PD Cable", price: 9, image: '', link: '/products/dwild-cable' }
|
||
]
|
||
}
|
||
},
|
||
{
|
||
id: 'sec_brands',
|
||
section_key: 'official_brands',
|
||
name: 'Official Tech Brands Showcase',
|
||
type: 'brands',
|
||
page: 'home',
|
||
is_active: true,
|
||
display_order: 8,
|
||
title: 'Official Tech Brands',
|
||
subtitle: 'Authorized brand partners',
|
||
metadata_json: {
|
||
brands: [
|
||
{ name: 'Apple', logo: 'apple', link: '/shop?brand=apple' },
|
||
{ name: 'Samsung', logo: 'samsung', link: '/shop?brand=samsung' },
|
||
{ name: 'Sony', logo: 'sony', link: '/shop?brand=sony' },
|
||
{ name: 'Dell', logo: 'dell', link: '/shop?brand=dell' },
|
||
{ name: 'Bose', logo: 'bose', link: '/shop?brand=bose' }
|
||
]
|
||
}
|
||
},
|
||
{
|
||
id: 'sec_ultimate',
|
||
section_key: 'ultimate_tech',
|
||
name: 'Ultimate Tech Gadgets & Banner Grid',
|
||
type: 'ultimate_tech',
|
||
page: 'home',
|
||
is_active: true,
|
||
display_order: 9,
|
||
title: 'Ultimate Tech Gadgets',
|
||
subtitle: 'High-end smart home appliances, wearables, and audio gear',
|
||
metadata_json: {
|
||
banners: [
|
||
{ title: 'Smart Watches', image: '', buttonText: 'Explore Watches', buttonUrl: '/shop?category=smart-watches' },
|
||
{ title: 'VR Gaming Headsets', image: '', buttonText: 'VR Gear', buttonUrl: '/shop?category=accessories' }
|
||
]
|
||
}
|
||
},
|
||
{
|
||
id: 'sec_blog',
|
||
section_key: 'tech_blog',
|
||
name: 'Tech Blog & Featured Articles',
|
||
type: 'blog',
|
||
page: 'home',
|
||
is_active: true,
|
||
display_order: 10,
|
||
title: 'From Our Articles',
|
||
subtitle: 'Latest tech news, repair guides, and trends',
|
||
metadata_json: {
|
||
articles: [
|
||
{ id: 'b-1', title: 'The Future of Wireless Audio: True Stereo Earbuds', excerpt: 'Explore how audio drivers and high bitrates are redefining our daily listening routines...', date: 'July 10, 2026', author: 'Sarah Connor', readTime: '5 min read', image: '', link: '/blog/wireless-audio' },
|
||
{ id: 'b-2', title: 'Top 5 Tech Gadgets Every Student Needs in 2026', excerpt: 'From digital note-taking tablets to portable power delivery adapters...', date: 'July 05, 2026', author: 'James Smith', readTime: '8 min read', image: '', link: '/blog/student-gadgets' }
|
||
]
|
||
}
|
||
},
|
||
{
|
||
id: 'sec_trust',
|
||
section_key: 'trust_badges',
|
||
name: 'Service Guarantee & Trust Badges Bar',
|
||
type: 'badges',
|
||
page: 'home',
|
||
is_active: true,
|
||
display_order: 11,
|
||
title: 'Certified Quality Guarantee',
|
||
subtitle: 'Free Shipping, 1-Year Warranty & 24/7 Technical Support',
|
||
metadata_json: {
|
||
features: [
|
||
{ title: '6-Month Spare Parts Warranty', desc: 'Secure protective repair warranties', icon: 'ShieldCheck' },
|
||
{ title: 'Skilled Tech Diagnostics', desc: 'Certified technician repairs', icon: 'Wrench' },
|
||
{ title: 'Fast Doorstep Repairs', desc: 'In-home diagnostic booking', icon: 'Clock' }
|
||
]
|
||
}
|
||
},
|
||
|
||
// Header & Navigation Dynamic Sections
|
||
{
|
||
id: 'sec_announcement_bar',
|
||
section_key: 'announcement_bar',
|
||
name: 'Announcement Bar Links (Top Bar)',
|
||
type: 'announcement_bar',
|
||
page: 'home',
|
||
is_active: true,
|
||
display_order: 0,
|
||
title: 'Top Navigation Links',
|
||
subtitle: 'Links displayed in the announcement bar above the header',
|
||
metadata_json: {
|
||
links: [
|
||
{ label: 'Track Order', url: '/track-order', icon: 'package' },
|
||
{ label: 'About Us', url: '/about' },
|
||
{ label: 'Blog', url: '/blog' },
|
||
{ label: 'Contact Us', url: '/contact' },
|
||
{ label: 'FAQs', url: '/faqs' }
|
||
]
|
||
}
|
||
},
|
||
{
|
||
id: 'sec_nav_links',
|
||
section_key: 'nav_links',
|
||
name: 'Main Navigation Links (Navbar)',
|
||
type: 'nav_links',
|
||
page: 'home',
|
||
is_active: true,
|
||
display_order: 0,
|
||
title: 'Main Navigation Menu',
|
||
subtitle: 'Links displayed in the primary navigation bar',
|
||
metadata_json: {
|
||
links: [
|
||
{ label: 'Home', url: '/' },
|
||
{ label: 'Shop', url: '/shop', hasDropdown: true },
|
||
{ label: 'Service', url: '/repair-services' },
|
||
{ label: 'Top Deals', url: '/shop?tag=deals' },
|
||
{ label: 'Featured Products', url: '/shop?featured=true' }
|
||
]
|
||
}
|
||
},
|
||
|
||
// Service E-commerce Booking Page Sections
|
||
|
||
{
|
||
id: 'sec_srv_hero',
|
||
section_key: 'service_hero',
|
||
name: 'Service Hero Banner',
|
||
type: 'service_hero',
|
||
page: 'services',
|
||
is_active: true,
|
||
display_order: 12,
|
||
title: 'Mobile phone repair at your doorstep',
|
||
subtitle: 'Select your device, get an instant quote, and book skilled technicians to fix your phone screen, battery, or hardware issues at your home or nearest iFixKart store.',
|
||
metadata_json: {
|
||
badge_tag: 'iFixKart Trusted Repair Partner',
|
||
button_text: 'Book Repair Now',
|
||
gradient_from: '#00b574',
|
||
gradient_to: '#009b62'
|
||
}
|
||
},
|
||
{
|
||
id: 'sec_srv_steps',
|
||
section_key: 'service_how_it_works',
|
||
name: 'Service How It Works (Steps)',
|
||
type: 'service_how_it_works',
|
||
page: 'services',
|
||
is_active: true,
|
||
display_order: 13,
|
||
title: 'How it works',
|
||
subtitle: 'Get your device restored in 3 simple steps',
|
||
metadata_json: {
|
||
steps: [
|
||
{ number: '1', title: 'Check Price', desc: 'Select your phone model and choice of repairs to see your custom instant pricing quote.' },
|
||
{ number: '2', title: 'Schedule Service', desc: 'Choose a convenient date and location - your home, office, or our local service workshop.' },
|
||
{ number: '3', title: 'Get Device Repaired', desc: 'Our certified technician handles the repair on-site. Test it, pay, and receive your 6-month warranty.' }
|
||
]
|
||
}
|
||
},
|
||
{
|
||
id: 'sec_srv_why',
|
||
section_key: 'service_why_us',
|
||
name: 'Service Why Choose Us (Cards)',
|
||
type: 'service_why_us',
|
||
page: 'services',
|
||
is_active: true,
|
||
display_order: 14,
|
||
title: 'Why Choose iFixKart?',
|
||
subtitle: 'Top tier hardware repair guarantees',
|
||
metadata_json: {
|
||
cards: [
|
||
{ title: 'Premium Quality Parts', desc: 'We source only certified top-grade components to guarantee perfect compatibility and longevity.', icon: 'ShieldCheck' },
|
||
{ title: 'Instant On-Site Repair', desc: 'No need to leave your phone at a shop for days. Most repairs completed in under 45 minutes.', icon: 'Clock' },
|
||
{ title: '6 Months Warranty', desc: 'Every screen and spare part replacement includes our worry-free protection warranty cover.', icon: 'ThumbsUp' }
|
||
]
|
||
}
|
||
},
|
||
{
|
||
id: 'sec_srv_faq',
|
||
section_key: 'service_faq',
|
||
name: 'Service FAQ Q&A pairs',
|
||
type: 'service_faq',
|
||
page: 'services',
|
||
is_active: true,
|
||
display_order: 15,
|
||
title: 'Frequently Asked Questions',
|
||
subtitle: 'Got questions? We have answers.',
|
||
metadata_json: {
|
||
faqs: [
|
||
{ question: 'What happens when I place my booking?', answer: 'Our support rep confirms the time slot. A technician is assigned and visits your address to handle the repair on-site.' },
|
||
{ question: 'How do I pay for the service?', answer: 'You pay only after the repair is complete and verified. We accept UPI, Cards, Cash, and online payments.' },
|
||
{ question: 'Is there a warranty on parts replaced?', answer: 'Yes! All screens and battery replacements include a worry-free 6-month replacement warranty cover.' },
|
||
{ question: 'Will my data remain secure?', answer: 'We strictly ensure data security. Repair is conducted in front of you, ensuring zero access to storage components.' }
|
||
]
|
||
}
|
||
}
|
||
];
|
||
|
||
interface SectionImageUploaderProps {
|
||
value: string;
|
||
onChange: (url: string) => void;
|
||
recommendedSize?: string;
|
||
}
|
||
|
||
const SectionImageUploader: React.FC<SectionImageUploaderProps> = ({ value, onChange, recommendedSize }) => {
|
||
const [dragActive, setDragActive] = useState(false);
|
||
const [uploading, setUploading] = useState(false);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
const handleDrag = (e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (e.type === "dragenter" || e.type === "dragover") {
|
||
setDragActive(true);
|
||
} else if (e.type === "dragleave") {
|
||
setDragActive(false);
|
||
}
|
||
};
|
||
|
||
const handleDrop = async (e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setDragActive(false);
|
||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||
await uploadFile(e.dataTransfer.files[0]);
|
||
}
|
||
};
|
||
|
||
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
e.preventDefault();
|
||
if (e.target.files && e.target.files[0]) {
|
||
await uploadFile(e.target.files[0]);
|
||
}
|
||
};
|
||
|
||
const uploadFile = async (file: File) => {
|
||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
|
||
if (!allowedTypes.includes(file.type)) {
|
||
toast.error('Only JPG, JPEG, PNG, or WebP images are allowed.');
|
||
return;
|
||
}
|
||
|
||
const maxSize = 20 * 1024 * 1024;
|
||
if (file.size > maxSize) {
|
||
toast.error('Image is larger than 20MB.');
|
||
return;
|
||
}
|
||
|
||
setUploading(true);
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
const loaderId = toast.loading('Uploading media...');
|
||
|
||
try {
|
||
const token = getAccessToken();
|
||
const backendUrl = '';
|
||
const headers: Record<string, string> = {};
|
||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||
|
||
const res = await fetch(`${backendUrl}/api/v1/admin/storefront/upload-image`, {
|
||
method: 'POST',
|
||
headers,
|
||
body: formData,
|
||
});
|
||
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
onChange(data.image_url);
|
||
toast.success('Section image uploaded', { id: loaderId });
|
||
} else {
|
||
const errData = await res.json().catch(() => ({}));
|
||
const msg = errData.detail || 'Image upload failed.';
|
||
toast.error(`Upload failed: ${msg}`, { id: loaderId });
|
||
}
|
||
} catch (err: any) {
|
||
toast.error('Network error or CORS issue. Please check console for details.', { id: loaderId });
|
||
console.error('File upload error:', err);
|
||
} finally {
|
||
setUploading(false);
|
||
}
|
||
};
|
||
|
||
const handleRemove = (e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
onChange('');
|
||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||
};
|
||
|
||
const backendUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||
const displayUrl = value
|
||
? value.startsWith('http://') || value.startsWith('https://') || value.startsWith('data:')
|
||
? value
|
||
: `${backendUrl}${value.startsWith('/') ? value : `/${value}`}`
|
||
: '';
|
||
|
||
return (
|
||
<div className="space-y-1" onClick={(e) => e.stopPropagation()}>
|
||
<div
|
||
onDragEnter={handleDrag}
|
||
onDragOver={handleDrag}
|
||
onDragLeave={handleDrag}
|
||
onDrop={handleDrop}
|
||
onClick={() => fileInputRef.current?.click()}
|
||
className={`relative w-full h-32 crm-radius-card border border-dashed flex flex-col items-center justify-center p-3 text-center cursor-pointer transition-colors ${
|
||
dragActive
|
||
? 'border-primary bg-primary/5'
|
||
: 'border-border bg-muted/20 hover:bg-muted/40 hover:border-primary/40'
|
||
}`}
|
||
>
|
||
<input
|
||
type="file"
|
||
ref={fileInputRef}
|
||
onChange={handleChange}
|
||
accept="image/*"
|
||
className="hidden"
|
||
/>
|
||
|
||
{uploading ? (
|
||
<div className="flex flex-col items-center gap-1.5 text-[12px] text-muted-foreground">
|
||
<RefreshCw className="w-5 h-5 animate-spin text-primary" />
|
||
<span>Uploading...</span>
|
||
</div>
|
||
) : displayUrl ? (
|
||
<div className="relative w-full h-full flex items-center justify-center overflow-hidden crm-radius-icon">
|
||
<img src={displayUrl} alt="Preview" className="max-h-full max-w-full object-contain" />
|
||
<div className="absolute inset-0 bg-black/40 opacity-0 hover:opacity-100 flex items-center justify-center transition-opacity">
|
||
<span className="text-[11px] font-semibold text-white bg-primary px-2 py-1 crm-radius-badge">Change</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={handleRemove}
|
||
className="absolute top-1 right-1 w-6 h-6 bg-destructive hover:bg-destructive/90 text-white crm-radius-toggle z-10 flex items-center justify-center"
|
||
title="Remove image"
|
||
>
|
||
<X size={12} />
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col items-center gap-1 text-muted-foreground">
|
||
<Upload size={16} className="text-primary" />
|
||
<div className="text-[12px]">
|
||
<span className="font-semibold text-primary">Click to upload</span> or drag
|
||
</div>
|
||
{recommendedSize && (
|
||
<div className="text-[11px] text-muted-foreground mt-0.5">
|
||
{recommendedSize}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
interface UppercaseButtonProps {
|
||
value: string;
|
||
onChange: (val: string) => void;
|
||
}
|
||
|
||
const UppercaseButton: React.FC<UppercaseButtonProps> = ({ value, onChange }) => {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => onChange((value || '').toUpperCase())}
|
||
className="absolute right-2 top-1/2 -translate-y-1/2 h-6 px-1.5 text-[10px] font-semibold uppercase bg-primary/10 text-primary hover:bg-primary hover:text-white crm-radius-badge border border-primary/20 transition-colors z-10 cursor-pointer"
|
||
title="Convert to Uppercase"
|
||
>
|
||
ABC
|
||
</button>
|
||
);
|
||
};
|
||
|
||
// ─── ProductPicker ────────────────────────────────────────────────────────────
|
||
interface ProductPickerProps {
|
||
sectionKey: string;
|
||
pinnedSlugs: string[];
|
||
onUpdate: (slugs: string[]) => void;
|
||
label?: string;
|
||
}
|
||
|
||
const ProductPicker: React.FC<ProductPickerProps> = ({ pinnedSlugs, onUpdate, label }) => {
|
||
const [query, setQuery] = useState('');
|
||
const [results, setResults] = useState<any[]>([]);
|
||
const [searching, setSearching] = useState(false);
|
||
const [allProducts, setAllProducts] = useState<any[]>([]);
|
||
const [loaded, setLoaded] = useState(false);
|
||
|
||
// Lazy-load full product list on first focus
|
||
const loadProducts = async () => {
|
||
if (loaded) return;
|
||
setSearching(true);
|
||
try {
|
||
const backendUrl = '';
|
||
const res = await fetch(`${backendUrl}/api/v1/catalog/products/all?page=1&limit=200`);
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
const list = Array.isArray(data) ? data : data?.products || [];
|
||
setAllProducts(list);
|
||
setResults(list.slice(0, 12));
|
||
}
|
||
} catch {}
|
||
setSearching(false);
|
||
setLoaded(true);
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!loaded || allProducts.length === 0) { setResults([]); return; }
|
||
const q = query.trim().toLowerCase();
|
||
if (!q) { setResults(allProducts.slice(0, 12)); return; }
|
||
setResults(allProducts.filter((p: any) =>
|
||
(p.name || p.title || '').toLowerCase().includes(q) ||
|
||
(p.slug || '').toLowerCase().includes(q)
|
||
).slice(0, 20));
|
||
}, [query, allProducts, loaded]);
|
||
|
||
const addProduct = (p: any) => {
|
||
const slug = p.slug || p.product_id || p.id || p.name;
|
||
if (!slug || pinnedSlugs.includes(slug)) return;
|
||
onUpdate([...pinnedSlugs, slug]);
|
||
};
|
||
|
||
const removeProduct = (slug: string) => {
|
||
onUpdate(pinnedSlugs.filter((s) => s !== slug));
|
||
};
|
||
|
||
const getDisplayName = (p: any) => p.name || p.title || p.slug || p.product_id || 'Unknown';
|
||
const getSlug = (p: any) => p.slug || p.product_id || p.id || p.name;
|
||
|
||
return (
|
||
<div className="space-y-3" onClick={(e) => e.stopPropagation()}>
|
||
<h5 className="text-[11px] font-semibold text-muted-foreground uppercase">{label || 'Pinned products'}</h5>
|
||
|
||
{pinnedSlugs.length > 0 && (
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{pinnedSlugs.map((slug) => {
|
||
const match = allProducts.find((p: any) => getSlug(p) === slug);
|
||
const name = match ? getDisplayName(match) : slug;
|
||
return (
|
||
<span
|
||
key={slug}
|
||
className="inline-flex items-center gap-1.5 text-[11px] bg-muted text-foreground crm-radius-badge px-2 py-0.5 font-semibold max-w-[220px]"
|
||
>
|
||
<span className="truncate">{name}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => removeProduct(slug)}
|
||
className="text-muted-foreground hover:text-destructive shrink-0 cursor-pointer"
|
||
title="Remove"
|
||
>
|
||
<X size={10} />
|
||
</button>
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
<div className="relative">
|
||
<input
|
||
type="text"
|
||
placeholder="Search and add products..."
|
||
value={query}
|
||
onFocus={loadProducts}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
className="w-full h-9 px-3 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary pr-8"
|
||
/>
|
||
{searching && (
|
||
<RefreshCw size={12} className="absolute right-2 top-1/2 -translate-y-1/2 animate-spin text-primary" />
|
||
)}
|
||
</div>
|
||
|
||
{loaded && results.length > 0 && (
|
||
<div className="border border-border crm-radius-card bg-card shadow-lg max-h-48 overflow-y-auto divide-y divide-border">
|
||
{results.map((p: any) => {
|
||
const slug = getSlug(p);
|
||
const isPinned = pinnedSlugs.includes(slug);
|
||
return (
|
||
<button
|
||
key={slug}
|
||
type="button"
|
||
onClick={() => isPinned ? removeProduct(slug) : addProduct(p)}
|
||
className={`w-full flex items-center justify-between px-3 py-2 text-left text-[13px] cursor-pointer hover:bg-muted ${
|
||
isPinned ? 'text-primary font-semibold' : 'text-foreground'
|
||
}`}
|
||
>
|
||
<span className="truncate max-w-[70%]">{getDisplayName(p)}</span>
|
||
<span className="text-[11px] font-medium text-muted-foreground ml-2 shrink-0">
|
||
{isPinned ? 'Pinned' : 'Add'}
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{pinnedSlugs.length === 0 && (
|
||
<p className="text-[12px] text-muted-foreground">No products pinned. This section will use catalog products automatically.</p>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
// ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
export default function StorefrontSectionsPage() {
|
||
const [sections, setSections] = useState<HomepageSection[]>(INITIAL_SECTIONS);
|
||
const [activeTab, setActiveTab] = useState<'home' | 'services'>('home');
|
||
const [loading, setLoading] = useState(true);
|
||
const [saving, setSaving] = useState(false);
|
||
const [activeEditingId, setActiveEditingId] = useState<string | null>(null);
|
||
|
||
const [deleteConfirm, setDeleteConfirm] = useState<{
|
||
isOpen: boolean;
|
||
message: string;
|
||
onConfirm: () => void;
|
||
}>({
|
||
isOpen: false,
|
||
message: '',
|
||
onConfirm: () => {},
|
||
});
|
||
|
||
const requestDeleteConfirm = (message: string, onConfirm: () => void) => {
|
||
setDeleteConfirm({
|
||
isOpen: true,
|
||
message,
|
||
onConfirm,
|
||
});
|
||
};
|
||
|
||
const fetchSections = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const token = getAccessToken();
|
||
const backendUrl = '';
|
||
const headers: Record<string, string> = {};
|
||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||
|
||
const res = await fetch(`${backendUrl}/api/v1/admin/storefront/content/all`, { headers });
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
if (data && data.length > 0) {
|
||
const dbMap = new Map<string, any>();
|
||
data.forEach((d: any) => {
|
||
const key = d.metadata?.section_key || d.region;
|
||
dbMap.set(key, d);
|
||
});
|
||
|
||
const merged = INITIAL_SECTIONS.map((initSec, idx) => {
|
||
const dbSec = dbMap.get(initSec.section_key);
|
||
if (dbSec) {
|
||
return {
|
||
id: dbSec.content_id,
|
||
section_key: initSec.section_key,
|
||
name: initSec.name,
|
||
type: dbSec.type,
|
||
page: (initSec.page || (initSec.section_key.startsWith('service_') ? 'services' : 'home')) as any,
|
||
is_active: typeof dbSec.metadata_json?.is_active === 'boolean' ? dbSec.metadata_json.is_active : true,
|
||
display_order: dbSec.display_order || (idx + 1),
|
||
title: dbSec.title || initSec.title,
|
||
subtitle: dbSec.subtitle || initSec.subtitle,
|
||
metadata_json: {
|
||
...initSec.metadata_json,
|
||
...dbSec.metadata_json,
|
||
}
|
||
};
|
||
}
|
||
return {
|
||
...initSec,
|
||
display_order: idx + 1
|
||
};
|
||
});
|
||
|
||
// Sort by display order
|
||
merged.sort((a, b) => a.display_order - b.display_order);
|
||
setSections(merged);
|
||
}
|
||
}
|
||
} catch {
|
||
toast.error('Failed to load live configurations. Using default templates.');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
fetchSections();
|
||
}, []);
|
||
|
||
const moveSection = (index: number, direction: 'up' | 'down') => {
|
||
const filtered = sections.filter((s) => (s.page || (s.section_key.startsWith('service_') ? 'services' : 'home')) === activeTab);
|
||
const targetIndex = direction === 'up' ? index - 1 : index + 1;
|
||
if (targetIndex < 0 || targetIndex >= filtered.length) return;
|
||
|
||
const item1 = filtered[index];
|
||
const item2 = filtered[targetIndex];
|
||
|
||
const updated = sections.map((s) => {
|
||
if (s.id === item1.id) return { ...s, display_order: item2.display_order };
|
||
if (s.id === item2.id) return { ...s, display_order: item1.display_order };
|
||
return s;
|
||
});
|
||
|
||
updated.sort((a, b) => a.display_order - b.display_order);
|
||
setSections(updated);
|
||
toast.success('Section order updated. Click Save & publish to apply.');
|
||
};
|
||
|
||
const toggleActive = async (id: string) => {
|
||
const updated = sections.map((s) => {
|
||
if (s.id === id) {
|
||
const next = !s.is_active;
|
||
toast.success(`${s.name} is now ${next ? 'visible on the storefront' : 'hidden on the storefront'}`);
|
||
return { ...s, is_active: next };
|
||
}
|
||
return s;
|
||
});
|
||
setSections(updated);
|
||
await saveSectionsToBackend(updated);
|
||
};
|
||
|
||
const saveSectionsToBackend = async (sectionsToSave: HomepageSection[]) => {
|
||
setSaving(true);
|
||
try {
|
||
const payload = {
|
||
items: sectionsToSave.map((s) => ({
|
||
content_id: s.id.startsWith('sec_') ? null : s.id,
|
||
page: s.page || (s.section_key.startsWith('service_') ? 'services' : 'home'),
|
||
region: s.section_key,
|
||
type: s.type,
|
||
title: s.title,
|
||
subtitle: s.subtitle,
|
||
display_order: s.display_order,
|
||
metadata_json: {
|
||
...s.metadata_json,
|
||
section_key: s.section_key,
|
||
is_active: s.is_active
|
||
}
|
||
}))
|
||
};
|
||
|
||
const token = getAccessToken();
|
||
const backendUrl = '';
|
||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||
|
||
const res = await fetch(`${backendUrl}/api/v1/admin/storefront/content/bulk-save`, {
|
||
method: 'POST',
|
||
headers,
|
||
body: JSON.stringify(payload)
|
||
});
|
||
|
||
if (res.ok) {
|
||
toast.success('Layout published to the storefront');
|
||
await fetchSections();
|
||
} else {
|
||
toast.error('Could not save the layout');
|
||
}
|
||
} catch {
|
||
toast.error('Could not reach the server to save the layout');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>, callback: (url: string) => void) => {
|
||
if (!e.target.files?.[0]) return;
|
||
const file = e.target.files[0];
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
|
||
const loaderId = toast.loading('Uploading image...');
|
||
try {
|
||
const token = getAccessToken();
|
||
const backendUrl = '';
|
||
const headers: Record<string, string> = {};
|
||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||
|
||
const res = await fetch(`${backendUrl}/api/v1/admin/storefront/upload-image`, {
|
||
method: 'POST',
|
||
headers,
|
||
body: formData,
|
||
});
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
callback(data.image_url);
|
||
toast.success('Section image uploaded', { id: loaderId });
|
||
} else {
|
||
const errData = await res.json().catch(() => ({}));
|
||
const msg = errData.detail || 'Image upload failed.';
|
||
toast.error(`Upload failed: ${msg}`, { id: loaderId });
|
||
}
|
||
} catch {
|
||
toast.error('Network error uploading image.', { id: loaderId });
|
||
}
|
||
};
|
||
|
||
const updateMetadataField = (sectionKey: string, field: string, value: any) => {
|
||
setSections((prev) =>
|
||
prev.map((s) => {
|
||
if (s.section_key === sectionKey) {
|
||
return {
|
||
...s,
|
||
metadata_json: {
|
||
...s.metadata_json,
|
||
[field]: value
|
||
}
|
||
};
|
||
}
|
||
return s;
|
||
})
|
||
);
|
||
};
|
||
|
||
// Render Section Field Editors dynamically based on layout type
|
||
const renderSectionFields = (sec: HomepageSection) => {
|
||
const meta = sec.metadata_json || {};
|
||
|
||
switch (sec.section_key) {
|
||
case 'hero_slider': {
|
||
const slides = meta.slides || [];
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex justify-between items-center">
|
||
<h5 className="text-[13px] font-semibold text-foreground">Swiper Slide Cards</h5>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const newSlides = [
|
||
...slides,
|
||
{
|
||
id: String(Date.now()),
|
||
badge: '',
|
||
title: '',
|
||
subtitle: '',
|
||
image: '',
|
||
buttonText: '',
|
||
buttonUrl: ''
|
||
}
|
||
];
|
||
updateMetadataField(sec.section_key, 'slides', newSlides);
|
||
}}
|
||
className="inline-flex items-center gap-1.5 h-8 px-3 crm-radius-control bg-primary hover:bg-primary/90 text-white text-[12px] font-semibold cursor-pointer"
|
||
>
|
||
<Plus size={12} /> Add Slide Card
|
||
</button>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
{slides.map((slide: any, idx: number) => (
|
||
<div key={slide.id || idx} className="p-4 bg-card border border-border crm-radius-card space-y-3 relative text-[13px]">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
requestDeleteConfirm("Are you sure you want to delete this slide card?", () => {
|
||
const newSlides = slides.filter((_: any, i: number) => i !== idx);
|
||
updateMetadataField(sec.section_key, 'slides', newSlides);
|
||
});
|
||
}}
|
||
className="absolute top-2 right-2 w-7 h-7 crm-radius-toggle text-muted-foreground hover:text-destructive hover:bg-muted z-10 flex items-center justify-center cursor-pointer"
|
||
>
|
||
<Trash2 size={14} />
|
||
</button>
|
||
<span className="text-[11px] font-semibold text-muted-foreground uppercase">Slide {idx + 1}</span>
|
||
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Promo Tag / Badge</label>
|
||
<div className="relative">
|
||
<input
|
||
type="text"
|
||
placeholder="e.g. SUPER BUNDLE PROMO"
|
||
value={slide.badge || ''}
|
||
onChange={(e) => {
|
||
const updated = [...slides];
|
||
updated[idx].badge = e.target.value;
|
||
updateMetadataField(sec.section_key, 'slides', updated);
|
||
}}
|
||
className="w-full h-9 px-3 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary pr-10"
|
||
/>
|
||
<UppercaseButton
|
||
value={slide.badge || ''}
|
||
onChange={(val) => {
|
||
const updated = [...slides];
|
||
updated[idx].badge = val;
|
||
updateMetadataField(sec.section_key, 'slides', updated);
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Button Text</label>
|
||
<input
|
||
type="text"
|
||
placeholder="e.g. Shop Now"
|
||
value={slide.buttonText || ''}
|
||
onChange={(e) => {
|
||
const updated = [...slides];
|
||
updated[idx].buttonText = e.target.value;
|
||
updateMetadataField(sec.section_key, 'slides', updated);
|
||
}}
|
||
className="w-full px-3 py-2 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary resize-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Heading Title</label>
|
||
<div className="relative">
|
||
<textarea
|
||
placeholder="e.g. CUSTOMIZE YOUR HERO SLIDE TITLE HERE"
|
||
value={slide.title || ''}
|
||
onChange={(e) => {
|
||
const updated = [...slides];
|
||
updated[idx].title = e.target.value;
|
||
updateMetadataField(sec.section_key, 'slides', updated);
|
||
}}
|
||
rows={2}
|
||
className="w-full px-3 py-2 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary resize-none pr-10"
|
||
/>
|
||
<UppercaseButton
|
||
value={slide.title || ''}
|
||
onChange={(val) => {
|
||
const updated = [...slides];
|
||
updated[idx].title = val;
|
||
updateMetadataField(sec.section_key, 'slides', updated);
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Descriptor Subtitle</label>
|
||
<input
|
||
type="text"
|
||
placeholder="e.g. Add descriptive slide context wording..."
|
||
value={slide.subtitle || ''}
|
||
onChange={(e) => {
|
||
const updated = [...slides];
|
||
updated[idx].subtitle = e.target.value;
|
||
updateMetadataField(sec.section_key, 'slides', updated);
|
||
}}
|
||
className="w-full px-3 py-2 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary resize-none"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Redirection Link URL</label>
|
||
<input
|
||
type="text"
|
||
placeholder="e.g. /products"
|
||
value={slide.buttonUrl || ''}
|
||
onChange={(e) => {
|
||
const updated = [...slides];
|
||
updated[idx].buttonUrl = e.target.value;
|
||
updateMetadataField(sec.section_key, 'slides', updated);
|
||
}}
|
||
className="w-full px-3 py-2 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary resize-none"
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Banner Background Image</label>
|
||
<SectionImageUploader
|
||
value={slide.image || ''}
|
||
onChange={(url) => {
|
||
const updated = [...slides];
|
||
updated[idx].image = url;
|
||
updateMetadataField(sec.section_key, 'slides', updated);
|
||
}}
|
||
recommendedSize="1200 × 768 px"
|
||
/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
case 'promo_cards': {
|
||
const cards = meta.cards || [];
|
||
// Ensure default cards list if empty
|
||
const activeCards = cards.length > 0 ? cards : [
|
||
{ id: 'card-0', badge: 'Weekend Discount', title: 'iPhone Vision', subtitle: 'Only for this week...', priceText: 'from $ 239.99', image: '', bgColor: 'bg-slate-800', link: '#' },
|
||
{ id: 'card-1', badge: 'Amazing Deal', title: 'Vibe Your Sound', subtitle: 'This Week Exclusively...', priceText: 'from $ 399.99', image: '', bgColor: 'bg-slate-800', link: '#' },
|
||
{ id: 'card-2', badge: 'Flat 30% OFF', title: 'True Harmony', subtitle: 'Limited Time Only...', priceText: 'from $ 189.99', image: '', bgColor: 'bg-slate-800', link: '#' }
|
||
];
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex justify-between items-center">
|
||
<h5 className="text-[13px] font-semibold text-foreground">Feature banners (2–3)</h5>
|
||
{activeCards.length < 3 && (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const updated = [
|
||
...activeCards,
|
||
{
|
||
id: `card-${Date.now()}`,
|
||
badge: '',
|
||
title: '',
|
||
subtitle: '',
|
||
priceText: '',
|
||
image: '',
|
||
bgColor: 'bg-slate-800',
|
||
link: '#'
|
||
}
|
||
];
|
||
updateMetadataField(sec.section_key, 'cards', updated);
|
||
}}
|
||
className="inline-flex items-center gap-1.5 h-8 px-3 crm-radius-control bg-primary hover:bg-primary/90 text-white text-[12px] font-semibold cursor-pointer"
|
||
>
|
||
<Plus size={12} /> Add Promo Card
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className={`grid grid-cols-1 gap-4 ${
|
||
activeCards.length === 2 ? 'md:grid-cols-2' : 'md:grid-cols-3'
|
||
}`}>
|
||
{activeCards.map((card: any, idx: number) => {
|
||
return (
|
||
<div key={card.id || idx} className="p-4 bg-card border border-border crm-radius-card space-y-3 relative text-[13px]">
|
||
{activeCards.length > 2 && (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
requestDeleteConfirm("Are you sure you want to delete this promotional card? The remaining 2 cards will automatically resize to fill the page layout.", () => {
|
||
const updated = activeCards.filter((_: any, i: number) => i !== idx);
|
||
updateMetadataField(sec.section_key, 'cards', updated);
|
||
});
|
||
}}
|
||
className="absolute top-2 right-2 w-7 h-7 crm-radius-toggle text-muted-foreground hover:text-destructive hover:bg-muted z-10 flex items-center justify-center cursor-pointer"
|
||
title="Delete Card"
|
||
>
|
||
<Trash2 size={14} />
|
||
</button>
|
||
)}
|
||
<span className="text-[11px] font-semibold text-muted-foreground uppercase">Card {idx + 1}</span>
|
||
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Badge Promo Text</label>
|
||
<div className="relative">
|
||
<input
|
||
type="text"
|
||
placeholder="e.g. WEEKEND SALE"
|
||
value={card.badge || ''}
|
||
onChange={(e) => {
|
||
const updated = [...activeCards];
|
||
updated[idx] = { ...card, badge: e.target.value };
|
||
updateMetadataField(sec.section_key, 'cards', updated);
|
||
}}
|
||
className="w-full h-9 px-3 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary pr-10"
|
||
/>
|
||
<UppercaseButton
|
||
value={card.badge || ''}
|
||
onChange={(val) => {
|
||
const updated = [...activeCards];
|
||
updated[idx] = { ...card, badge: val };
|
||
updateMetadataField(sec.section_key, 'cards', updated);
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Title Heading</label>
|
||
<div className="relative">
|
||
<input
|
||
type="text"
|
||
placeholder="e.g. SMARTPHONE REPAIRS"
|
||
value={card.title || ''}
|
||
onChange={(e) => {
|
||
const updated = [...activeCards];
|
||
updated[idx] = { ...card, title: e.target.value };
|
||
updateMetadataField(sec.section_key, 'cards', updated);
|
||
}}
|
||
className="w-full h-9 px-3 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary pr-10"
|
||
/>
|
||
<UppercaseButton
|
||
value={card.title || ''}
|
||
onChange={(val) => {
|
||
const updated = [...activeCards];
|
||
updated[idx] = { ...card, title: val };
|
||
updateMetadataField(sec.section_key, 'cards', updated);
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Descriptor Subtitle</label>
|
||
<input
|
||
type="text"
|
||
placeholder="e.g. High quality OEM screen and battery replacements..."
|
||
value={card.subtitle || ''}
|
||
onChange={(e) => {
|
||
const updated = [...activeCards];
|
||
updated[idx] = { ...card, subtitle: e.target.value };
|
||
updateMetadataField(sec.section_key, 'cards', updated);
|
||
}}
|
||
className="w-full px-3 py-2 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary resize-none"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Price Display Text</label>
|
||
<input
|
||
type="text"
|
||
placeholder="e.g. starting at $99"
|
||
value={card.priceText || ''}
|
||
onChange={(e) => {
|
||
const updated = [...activeCards];
|
||
updated[idx] = { ...card, priceText: e.target.value };
|
||
updateMetadataField(sec.section_key, 'cards', updated);
|
||
}}
|
||
className="w-full px-3 py-2 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary resize-none"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Button Link URL</label>
|
||
<input
|
||
type="text"
|
||
placeholder="e.g. /services"
|
||
value={card.link || ''}
|
||
onChange={(e) => {
|
||
const updated = [...activeCards];
|
||
updated[idx] = { ...card, link: e.target.value };
|
||
updateMetadataField(sec.section_key, 'cards', updated);
|
||
}}
|
||
className="w-full px-3 py-2 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary resize-none"
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Device Image Asset</label>
|
||
<SectionImageUploader
|
||
value={card.image || ''}
|
||
onChange={(url) => {
|
||
const updated = [...activeCards];
|
||
updated[idx] = { ...card, image: url };
|
||
updateMetadataField(sec.section_key, 'cards', updated);
|
||
}}
|
||
recommendedSize="600 × 400 px"
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
case 'featured_categories': {
|
||
return (
|
||
<div className="p-4 bg-muted/20 border border-border crm-radius-card text-[13px] space-y-2">
|
||
<h5 className="text-[13px] font-semibold text-foreground">Shop by categories</h5>
|
||
<p className="text-muted-foreground">
|
||
This section dynamically displays categories pulled directly from your active e-commerce categories registry.
|
||
</p>
|
||
<div className="text-muted-foreground text-[11px] pt-1">
|
||
To add, remove, or modify categories in this list, please manage them under the <strong className="text-foreground">Catalog Management > Categories</strong> menu on the sidebar.
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
case 'editorial_story': {
|
||
const pinnedSlugs = meta.pinned_product_slugs || [];
|
||
return (
|
||
<div className="space-y-4 text-xs">
|
||
<div className="p-4 bg-muted/20 border border-border crm-radius-card space-y-3">
|
||
<ProductPicker
|
||
sectionKey={sec.section_key}
|
||
pinnedSlugs={pinnedSlugs}
|
||
onUpdate={(slugs) => updateMetadataField(sec.section_key, 'pinned_product_slugs', slugs)}
|
||
label="Pin Specific Products (New Arrivals)"
|
||
/>
|
||
</div>
|
||
<div className="p-3 bg-muted/20 border border-border crm-radius-card text-[13px] text-muted-foreground">
|
||
💡 When no products are pinned, this section auto-displays the latest 8 products from your catalog.
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
case 'promo_banners': {
|
||
const banners = meta.banners || [];
|
||
return (
|
||
<div className="space-y-4 text-xs">
|
||
<h5 className="text-[13px] font-semibold text-foreground">Dual Promotional Banner Info</h5>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
{[0, 1].map((idx) => {
|
||
const banner = banners[idx] || { title: '', subtitle: '', image: '', buttonText: 'Explore', buttonUrl: '#' };
|
||
return (
|
||
<div key={idx} className="p-4 bg-muted/20 border border-border crm-radius-card space-y-3">
|
||
<span className="text-[11px] font-semibold text-muted-foreground uppercase">Banner Box {idx + 1}</span>
|
||
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Banner Heading Title</label>
|
||
<input
|
||
type="text"
|
||
value={banner.title || ''}
|
||
onChange={(e) => {
|
||
const updated = [...banners];
|
||
if (!updated[idx]) updated[idx] = banner;
|
||
updated[idx].title = e.target.value;
|
||
updateMetadataField(sec.section_key, 'banners', updated);
|
||
}}
|
||
className="w-full px-3 py-2 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary resize-none"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Sub-heading descriptor</label>
|
||
<input
|
||
type="text"
|
||
value={banner.subtitle || ''}
|
||
onChange={(e) => {
|
||
const updated = [...banners];
|
||
if (!updated[idx]) updated[idx] = banner;
|
||
updated[idx].subtitle = e.target.value;
|
||
updateMetadataField(sec.section_key, 'banners', updated);
|
||
}}
|
||
className="w-full px-3 py-2 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary resize-none"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Button Label</label>
|
||
<input
|
||
type="text"
|
||
value={banner.buttonText || ''}
|
||
onChange={(e) => {
|
||
const updated = [...banners];
|
||
if (!updated[idx]) updated[idx] = banner;
|
||
updated[idx].buttonText = e.target.value;
|
||
updateMetadataField(sec.section_key, 'banners', updated);
|
||
}}
|
||
className="w-full px-3 py-2 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary resize-none"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Target URL Link</label>
|
||
<input
|
||
type="text"
|
||
value={banner.buttonUrl || ''}
|
||
onChange={(e) => {
|
||
const updated = [...banners];
|
||
if (!updated[idx]) updated[idx] = banner;
|
||
updated[idx].buttonUrl = e.target.value;
|
||
updateMetadataField(sec.section_key, 'banners', updated);
|
||
}}
|
||
className="w-full px-3 py-2 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary resize-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Background Banner Image</label>
|
||
<SectionImageUploader
|
||
value={banner.image || ''}
|
||
onChange={(url) => {
|
||
const updated = [...banners];
|
||
if (!updated[idx]) updated[idx] = banner;
|
||
updated[idx].image = url;
|
||
updateMetadataField(sec.section_key, 'banners', updated);
|
||
}}
|
||
recommendedSize="800 × 300 px"
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
case 'service_hero': {
|
||
return (
|
||
<div className="space-y-4 text-xs">
|
||
<h5 className="text-[13px] font-semibold text-foreground">Service Page Hero Banner Wording & Aesthetics</h5>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div className="p-4 bg-muted/20 border border-border crm-radius-card space-y-3">
|
||
<span className="text-[11px] font-semibold text-muted-foreground uppercase">Text Elements</span>
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Badge Top Tag</label>
|
||
<input
|
||
type="text"
|
||
value={meta.badge_tag || ''}
|
||
onChange={(e) => updateMetadataField(sec.section_key, 'badge_tag', e.target.value)}
|
||
className="w-full px-3 py-2 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary resize-none"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Main Hero Title</label>
|
||
<input
|
||
type="text"
|
||
value={sec.title || ''}
|
||
onChange={(e) => {
|
||
const val = e.target.value;
|
||
setSections((prev) => prev.map((s) => (s.section_key === sec.section_key ? { ...s, title: val } : s)));
|
||
}}
|
||
className="w-full px-3 py-2 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary resize-none"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Hero Subtitle</label>
|
||
<textarea
|
||
value={sec.subtitle || ''}
|
||
onChange={(e) => {
|
||
const val = e.target.value;
|
||
setSections((prev) => prev.map((s) => (s.section_key === sec.section_key ? { ...s, subtitle: val } : s)));
|
||
}}
|
||
rows={3}
|
||
className="w-full px-3 py-2 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary resize-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="p-4 bg-muted/20 border border-border crm-radius-card space-y-3">
|
||
<span className="text-[11px] font-semibold text-muted-foreground uppercase">Design & Action Controls</span>
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Button Text</label>
|
||
<input
|
||
type="text"
|
||
value={meta.button_text || ''}
|
||
onChange={(e) => updateMetadataField(sec.section_key, 'button_text', e.target.value)}
|
||
className="w-full px-3 py-2 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary resize-none"
|
||
/>
|
||
</div>
|
||
|
||
|
||
<div className="space-y-1">
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Featured Image (Optional)</label>
|
||
<SectionImageUploader
|
||
value={meta.image_url || ''}
|
||
onChange={(url) => updateMetadataField(sec.section_key, 'image_url', url)}
|
||
recommendedSize="800 × 500 px"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
case 'service_how_it_works': {
|
||
const steps = meta.steps || [];
|
||
return (
|
||
<div className="space-y-4 text-xs">
|
||
<div className="flex justify-between items-center">
|
||
<h5 className="text-[13px] font-semibold text-foreground">Repair Workflow Steps</h5>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const updated = [
|
||
...steps,
|
||
{ number: String(steps.length + 1), title: 'Step Title', desc: 'Step detail description...' }
|
||
];
|
||
updateMetadataField(sec.section_key, 'steps', updated);
|
||
}}
|
||
className="inline-flex items-center gap-1.5 h-8 px-3 crm-radius-control bg-primary hover:bg-primary/90 text-white text-[12px] font-semibold cursor-pointer"
|
||
>
|
||
<Plus size={12} /> Add Step Card
|
||
</button>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
{steps.map((step: any, idx: number) => (
|
||
<div key={idx} className="p-4 bg-muted/20 border border-border crm-radius-card space-y-3 relative">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const updated = steps.filter((_: any, i: number) => i !== idx).map((s: any, i: number) => ({ ...s, number: String(i + 1) }));
|
||
updateMetadataField(sec.section_key, 'steps', updated);
|
||
}}
|
||
className="absolute top-2 right-2 w-7 h-7 crm-radius-toggle text-muted-foreground hover:text-destructive hover:bg-muted z-10 flex items-center justify-center cursor-pointer"
|
||
>
|
||
<Trash2 size={14} />
|
||
</button>
|
||
<span className="text-[11px] font-semibold text-muted-foreground uppercase">Step {step.number}</span>
|
||
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Step Title</label>
|
||
<input
|
||
type="text"
|
||
value={step.title || ''}
|
||
onChange={(e) => {
|
||
const updated = [...steps];
|
||
updated[idx].title = e.target.value;
|
||
updateMetadataField(sec.section_key, 'steps', updated);
|
||
}}
|
||
className="w-full h-9 px-3 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary font-bold"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Detailed Description</label>
|
||
<textarea
|
||
value={step.desc || ''}
|
||
onChange={(e) => {
|
||
const updated = [...steps];
|
||
updated[idx].desc = e.target.value;
|
||
updateMetadataField(sec.section_key, 'steps', updated);
|
||
}}
|
||
rows={3}
|
||
className="w-full px-3 py-2 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary resize-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
case 'service_why_us': {
|
||
const cards = meta.cards || [];
|
||
return (
|
||
<div className="space-y-4 text-xs">
|
||
<div className="flex justify-between items-center">
|
||
<h5 className="text-[13px] font-semibold text-foreground">Why Choose Us Feature Cards</h5>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const updated = [
|
||
...cards,
|
||
{ title: 'New Guarantee', desc: 'Details about quality assurances...', icon: 'ShieldCheck' }
|
||
];
|
||
updateMetadataField(sec.section_key, 'cards', updated);
|
||
}}
|
||
className="inline-flex items-center gap-1.5 h-8 px-3 crm-radius-control bg-primary hover:bg-primary/90 text-white text-[12px] font-semibold cursor-pointer"
|
||
>
|
||
<Plus size={12} /> Add Guarantee Box
|
||
</button>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
{cards.map((card: any, idx: number) => (
|
||
<div key={idx} className="p-4 bg-muted/20 border border-border crm-radius-card space-y-3 relative">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const updated = cards.filter((_: any, i: number) => i !== idx);
|
||
updateMetadataField(sec.section_key, 'cards', updated);
|
||
}}
|
||
className="absolute top-2 right-2 w-7 h-7 crm-radius-toggle text-muted-foreground hover:text-destructive hover:bg-muted z-10 flex items-center justify-center cursor-pointer"
|
||
>
|
||
<Trash2 size={14} />
|
||
</button>
|
||
<span className="text-[11px] font-semibold text-muted-foreground uppercase">Card {idx + 1}</span>
|
||
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Card Heading Title</label>
|
||
<input
|
||
type="text"
|
||
value={card.title || ''}
|
||
onChange={(e) => {
|
||
const updated = [...cards];
|
||
updated[idx].title = e.target.value;
|
||
updateMetadataField(sec.section_key, 'cards', updated);
|
||
}}
|
||
className="w-full h-9 px-3 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary font-bold"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Lucide Icon Name</label>
|
||
<CustomSelect
|
||
value={card.icon || 'ShieldCheck'}
|
||
onChange={(value) => {
|
||
const updated = [...cards];
|
||
updated[idx].icon = value;
|
||
updateMetadataField(sec.section_key, 'cards', updated);
|
||
}}
|
||
options={[
|
||
{ value: 'ShieldCheck', label: 'ShieldCheck' },
|
||
{ value: 'Clock', label: 'Clock' },
|
||
{ value: 'ThumbsUp', label: 'ThumbsUp' },
|
||
{ value: 'Wrench', label: 'Wrench' },
|
||
{ value: 'Award', label: 'Award' },
|
||
{ value: 'Heart', label: 'Heart' },
|
||
]}
|
||
className="w-full"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Descriptor Text</label>
|
||
<textarea
|
||
value={card.desc || ''}
|
||
onChange={(e) => {
|
||
const updated = [...cards];
|
||
updated[idx].desc = e.target.value;
|
||
updateMetadataField(sec.section_key, 'cards', updated);
|
||
}}
|
||
rows={3}
|
||
className="w-full px-3 py-2 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary resize-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
case 'service_faq': {
|
||
const faqs = meta.faqs || [];
|
||
return (
|
||
<div className="space-y-4 text-xs">
|
||
<div className="flex justify-between items-center">
|
||
<h5 className="text-[13px] font-semibold text-foreground">Frequently Asked Questions List</h5>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const updated = [
|
||
...faqs,
|
||
{ question: 'New FAQ Question?', answer: 'Answer details...' }
|
||
];
|
||
updateMetadataField(sec.section_key, 'faqs', updated);
|
||
}}
|
||
className="inline-flex items-center gap-1.5 h-8 px-3 crm-radius-control bg-primary hover:bg-primary/90 text-white text-[12px] font-semibold cursor-pointer"
|
||
>
|
||
<Plus size={12} /> Add FAQ Pair
|
||
</button>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
{faqs.map((faq: any, idx: number) => (
|
||
<div key={idx} className="p-4 bg-muted/20 border border-border crm-radius-card space-y-3 relative">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const updated = faqs.filter((_: any, i: number) => i !== idx);
|
||
updateMetadataField(sec.section_key, 'faqs', updated);
|
||
}}
|
||
className="absolute top-2 right-2 w-7 h-7 crm-radius-toggle text-muted-foreground hover:text-destructive hover:bg-muted z-10 flex items-center justify-center cursor-pointer"
|
||
>
|
||
<Trash2 size={14} />
|
||
</button>
|
||
<span className="text-[11px] font-semibold text-muted-foreground uppercase">Q&A Pair {idx + 1}</span>
|
||
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Question Wording</label>
|
||
<input
|
||
type="text"
|
||
value={faq.question || ''}
|
||
onChange={(e) => {
|
||
const updated = [...faqs];
|
||
updated[idx].question = e.target.value;
|
||
updateMetadataField(sec.section_key, 'faqs', updated);
|
||
}}
|
||
className="w-full px-3 py-2 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary resize-none"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Answer Description</label>
|
||
<textarea
|
||
value={faq.answer || ''}
|
||
onChange={(e) => {
|
||
const updated = [...faqs];
|
||
updated[idx].answer = e.target.value;
|
||
updateMetadataField(sec.section_key, 'faqs', updated);
|
||
}}
|
||
rows={2}
|
||
className="w-full px-3 py-2 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary resize-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
case 'deal_of_the_day': {
|
||
const countdown_end = meta.countdown_end || '2026-12-31T23:59:59Z';
|
||
const pinnedDealSlugs = meta.pinned_product_slugs || [];
|
||
const dealProducts = meta.products || [];
|
||
|
||
const isoToDatetimeLocal = (isoStr?: string): string => {
|
||
if (!isoStr) return '';
|
||
try {
|
||
const date = new Date(isoStr);
|
||
if (isNaN(date.getTime())) return '';
|
||
const pad = (num: number) => String(num).padStart(2, '0');
|
||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||
} catch {
|
||
return '';
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-4 text-xs">
|
||
{/* Countdown Timer with Calendar Picker */}
|
||
<div className="p-4 bg-muted/20 border border-border crm-radius-card space-y-3">
|
||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||
<span className="text-[11px] font-semibold text-muted-foreground uppercase flex items-center gap-1.5">
|
||
<Calendar className="w-3.5 h-3.5 text-primary" /> Countdown Timer
|
||
</span>
|
||
{countdown_end && (
|
||
<span className="text-[11px] font-mono text-muted-foreground bg-card border border-border px-2 py-0.5 rounded">
|
||
UTC ISO: {countdown_end}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="max-w-md">
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">
|
||
Select Deal End Date & Time
|
||
</label>
|
||
<input
|
||
type="datetime-local"
|
||
value={isoToDatetimeLocal(countdown_end)}
|
||
onChange={(e) => {
|
||
const val = e.target.value;
|
||
if (!val) return;
|
||
try {
|
||
const iso = new Date(val).toISOString();
|
||
updateMetadataField(sec.section_key, 'countdown_end', iso);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}}
|
||
className="w-full h-9 px-3 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary font-sans cursor-pointer [&::-webkit-calendar-picker-indicator]:cursor-pointer [&::-webkit-calendar-picker-indicator]:opacity-70 hover:[&::-webkit-calendar-picker-indicator]:opacity-100"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Product Picker — Pinned from catalog */}
|
||
<div className="p-4 bg-muted/20 border border-border crm-radius-card space-y-3">
|
||
<ProductPicker
|
||
sectionKey={sec.section_key}
|
||
pinnedSlugs={pinnedDealSlugs}
|
||
onUpdate={(slugs) => updateMetadataField(sec.section_key, 'pinned_product_slugs', slugs)}
|
||
label="Pin Deal Products from Catalog"
|
||
/>
|
||
<p className="text-[10px] text-muted-foreground italic">Pinned catalog products appear as the featured hot deal. If none pinned, a deal product is picked automatically.</p>
|
||
</div>
|
||
|
||
{/* Manual Override Products */}
|
||
<div className="space-y-3">
|
||
<div className="flex justify-between items-center">
|
||
<h5 className="text-[11px] font-semibold text-muted-foreground uppercase">Manual deal products</h5>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const updated = [
|
||
...dealProducts,
|
||
{ id: `deal-${Date.now()}`, title: '', image: '', originalPrice: 0, dealPrice: 0, link: '' }
|
||
];
|
||
updateMetadataField(sec.section_key, 'products', updated);
|
||
}}
|
||
className="inline-flex items-center gap-1.5 h-8 px-3 crm-radius-control bg-primary hover:bg-primary/90 text-white text-[12px] font-semibold cursor-pointer"
|
||
>
|
||
<Plus size={12} /> Add Deal Item
|
||
</button>
|
||
</div>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
{dealProducts.map((p: any, idx: number) => (
|
||
<div key={p.id || idx} className="p-4 bg-muted/20 border border-border crm-radius-card space-y-3 relative">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
requestDeleteConfirm("Are you sure you want to delete this deal item?", () => {
|
||
const updated = dealProducts.filter((_: any, i: number) => i !== idx);
|
||
updateMetadataField(sec.section_key, 'products', updated);
|
||
});
|
||
}}
|
||
className="absolute top-2 right-2 w-7 h-7 crm-radius-toggle text-muted-foreground hover:text-destructive hover:bg-muted z-10 flex items-center justify-center cursor-pointer"
|
||
title="Delete Item"
|
||
>
|
||
<Trash2 size={14} />
|
||
</button>
|
||
<span className="text-[11px] font-semibold text-muted-foreground uppercase">Deal Product {idx + 1}</span>
|
||
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Title / Label</label>
|
||
<input
|
||
type="text"
|
||
placeholder="e.g. HP LaserJet Mono Printer"
|
||
value={p.title || ''}
|
||
onChange={(e) => {
|
||
const updated = [...dealProducts];
|
||
updated[idx].title = e.target.value;
|
||
updateMetadataField(sec.section_key, 'products', updated);
|
||
}}
|
||
className="w-full px-3 py-2 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary resize-none"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Original Price (₹)</label>
|
||
<input
|
||
type="number"
|
||
placeholder="e.g. 199"
|
||
value={p.originalPrice || ''}
|
||
onChange={(e) => {
|
||
const updated = [...dealProducts];
|
||
updated[idx].originalPrice = Number(e.target.value);
|
||
updateMetadataField(sec.section_key, 'products', updated);
|
||
}}
|
||
className="w-full px-3 py-2 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary resize-none"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Deal Price (₹)</label>
|
||
<input
|
||
type="number"
|
||
placeholder="e.g. 149"
|
||
value={p.dealPrice || ''}
|
||
onChange={(e) => {
|
||
const updated = [...dealProducts];
|
||
updated[idx].dealPrice = Number(e.target.value);
|
||
updateMetadataField(sec.section_key, 'products', updated);
|
||
}}
|
||
className="w-full px-3 py-2 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary resize-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Redirect URL / Link</label>
|
||
<input
|
||
type="text"
|
||
placeholder="e.g. /products/hp-laserjet"
|
||
value={p.link || ''}
|
||
onChange={(e) => {
|
||
const updated = [...dealProducts];
|
||
updated[idx].link = e.target.value;
|
||
updateMetadataField(sec.section_key, 'products', updated);
|
||
}}
|
||
className="w-full px-3 py-2 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary resize-none"
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Product Image</label>
|
||
<SectionImageUploader
|
||
value={p.image || ''}
|
||
onChange={(url) => {
|
||
const updated = [...dealProducts];
|
||
updated[idx].image = url;
|
||
updateMetadataField(sec.section_key, 'products', updated);
|
||
}}
|
||
recommendedSize="400 × 400 px"
|
||
/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
case 'recently_launched': {
|
||
const pinnedRLSlugs = meta.pinned_product_slugs || [];
|
||
return (
|
||
<div className="space-y-4 text-xs">
|
||
{/* Side Banner Customization */}
|
||
<div className="p-4 bg-muted/20 border border-border crm-radius-card space-y-3">
|
||
<span className="text-[11px] font-semibold text-muted-foreground uppercase">Left Feature Banner</span>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Banner Title</label>
|
||
<input
|
||
type="text"
|
||
placeholder="e.g. iPhone Prodigy"
|
||
value={meta.banner_title || ''}
|
||
onChange={(e) => updateMetadataField(sec.section_key, 'banner_title', e.target.value)}
|
||
className="w-full h-9 px-3 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary font-bold"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Banner Subtitle</label>
|
||
<input
|
||
type="text"
|
||
placeholder="e.g. Performance in Motion"
|
||
value={meta.banner_subtitle || ''}
|
||
onChange={(e) => updateMetadataField(sec.section_key, 'banner_subtitle', e.target.value)}
|
||
className="w-full px-3 py-2 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary resize-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Banner Link URL</label>
|
||
<input
|
||
type="text"
|
||
placeholder="e.g. /products/iphone"
|
||
value={meta.banner_link || ''}
|
||
onChange={(e) => updateMetadataField(sec.section_key, 'banner_link', e.target.value)}
|
||
className="w-full h-9 px-3 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary font-mono"
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Banner Background Image</label>
|
||
<SectionImageUploader
|
||
value={meta.banner_image || ''}
|
||
onChange={(url) => updateMetadataField(sec.section_key, 'banner_image', url)}
|
||
recommendedSize="400 × 500 px"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Product Picker */}
|
||
<div className="p-4 bg-muted/20 border border-border crm-radius-card space-y-3">
|
||
<ProductPicker
|
||
sectionKey={sec.section_key}
|
||
pinnedSlugs={pinnedRLSlugs}
|
||
onUpdate={(slugs) => updateMetadataField(sec.section_key, 'pinned_product_slugs', slugs)}
|
||
label="Pin Products for Carousel (Recently Launched)"
|
||
/>
|
||
<p className="text-[10px] text-muted-foreground italic">When none are pinned, recently-launched catalog products auto-fill this carousel.</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
case 'official_brands': {
|
||
const brands = meta.brands || [];
|
||
return (
|
||
<div className="space-y-4 text-xs">
|
||
<div className="flex justify-between items-center">
|
||
<h5 className="text-[13px] font-semibold text-foreground">Tech Brands Grid Showcase</h5>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const updated = [
|
||
...brands,
|
||
{ name: '', logo: '', link: '' }
|
||
];
|
||
updateMetadataField(sec.section_key, 'brands', updated);
|
||
}}
|
||
className="inline-flex items-center gap-1.5 h-8 px-3 crm-radius-control bg-primary hover:bg-primary/90 text-white text-[12px] font-semibold cursor-pointer"
|
||
>
|
||
<Plus size={12} /> Add Brand Logo
|
||
</button>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3">
|
||
{brands.map((b: any, idx: number) => (
|
||
<div key={idx} className="p-3 bg-muted/20 border border-border crm-radius-card space-y-2 relative flex flex-col justify-between min-h-[220px]">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
requestDeleteConfirm("Are you sure you want to delete this brand?", () => {
|
||
const updated = brands.filter((_: any, i: number) => i !== idx);
|
||
updateMetadataField(sec.section_key, 'brands', updated);
|
||
});
|
||
}}
|
||
className="absolute top-1.5 right-1.5 w-7 h-7 crm-radius-toggle text-muted-foreground hover:text-destructive hover:bg-muted z-10 flex items-center justify-center cursor-pointer"
|
||
title="Delete Brand"
|
||
>
|
||
<Trash2 size={12} />
|
||
</button>
|
||
|
||
<div className="w-full">
|
||
<SectionImageUploader
|
||
value={b.logo || ''}
|
||
onChange={(url) => {
|
||
const updated = [...brands];
|
||
updated[idx].logo = url;
|
||
updateMetadataField(sec.section_key, 'brands', updated);
|
||
}}
|
||
recommendedSize="200 × 80 px"
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<input
|
||
type="text"
|
||
placeholder="Brand Name"
|
||
value={b.name || ''}
|
||
onChange={(e) => {
|
||
const updated = [...brands];
|
||
updated[idx].name = e.target.value;
|
||
updateMetadataField(sec.section_key, 'brands', updated);
|
||
}}
|
||
className="w-full h-9 px-3 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary"
|
||
/>
|
||
<input
|
||
type="text"
|
||
placeholder="Redirect URL"
|
||
value={b.link || ''}
|
||
onChange={(e) => {
|
||
const updated = [...brands];
|
||
updated[idx].link = e.target.value;
|
||
updateMetadataField(sec.section_key, 'brands', updated);
|
||
}}
|
||
className="w-full h-9 px-3 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary"
|
||
/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
case 'ultimate_tech': {
|
||
const banners = meta.banners || [];
|
||
return (
|
||
<div className="space-y-4 text-xs">
|
||
<h5 className="text-[13px] font-semibold text-foreground">Dual Banner Highlight Grid</h5>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
{[0, 1].map((idx) => {
|
||
const banner = banners[idx] || { title: '', image: '', buttonText: 'Explore', buttonUrl: '#' };
|
||
return (
|
||
<div key={idx} className="p-4 bg-muted/20 border border-border crm-radius-card space-y-3">
|
||
<span className="text-[11px] font-semibold text-muted-foreground uppercase">Banner Box {idx + 1}</span>
|
||
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Banner Heading Title</label>
|
||
<input
|
||
type="text"
|
||
placeholder="e.g. Premium VR Headsets"
|
||
value={banner.title || ''}
|
||
onChange={(e) => {
|
||
const updated = [...banners];
|
||
if (!updated[idx]) updated[idx] = banner;
|
||
updated[idx].title = e.target.value;
|
||
updateMetadataField(sec.section_key, 'banners', updated);
|
||
}}
|
||
className="w-full h-9 px-3 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary font-bold"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Button Text</label>
|
||
<input
|
||
type="text"
|
||
placeholder="e.g. Explore"
|
||
value={banner.buttonText || ''}
|
||
onChange={(e) => {
|
||
const updated = [...banners];
|
||
if (!updated[idx]) updated[idx] = banner;
|
||
updated[idx].buttonText = e.target.value;
|
||
updateMetadataField(sec.section_key, 'banners', updated);
|
||
}}
|
||
className="w-full px-3 py-2 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary resize-none"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Redirect URL Link</label>
|
||
<input
|
||
type="text"
|
||
placeholder="e.g. /shop"
|
||
value={banner.buttonUrl || ''}
|
||
onChange={(e) => {
|
||
const updated = [...banners];
|
||
if (!updated[idx]) updated[idx] = banner;
|
||
updated[idx].buttonUrl = e.target.value;
|
||
updateMetadataField(sec.section_key, 'banners', updated);
|
||
}}
|
||
className="w-full h-9 px-3 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary font-mono"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Background Highlight Image</label>
|
||
<SectionImageUploader
|
||
value={banner.image || ''}
|
||
onChange={(url) => {
|
||
const updated = [...banners];
|
||
if (!updated[idx]) updated[idx] = banner;
|
||
updated[idx].image = url;
|
||
updateMetadataField(sec.section_key, 'banners', updated);
|
||
}}
|
||
recommendedSize="800 × 400 px"
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
case 'tech_blog': {
|
||
const articles = meta.articles || [];
|
||
return (
|
||
<div className="space-y-4 text-xs">
|
||
<div className="flex justify-between items-center">
|
||
<h5 className="text-[13px] font-semibold text-foreground">Featured Blog Articles</h5>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const updated = [
|
||
...articles,
|
||
{ id: `b-${Date.now()}`, title: '', excerpt: '', date: 'August 2026', author: 'Staff Reporter', readTime: '5 min read', image: '', link: '' }
|
||
];
|
||
updateMetadataField(sec.section_key, 'articles', updated);
|
||
}}
|
||
className="inline-flex items-center gap-1.5 h-8 px-3 crm-radius-control bg-primary hover:bg-primary/90 text-white text-[12px] font-semibold cursor-pointer"
|
||
>
|
||
<Plus size={12} /> Add Article Card
|
||
</button>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
{articles.map((art: any, idx: number) => (
|
||
<div key={art.id || idx} className="p-4 bg-muted/20 border border-border crm-radius-card space-y-3 relative">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
requestDeleteConfirm("Are you sure you want to delete this article?", () => {
|
||
const updated = articles.filter((_: any, i: number) => i !== idx);
|
||
updateMetadataField(sec.section_key, 'articles', updated);
|
||
});
|
||
}}
|
||
className="absolute top-2 right-2 w-7 h-7 crm-radius-toggle text-muted-foreground hover:text-destructive hover:bg-muted z-10 flex items-center justify-center cursor-pointer"
|
||
title="Delete Article"
|
||
>
|
||
<Trash2 size={14} />
|
||
</button>
|
||
<span className="text-[11px] font-semibold text-muted-foreground uppercase">Article Card {idx + 1}</span>
|
||
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Article Title</label>
|
||
<input
|
||
type="text"
|
||
placeholder="e.g. Swapping a phone battery safely"
|
||
value={art.title || ''}
|
||
onChange={(e) => {
|
||
const updated = [...articles];
|
||
updated[idx].title = e.target.value;
|
||
updateMetadataField(sec.section_key, 'articles', updated);
|
||
}}
|
||
className="w-full h-9 px-3 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary font-bold"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Short Excerpt</label>
|
||
<textarea
|
||
placeholder="Enter article synopsis..."
|
||
value={art.excerpt || ''}
|
||
onChange={(e) => {
|
||
const updated = [...articles];
|
||
updated[idx].excerpt = e.target.value;
|
||
updateMetadataField(sec.section_key, 'articles', updated);
|
||
}}
|
||
rows={2}
|
||
className="w-full px-3 py-2 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary resize-none"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-3 gap-2">
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Publish Date</label>
|
||
<input
|
||
type="text"
|
||
placeholder="e.g. July 2026"
|
||
value={art.date || ''}
|
||
onChange={(e) => {
|
||
const updated = [...articles];
|
||
updated[idx].date = e.target.value;
|
||
updateMetadataField(sec.section_key, 'articles', updated);
|
||
}}
|
||
className="w-full px-3 py-2 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary resize-none"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Author</label>
|
||
<input
|
||
type="text"
|
||
placeholder="e.g. John Smith"
|
||
value={art.author || ''}
|
||
onChange={(e) => {
|
||
const updated = [...articles];
|
||
updated[idx].author = e.target.value;
|
||
updateMetadataField(sec.section_key, 'articles', updated);
|
||
}}
|
||
className="w-full px-3 py-2 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary resize-none"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Read Time</label>
|
||
<input
|
||
type="text"
|
||
placeholder="e.g. 5 min read"
|
||
value={art.readTime || ''}
|
||
onChange={(e) => {
|
||
const updated = [...articles];
|
||
updated[idx].readTime = e.target.value;
|
||
updateMetadataField(sec.section_key, 'articles', updated);
|
||
}}
|
||
className="w-full px-3 py-2 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary resize-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Article Path</label>
|
||
<input
|
||
type="text"
|
||
placeholder="e.g. /blog/repair-battery"
|
||
value={art.link || ''}
|
||
onChange={(e) => {
|
||
const updated = [...articles];
|
||
updated[idx].link = e.target.value;
|
||
updateMetadataField(sec.section_key, 'articles', updated);
|
||
}}
|
||
className="w-full h-9 px-3 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary font-mono"
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Featured Image</label>
|
||
<SectionImageUploader
|
||
value={art.image || ''}
|
||
onChange={(url) => {
|
||
const updated = [...articles];
|
||
updated[idx].image = url;
|
||
updateMetadataField(sec.section_key, 'articles', updated);
|
||
}}
|
||
recommendedSize="600 × 400 px"
|
||
/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
case 'badges':
|
||
case 'trust_badges': {
|
||
const features = meta.features || [];
|
||
return (
|
||
<div className="space-y-4 text-xs">
|
||
{/* Section Header Customization */}
|
||
<div className="p-4 bg-muted/20 border border-border crm-radius-card space-y-3">
|
||
<span className="text-[11px] font-semibold text-muted-foreground uppercase">Section Header Text</span>
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Section Title</label>
|
||
<input
|
||
type="text"
|
||
placeholder="e.g. Certified Quality Guarantee"
|
||
value={sec.title || ''}
|
||
onChange={(e) => {
|
||
const val = e.target.value;
|
||
setSections((prev) => prev.map((s) => (s.section_key === sec.section_key ? { ...s, title: val } : s)));
|
||
}}
|
||
className="w-full h-9 px-3 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary font-bold"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Section Subtitle</label>
|
||
<input
|
||
type="text"
|
||
placeholder="e.g. Free Shipping, 1-Year Warranty & 24/7 Technical Support"
|
||
value={sec.subtitle || ''}
|
||
onChange={(e) => {
|
||
const val = e.target.value;
|
||
setSections((prev) => prev.map((s) => (s.section_key === sec.section_key ? { ...s, subtitle: val } : s)));
|
||
}}
|
||
className="w-full h-9 px-3 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex justify-between items-center">
|
||
<h5 className="text-[13px] font-semibold text-foreground">Trust Badge Feature Items</h5>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const updated = [
|
||
...features,
|
||
{ title: '', desc: '', icon: 'ShieldCheck' }
|
||
];
|
||
updateMetadataField(sec.section_key, 'features', updated);
|
||
}}
|
||
className="inline-flex items-center gap-1.5 h-8 px-3 crm-radius-control bg-primary hover:bg-primary/90 text-white text-[12px] font-semibold cursor-pointer"
|
||
>
|
||
<Plus size={12} /> Add Badge Pair
|
||
</button>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
{features.map((feat: any, idx: number) => (
|
||
<div key={idx} className="p-4 bg-muted/20 border border-border crm-radius-card space-y-3 relative">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
requestDeleteConfirm("Are you sure you want to delete this trust badge?", () => {
|
||
const updated = features.filter((_: any, i: number) => i !== idx);
|
||
updateMetadataField(sec.section_key, 'features', updated);
|
||
});
|
||
}}
|
||
className="absolute top-2 right-2 w-7 h-7 crm-radius-toggle text-muted-foreground hover:text-destructive hover:bg-muted z-10 flex items-center justify-center cursor-pointer"
|
||
title="Delete Badge"
|
||
>
|
||
<Trash2 size={14} />
|
||
</button>
|
||
<span className="text-[11px] font-semibold text-muted-foreground uppercase">Trust Badge {idx + 1}</span>
|
||
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Badge Title</label>
|
||
<input
|
||
type="text"
|
||
placeholder="e.g. 6-Month Warranty"
|
||
value={feat.title || ''}
|
||
onChange={(e) => {
|
||
const updated = [...features];
|
||
updated[idx].title = e.target.value;
|
||
updateMetadataField(sec.section_key, 'features', updated);
|
||
}}
|
||
className="w-full h-9 px-3 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary font-bold"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Icon Style</label>
|
||
<CustomSelect
|
||
value={feat.icon || 'ShieldCheck'}
|
||
onChange={(value) => {
|
||
const updated = [...features];
|
||
updated[idx].icon = value;
|
||
updateMetadataField(sec.section_key, 'features', updated);
|
||
}}
|
||
options={[
|
||
{ value: 'ShieldCheck', label: 'ShieldCheck' },
|
||
{ value: 'Clock', label: 'Clock' },
|
||
{ value: 'ThumbsUp', label: 'ThumbsUp' },
|
||
{ value: 'Wrench', label: 'Wrench' },
|
||
{ value: 'Award', label: 'Award' },
|
||
{ value: 'Truck', label: 'Truck' },
|
||
{ value: 'Heart', label: 'Heart' },
|
||
]}
|
||
className="w-full"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Description Text</label>
|
||
<textarea
|
||
placeholder="e.g. Warranty details here..."
|
||
value={feat.desc || ''}
|
||
onChange={(e) => {
|
||
const updated = [...features];
|
||
updated[idx].desc = e.target.value;
|
||
updateMetadataField(sec.section_key, 'features', updated);
|
||
}}
|
||
rows={2}
|
||
className="w-full px-3 py-2 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary resize-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
case 'announcement_bar':
|
||
case 'nav_links': {
|
||
const links = meta.links || [];
|
||
const isNav = sec.section_key === 'nav_links';
|
||
return (
|
||
<div className="space-y-4 text-xs">
|
||
<div className="flex justify-between items-center">
|
||
<h5 className="text-[13px] font-semibold text-foreground">
|
||
{isNav ? 'Main Navigation Links' : 'Top Bar Links'}
|
||
</h5>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const updated = [
|
||
...links,
|
||
{ label: 'New Link', url: '/', ...(isNav ? { hasDropdown: false } : {}) }
|
||
];
|
||
updateMetadataField(sec.section_key, 'links', updated);
|
||
}}
|
||
className="inline-flex items-center gap-1.5 h-8 px-3 crm-radius-control bg-primary hover:bg-primary/90 text-white text-[12px] font-semibold cursor-pointer"
|
||
>
|
||
<Plus size={12} /> Add Link
|
||
</button>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
{links.map((link: any, idx: number) => (
|
||
<div key={idx} className="p-3 bg-muted/20 border border-border crm-radius-card space-y-2 relative">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const updated = links.filter((_: any, i: number) => i !== idx);
|
||
updateMetadataField(sec.section_key, 'links', updated);
|
||
}}
|
||
className="absolute top-2 right-2 w-7 h-7 crm-radius-toggle text-muted-foreground hover:text-destructive hover:bg-muted z-10 flex items-center justify-center cursor-pointer"
|
||
>
|
||
<Trash2 size={12} />
|
||
</button>
|
||
<span className="text-[11px] font-semibold text-muted-foreground uppercase">Link {idx + 1}</span>
|
||
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">Label</label>
|
||
<input
|
||
type="text"
|
||
value={link.label || ''}
|
||
onChange={(e) => {
|
||
const updated = [...links];
|
||
updated[idx] = { ...updated[idx], label: e.target.value };
|
||
updateMetadataField(sec.section_key, 'links', updated);
|
||
}}
|
||
className="w-full px-3 py-2 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary resize-none"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5">URL / Path</label>
|
||
<input
|
||
type="text"
|
||
value={link.url || ''}
|
||
onChange={(e) => {
|
||
const updated = [...links];
|
||
updated[idx] = { ...updated[idx], url: e.target.value };
|
||
updateMetadataField(sec.section_key, 'links', updated);
|
||
}}
|
||
className="w-full h-9 px-3 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary font-mono"
|
||
placeholder="/shop?tag=deals"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{isNav && (
|
||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||
<input
|
||
type="checkbox"
|
||
checked={!!link.hasDropdown}
|
||
onChange={(e) => {
|
||
const updated = [...links];
|
||
updated[idx] = { ...updated[idx], hasDropdown: e.target.checked };
|
||
updateMetadataField(sec.section_key, 'links', updated);
|
||
}}
|
||
className="accent-primary"
|
||
/>
|
||
<span className="text-muted-foreground">Show categories dropdown on hover</span>
|
||
</label>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
default:
|
||
return (
|
||
<div className="py-2 text-[13px] text-muted-foreground">
|
||
Customize layout settings directly under the main fields. Use custom metadata parameters where available.
|
||
</div>
|
||
);
|
||
}
|
||
};
|
||
|
||
|
||
const visibleSections = sections.filter(
|
||
(sec) => (sec.page || (sec.section_key.startsWith('service_') ? 'services' : 'home')) === activeTab
|
||
);
|
||
const homeCount = sections.filter(
|
||
(s) => (s.page || (s.section_key.startsWith('service_') ? 'services' : 'home')) === 'home'
|
||
).length;
|
||
const servicesCount = sections.filter(
|
||
(s) => (s.page || (s.section_key.startsWith('service_') ? 'services' : 'home')) === 'services'
|
||
).length;
|
||
|
||
const dataCardShell =
|
||
'crm-radius-section border border-border bg-card shadow-[0_1px_3px_rgba(16,24,40,0.06)] overflow-hidden min-w-0';
|
||
|
||
return (
|
||
<div className="flex flex-col gap-5 min-w-0">
|
||
<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">Homepage Layout Sections</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">
|
||
{visibleSections.length}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 shrink-0">
|
||
<button
|
||
type="button"
|
||
onClick={fetchSections}
|
||
className="w-8 h-8 crm-radius-control border border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted cursor-pointer transition-colors flex items-center justify-center"
|
||
title="Reload"
|
||
>
|
||
<RefreshCw className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => saveSectionsToBackend(sections)}
|
||
disabled={saving}
|
||
className="inline-flex items-center gap-2 h-9 px-4 crm-radius-control bg-primary hover:bg-primary/90 text-white text-[13px] font-semibold cursor-pointer disabled:opacity-50"
|
||
>
|
||
{saving ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
||
{saving ? 'Saving...' : 'Save & publish'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={dataCardShell}>
|
||
<div className="px-5 py-4 border-b border-border flex flex-wrap items-center justify-between gap-3">
|
||
<div className="crm-radius-toggle inline-flex items-center gap-1 border border-border bg-card p-1">
|
||
<button
|
||
type="button"
|
||
onClick={() => setActiveTab('home')}
|
||
className={`h-8 px-3 crm-radius-toggle text-[13px] font-medium cursor-pointer transition-colors ${
|
||
activeTab === 'home' ? 'bg-primary text-white' : 'text-foreground hover:bg-muted'
|
||
}`}
|
||
>
|
||
Homepage ({homeCount})
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setActiveTab('services')}
|
||
className={`h-8 px-3 crm-radius-toggle text-[13px] font-medium cursor-pointer transition-colors ${
|
||
activeTab === 'services' ? 'bg-primary text-white' : 'text-foreground hover:bg-muted'
|
||
}`}
|
||
>
|
||
Repair page ({servicesCount})
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<div className="flex flex-col items-center justify-center py-16 gap-2.5">
|
||
<RefreshCw className="w-6 h-6 text-primary animate-spin" />
|
||
<span className="text-[13px] text-muted-foreground">Loading layout sections...</span>
|
||
</div>
|
||
) : visibleSections.length === 0 ? (
|
||
<div className="py-12 text-center text-[13px] text-muted-foreground">No sections for this page.</div>
|
||
) : (
|
||
<div className="divide-y divide-border">
|
||
{visibleSections.map((sec, idx) => (
|
||
<div
|
||
key={sec.section_key}
|
||
id={`section-card-${sec.id}`}
|
||
className={sec.is_active ? 'bg-card' : 'bg-muted/20'}
|
||
>
|
||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3 px-5 py-4">
|
||
<button
|
||
type="button"
|
||
onClick={() => setActiveEditingId(activeEditingId === sec.id ? null : sec.id)}
|
||
className="flex items-center gap-3 min-w-0 text-left cursor-pointer"
|
||
>
|
||
<span className="w-9 h-9 crm-radius-icon bg-primary/10 text-primary text-[12px] font-semibold flex items-center justify-center shrink-0">
|
||
{idx + 1}
|
||
</span>
|
||
<div className="min-w-0">
|
||
<p className="text-[14px] font-semibold text-foreground truncate">{sec.name}</p>
|
||
<p className="text-[12px] text-muted-foreground truncate mt-0.5">{sec.title}</p>
|
||
</div>
|
||
</button>
|
||
|
||
<div className="flex items-center gap-2 shrink-0">
|
||
<button
|
||
type="button"
|
||
onClick={() => moveSection(idx, 'up')}
|
||
disabled={idx === 0}
|
||
className="w-8 h-8 crm-radius-control border border-border bg-card text-muted-foreground hover:bg-muted cursor-pointer disabled:opacity-40 flex items-center justify-center"
|
||
title="Move up"
|
||
>
|
||
<ArrowUp className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => moveSection(idx, 'down')}
|
||
disabled={idx === visibleSections.length - 1}
|
||
className="w-8 h-8 crm-radius-control border border-border bg-card text-muted-foreground hover:bg-muted cursor-pointer disabled:opacity-40 flex items-center justify-center"
|
||
title="Move down"
|
||
>
|
||
<ArrowDown className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => toggleActive(sec.id)}
|
||
className={`inline-flex items-center gap-1.5 h-8 px-2.5 crm-radius-badge text-[11px] font-semibold cursor-pointer ${
|
||
sec.is_active ? 'bg-success text-white' : 'bg-destructive text-white'
|
||
}`}
|
||
>
|
||
{sec.is_active ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
|
||
{sec.is_active ? 'Visible' : 'Hidden'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setActiveEditingId(activeEditingId === sec.id ? null : sec.id)}
|
||
className="w-8 h-8 crm-radius-control border border-border bg-card text-muted-foreground hover:bg-muted cursor-pointer flex items-center justify-center"
|
||
title={activeEditingId === sec.id ? 'Collapse' : 'Edit'}
|
||
>
|
||
<ChevronDown className={`w-4 h-4 transition-transform ${activeEditingId === sec.id ? 'rotate-180' : ''}`} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{activeEditingId === sec.id && (
|
||
<div className="mx-5 mb-5 border border-border crm-radius-card bg-muted/10 p-4 space-y-4">
|
||
<p className="text-[13px] font-semibold text-foreground">Edit {sec.name}</p>
|
||
<div>{renderSectionFields(sec)}</div>
|
||
<div className="flex justify-end">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setActiveEditingId(null);
|
||
toast.success('Edits saved locally. Click Save & publish to go live.');
|
||
}}
|
||
className="inline-flex items-center h-9 px-4 crm-radius-control bg-primary hover:bg-primary/90 text-white text-[13px] font-semibold cursor-pointer"
|
||
>
|
||
Done
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<ConfirmDeleteModal
|
||
isOpen={deleteConfirm.isOpen}
|
||
message={deleteConfirm.message}
|
||
onConfirm={deleteConfirm.onConfirm}
|
||
onClose={() => setDeleteConfirm(prev => ({ ...prev, isOpen: false }))}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|