// 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 (
);
}
function Spinner({ className }) {
return (
);
}
function CheckIcon({ className }) {
return (
);
}
function MailIcon({ className }) {
return (
);
}
function StepHeading({ n, children, right }) {
return (
0{n}
{children}
{right || null}
);
}
function SectionTitle({ label, title, className }) {
return (
{label ? {label} : null}
{title}
);
}
/* ---------- side-hero (mørk, nordlys) ---------- */
function PageHero({ label, title, sub, children, center }) {
return (
{label ?
{label} : null}
{title}
{sub ?
{sub}
: null}
{children ?
{children}
: null}
);
}
/* ---------- AI-prompt-aktig søkeboks ---------- */
function AISearchBox({ value, onChange, onSubmit, placeholder, chips, onChip, label, children }) {
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 ? (
{features.map((f, i) => (
-
{f}
))}
) : 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 (
{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,
};