// Delte UI-primitiver. Retning: "nordisk aurora-tech" – dyp marineblå med // nordlys-glød (emerald/lime), serif-overskrifter, mono-mikroetiketter. // Andre filer bruker disse via window.STWORDER.ui – alltid lazy, inne i // komponent-funksjonene, aldri på toppnivå. import React, { useSyncExternalStore, useEffect } from 'react'; const NS = window.STWORDER; /* ---------- hooks ---------- */ function useCart() { return useSyncExternalStore(NS.store.subscribe, NS.store.get); } function useI18n() { useSyncExternalStore(NS.i18n.subscribe, () => NS.i18n.lang); return { t: NS.i18n.t, lang: NS.i18n.lang, setLang: NS.i18n.setLang }; } /* ---------- helpers ---------- */ const fmt = new Intl.NumberFormat('nb-NO', { style: 'currency', currency: 'NOK', minimumFractionDigits: 0, maximumFractionDigits: 0, }); function nok(n) { return fmt.format(n); } function localName(v, lang) { if (v && typeof v === 'object') return v[lang] || v.no || v.en; return v; } /* ---------- små byggeklosser ---------- */ function MonoLabel({ children, className }) { return (
{children}
); } function Btn({ variant, className, ...props }) { const variants = { navy: 'bg-navy text-white hover:bg-navy-soft', brand: 'bg-brand text-white hover:bg-emerald-700', ghost: 'border border-gray-300 bg-white text-navy hover:border-navy', outline: 'border border-navy/20 bg-white text-navy hover:bg-navy hover:text-white', }; return ( ))} {children ? (
{children}
) : null} ); } /* ---------- produktkort (moderne prising, pill-badge) ---------- */ function ProductCard({ badge, tone, icon, title, sub, desc, features, priceLabel, price, cta, onCta, selected, featured }) { const pill = tone === 'orange' ? 'bg-orange-500' : 'bg-brand'; const hot = featured || selected; return (
{badge ? (
{badge}
) : null} {icon ? (
{icon}
) : null}

{title}

{sub ? {sub} : null} {desc ?

{desc}

: null} {features && features.length ? ( ) : null}
{priceLabel ? {priceLabel} : null} {price}
{cta ? ( {cta} ) : null}
); } /* ---------- header / footer / banner ---------- */ function DemoBanner() { const { t } = useI18n(); return (
{t('banner.demo')}
); } function Header({ route, onCart }) { const { t, lang, setLang } = useI18n(); const cart = useCart(); const links = [ { href: '#/domener', route: 'domener', label: t('nav.domains') }, { href: '#/hosting', route: 'hosting', label: t('nav.hosting') }, { href: '#/vps', route: 'vps', label: t('nav.vps') }, ]; return (
servetheworld.
/
{t('nav.panel')}
); } function Footer() { const { t } = useI18n(); return ( ); } /* ---------- trust-linje (konvertering) ---------- */ function TrustBar({ className, dark }) { const { t } = useI18n(); const items = ['trust.registrar', 'trust.support', 'trust.dc', 'trust.cancel']; return (
{items.map((k) => ( {t(k)} ))}
); } /* ---------- handlevogn ---------- */ /* Flat, domene-gruppert vogn: domene + tilhørende hosting i samme delkort. Domener uten hosting får en "Legg til webhotell"-knapp som åpner upsell-dialogen ('upsell:open' på bussen). */ function groupCart(items) { const groups = []; const byDomain = {}; for (const item of items) { if (item.type === 'domain' || item.type === 'transfer') { const g = { key: 'd' + item.id, title: item.domain, domainItem: item, products: [], vps: null }; byDomain[item.domain] = g; groups.push(g); } } for (const item of items) { if (item.type === 'product') { const name = item.domain && item.domain.name; if (name && byDomain[name]) { byDomain[name].products.push(item); } else { groups.push({ key: 'p' + item.id, title: name || item.name, domainItem: null, products: [item], vps: null }); } } else if (item.type === 'vps') { groups.push({ key: 'v' + item.id, title: item.name, domainItem: null, products: [], vps: item }); } } return groups; } function groupDue(g) { let sum = 0; if (g.domainItem) sum += NS.store.itemDue(g.domainItem); if (g.vps) sum += NS.store.itemDue(g.vps); for (const p of g.products) sum += NS.store.itemDue(p); return sum; } function RemoveBtn({ onClick, t }) { return ( ); } function CartItems({ compact, upsell }) { const { t } = useI18n(); const cart = useCart(); const groups = groupCart(cart.items); const cycleLabel = (item) => item.cycle === 'annually' ? t('hosting.cycleAnnually') : t('hosting.cycleMonthly'); const modeNote = (d) => d.mode === 'new' ? t('cart.domainReg') : d.mode === 'transfer' ? t('cart.domainTransfer') : d.mode === 'cart' ? t('cart.domainLinked') : t('cart.domainOwn'); return (
{groups.map((g) => (
{g.title} {nok(groupDue(g))}
{g.domainItem ? (
{g.domainItem.type === 'transfer' ? t('cart.domainTransfer') : t('cart.domainReg') + ' · ' + t('domains.emailIncluded')}
{nok(NS.store.itemDue(g.domainItem))} {!compact ? NS.store.remove(g.domainItem.id)} /> : null}
) : null} {g.products.map((p) => (
{p.name}
{cycleLabel(p)} {!g.domainItem && p.domain ? ' · ' + modeNote(p.domain) : ''}
{nok(NS.store.itemDue(p))} {!compact ? NS.store.remove(p.id)} /> : null}
))} {g.vps ? (
{cycleLabel(g.vps)} · {g.vps.os}{g.vps.hostname ? ' · ' + g.vps.hostname : ''}
{!compact ? NS.store.remove(g.vps.id)} /> : null}
) : null} {upsell && g.domainItem && g.products.length === 0 ? ( ) : null}
))}
); } function CartDrawer({ open, onClose }) { const { t } = useI18n(); const cart = useCart(); useEffect(() => { function onKey(e) { if (e.key === 'Escape') onClose(); } if (open) window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [open, onClose]); if (!open) return null; return (

{t('cart.title')}

{cart.items.length === 0 ? (

{t('cart.empty')}

{ onClose(); location.hash = '#/domener'; }}> {t('cart.emptyCta')}
) : ( )}
{cart.items.length > 0 ? (
{t('cart.dueToday')} {nok(NS.store.cartDue())}
{ onClose(); location.hash = '#/kasse'; }} > {t('cart.checkout')}
) : null}
); } /* Kasse-nudge: dukker opp etter at hosting legges i vognen ('checkout:nudge' på bussen) – bekrefter og peker mot kassen uten å tvinge. */ function CheckoutToast() { const { t } = useI18n(); const [nudge, setNudge] = React.useState(null); useEffect(() => NS.bus.on('checkout:nudge', (payload) => setNudge(payload || {})), []); // Skjul toasten når upsell-dialogen åpner – aldri to lag med nudges. useEffect(() => { const clear = () => setNudge(null); const off1 = NS.bus.on('domain:added', clear); const off2 = NS.bus.on('upsell:open', clear); return () => { off1(); off2(); }; }, []); useEffect(() => { if (!nudge) return; const id = setTimeout(() => setNudge(null), 7000); return () => clearTimeout(id); }, [nudge]); if (!nudge) return null; return (
{nudge.name ? (
{t('toast.added', { name: nudge.name })}
) : null}
{t('toast.ready')}
); } NS.ui = { useCart, useI18n, nok, localName, MonoLabel, Btn, Spinner, CheckIcon, MailIcon, StepHeading, SectionTitle, PageHero, ProductCard, AISearchBox, DemoBanner, Header, Footer, TrustBar, CartItems, CartDrawer, CheckoutToast, };