// La Pharmacia — Mobile e-commerce prototype (Hebrew, RTL)
const { useState, useEffect, useRef } = React;
// ── Feature flags ───────────────────────────────────────────────────
// Flip any of these to `true` to re-enable a feature; the rest of the app
// already has the wiring intact, so nothing else needs to change.
// - customerAccounts: register/login for shoppers, account tab, "create
// account during checkout" toggle. Admin login on /admin is separate
// and unaffected by this flag.
// - coupons: promo-code input in cart + admin section
// - rewards: loyalty-points display on the account screen
const FEATURES = {
customerAccounts: false,
coupons: false,
rewards: false,
};
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"theme": "emerald",
"displayFont": "Heebo",
"showGlow": true,
"denseGrid": false
}/*EDITMODE-END*/;
const THEMES = {
emerald: {
bg: 'oklch(0.20 0.025 165)', bgSoft: 'oklch(0.24 0.030 165)', bgCard: 'oklch(0.28 0.035 165)',
line: 'oklch(0.34 0.030 165)', ink: 'oklch(0.96 0.012 85)', inkDim: 'oklch(0.78 0.020 90)',
inkMute: 'oklch(0.58 0.025 100)', accent: 'oklch(0.82 0.085 82)', accentDim: 'oklch(0.62 0.075 80)',
glow: 'rgba(220, 240, 220, 0.35)',
},
ink: {
bg: 'oklch(0.16 0.005 250)', bgSoft: 'oklch(0.20 0.005 250)', bgCard: 'oklch(0.24 0.008 250)',
line: 'oklch(0.30 0.008 250)', ink: 'oklch(0.96 0.005 85)', inkDim: 'oklch(0.78 0.010 85)',
inkMute: 'oklch(0.58 0.012 90)', accent: 'oklch(0.85 0.10 80)', accentDim: 'oklch(0.65 0.09 80)',
glow: 'rgba(255, 235, 200, 0.3)',
},
bone: {
bg: 'oklch(0.95 0.008 85)', bgSoft: 'oklch(0.92 0.010 85)', bgCard: 'oklch(0.98 0.005 85)',
line: 'oklch(0.86 0.012 85)', ink: 'oklch(0.20 0.020 165)', inkDim: 'oklch(0.40 0.018 165)',
inkMute: 'oklch(0.55 0.015 165)', accent: 'oklch(0.45 0.060 165)', accentDim: 'oklch(0.55 0.050 165)',
glow: 'rgba(50, 80, 60, 0.15)',
},
};
// Unsplash demo imagery
const IMG = {
bags: 'https://images.unsplash.com/photo-1548036328-c9fa89d128fa?w=800&q=80&auto=format&fit=crop',
perfumes: 'https://images.unsplash.com/photo-1541643600914-78b084683601?w=800&q=80&auto=format&fit=crop',
access: 'https://images.unsplash.com/photo-1535632787350-4e68ef0ac584?w=800&q=80&auto=format&fit=crop',
beauty: 'https://images.unsplash.com/photo-1596462502278-27bfdc403348?w=800&q=80&auto=format&fit=crop',
hero: 'https://images.unsplash.com/photo-1592945403244-b3fbafd7f539?w=1200&q=85&auto=format&fit=crop',
bag1: 'https://images.unsplash.com/photo-1584917865442-de89df76afd3?w=800&q=80&auto=format&fit=crop',
bag2: 'https://images.unsplash.com/photo-1591561954557-26941169b49e?w=800&q=80&auto=format&fit=crop',
bag3: 'https://images.unsplash.com/photo-1590874103328-eac38a683ce7?w=800&q=80&auto=format&fit=crop',
bag4: 'https://images.unsplash.com/photo-1566150905458-1bf1fc113f0d?w=800&q=80&auto=format&fit=crop',
bottle1: 'https://images.unsplash.com/photo-1523293182086-7651a899d37f?w=800&q=80&auto=format&fit=crop',
bottle2: 'https://images.unsplash.com/photo-1587017539504-67cfbddac569?w=800&q=80&auto=format&fit=crop',
bottle3: 'https://images.unsplash.com/photo-1594035910387-fea47794261f?w=800&q=80&auto=format&fit=crop',
bottle4: 'https://images.unsplash.com/photo-1557170334-a9086d21c1d6?w=800&q=80&auto=format&fit=crop',
ring1: 'https://images.unsplash.com/photo-1535632787350-4e68ef0ac584?w=800&q=80&auto=format&fit=crop',
scarf1: 'https://images.unsplash.com/photo-1601762603339-fd61e28b698a?w=800&q=80&auto=format&fit=crop',
watch1: 'https://images.unsplash.com/photo-1524805444758-089113d48a6d?w=800&q=80&auto=format&fit=crop',
sun1: 'https://images.unsplash.com/photo-1572635196237-14b3f281503f?w=800&q=80&auto=format&fit=crop',
lipstick: 'https://images.unsplash.com/photo-1586495777744-4413f21062fa?w=800&q=80&auto=format&fit=crop',
shoes: 'https://images.unsplash.com/photo-1543163521-1bf539c55dd2?w=800&q=80&auto=format&fit=crop',
jewelry: 'https://images.unsplash.com/photo-1515562141207-7a88fb7ce338?w=800&q=80&auto=format&fit=crop',
ig1: 'https://images.unsplash.com/photo-1490481651871-ab68de25d43d?w=600&q=80&auto=format&fit=crop',
ig2: 'https://images.unsplash.com/photo-1469334031218-e382a71b716b?w=600&q=80&auto=format&fit=crop',
ig3: 'https://images.unsplash.com/photo-1483985988355-763728e1935b?w=600&q=80&auto=format&fit=crop',
};
// These globals are reassigned at boot from /api/* endpoints.
// Components read them at render time; a `dataVersion` state in App forces a re-render
// after the fetch completes. Hardcoded values are an offline fallback.
let SETTINGS = {
store_name: 'La Pharmacia', store_address: 'רוטשילד 42, תל אביב',
store_hours: 'ראשון–חמישי 09:00–22:00 · שישי 09:00–15:00',
waze_lat: '32.0633', waze_lng: '34.7702',
social_instagram: 'https://instagram.com', social_facebook: 'https://facebook.com',
social_whatsapp: 'https://wa.me/972500000000', social_tiktok: '',
banner_active: false, banner_text: '', banner_link: '',
banner_bg: '#d4a85a', banner_fg: '#14201a',
};
let IG_POSTS = [];
let HOME_SLIDES = []; // hero slider entries from /api/home-slides — admin managed
let SHIPPING_METHODS = [
{ id: 'express', label: 'אקספרס', sub: 'מחר עד 12:00', price: 0, icon: 'truck', badge: 'חינם' },
{ id: 'standard', label: 'רגיל', sub: '2–4 ימי עסקים', price: 25, icon: 'truck' },
{ id: 'pickup', label: 'איסוף עצמי', sub: 'תל אביב — רוטשילד 42', price: 0, icon: 'home' },
];
let PAYMENT_METHODS = [
{ id: 'cod', label: 'במזומן בעת קבלה', description: 'תשלום במזומן או בכרטיס לשליח / בחנות בזמן האיסוף', icon: 'cash' },
{ id: 'credit', label: 'כרטיס אשראי', description: 'תועברי לעמוד תשלום מאובטח של Grow', icon: 'card' },
{ id: 'apple', label: 'Apple Pay', description: 'אישור עם Touch ID / Face ID דרך Grow', icon: 'apple' },
{ id: 'paypal', label: 'PayPal', description: 'התחברות לחשבון ה-PayPal שלך', icon: 'paypal' },
{ id: 'bit', label: 'Bit', description: 'תשלום דרך אפליקציית Bit', icon: 'bit' },
];
let CATEGORIES = [
{ id: 'bags', label: 'תיקים', sub: '142 פריטים', img: IMG.bags },
{ id: 'perfumes', label: 'בשמים', sub: '86 פריטים', img: IMG.perfumes },
{ id: 'access', label: 'אקססוריז', sub: '210 פריטים', img: IMG.access },
{ id: 'beauty', label: 'יופי', sub: '54 פריטים', img: IMG.beauty },
{ id: 'shoes', label: 'נעליים', sub: '78 פריטים', img: IMG.shoes },
{ id: 'jewelry', label: 'תכשיטים', sub: '64 פריטים', img: IMG.jewelry },
];
let PRODUCTS = [
{ id: 'p1', name: 'תיק טוט עור רך', brand: 'Maison Cervo', price: 285, was: null, cat: 'bags', img: IMG.bag1, tag: 'חדש' },
{ id: 'p2', name: 'Amber Veil — 50ml', brand: 'Atelier Noir', price: 142, was: 168, cat: 'perfumes', img: IMG.bottle1, tag: '15%-' },
{ id: 'p3', name: 'עגילי פנינה', brand: 'Lune & Co.', price: 64, was: null, cat: 'access', img: IMG.ring1, tag: null },
{ id: 'p4', name: 'צעיף משי 90', brand: 'Lume', price: 120, was: null, cat: 'access', img: IMG.scarf1, tag: 'חדש' },
{ id: 'p5', name: 'תיק קרוסבודי סהר', brand: 'Maison Cervo', price: 340, was: null, cat: 'bags', img: IMG.bag2, tag: null },
{ id: 'p6', name: 'Rose de Mai — 100ml', brand: 'Atelier Noir', price: 198, was: null, cat: 'perfumes', img: IMG.bottle2, tag: 'לוהט' },
{ id: 'p7', name: 'תיק דלי פשתן', brand: 'Casa Verde', price: 175, was: 210, cat: 'bags', img: IMG.bag3, tag: '20%-' },
{ id: 'p8', name: 'Vetiver Smoke — 50ml', brand: 'Atelier Noir', price: 156, was: null, cat: 'perfumes', img: IMG.bottle3, tag: null },
{ id: 'p9', name: 'משקפי שמש קלאסיים', brand: 'Lume', price: 89, was: null, cat: 'access', img: IMG.sun1, tag: null },
{ id: 'p10', name: 'שעון יד מינימלי', brand: 'Lune & Co.', price: 220, was: null, cat: 'access', img: IMG.watch1, tag: 'חדש' },
];
let STORIES = [
{ id: 's1', title: 'בשמי האביב', sub: 'מבט מקרוב', img: IMG.bottle2 },
{ id: 's2', title: 'תיקי הקיץ', sub: 'המלצות', img: IMG.bag2 },
{ id: 's3', title: 'אקססוריז יומי', sub: 'סטיילינג', img: IMG.scarf1 },
{ id: 's4', title: 'יופי בטבעיות', sub: 'טיפים', img: IMG.beauty },
{ id: 's5', title: 'מתנות אהובות', sub: 'נבחרים', img: IMG.ring1 },
{ id: 's6', title: 'מאחורי הקלעים', sub: 'הסטודיו', img: IMG.bottle1 },
];
let BRANDS = [
{ id: 'b1', name: 'Maison Cervo', count: 42, img: IMG.bag1 },
{ id: 'b2', name: 'Atelier Noir', count: 38, img: IMG.bottle3 },
{ id: 'b3', name: 'Lune & Co.', count: 27, img: IMG.ring1 },
{ id: 'b4', name: 'Lume', count: 31, img: IMG.scarf1 },
{ id: 'b5', name: 'Casa Verde', count: 19, img: IMG.bag3 },
{ id: 'b6', name: 'Sable', count: 24, img: IMG.bottle4 },
];
const fmt = (n) => '₪' + n.toFixed(0);
// ── Hero slider — admin-managed via /api/home-slides. Auto-cycles every 6s when 2+ slides.
// Renders both the mobile card (rounded, 380px tall) and the desktop full-bleed hero.
function HomeSlider({ T, displayFont, showGlow, heroParallax = 0, heroOpacity = 1, layout = 'mobile', onCta }) {
const slides = HOME_SLIDES.length ? HOME_SLIDES : [];
const [idx, setIdx] = useState(0);
useEffect(() => {
if (slides.length < 2) return;
const t = setInterval(() => setIdx(i => (i + 1) % slides.length), 6000);
return () => clearInterval(t);
}, [slides.length]);
if (slides.length === 0) return null;
const cur = slides[Math.min(idx, slides.length - 1)];
// Resolve a CTA link like 'cat:perfumes' or 'shop' into a navigation action
function clickCta() {
if (!cur.cta_link) return;
if (cur.cta_link.startsWith('cat:')) onCta?.({ type: 'category', id: cur.cta_link.slice(4) });
else if (cur.cta_link === 'shop') onCta?.({ type: 'tab', id: 'shop' });
else if (cur.cta_link.startsWith('http')) window.open(cur.cta_link, '_blank', 'noopener');
}
if (layout === 'mobile') {
return (
{/* Image fills the slide statically — no parallax. The previous transform caused
the image to translate above the slide as the page scrolled, leaving the
bottom half "empty" with just the dark background showing through. */}
{cur.eyebrow && (
{cur.eyebrow}
)}
{cur.title && (
{cur.title}
)}
{cur.subtitle && (
{cur.subtitle}
)}
{cur.cta_label && (
e.currentTarget.style.transform = 'scale(0.96)'}
onPointerUp={e => e.currentTarget.style.transform = 'scale(1)'}
onPointerLeave={e => e.currentTarget.style.transform = 'scale(1)'}>
{cur.cta_label}
)}
{/* Dots — only when there are multiple slides */}
{slides.length > 1 && (
{slides.map((_, i) => (
setIdx(i)} aria-label={`slide ${i+1}`} style={{
width: i === idx ? 18 : 6, height: 6, borderRadius: 3,
background: i === idx ? T.ink : `color-mix(in oklab, ${T.ink}, transparent 70%)`,
border: 'none', padding: 0, cursor: 'pointer', transition: 'width 0.3s',
}} />
))}
)}
);
}
// Desktop: full-bleed hero with overlay text
return (
{cur.eyebrow &&
{cur.eyebrow}
}
{cur.title &&
{cur.title} }
{cur.subtitle &&
{cur.subtitle}
}
{cur.cta_label && (
{cur.cta_label} ←
)}
{slides.length > 1 && (
{slides.map((_, i) => (
setIdx(i)} aria-label={`slide ${i+1}`} style={{
width: i === idx ? 24 : 8, height: 8, borderRadius: 4,
background: i === idx ? '#d4a85a' : 'rgba(240,236,228,0.4)',
border: 'none', padding: 0, cursor: 'pointer', transition: 'all 0.3s',
}} />
))}
)}
);
}
// ── Banner — slim promo bar from /api/settings, shown on the home screens ──
function Banner({ T }) {
const [dismissed, setDismissed] = useState(() => {
try { return localStorage.getItem('lp_banner_dismissed') === SETTINGS.banner_text; }
catch { return false; }
});
if (!SETTINGS.banner_active || !SETTINGS.banner_text || dismissed) return null;
const dismiss = (e) => {
e?.preventDefault();
e?.stopPropagation();
try { localStorage.setItem('lp_banner_dismissed', SETTINGS.banner_text); } catch {}
setDismissed(true);
};
const inner = (
<>
{SETTINGS.banner_text}
×
>
);
const baseStyle = {
position: 'relative',
display: 'flex', alignItems: 'center', justifyContent: 'center',
minHeight: 38, padding: '10px 16px',
background: SETTINGS.banner_bg || '#d4a85a',
color: SETTINGS.banner_fg || '#14201a',
fontSize: 12.5, fontWeight: 600, letterSpacing: 0.3,
textAlign: 'center', textDecoration: 'none', direction: 'rtl',
};
return SETTINGS.banner_link
? {inner}
: {inner}
;
}
// ── Instagram strip — fed by /api/instagram/posts, managed in admin ──
function InstagramStrip({ T, displayFont, layout = 'mobile' }) {
if (!IG_POSTS || IG_POSTS.length === 0) return null;
const visible = IG_POSTS.slice(0, layout === 'mobile' ? 3 : 6);
if (layout === 'mobile') {
return (
);
}
// Desktop
return (
@LAPHARMACIA
העולם שלנו באינסטגרם
{SETTINGS.social_instagram && (
עקבי לפיד המלא →
)}
);
}
// ── Image with subtle gradient overlay ──
function Img({ src, style, overlay }) {
return (
{overlay &&
}
);
}
// ── Icons ──
const Icon = ({ name, size = 22, color = 'currentColor', strokeWidth = 1.6 }) => {
const p = { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: color, strokeWidth, strokeLinecap: 'round', strokeLinejoin: 'round' };
switch (name) {
case 'search': return ;
case 'bag': return ;
case 'heart': return ;
case 'home': return ;
case 'grid': return ;
case 'user': return ;
case 'chevron': return ;
case 'chev-r': return ;
case 'left': return ;
case 'right': return ;
case 'plus': return ;
case 'minus': return ;
case 'close': return ;
case 'star': return ;
case 'filter': return ;
case 'sparkle': return ;
case 'truck': return ;
case 'shield': return ;
case 'leaf': return ;
case 'check': return ;
case 'waze': return ;
case 'insta': return ;
case 'fb': return ;
case 'wapp': return ;
case 'pin': return ;
default: return null;
}
};
// ── Tab bar — fixed to device frame, not scroll container ──
function TabBar({ active, onNav, T, cartCount }) {
const tabs = [
{ id: 'home', icon: 'home', label: 'בית' },
{ id: 'shop', icon: 'grid', label: 'חנות' },
{ id: 'wishlist', icon: 'heart', label: 'מועדפים' },
{ id: 'cart', icon: 'bag', label: 'סל' },
{ id: 'track', icon: 'truck', label: 'מעקב' },
FEATURES.customerAccounts && { id: 'account', icon: 'user', label: 'חשבון' },
].filter(Boolean);
return (
{tabs.map(t => {
const on = active === t.id;
return (
onNav(t.id)} style={{
background: 'none', border: 'none', cursor: 'pointer',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 3,
color: on ? T.ink : T.inkMute, padding: '4px 10px',
transition: 'color 0.25s ease', position: 'relative',
}}>
{t.id === 'cart' && cartCount > 0 && (
{cartCount}
)}
{t.label}
{on &&
}
);
})}
);
}
function IconBtn({ T, icon, onClick }) {
return (
e.currentTarget.style.transform = 'scale(0.92)'}
onPointerUp={e => e.currentTarget.style.transform = 'scale(1)'}
onPointerLeave={e => e.currentTarget.style.transform = 'scale(1)'}>
);
}
function SectionHead({ T, displayFont, title, link }) {
return (
);
}
// ── Home ──
function HomeScreen({ T, displayFont, showGlow, onOpenProduct, onOpenCategory, onOpenSearch, onOpenTrackOrder, onOpenLegal, scrollRef }) {
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const el = scrollRef.current; if (!el) return;
const h = () => setScrollY(el.scrollTop);
el.addEventListener('scroll', h, { passive: true });
return () => el.removeEventListener('scroll', h);
}, [scrollRef]);
// Parallax + glow fade were the source of the "slider goes empty" bug — disabled.
// Kept the scrollY listener around in case other sections want it later.
const heroOpacity = 1;
const heroParallax = 0;
return (
40 ? `color-mix(in oklab, ${T.bg}, transparent 10%)` : 'transparent',
backdropFilter: scrollY > 40 ? 'blur(16px)' : 'none',
WebkitBackdropFilter: scrollY > 40 ? 'blur(16px)' : 'none',
transition: 'background 0.3s ease',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
gap: 12,
}}>
window.open(`https://waze.com/ul?ll=${SETTINGS.waze_lat},${SETTINGS.waze_lng}&navigate=yes`, '_blank', 'noopener')}
style={{
display: 'flex', alignItems: 'center', gap: 6,
height: 40, padding: '0 12px', borderRadius: 20,
background: `color-mix(in oklab, ${T.accent}, transparent 80%)`,
border: `1px solid color-mix(in oklab, ${T.accent}, transparent 50%)`,
color: T.accent, cursor: 'pointer', fontFamily: 'inherit',
transition: 'transform 0.2s, background 0.2s',
}}
onPointerDown={e => e.currentTarget.style.transform = 'scale(0.94)'}
onPointerUp={e => e.currentTarget.style.transform = 'scale(1)'}
onPointerLeave={e => e.currentTarget.style.transform = 'scale(1)'}>
ניווט
{/* Hero slider — admin-managed */}
{
if (action.type === 'category') onOpenCategory(action.id);
else if (action.type === 'tab') onOpenCategory('__all'); // tab='shop' resolves to ShopScreen via __all
}} />
{/* Categories — featured + show all */}
onOpenCategory('__all')} style={{
padding: '8px 14px', borderRadius: 999,
background: 'transparent', border: `1px solid ${T.line}`,
color: T.ink, fontSize: 11, fontWeight: 600, cursor: 'pointer',
fontFamily: 'inherit', display: 'inline-flex', alignItems: 'center', gap: 6,
}}>
הצג הכל
{/* Featured cat — big card */}
onOpenCategory(CATEGORIES[0].id)} style={{
margin: '0 16px 12px', height: 220, width: 'calc(100% - 32px)',
borderRadius: 26, overflow: 'hidden', position: 'relative',
border: `1px solid ${T.line}`, background: 'transparent',
padding: 0, cursor: 'pointer', textAlign: 'right',
boxShadow: showGlow ? `0 20px 50px -20px ${T.glow}` : 'none',
}}>
★ נבחר
{CATEGORIES[0].label}
{CATEGORIES[0].sub}
לקטגוריה
{/* Other cats — grid 3 */}
{CATEGORIES.slice(1).map((c, i) => (
onOpenCategory(c.id)} style={{
position: 'relative', height: 130, borderRadius: 18, overflow: 'hidden',
border: `1px solid ${T.line}`, background: 'transparent', padding: 0,
cursor: 'pointer', textAlign: 'right',
}}>
))}
{/* Show all tile */}
onOpenCategory('__all')} style={{
position: 'relative', height: 130, borderRadius: 18, overflow: 'hidden',
border: `1px dashed ${T.line}`, background: T.bgSoft, padding: 0,
cursor: 'pointer', display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', gap: 6, color: T.ink,
}}>
הצג הכל
{CATEGORIES.length}+ קטגוריות
{/* (stories moved to top) */}
{PRODUCTS.slice(0, 6).map(p => (
onOpenProduct(p)} displayFont={displayFont} />
))}
{/* Brands */}
{BRANDS.map(b => )}
{/* Instagram feed — admin-curated */}
{[IMG.ig1, IMG.ig2, IMG.ig3].map((src, i) => (
e.preventDefault()} style={{
position: 'relative', aspectRatio: '1 / 1', borderRadius: 14,
overflow: 'hidden', border: `1px solid ${T.line}`,
display: 'block', textDecoration: 'none',
}}>
))}
{PRODUCTS.slice(2, 8).map(p => (
onOpenProduct(p)} displayFont={displayFont} />
))}
{[
{ icon: 'truck', l: 'משלוח חינם מ-300₪' },
{ icon: 'shield', l: 'אותנטי 100%' },
{ icon: 'leaf', l: 'החזרה תוך 30 יום' },
].map(x => (
))}
{/* Instagram strip (admin-managed) */}
{/* Brand footer + socials */}
La Pharmacia
{SETTINGS.store_address}
{[
{ icon: 'insta', label: 'Instagram', href: SETTINGS.social_instagram },
{ icon: 'fb', label: 'Facebook', href: SETTINGS.social_facebook },
{ icon: 'wapp', label: 'WhatsApp', href: SETTINGS.social_whatsapp },
{ icon: 'pin', label: 'Waze', href: `https://waze.com/ul?ll=${SETTINGS.waze_lat},${SETTINGS.waze_lng}&navigate=yes` },
].filter(s => s.href && s.href !== '').map(s => (
{ e.currentTarget.style.color = T.accent; e.currentTarget.style.borderColor = T.accent; }}
onPointerLeave={e => { e.currentTarget.style.color = T.inkDim; e.currentTarget.style.borderColor = T.line; }}>
))}
© 2026 La Pharmacia · כל הזכויות שמורות
VISA · MASTERCARD · BIT · APPLE PAY
{onOpenTrackOrder && (
מעקב הזמנה ←
)}
{onOpenLegal && (
)}
);
}
function CategoryCard({ T, c, i, onOpen, displayFont, showGlow }) {
const [hover, setHover] = useState(false);
return (
setHover(true)} onPointerLeave={() => setHover(false)}
style={{
position: 'relative', height: i % 3 === 0 ? 200 : 170,
borderRadius: 22, overflow: 'hidden',
border: `1px solid ${T.line}`, cursor: 'pointer',
background: 'transparent', padding: 0, textAlign: 'right',
transition: 'transform 0.4s cubic-bezier(0.2,0.8,0.2,1), box-shadow 0.4s',
transform: hover ? 'translateY(-2px)' : 'translateY(0)',
boxShadow: hover && showGlow ? `0 18px 40px -10px ${T.glow}` : 'none',
}}>
);
}
// ── Story circle (Instagram-style round avatar) ──
function StoryCircle({ T, s, displayFont }) {
const [pressed, setPressed] = useState(false);
return (
setPressed(true)} onPointerUp={() => setPressed(false)} onPointerLeave={() => setPressed(false)}
style={{
flex: '0 0 76px', scrollSnapAlign: 'start',
background: 'none', border: 'none', padding: 0, cursor: 'pointer',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6,
transform: pressed ? 'scale(0.94)' : 'scale(1)',
transition: 'transform 0.2s cubic-bezier(0.2,0.8,0.2,1)',
}}>
{s.title}
);
}
// ── Story big card (kept for future use) ──
function StoryCard({ T, s, displayFont, showGlow }) {
const [pressed, setPressed] = useState(false);
return (
setPressed(true)} onPointerUp={() => setPressed(false)} onPointerLeave={() => setPressed(false)}
style={{
flex: '0 0 110px', scrollSnapAlign: 'start',
position: 'relative', height: 160, borderRadius: 20, overflow: 'hidden',
background: 'transparent', border: 'none', padding: 0, cursor: 'pointer',
textAlign: 'right',
transform: pressed ? 'scale(0.96)' : 'scale(1)',
transition: 'transform 0.25s cubic-bezier(0.2,0.8,0.2,1)',
boxShadow: showGlow ? `0 0 0 2px ${T.accent}, 0 12px 28px -12px ${T.glow}` : `0 0 0 2px ${T.accent}`,
}}>
);
}
// ── Brand chip ──
function BrandCard({ T, b, displayFont }) {
const [pressed, setPressed] = useState(false);
return (
setPressed(true)} onPointerUp={() => setPressed(false)} onPointerLeave={() => setPressed(false)}
style={{
position: 'relative', height: 110, borderRadius: 18, overflow: 'hidden',
background: T.bgCard, border: `1px solid ${T.line}`, padding: 0, cursor: 'pointer',
textAlign: 'center',
transform: pressed ? 'scale(0.96)' : 'scale(1)',
transition: 'transform 0.25s cubic-bezier(0.2,0.8,0.2,1)',
}}>
{b.name}
{b.count} מוצרים
);
}
function ProductCardWide({ T, p, onOpen, displayFont }) {
return (
e.currentTarget.style.transform = 'scale(0.97)'}
onPointerUp={e => e.currentTarget.style.transform = 'scale(1)'}
onPointerLeave={e => e.currentTarget.style.transform = 'scale(1)'}>
{p.tag && (
{p.tag}
)}
{p.brand}
{p.name}
{fmt(p.price)}
{p.was && {fmt(p.was)} }
);
}
function ProductCard({ T, p, onOpen, displayFont, dense }) {
const [pressed, setPressed] = useState(false);
const oos = (p.stock ?? 999) <= 0;
return (
setPressed(true)} onPointerUp={() => setPressed(false)} onPointerLeave={() => setPressed(false)}
style={{
background: T.bgCard, border: `1px solid ${T.line}`,
borderRadius: 20, overflow: 'hidden',
padding: 0, cursor: 'pointer', textAlign: 'right',
transform: pressed ? 'scale(0.97)' : 'scale(1)',
transition: 'transform 0.25s cubic-bezier(0.2,0.8,0.2,1)',
position: 'relative',
opacity: oos ? 0.55 : 1,
}}>
{p.tag && !oos && (
{p.tag}
)}
{oos && (
אזל
)}
{p.brand}
{p.name}
{fmt(p.price)}
{p.was && {fmt(p.was)} }
);
}
// ── Category ──
function CategoryScreen({ T, displayFont, denseGrid, catId, onBack, onOpenProduct }) {
const cat = CATEGORIES.find(c => c.id === catId) || CATEGORIES[0];
const items = PRODUCTS.filter(p => p.cat === catId).concat(PRODUCTS.filter(p => p.cat !== catId));
const [filter, setFilter] = useState('all');
return (
קטגוריה
{cat.label}
{cat.sub} · נבחר עבורך
סינון
{['הכל', 'חדש', 'מתחת ל-500₪', 'במבצע', 'Maison Cervo', 'Atelier Noir'].map(f => {
const on = (f === 'הכל' && filter === 'all') || filter === f;
return (
setFilter(f === 'הכל' ? 'all' : f)} style={{
padding: '8px 14px', borderRadius: 999,
border: `1px solid ${on ? T.ink : T.line}`,
background: on ? T.ink : 'transparent',
color: on ? T.bg : T.inkDim,
fontSize: 11, fontWeight: 500,
flexShrink: 0, cursor: 'pointer', transition: 'all 0.2s',
}}>{f}
);
})}
{items.length} מוצרים
מיון: מומלץ
{items.map(p =>
onOpenProduct(p)} displayFont={displayFont} dense={denseGrid} />)}
);
}
// ── Product ──
function ProductScreen({ T, displayFont, showGlow, product, onBack, onAdd, inWishlist, onToggleWish }) {
const [size, setSize] = useState(product.cat === 'perfumes' ? '50ml' : 'M');
const [added, setAdded] = useState(false);
const oos = (product.stock ?? 999) <= 0;
const handleAdd = () => { if (oos) return; onAdd(product, size); setAdded(true); setTimeout(() => setAdded(false), 1400); };
return (
{showGlow && (
)}
{[0,1,2,3].map(i => (
))}
{product.brand}
4.8 · 124 ביקורות
{product.name}
{fmt(product.price)}
{product.was &&
{fmt(product.was)}
}
מדיטציה של ענבר חם, וניל קטיפתי וארז אטלס.
תווי פתיחה של ברגמוט נמסים לבסיס רך —
מעוצב לרגעים שנמשכים.
{product.cat === 'perfumes' ? 'נפח' : 'מידה'}
{(product.cat === 'perfumes' ? ['30ml', '50ml', '100ml'] : ['S', 'M', 'L']).map(s => {
const on = size === s;
return (
setSize(s)} style={{
flex: 1, padding: '12px 0', borderRadius: 14,
border: `1px solid ${on ? T.ink : T.line}`,
background: on ? T.ink : 'transparent',
color: on ? T.bg : T.ink,
fontWeight: on ? 700 : 500, fontSize: 12,
cursor: 'pointer', transition: 'all 0.25s',
}}>{s}
);
})}
{[
{ l: 'תווי פתיחה', v: 'ברגמוט · פלפל ורוד' },
{ l: 'תווי לב', v: 'ורד · איריס' },
{ l: 'תווי בסיס', v: 'ענבר · ארז · וניל' },
{ l: 'עוצמה', v: 'רכה, נמשכת 6+ שעות' },
].map(x => (
))}
משלוח אקספרס חינם
מגיע ביום ג', 9 במאי
!oos && (e.currentTarget.style.transform = 'scale(0.98)')}
onPointerUp={e => e.currentTarget.style.transform = 'scale(1)'}
onPointerLeave={e => e.currentTarget.style.transform = 'scale(1)'}>
{oos ? 'אזל מהמלאי' : added ? 'נוסף ✓' : 'הוסף לסל'}
{fmt(product.price)}
);
}
// ── Cart ──
function CartScreen({ T, displayFont, items, actions, onBack, onCheckout }) {
const subtotal = items.reduce((s, x) => s + x.product.price * x.qty, 0);
const [promoCode, setPromoCode] = useState('');
const [promo, setPromo] = useState(null); // { code, discount, description }
const [promoError, setPromoError] = useState('');
const [promoBusy, setPromoBusy] = useState(false);
const discount = promo?.discount || 0;
const subtotalAfterDiscount = Math.max(0, subtotal - discount);
const shipping = subtotalAfterDiscount > 300 ? 0 : 25;
const total = subtotalAfterDiscount + shipping;
const updateQty = (item, d) => actions.setQty(item, item.qty + d);
const PROMO_ERRORS = {
invalid_code: 'קוד לא תקין',
inactive: 'הקוד אינו פעיל',
expired: 'הקוד פג תוקף',
exhausted: 'הקוד נוצל מקסימום פעמים',
min_not_met: 'הזמנה מתחת למינימום',
};
async function applyPromo() {
if (!promoCode.trim()) return;
setPromoError(''); setPromoBusy(true);
try {
const r = await window.API.post('/promo/apply', { code: promoCode.trim(), subtotal });
setPromo(r);
setPromoError('');
} catch (err) {
setPromo(null);
setPromoError(PROMO_ERRORS[err.message] || 'אירעה שגיאה');
} finally { setPromoBusy(false); }
}
function clearPromo() { setPromo(null); setPromoCode(''); setPromoError(''); }
return (
{items.length === 0 ? (
הסל שלך ריק
הוסיפו כמה פריטים אהובים כדי להתחיל.
) : (
{items.map((it, idx) => (
{it.product.brand}
{it.product.name}
{it.size &&
מידה: {it.size}
}
actions.remove(it)} style={{
background: 'none', border: 'none', color: T.inkMute, cursor: 'pointer', padding: 4, marginTop: -4,
}}>
updateQty(it, -1)} style={{ width: 28, height: 28, borderRadius: 14, background: 'none', border: 'none', color: T.ink, cursor: 'pointer', display: 'grid', placeItems: 'center' }}>
{it.qty}
updateQty(it, 1)} style={{ width: 28, height: 28, borderRadius: 14, background: 'none', border: 'none', color: T.ink, cursor: 'pointer', display: 'grid', placeItems: 'center' }}>
{fmt(it.product.price * it.qty)}
))}
{FEATURES.coupons && (
{promo ? (
{promo.code}
{promo.description || (promo.kind === 'percent' ? `${promo.amount}% הנחה` : `${fmt(promo.amount)} הנחה`)}
הסר ×
) : (
<>
setPromoCode(e.target.value.toUpperCase())}
onKeyDown={e => e.key === 'Enter' && applyPromo()}
style={{
flex: 1, padding: '13px 16px', borderRadius: 14,
background: T.bgCard, border: `1px solid ${promoError ? '#c0606080' : T.line}`,
color: T.ink, fontSize: 12, outline: 'none', fontFamily: 'inherit',
textAlign: 'right', direction: 'rtl',
}} />
{promoBusy ? '...' : 'החל'}
{promoError &&
{promoError}
}
>
)}
)}
{FEATURES.coupons && discount > 0 &&
}
)}
{items.length > 0 && (
לתשלום
{fmt(total)}
)}
);
}
function Row({ T, l, v }) {
return (
{l}
{v}
);
}
// ── Checkout ──
function CheckoutScreen({ T, displayFont, items, user, onBack, onPlace }) {
const [step, setStep] = useState(0); // 0=address, 1=delivery, 2=payment, 3=review
const [addr, setAddr] = useState({ name: user?.name || '', email: '', street: '', city: '', zip: '', phone: user?.phone || '' });
const [savedAddresses, setSavedAddresses] = useState([]);
const [pickedAddrId, setPickedAddrId] = useState(null);
const [saveAddress, setSaveAddress] = useState(false); // logged-in: save this new address
// Load saved addresses + pre-fill from default
useEffect(() => {
if (!user) return;
window.API.get('/addresses').then(r => {
setSavedAddresses(r.addresses);
const def = r.addresses.find(a => a.isDefault) || r.addresses[0];
if (def) {
setAddr(p => ({ ...p, street: def.street, city: def.city, zip: def.zip || '', phone: def.phone || p.phone }));
setPickedAddrId(def.id);
}
}).catch(() => {});
}, [user]);
function pickSaved(a) {
setPickedAddrId(a.id);
setAddr(p => ({ ...p, street: a.street, city: a.city, zip: a.zip || '', phone: a.phone || p.phone }));
}
function clearPick() {
setPickedAddrId(null);
setAddr(p => ({ ...p, street: '', city: '', zip: '' }));
}
const [delivery, setDelivery] = useState(() => SHIPPING_METHODS[0]?.id || 'express');
const [pay, setPay] = useState(() => (PAYMENT_METHODS.find(m => m.id === 'cod') ? 'cod' : PAYMENT_METHODS[0]?.id) || 'cod');
const [wantAccount, setWantAccount] = useState(false);
const [password, setPassword] = useState('');
const [attempted, setAttempted] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState('');
// Email-acknowledgement modal: shown after user clicks "אשר הזמנה" on step 4.
// The order won't actually submit until the user ticks the checkbox + confirms.
const [showEmailGate, setShowEmailGate] = useState(false);
const [emailAck, setEmailAck] = useState(false);
const isGuest = !user;
const subtotal = items.reduce((s, x) => s + x.product.price * x.qty, 0);
const deliveryCost = SHIPPING_METHODS.find(m => m.id === delivery)?.price ?? 0;
const total = subtotal + deliveryCost;
const stepLabels = ['כתובת', 'משלוח', 'תשלום', 'אישור'];
// Per-step validation. Errors are keyed by field name.
const errors = (() => {
const e = {};
if (step === 0) {
if (!addr.name?.trim()) e.name = 'נא להזין שם מלא';
if (!addr.street?.trim()) e.street = 'נא להזין כתובת';
if (!addr.city?.trim()) e.city = 'נא להזין עיר';
if (!addr.phone || addr.phone.replace(/\D/g,'').length < 8) e.phone = 'מספר טלפון לא תקין';
if (isGuest) {
if (!addr.email || !/^\S+@\S+\.\S+$/.test(addr.email)) e.email = 'נא להזין אימייל תקין';
if (wantAccount && (!password || password.length < 6)) e.password = 'סיסמה לפחות 6 תווים';
}
}
if (step === 1 && !SHIPPING_METHODS.find(m => m.id === delivery)) e.delivery = 'יש לבחור אופן משלוח';
if (step === 2 && !PAYMENT_METHODS.find(m => m.id === pay)) e.pay = 'יש לבחור אופן תשלום';
return e;
})();
const stepValid = Object.keys(errors).length === 0;
const next = () => {
if (!stepValid) { setAttempted(true); return; }
setAttempted(false);
setStep(s => Math.min(3, s + 1));
};
const prev = () => step === 0 ? onBack() : setStep(s => s - 1);
async function place() {
setSubmitting(true); setSubmitError('');
try {
await onPlace({
addr, delivery, pay, total, subtotal, deliveryCost,
wantAccount: isGuest && wantAccount,
password: isGuest && wantAccount ? password : null,
saveAddress: !isGuest && saveAddress && !pickedAddrId,
});
} catch (err) {
setSubmitError(err?.message || 'שגיאה');
} finally { setSubmitting(false); }
}
return (
{/* Header with progress */}
{/* Stepper */}
{stepLabels.map((label, i) => (
{i < step ? : i + 1}
{label}
{i < 3 && (
)}
))}
{step === 0 && (
)}
{step === 1 &&
}
{step === 2 &&
}
{step === 3 &&
}
{submitError &&
{submitError}
}
{/* Sticky footer */}
סך הכל
{fmt(total)}
{ setEmailAck(false); setShowEmailGate(true); } : next} disabled={submitting} style={{
width: '100%', padding: '18px 24px', borderRadius: 999,
background: T.ink, color: T.bg, border: 'none',
fontSize: 13, fontWeight: 700, cursor: submitting ? 'wait' : 'pointer',
display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 8,
transition: 'transform 0.2s',
opacity: submitting ? 0.7 : 1,
}}
onPointerDown={e => !submitting && (e.currentTarget.style.transform = 'scale(0.98)')}
onPointerUp={e => e.currentTarget.style.transform = 'scale(1)'}
onPointerLeave={e => e.currentTarget.style.transform = 'scale(1)'}>
{submitting ? '...' : step === 3 ? (<> אשר הזמנה>) : 'המשך'}
{showEmailGate && (
setShowEmailGate(false)}
onConfirm={async () => {
setShowEmailGate(false);
await place();
}} />
)}
);
}
// ── Pre-submit consent modal: tells the user a confirmation email is on the way and
// reminds them to check the spam folder. Order doesn't submit until the checkbox is ticked.
function EmailConsentModal({ T, displayFont, email, ack, setAck, submitting, onCancel, onConfirm }) {
return (
{ if (e.target === e.currentTarget) onCancel(); }}>
e.stopPropagation()}>
{/* Envelope icon */}
רגע לפני סיום
מיד לאחר אישור ההזמנה נשלח אישור למייל
{email && (<> {email} >)}
.
חשוב: ייתכן שהמייל ייכנס לתיקיית הספאם / קידומי מכירות — מומלץ להציץ גם שם.
{/* Save the order number — used for tracking */}
שמרי את מספר ההזמנה שיופיע מיד אחרי האישור (בפורמט LP-XXXX-XXXX ).
ניתן לעקוב אחר ההזמנה בכל זמן דרך הכפתור מעקב בתפריט התחתון.
setAck(e.target.checked)}
style={{ width: 20, height: 20, accentColor: T.accent, cursor: 'pointer', marginTop: 1, flexShrink: 0 }} />
קראתי והבנתי. אבדוק גם בתיקיית הספאם, ואשמור את מספר ההזמנה למעקב.
חזרה
{submitting ? '...' : 'אשרי וסיימי הזמנה'}
);
}
function CheckoutField({ T, label, value, onChange, placeholder, type = 'text', required, error }) {
const isRtl = type !== 'email' && type !== 'password' && type !== 'tel';
return (
{label}{required && * }
onChange(e.target.value)} placeholder={placeholder}
required={required}
style={{
width: '100%', padding: '14px 16px', borderRadius: 14,
background: T.bgCard,
border: `1px solid ${error ? '#c0606080' : T.line}`,
color: T.ink, fontSize: 13, outline: 'none', fontFamily: 'inherit',
textAlign: isRtl ? 'right' : 'left', direction: isRtl ? 'rtl' : 'ltr',
boxSizing: 'border-box',
}} />
{error && (
{error}
)}
);
}
function CheckoutAddress({
T, displayFont, addr, setAddr,
isGuest, wantAccount, setWantAccount, password, setPassword, errors = {},
savedAddresses = [], pickedAddrId, onPickSaved, onClearPick,
saveAddress, setSaveAddress,
}) {
const set = (k) => (v) => setAddr({ ...addr, [k]: v });
return (
פרטי משלוח
{savedAddresses.length > 0 && (
כתובות שמורות
{savedAddresses.map(a => {
const on = pickedAddrId === a.id;
return (
onPickSaved(a)} style={{
flex: '0 0 auto', minWidth: 200, padding: '12px 14px', borderRadius: 14,
background: on ? T.bgCard : 'transparent',
border: `1.5px solid ${on ? T.accent : T.line}`,
color: T.ink, cursor: 'pointer', fontFamily: 'inherit',
textAlign: 'right', direction: 'rtl',
}}>
{a.label || 'כתובת'}{a.isDefault && ' · ברירת מחדל'}
{a.street}, {a.city}
);
})}
+ חדשה
)}
{isGuest && (
)}
{isGuest && FEATURES.customerAccounts && (
setWantAccount(e.target.checked)}
style={{ width: 18, height: 18, accentColor: T.accent, cursor: 'pointer' }} />
שמרי את הפרטים שלי
ייווצר חשבון עם המייל שלך כדי שתוכלי לעקוב אחרי ההזמנה ולקנות מהר יותר בעתיד.
{wantAccount && (
)}
)}
{!isGuest && !pickedAddrId && (
setSaveAddress(e.target.checked)}
style={{ width: 18, height: 18, accentColor: T.accent }} />
שמרי את הכתובת לפעם הבאה
)}
);
}
function CheckoutDelivery({ T, displayFont, value, onChange }) {
// Admin-editable shipping methods, fetched at boot from /api/shipping
const opts = SHIPPING_METHODS;
// If the previously selected option no longer exists (admin removed it), pick the first available
useEffect(() => {
if (opts.length && !opts.find(o => o.id === value)) onChange(opts[0].id);
}, [opts, value]);
return (
בחרו אופן משלוח
{opts.map(o => {
const active = value === o.id;
return (
onChange(o.id)} style={{
padding: 16, borderRadius: 18, textAlign: 'right',
background: active ? T.bgCard : 'transparent',
border: `1.5px solid ${active ? T.accent : T.line}`,
cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 12,
fontFamily: 'inherit', transition: 'all 0.2s',
}}>
{o.label}
{o.badge && {o.badge} }
{o.sub}
{o.price === 0 ? '—' : fmt(o.price)}
);
})}
);
}
function CheckoutPayment({ T, displayFont, pay, setPay }) {
const methods = PAYMENT_METHODS;
// If admin disabled the previously-selected method, snap to the first one
useEffect(() => {
if (methods.length && !methods.find(m => m.id === pay)) setPay(methods[0].id);
}, [methods, pay]);
const active = methods.find(m => m.id === pay);
if (!methods.length) return (
אין כרגע אפשרויות תשלום זמינות. נסי שוב מאוחר יותר.
);
// Auto-grid: max 3 per row, more rows as methods grow
const cols = Math.min(methods.length, 3);
return (
אופן תשלום
{methods.map(m => {
const on = pay === m.id;
return (
setPay(m.id)} style={{
padding: '14px 8px', borderRadius: 14, fontFamily: 'inherit',
background: on ? T.bgCard : 'transparent',
border: `1.5px solid ${on ? T.accent : T.line}`,
color: T.ink, cursor: 'pointer',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6,
transition: 'all 0.2s', minHeight: 78,
}}>
{m.label}
);
})}
{active.label}
{active.description && (
{active.description}
)}
{pay === 'cod' && (
ללא עמלה נוספת
)}
);
}
function PayIcon({ name, color = '#fff', size = 22 }) {
if (name === 'apple') return (
);
if (name === 'paypal') return (
);
if (name === 'cash') return (
);
if (name === 'bit') return (
);
// card
return (
);
}
function CheckoutReview({ T, displayFont, items, addr, delivery, pay, subtotal, deliveryCost, total }) {
const ship = SHIPPING_METHODS.find(m => m.id === delivery);
const deliveryLabel = ship ? `${ship.label}${ship.sub ? ' · ' + ship.sub : ''}` : delivery;
const payLabel = {
card: 'כרטיס אשראי (דרך Grow)',
cod: 'במזומן בעת קבלה',
apple: 'Apple Pay',
paypal: 'PayPal',
bit: 'Bit',
}[pay] || pay;
return (
בדקו את ההזמנה
{/* Items */}
{items.length} פריטים
{items.map((it, i) => (
{it.product.brand}
{it.product.name}
מידה: {it.size} · כמות: {it.qty}
{fmt(it.product.price * it.qty)}
))}
{/* Summary cards */}
{/* Totals */}
);
}
function ReviewLine({ T, icon, label, value }) {
return (
);
}
// ── Order success ──
function OrderSuccessScreen({ T, displayFont, onDone }) {
return (
תודה על הקנייה!
ההזמנה שלך התקבלה ואנחנו כבר מתחילים להכין את החבילה.
מספר הזמנה
#AA-{Math.floor(Math.random() * 90000 + 10000)}
חזרה לחנות
);
}
// ── Search ──
function SearchOverlay({ T, displayFont, onClose, onOpenProduct, onOpenCategory }) {
const [q, setQ] = useState('');
const inputRef = useRef(null);
useEffect(() => { setTimeout(() => inputRef.current?.focus(), 100); }, []);
const results = q ? PRODUCTS.filter(p =>
p.name.toLowerCase().includes(q.toLowerCase()) ||
p.brand.toLowerCase().includes(q.toLowerCase())
) : [];
return (
setQ(e.target.value)}
placeholder="חיפוש תיקים, בשמים, מותגים…" style={{
flex: 1, background: 'none', border: 'none', outline: 'none',
color: T.ink, padding: '14px 12px', fontSize: 13,
fontFamily: 'inherit', textAlign: 'right', direction: 'rtl',
}} />
ביטול
{!q && (
פופולרי
{['Amber Veil', 'קרוסבודי', 'צעיף משי', 'Rose de Mai', 'פנינה', 'תיק דלי', 'Atelier Noir'].map(t => (
{t}
))}
עיון
{CATEGORIES.map(c => (
onOpenCategory(c.id)} style={{
width: '100%', textAlign: 'right',
display: 'flex', alignItems: 'center', gap: 14,
padding: '12px 0',
background: 'none', border: 'none', borderBottom: `1px solid ${T.line}`, cursor: 'pointer',
}}>
))}
)}
{q && (
{results.length === 0 ? (
לא נמצאו תוצאות עבור "{q}"
) : (
{results.map(p =>
onOpenProduct(p)} displayFont={displayFont} />)}
)}
)}
);
}
// ── Wishlist ──
function WishlistScreen({ T, displayFont, items, onOpenProduct, onRemove }) {
return (
{items.length === 0 ? (
אין כאן עדיין כלום
הקישי על הלב בכל מוצר כדי לשמור אותו לכאן.
) : (
{items.map(p => (
onOpenProduct(p)} displayFont={displayFont} />
onRemove(p)} style={{
position: 'absolute', top: 8, insetInlineEnd: 8,
width: 32, height: 32, borderRadius: 16,
background: `color-mix(in oklab, ${T.bgCard}, transparent 20%)`,
backdropFilter: 'blur(10px)',
border: `1px solid ${T.line}`,
color: T.accent, cursor: 'pointer',
display: 'grid', placeItems: 'center',
}} aria-label="הסר מהמועדפים">
))}
)}
);
}
// ── Account ──
// ── Legal pages (privacy / terms / returns) ──
// Copy is a placeholder template. Have a lawyer review before going live.
// Replace the [...] tokens with the actual entity name + contact info from settings,
// or hard-code once you've finalized the wording.
function LegalPageScreen({ T, displayFont, page, onBack }) {
const PAGES = {
privacy: {
title: 'מדיניות פרטיות',
eyebrow: 'PRIVACY',
body: [
['איזה מידע אנחנו אוספים',
'בעת ביצוע הזמנה אנחנו אוספים: שם מלא, אימייל, כתובת למשלוח, מספר טלפון, ופירוט פריטי ההזמנה. בעת תשלום באשראי הנתונים עוברים ישירות לסולק (CardCom) — אנחנו לא רואים את פרטי הכרטיס.'],
['במה משתמשים במידע',
'לעיבוד הזמנות, לאספקה, ליצירת קשר במידת הצורך, ולשליחת אישורי הזמנה ועדכוני סטטוס לאימייל שמסרת.'],
['שמירת המידע',
'המידע נשמר על שרת מאובטח. הזמנות נשמרות ל-7 שנים לצרכי דיווח מס. ניתן לבקש מחיקת פרטים אישיים שלא משויכים להזמנות פעילות.'],
['העברה לצדדים שלישיים',
'אנחנו לא מוכרים, משכירים או חולקים את המידע שלך עם צד שלישי, למעט: ספק הסליקה (CardCom), חברת המשלוחים, ושירות הדואר האלקטרוני (Resend). כולם פועלים לפי הסכמי שמירת פרטיות.'],
['הזכויות שלך',
'יש לך זכות לדעת איזה מידע נשמר עליך, לתקן מידע שגוי, ולבקש מחיקה. פנייה בנושא: privacy@lapharmacia.co.il'],
],
reviewed: false,
},
terms: {
title: 'תקנון האתר',
eyebrow: 'TERMS',
body: [
['הרכישה',
'אתר La Pharmacia מציע פריטי לייפסטייל ויופי, כולל תיקים, בשמים ואקססוריז. ההזמנה נכנסת לתוקף עם אישור התשלום ושליחת אישור הזמנה למייל.'],
['מחירים',
'כל המחירים באתר כוללים מע"מ ובשקלים חדשים. אנחנו שומרים את הזכות לעדכן מחירים — המחיר התקף הוא זה שמופיע באישור ההזמנה.'],
['משלוחים',
'משלוח אקספרס: מחר עד 12:00. משלוח רגיל: 2-4 ימי עסקים. איסוף עצמי: רוטשילד 42, תל אביב.'],
['ביטולים והחזרות',
'ראי "החזרות והחזרים".'],
['אחריות',
'כל הפריטים אותנטיים ומגיעים עם אריזה מקורית. בעיה איכותית — נטפל בה מיד.'],
['פתרון מחלוקות',
'הדין החל הוא הדין הישראלי. סמכות שיפוט בלעדית לבתי המשפט בתל אביב.'],
],
reviewed: false,
},
returns: {
title: 'החזרות והחזרים',
eyebrow: 'RETURNS',
body: [
['חלון ההחזר',
'14 יום מקבלת המשלוח. הפריט חייב להיות באריזתו המקורית, ללא שימוש.'],
['פריטים שלא ניתן להחזיר',
'בשמים שנפתחו או שאריזת המגן הוסרה, פריטי היגיינה, ופריטים בהזמנה אישית.'],
['איך מחזירים',
'יצרי קשר ב-returns@lapharmacia.co.il עם מספר ההזמנה. נשלח אלייך תווית החזר.'],
['החזר כספי',
'מתבצע באותו אמצעי תשלום ששימש לרכישה, תוך 7 ימי עסקים מקבלת הפריט.'],
['פגם או טעות',
'אם קיבלת פריט פגום או שגוי — אנחנו מטפלים בהחזרה על חשבוננו ושולחים פריט חלופי או החזר מלא לבחירתך.'],
],
reviewed: false,
},
};
const p = PAGES[page] || PAGES.privacy;
return (
{!p.reviewed && (
⚠ נוסח טיוטה. יש להחליף בנוסח עם עו"ד לפני פרסום בפועל.
)}
{p.body.map(([head, text], i) => (
))}
עודכן: {new Date().toLocaleDateString('he-IL')} · La Pharmacia · רוטשילד 42, תל אביב
);
}
// ── Public order tracking (footer link, works without login) ──
function TrackOrderScreen({ T, displayFont, onBack }) {
const [orderNumber, setOrderNumber] = useState('');
const [email, setEmail] = useState('');
const [order, setOrder] = useState(null);
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const STATUS_LABELS = { pending: 'ממתין', paid: 'שולם', shipped: 'נשלח', delivered: 'נמסר', cancelled: 'בוטל', refunded: 'הוחזר' };
const SHIP_LABELS = { express: 'אקספרס', standard: 'רגיל', regular: 'רגיל', pickup: 'איסוף עצמי' };
async function track(e) {
e.preventDefault();
setBusy(true); setError(''); setOrder(null);
try {
const r = await window.API.post('/orders/track', { orderNumber: orderNumber.trim(), email: email.trim() });
setOrder(r.order);
} catch (err) {
setError(err.message === 'not_found' ? 'הזמנה לא נמצאה. ודאי שמספר ההזמנה והאימייל נכונים.' : 'אירעה שגיאה');
} finally { setBusy(false); }
}
return (
{order && (
{order.orderNumber}
{new Date(order.createdAt).toLocaleString('he-IL')}
{order.email && (
{order.email}
)}
{STATUS_LABELS[order.status] || order.status}
{order.items.map((it, i) => (
{it.img &&
}
{it.brand}
{it.name}
×{it.qty}
{fmt(it.price * it.qty)}
))}
{fmt(order.total)}} />
)}
);
}
// ── Forgot password (request reset link) ──
function ForgotPasswordScreen({ T, displayFont, onBack }) {
const [email, setEmail] = useState('');
const [busy, setBusy] = useState(false);
const [done, setDone] = useState(false);
async function submit(e) {
e.preventDefault();
setBusy(true);
try { await window.API.post('/auth/forgot', { email: email.trim() }); }
catch { /* always show same success message — don't leak account existence */ }
finally { setBusy(false); setDone(true); }
}
return (
{done ? (
בדקי את המייל
אם הכתובת רשומה אצלנו — שלחנו אליה קישור לאיפוס סיסמה. הקישור בתוקף לשעה.
חזרה
) : (
)}
);
}
// ── Reset password (lands here from email link with ?reset=TOKEN) ──
function ResetPasswordScreen({ T, displayFont, token, onDone }) {
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
async function submit(e) {
e.preventDefault();
setError('');
if (password.length < 6) { setError('סיסמה לפחות 6 תווים'); return; }
if (password !== confirm) { setError('הסיסמאות לא תואמות'); return; }
setBusy(true);
try {
await window.API.post('/auth/reset', { token, password });
onDone();
} catch (err) {
setError(err.message === 'expired_token' ? 'הקישור פג תוקף. בקשי איפוס חדש.'
: err.message === 'invalid_token' ? 'קישור לא תקין או שכבר נוצל.'
: 'אירעה שגיאה');
} finally { setBusy(false); }
}
return (
סיסמה חדשה
בחרי סיסמה חדשה לחשבון שלך.
);
}
// ── Auth screen — shown in the account tab when there's no logged-in user ──
function AuthScreen({ T, displayFont, onAuthed, onForgot }) {
const [mode, setMode] = useState('login'); // 'login' | 'register'
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [name, setName] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const ERR_MSG = {
invalid_credentials: 'אימייל או סיסמה שגויים',
invalid_email: 'כתובת מייל לא תקינה',
weak_password: 'הסיסמה צריכה לפחות 6 תווים',
name_required: 'נא להזין שם',
email_taken: 'אימייל זה כבר רשום במערכת',
};
async function submit(e) {
e.preventDefault();
setError(''); setLoading(true);
try {
const path = mode === 'login' ? '/auth/login' : '/auth/register';
const body = mode === 'login' ? { email, password } : { email, password, name };
const { token, user } = await window.API.post(path, body);
window.API.setToken(token);
onAuthed(user);
} catch (err) {
setError(ERR_MSG[err.message] || 'משהו השתבש, נסי שוב');
} finally {
setLoading(false);
}
}
const inputStyle = {
width: '100%', padding: '14px 16px', borderRadius: 14,
background: T.bgCard, border: `1px solid ${T.line}`,
color: T.ink, fontSize: 14, fontFamily: 'inherit',
direction: 'rtl', outline: 'none', boxSizing: 'border-box',
transition: 'border-color 0.2s, background 0.2s',
};
return (
{mode === 'login' ? 'ברוכה השבה' : 'הצטרפות'}
{mode === 'login' ? 'התחברי לחשבון שלך' : 'פתחי חשבון חדש'}
{mode === 'login'
? 'גישה לסל, להזמנות ולתוכנית הנקודות.'
: 'נשמור לך את הסל, נעדכן על הזמנות ונצרף אותך למועדון.'}
בהצטרפות את מאשרת את תנאי השימוש ומדיניות הפרטיות
);
}
function AccountScreen({ T, displayFont, user, onOpenOrders, onOpenProfile, onOpenAddresses, onLogout }) {
const items = [
{ l: 'הזמנות שלי', s: 'צפיה בהיסטוריית הזמנות', action: onOpenOrders },
{ l: 'פרטים אישיים', s: user?.phone || 'הוספת מספר טלפון', action: onOpenProfile },
{ l: 'כתובות', s: 'ניהול כתובות שמורות', action: onOpenAddresses },
{ l: 'התראות', s: 'אימייל + פוש' },
{ l: 'עזרה ותמיכה', s: '' },
{ l: 'התנתקות', s: '', action: onLogout, danger: true },
];
return (
חברה מאז {new Date(user?.created_at || Date.now()).getFullYear()}
{user?.name || 'אורחת'}
{user?.email || ''}
חברת אטלייה
1,240 נק׳
260 לדרגה הבאה
{items.map((x, i) => (
))}
);
}
// ── Shop ──
function ShopScreen({ T, displayFont, showGlow, onOpenCategory, onOpenSearch, onBack }) {
return (
{CATEGORIES.map((c, i) => (
onOpenCategory(c.id)} style={{
position: 'relative', height: 180, borderRadius: 24, overflow: 'hidden',
border: `1px solid ${T.line}`, background: 'transparent', padding: 0,
cursor: 'pointer', textAlign: 'right',
}}>
0{i + 1}
{c.label}
{c.sub}
))}
);
}
// ── Splash ──
function Splash({ T, displayFont, onEnter }) {
return (
בריאות ויופי,במיטבם.
תיקים, בשמים ואקססוריז שנבחרו עבור היומיומי והנדיר.
כניסה לחנות
);
}
// ── Toast ──
function Toast({ T, message, show }) {
return (
{message}
);
}
// ── Tiny inline brand-icon SVGs used in desktop chrome ──
const Insta = () => (
);
const Fb = () => (
);
const Wa = () => (
);
// ── Desktop top nav (hidden under 768px via CSS) ──
function DesktopTopNav({ active, cartCount, transparent, onTab, onOpenCategory, onSearch }) {
return (
onTab('home')} style={{ display: 'flex', alignItems: 'center' }}>
onTab('wishlist')} aria-label="מועדפים">
{FEATURES.customerAccounts && (
onTab('account')} aria-label="חשבון">
)}
onTab('cart')} aria-label="סל">
{cartCount > 0 && {cartCount} }
);
}
// ── useMediaQuery — observes a CSS media query and re-renders on change ──
function useMediaQuery(query) {
const [matches, setMatches] = useState(() =>
typeof window !== 'undefined' && window.matchMedia(query).matches
);
useEffect(() => {
const mq = window.matchMedia(query);
const onChange = () => setMatches(mq.matches);
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}, [query]);
return matches;
}
// ── Desktop home page — full-bleed editorial layout (renders only on >=768px home) ──
function DesktopHome({ onOpenCategory, onOpenProduct, onOpenSearch }) {
return (
{/* HERO — admin-managed slider */}
{HOME_SLIDES.length > 0 ? (
{
if (action.type === 'category') onOpenCategory(action.id);
else if (action.type === 'tab') onOpenCategory('__all');
}} />
) : (
// Empty-state fallback so a fresh DB doesn't show a blank page
ATELIER · TEL AVIV
אוצרת בידיים,נשאית בלב
בית של אובייקטים יפים — תיקים, בשמים ואקססוריז שנבחרו אחד אחד.
onOpenCategory('__all')}>גלי את הקולקציה
)}
{/* TRUST STRIP */}
משלוח חינם בהזמנה מעל ₪400
בית מרקחת מורשה בפיקוח משרד הבריאות
איסוף בחנות רוטשילד 42, תל אביב
{/* CATEGORIES */}
קולקציות
הקטגוריות שלנו
שש משפחות, אינסוף שילובים. כל פריט נבחר ביד מתוך מאות.
{CATEGORIES.map(c => (
onOpenCategory(c.id)}>
{c.label}
{c.sub} · גלי ←
))}
{/* EDITORIAL BANNER — full-bleed alt section */}
סיפור
היופי שבשקט
אנחנו מאמינות שיופי אמיתי לא צועק. הוא נישא לאט, מתגלה ברגעים. הקטלוג שלנו הוא אסיף של פריטים שעוברים את מבחן הזמן — תיקי עור שמתעדנים עם השימוש, ניחוחות שמשתנים על העור, אובייקטים שאפשר להעביר בירושה.
onOpenCategory('__all')}>קראי את הסיפור המלא
{/* FEATURED PRODUCTS */}
חידושים השבוע
המוצרים החמים
חיבורים חדשים מאחרוני המותגים שאנחנו אוהבים
{PRODUCTS.slice(0, 8).map(p => (
onOpenProduct(p)}>
{p.tag &&
{p.tag} }
{p.brand}
{p.name}
{p.was && {fmt(p.was)} }
{fmt(p.price)}
))}
onOpenCategory('__all')}>צפי בכל המוצרים ←
{/* INSTAGRAM STRIP (admin-managed) */}
{/* BRANDS — alt section */}
בעולם שלנו
מותגים מובילים
מסטודיואים קטנים בפריז ועד בתי בושם בפלורנס
{BRANDS.map(b => (
{b.name}
{b.count} פריטים
))}
{/* NEWSLETTER */}
מועדון La Pharmacia
הצטרפי לסיפור
השקות חדשות, אירועי מותג, ומבצעים בלעדיים — ישירות לתיבה שלך, פעם בשבוע.
);
}
// ── Desktop footer ──
function DesktopFooter({ onTab, onOpenCategory, onOpenTrackOrder, onOpenLegal }) {
return (
La Pharmacia
בית של אובייקטים יפים — תיקים, בשמים ואקססוריז שנבחרו ביד אחת אחת. {SETTINGS.store_address}.
{SETTINGS.social_instagram &&
}
{SETTINGS.social_facebook &&
}
{SETTINGS.social_whatsapp &&
}
© 2026 La Pharmacia · כל הזכויות שמורות
VISA · MASTERCARD · BIT · APPLE PAY
);
}
// ── Saved addresses (account → כתובות) ──
function AddressesScreen({ T, displayFont, onBack }) {
const [rows, setRows] = useState(null);
const [editing, setEditing] = useState(null); // null | {} for new | { ...row } for edit
const [error, setError] = useState('');
const load = () => window.API.get('/addresses').then(r => setRows(r.addresses)).catch(() => setError('load_failed'));
useEffect(() => { load(); }, []);
async function save(form) {
setError('');
try {
if (form.id) await window.API.patch(`/addresses/${form.id}`, form);
else await window.API.post('/addresses', form);
setEditing(null); load();
} catch (err) { setError(err.data?.error || 'save_failed'); }
}
async function remove(id) {
await window.API.del(`/addresses/${id}`); load();
}
async function setDefault(a) {
await window.API.patch(`/addresses/${a.id}`, { isDefault: true }); load();
}
return (
{!rows &&
טוען...
}
{rows?.length === 0 && (
עדיין אין כתובות שמורות. הוסיפי כתובת ראשונה כדי לחסוך זמן בהזמנות הבאות.
)}
{rows?.map(a => (
{a.label || 'כתובת'}
{a.isDefault && · ברירת מחדל }
{a.street} {a.city}{a.zip ? ` · ${a.zip}` : ''} {a.phone}
{!a.isDefault && (
setDefault(a)} style={{
background: 'none', border: `1px solid ${T.line}`, borderRadius: 999,
padding: '6px 12px', fontSize: 11, color: T.ink, cursor: 'pointer', fontFamily: 'inherit',
}}>הגדרי כברירת מחדל
)}
setEditing(a)} style={{
background: 'none', border: `1px solid ${T.line}`, borderRadius: 999,
padding: '6px 12px', fontSize: 11, color: T.ink, cursor: 'pointer', fontFamily: 'inherit',
}}>עריכה
remove(a.id)} style={{
background: 'none', border: 'none', padding: '6px 12px', fontSize: 11,
color: T.accent, cursor: 'pointer', fontFamily: 'inherit',
}}>מחיקה
))}
setEditing({})} style={{
width: '100%', padding: '14px', marginTop: 8, borderRadius: 14,
background: 'none', border: `1.5px dashed ${T.line}`, color: T.ink,
fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
}}>+ כתובת חדשה
{editing !== null && (
setEditing(null)} error={error} />
)}
);
}
function AddressForm({ T, displayFont, initial, onSave, onCancel, error }) {
const [f, setF] = useState({
id: initial.id, label: initial.label || '',
street: initial.street || '', city: initial.city || '',
zip: initial.zip || '', phone: initial.phone || '',
isDefault: initial.isDefault || false,
});
const set = (k) => (v) => setF(p => ({ ...p, [k]: v }));
const valid = f.street?.trim() && f.city?.trim();
return (
{initial.id ? 'עריכת כתובת' : 'כתובת חדשה'}
set('isDefault')(e.target.checked)}
style={{ width: 18, height: 18, accentColor: T.accent }} />
הגדרי כברירת מחדל
{error &&
שמירה נכשלה
}
valid && onSave(f)} disabled={!valid} style={{
width: '100%', padding: '15px', borderRadius: 999,
background: T.accent, color: T.bg, border: 'none',
fontSize: 14, fontWeight: 700, fontFamily: 'inherit',
cursor: valid ? 'pointer' : 'not-allowed', opacity: valid ? 1 : 0.5,
marginTop: 8,
}}>{initial.id ? 'שמירת שינויים' : 'הוספת כתובת'}
);
}
// ── Orders list ──
function OrdersScreen({ T, displayFont, onBack, onOpenOrder }) {
const [orders, setOrders] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
window.API.get('/orders')
.then(r => { if (!cancelled) setOrders(r.orders); })
.catch(e => { if (!cancelled) setError(e.message || 'load_failed'); });
return () => { cancelled = true; };
}, []);
return (
{error && (
שגיאה בטעינה
)}
{!orders && !error && (
טוען...
)}
{orders && orders.length === 0 && (
עדיין אין הזמנות
ההזמנה הראשונה שלך תופיע כאן.
)}
{orders && orders.map(o => (
onOpenOrder(o.id)} style={{
width: '100%', display: 'block', textAlign: 'right', direction: 'rtl',
background: T.bgCard, border: `1px solid ${T.line}`, borderRadius: 16,
padding: 16, marginBottom: 10, cursor: 'pointer', color: T.ink, fontFamily: 'inherit',
transition: 'transform 0.15s, border-color 0.2s',
}}
onPointerDown={e => e.currentTarget.style.transform = 'scale(0.99)'}
onPointerUp={e => e.currentTarget.style.transform = 'scale(1)'}
onPointerLeave={e => e.currentTarget.style.transform = 'scale(1)'}>
{o.order_number}
{new Date(o.created_at).toLocaleDateString('he-IL')}
{o.status === 'paid' ? 'שולם' : o.status}
{fmt(o.total)}
))}
);
}
// ── Single order detail ──
function OrderDetailScreen({ T, displayFont, orderId, onBack }) {
const [order, setOrder] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
window.API.get('/orders/' + orderId)
.then(r => { if (!cancelled) setOrder(r.order); })
.catch(e => { if (!cancelled) setError(e.message || 'load_failed'); });
return () => { cancelled = true; };
}, [orderId]);
if (error) return שגיאה בטעינה
;
if (!order) return טוען...
;
const addr = order.address || {};
const SHIP_LABEL = { express: 'אקספרס · מחר', regular: 'משלוח רגיל', pickup: 'איסוף עצמי' };
const PAY_LABEL = { credit: 'כרטיס אשראי', apple_pay: 'Apple Pay', paypal: 'PayPal', bit: 'Bit' };
return (
הזמנה
{order.order_number}
{order.status === 'paid' ? 'התשלום אושר' : order.status}
{new Date(order.created_at).toLocaleString('he-IL')}
{/* Items */}
פריטים
{(order.items || []).map(it => (
{it.brand}
{it.name}
{it.size && <>מידה: {it.size} · >}כמות: {it.qty}
{fmt(it.price * it.qty)}
))}
{/* Address */}
כתובת משלוח
{addr.name && <>{addr.name} >}
{addr.street}
{addr.city}{addr.zip ? ` · ${addr.zip}` : ''}
{addr.phone}
{/* Totals */}
סך הכל
{fmt(order.total)}
);
}
// ── Profile edit ──
function ProfileEditScreen({ T, displayFont, user, onBack, onSave }) {
const [name, setName] = useState(user?.name || '');
const [phone, setPhone] = useState(user?.phone || '');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const inputStyle = {
width: '100%', padding: '14px 16px', borderRadius: 14,
background: T.bgCard, border: `1px solid ${T.line}`,
color: T.ink, fontSize: 14, fontFamily: 'inherit',
direction: 'rtl', outline: 'none', boxSizing: 'border-box',
};
async function submit(e) {
e.preventDefault();
setError(''); setSaving(true);
try { await onSave({ name, phone: phone || null }); }
catch (err) { setError('שגיאה בשמירה'); }
finally { setSaving(false); }
}
return (
);
}
// ── Newsletter form (used by home + desktop home) ──
function NewsletterForm({ T, compact = false }) {
const [email, setEmail] = useState('');
const [state, setState] = useState('idle'); // idle | sending | done | error
async function submit(e) {
e.preventDefault();
if (!email.includes('@')) { setState('error'); return; }
setState('sending');
try {
await window.API.post('/newsletter', { email });
setState('done'); setEmail('');
} catch { setState('error'); }
}
if (compact) {
return (
);
}
return (
);
}
// ── App shell ──
function App() {
const [tweaks, setTweak] = window.useTweaks(TWEAK_DEFAULTS);
const T = THEMES[tweaks.theme] || THEMES.emerald;
const [tab, setTab] = useState('home');
const [searchOpen, setSearchOpen] = useState(false);
const [stack, setStack] = useState([]);
// Cart + wishlist are persisted to localStorage so guests don't lose them on refresh.
// When customer accounts are on AND the user is logged in, the boot effect overwrites
// these from the server (server is the source of truth in that case).
const [cart, setCart] = useState(() => {
try { return JSON.parse(localStorage.getItem('lp_cart') || '[]'); } catch { return []; }
});
const [wishlist, setWishlist] = useState(() => {
try { return JSON.parse(localStorage.getItem('lp_wishlist') || '[]'); } catch { return []; }
});
const [toast, setToast] = useState({ msg: '', show: false });
const [user, setUser] = useState(null);
const [bootError, setBootError] = useState(null);
const [dataVersion, setDataVersion] = useState(0); // bumped after API replaces CATEGORIES/PRODUCTS/BRANDS
const scrollRef = useRef(null);
const isDesktop = useMediaQuery('(min-width: 768px)');
useEffect(() => { if (scrollRef.current) scrollRef.current.scrollTop = 0; }, [stack, tab]);
useEffect(() => { window.scrollTo({ top: 0 }); }, [tab, stack.length]);
// Persist cart + wishlist to localStorage on every change so a refresh keeps them.
// Skipped when the user is logged in — server is the source of truth in that case.
useEffect(() => {
if (user) return;
try { localStorage.setItem('lp_cart', JSON.stringify(cart)); } catch {}
}, [cart, user]);
useEffect(() => {
if (user) return;
try { localStorage.setItem('lp_wishlist', JSON.stringify(wishlist)); } catch {}
}, [wishlist, user]);
// Track page scroll for transparent-topnav-over-hero on desktop home
const [pageScrolled, setPageScrolled] = useState(false);
useEffect(() => {
const onScroll = () => setPageScrolled(window.scrollY > 80);
window.addEventListener('scroll', onScroll, { passive: true });
onScroll();
return () => window.removeEventListener('scroll', onScroll);
}, []);
// ── Boot: detect ?reset=TOKEN in the URL and push the reset screen ──
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const t = params.get('reset');
if (t) {
setStack([{ type: 'reset-password', token: t }]);
// Strip the param so refresh doesn't re-trigger
const cleanUrl = window.location.pathname + window.location.hash;
window.history.replaceState({}, '', cleanUrl);
}
}, []);
// ── Boot: fetch catalog + settings + IG from API + restore session if token exists ──
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [c, b, p, s, ig, sh, pm, sl] = await Promise.all([
window.API.get('/categories'),
window.API.get('/brands'),
window.API.get('/products'),
window.API.get('/settings').catch(() => ({ settings: SETTINGS })),
window.API.get('/instagram/posts').catch(() => ({ posts: [] })),
window.API.get('/shipping').catch(() => ({ methods: SHIPPING_METHODS })),
window.API.get('/payments').catch(() => ({ methods: PAYMENT_METHODS })),
window.API.get('/home-slides').catch(() => ({ slides: [] })),
]);
if (cancelled) return;
if (c.categories?.length) CATEGORIES = c.categories;
if (b.brands?.length) BRANDS = b.brands;
if (p.products?.length) PRODUCTS = p.products;
if (s.settings) SETTINGS = { ...SETTINGS, ...s.settings };
if (ig.posts) IG_POSTS = ig.posts;
if (sh.methods?.length) SHIPPING_METHODS = sh.methods;
if (pm.methods?.length) PAYMENT_METHODS = pm.methods;
if (sl.slides) HOME_SLIDES = sl.slides;
setDataVersion(v => v + 1);
} catch (err) {
console.warn('[boot] catalog fetch failed, using offline fallback:', err.message);
if (!cancelled) setBootError('catalog');
}
// Only restore a user session when customer accounts are enabled.
// The admin token is stored under the same localStorage key by /admin, so without
// this gate, signing in to admin would carry over to the storefront and hide
// the guest checkout flow (no email field, "save address" toggle, etc.).
if (FEATURES.customerAccounts && window.API.token) {
try {
const me = await window.API.get('/auth/me');
if (!cancelled) setUser(me.user);
// Hydrate cart + wishlist from the server
const [cartRes, wishRes] = await Promise.all([
window.API.get('/cart').catch(() => ({ items: [] })),
window.API.get('/wishlist').catch(() => ({ items: [] })),
]);
if (!cancelled) {
setCart(cartRes.items.map(i => ({ product: i.product, size: i.size, qty: i.qty, serverId: i.id })));
setWishlist(wishRes.items);
}
} catch (err) {
// Token was rejected (likely expired) — drop it silently
window.API.setToken(null);
}
}
})();
return () => { cancelled = true; };
}, []);
// ── Auth helpers ──
const onAuthed = async (u) => {
setUser(u);
try {
const [cartRes, wishRes] = await Promise.all([
window.API.get('/cart').catch(() => ({ items: [] })),
window.API.get('/wishlist').catch(() => ({ items: [] })),
]);
setCart(cartRes.items.map(i => ({ product: i.product, size: i.size, qty: i.qty, serverId: i.id })));
setWishlist(wishRes.items);
} catch { /* empty cart on fresh signup is fine */ }
};
const logout = () => {
window.API.setToken(null);
setUser(null);
setCart([]);
setWishlist([]);
setTab('home');
};
const flashToast = (msg) => {
setToast({ msg, show: true });
setTimeout(() => setToast(t => ({ ...t, show: false })), 1800);
};
const updateProfile = async (patch) => {
const { user: updated } = await window.API.patch('/auth/me', patch);
setUser(updated);
return updated;
};
// ── Cart actions: add / setQty / remove / clear ───────────────────────────
// When logged in, every mutation hits /api/cart and we mirror the server response.
// When logged out, mutations stay local. Cart shape: { product, size, qty, serverId? }
const cartActions = {
add: async (product, size, qty = 1) => {
if (user) {
try {
const { items } = await window.API.post('/cart', { productId: product.id, size: size || null, qty });
setCart(items.map(i => ({ product: i.product, size: i.size, qty: i.qty, serverId: i.id })));
} catch (err) {
console.error('[cart.add]', err);
flashToast('שגיאה בהוספה לסל');
return;
}
} else {
setCart(c => {
const ix = c.findIndex(x => x.product.id === product.id && x.size === size);
if (ix >= 0) { const next = [...c]; next[ix] = { ...next[ix], qty: next[ix].qty + qty }; return next; }
return [...c, { product, size, qty }];
});
}
flashToast('נוסף לסל');
// After a successful add, jump to the cart so the user sees what they just added.
setStack([]);
setTab('cart');
},
setQty: async (item, qty) => {
if (user && item.serverId) {
try {
const { items } = await window.API.patch(`/cart/${item.serverId}`, { qty });
setCart(items.map(i => ({ product: i.product, size: i.size, qty: i.qty, serverId: i.id })));
return;
} catch (err) {
console.error('[cart.setQty]', err);
flashToast('שגיאה בעדכון');
return;
}
}
setCart(c => qty <= 0
? c.filter(x => !(x.product.id === item.product.id && x.size === item.size))
: c.map(x => (x.product.id === item.product.id && x.size === item.size) ? { ...x, qty } : x)
);
},
remove: (item) => cartActions.setQty(item, 0),
clearLocal: () => setCart([]),
};
// ── Wishlist actions: toggle / has ────────────────────────────────────────
const wishActions = {
has: (productId) => wishlist.some(w => w.id === productId),
toggle: async (product) => {
if (user) {
try {
const has = wishActions.has(product.id);
const { items } = await (has
? window.API.del(`/wishlist/${product.id}`)
: window.API.post('/wishlist', { productId: product.id }));
setWishlist(items);
flashToast(has ? 'הוסר מהמועדפים' : 'נשמר במועדפים');
} catch (err) {
console.error('[wishlist]', err);
flashToast('שגיאה');
}
} else {
setWishlist(w => {
const has = w.some(x => x.id === product.id);
if (has) return w.filter(x => x.id !== product.id);
return [...w, { id: product.id, name: product.name, brand: product.brand, price: product.price, was: product.was, img: product.img, tag: product.tag, cat: product.cat }];
});
flashToast(wishActions.has(product.id) ? 'הוסר מהמועדפים' : 'נשמר במועדפים');
}
},
};
const top = stack[stack.length - 1];
const openCategory = (id) => setStack(s => [...s, { type: 'category', id }]);
const openProduct = (p) => setStack(s => [...s, { type: 'product', product: p }]);
const openCheckout = () => setStack(s => [...s, { type: 'checkout' }]);
const openOrders = () => setStack(s => [...s, { type: 'orders' }]);
const openOrderDetail = (orderId) => setStack(s => [...s, { type: 'order-detail', orderId }]);
const openProfileEdit = () => setStack(s => [...s, { type: 'profile-edit' }]);
const openAddresses = () => setStack(s => [...s, { type: 'addresses' }]);
const openTrackOrder = () => setStack(s => [...s, { type: 'track-order' }]);
const openForgotPwd = () => setStack(s => [...s, { type: 'forgot-password' }]);
const openLegal = (page) => setStack(s => [...s, { type: 'legal', page }]);
const openOrderSuccess = () => setStack([{ type: 'order-success' }]);
const back = () => setStack(s => s.slice(0, -1));
const cartCount = cart.reduce((n, x) => n + x.qty, 0);
let content;
if (top?.type === 'product') {
content = wishActions.toggle(top.product)} />;
} else if (top?.type === 'orders') {
content = ;
} else if (top?.type === 'order-detail') {
content = ;
} else if (top?.type === 'profile-edit') {
content = { await updateProfile(patch); back(); flashToast('הפרופיל עודכן'); }} />;
} else if (top?.type === 'addresses') {
content = ;
} else if (top?.type === 'track-order') {
content = ;
} else if (top?.type === 'forgot-password') {
content = ;
} else if (top?.type === 'reset-password') {
content = { back(); setTab('account'); flashToast('הסיסמה הוחלפה — התחברי שוב'); }} />;
} else if (top?.type === 'legal') {
content = ;
} else if (top?.type === 'checkout') {
content = {
const paymentMap = { card: 'credit', apple: 'apple_pay', paypal: 'paypal', bit: 'bit', cod: 'cod', credit: 'credit' };
const address = {
name: form.addr.name,
email: form.addr.email,
street: form.addr.street, city: form.addr.city,
zip: form.addr.zip, phone: form.addr.phone,
};
const body = {
address,
shippingMethod: form.delivery,
paymentMethod: paymentMap[form.pay] || 'cod',
};
if (user) {
// Logged-in: server cart is the source of truth. Mirror local → server, then place.
await window.API.del('/cart').catch(() => {});
for (const it of cart) {
await window.API.post('/cart', { productId: it.product.id, size: it.size || null, qty: it.qty });
}
} else {
// Guest: items + (optional) account creation flow into the body.
body.items = cart.map(it => ({ productId: it.product.id, size: it.size || null, qty: it.qty }));
if (form.wantAccount && form.password) {
body.createAccount = true;
body.password = form.password;
}
}
let result;
try {
result = await window.API.post('/orders', body);
} catch (err) {
if (err.message === 'out_of_stock') {
const lines = (err.data?.items || []).map(i => `${i.name} (זמין: ${i.available}, ביקשת: ${i.requested})`).join('\n');
throw new Error('אזלו פריטים מהמלאי:\n' + lines);
}
if (err.message === 'email_taken') throw new Error('האימייל כבר רשום במערכת — נסי להתחבר במקום ליצור חשבון.');
if (err.message === 'guest_email_required') throw new Error('נדרש אימייל לסיום הזמנת אורח');
if (err.message === 'address_incomplete') throw new Error('נא למלא את כל פרטי המשלוח');
if (err.message === 'cart_empty') throw new Error('הסל ריק');
throw err;
}
console.log('[order placed]', result.order.orderNumber, '₪' + result.order.total);
// If we created an account during checkout, sign the user in seamlessly.
if (result.auth) {
window.API.setToken(result.auth.token);
setUser(result.auth.user);
}
// Redirect to Grow if the server returned a payment URL (card / wallets)
// Save the address for next time if user opted in (logged in only)
if (form.saveAddress && (user || result.auth)) {
window.API.post('/addresses', {
label: '', street: form.addr.street, city: form.addr.city,
zip: form.addr.zip || null, phone: form.addr.phone, isDefault: false,
}).catch(() => {});
}
if (result.paymentUrl) { window.location.href = result.paymentUrl; return; }
setCart([]);
openOrderSuccess();
}} />;
} else if (top?.type === 'order-success') {
content = { setStack([]); setTab('home'); }} />;
} else if (top?.type === 'category' && top.id === '__all') {
content = setTab('search')} onBack={back} />;
} else if (top?.type === 'category') {
content = ;
} else if (tab === 'shop') {
content = setSearchOpen(true)} />;
} else if (tab === 'wishlist') {
content = wishActions.toggle(p)} />;
} else if (tab === 'cart') {
content = setTab('home')} onCheckout={openCheckout} />;
} else if (tab === 'track') {
// No onBack → back button hidden; user switches tabs to leave
content = ;
} else if (tab === 'account' && FEATURES.customerAccounts) {
content = user
?
: ;
} else {
content = setSearchOpen(true)} onOpenTrackOrder={openTrackOrder}
onOpenLegal={openLegal}
scrollRef={scrollRef} />;
}
// Show tab bar only on root tabs (not when drilled into stack)
const showTabBar = stack.length === 0;
// Helpers wired to desktop nav: jumping to a tab or category resets the screen stack
const goTab = (t) => { setStack([]); setTab(t); };
const goCategory = (id) => { setTab('home'); setStack([{ type: 'category', id }]); };
// Desktop home shows the full-bleed editorial layout. All other screens fall back to the
// centred mobile shell on desktop (intentional — it's a v1 scope decision).
const isDesktopHome = isDesktop && tab === 'home' && stack.length === 0;
const topnavTransparent = isDesktopHome && !pageScrolled;
return (
<>
{/* Desktop topnav lives only on the desktop home page; column views use their own in-screen header */}
{isDesktopHome && (
setSearchOpen(true)} />
)}
{isDesktopHome ? (
setSearchOpen(true)} />
) : (
{/* Fixed tab bar — OUTSIDE scrollable region, hidden on desktop via class */}
{showTabBar &&
}
)}
{searchOpen && (
setSearchOpen(false)}
onOpenProduct={(p) => { setSearchOpen(false); openProduct(p); }}
onOpenCategory={(id) => { setSearchOpen(false); openCategory(id); }} />
)}
>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render( );