// 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 / banner ---------- */ // Loud, unmissable bar shown on every non-prod environment so nobody mistakes // staging for production. It also prints the resolved API endpoints this build // talks to (handy when juggling dev/prod). Renders NOTHING in prod // (config.staging === false), so production never exposes its endpoints. function StagingBanner() { const { t } = useI18n(); const cfg = (window.STWORDER && window.STWORDER.config) || {}; if (!cfg.staging) return null; // '' bases mean same-origin — show where that actually resolves to. const api = cfg.apiBase || (location.origin + '/api'); const domains = cfg.domainApiBase || location.origin; const env = (cfg.env || 'dev').toUpperCase(); return (
⚠ {env} · {t('banner.staging')} API {api} DOMAINS {domains}
); } // Kirby-tvilling: hovedmenyen speiler stw-dev.dynavee.net. Menylenkene peker på // Kirby-innholdssidene (ekte full-side-navigasjon UT av SPA-en) – butikkens // transaksjonsruter (#/domener osv.) nås via CTA-er/søk på de sidene, ikke via // global-navet. SITE byttes dev→prod og blir til slutt samme-origin (/no/...). // `match` knytter en Kirby-side til SPA-ruten den tilsvarer, så riktig element // markeres aktivt mens man er inne i flyten. Handlevognen er SPA-ens egen og // finnes ikke i Kirby-headeren ("behold Handlevogn"). Neste steg: hente menyen // som JSON fra Kirby (én kilde) i stedet for denne hardkodede speilingen. const SITE = 'https://stw-dev.dynavee.net'; const LOGIN_URL = 'https://my.servetheworld.net/login?language=norwegian'; function Header({ route, onCart }) { const { t, lang, setLang } = useI18n(); const cart = useCart(); const links = [ { href: SITE + '/no/domene/', label: 'Domene', match: 'domener' }, { href: SITE + '/no/webhotell/', label: 'Webhotell', match: 'hosting' }, { href: SITE + '/no/wordpress-hosting/', label: 'WordPress' }, { href: SITE + '/no/epost/', label: 'Epost' }, { href: SITE + '/no/ssd-vps/', label: 'VPS', match: 'vps' }, { href: SITE + '/no/dedikert-server/', label: 'Dedikert Server' }, { href: SITE + '/no/colocation', label: 'Colocation' }, ]; const sideLinks = [ { href: SITE + '/no/blogg/', label: 'Blogg' }, { href: SITE + '/no/kundesenter/', label: 'Support' }, ]; return (
servetheworld.
{sideLinks.map((l) => ( {l.label} ))}
/
{t('nav.panel')}
); } /* ---------- 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') + (g.domainItem.epp ? ' · ' + t('transfer.eppSaved') : '') : 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); // Nudgen peker mot kassen – meningsløs når du allerede ER i kassen (der // oppsummeringen oppdateres live og "Til kassen" er rett foran deg). Dropp // den da, så f.eks. "Legg til webhotell" → avbryt/"Nei takk" ikke spretter // opp en kasse-pille midt på kasse-siden. useEffect(() => NS.bus.on('checkout:nudge', (payload) => { if (location.hash.replace(/^#\/?/, '').startsWith('kasse')) return; setNudge(payload || {}); }), []); // Skjul toasten KUN når upsell-dialogen faktisk åpner ('upsell:shown') – aldri // to lag med nudges. NB: ikke på 'domain:added', for når upsellen er dempet // ("ikke spør igjen") gir nettopp det eventet en checkout:nudge vi vil beholde. useEffect(() => NS.bus.on('upsell:shown', () => setNudge(null)), []); 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, StagingBanner, Header, TrustBar, CartItems, CartDrawer, CheckoutToast, };