// Domeneflyt: nordlys-hero med LiveSearch (AI-boks med resultater som faller ut // av boksen, tier 1 øyeblikkelig + tier 2 async-bekreftelse), diskret hosting- // upsell (GH30–32 / WP20–22) når et domene legges i vognen. // Domenesøket er butikkens inngang og bor på rot-ruten (#/ → #/domener). // TLD-pristabellen er flyttet til Kirby-domenesiden (SEO-innhold). import React, { useState, useEffect, useMemo } from 'react'; const NS = window.STWORDER; // Norids offisielle oppslagstjeneste – eneste lovlige vei til innehaver for // .no (RDAP publiserer den ikke, og web-oppslaget har bruksvilkår mot scraping). // Vi lenker dit i stedet for å hente det selv. ?query= er Norids EGET // URL-mønster (deres lookUpUrl bygger det, og siden kjører checkForQuery() ved // mount) – så domenet forhåndsutfylles. Dette er en enkelt, bruker-initiert // oppslag på Norids side, ikke scraping. const NORID_LOOKUP_URL = 'https://www.norid.no/en/domeneoppslag/oppslagstjeneste/'; const noridLookup = (domain) => NORID_LOOKUP_URL + '?query=' + encodeURIComponent(NS.api.toAce(domain)); function useDebounced(value, ms) { const [v, setV] = useState(value); useEffect(() => { const id = setTimeout(() => setV(value), ms); return () => clearTimeout(id); }, [value, ms]); return v; } // Async tier 1-søk. Forrige resultat står til nytt svar er inne (ingen // flimring), og svar som er utdatert når de kommer kastes – nettverket // garanterer ikke rekkefølge. function useSearch(q) { const [results, setResults] = useState(null); useEffect(() => { let stale = false; NS.api.search(q).then((r) => { if (!stale) setResults(r); }); return () => { stale = true; }; }, [q]); return results; } // Bekreftelses-cache (tier 2) deles mellom komponenter i økten. const confirmCache = {}; function useConfirmations(results) { const [, bump] = useState(0); useEffect(() => { if (!results) return; results.filter((r) => r.status === 'free' && !confirmCache[r.domain]).forEach((r) => { confirmCache[r.domain] = { state: 'checking' }; NS.api.confirm(r.domain).then((v) => { confirmCache[r.domain] = Object.assign({ state: 'done' }, v); bump((n) => n + 1); }); }); }, [results]); return confirmCache; } function statusFor(r, conf) { if (r.status === 'taken') return { kind: 'taken' }; // 'unknown' = backenden har ikke sonen lastet (eller .no-miss): tentativt // "Ledig?", ingen tier 2-confirm. Itereres senere (EPP-bekreftelse). if (r.status === 'unknown') return { kind: 'maybe', price: r.price, premium: r.premium }; const c = conf[r.domain]; if (!c || c.state === 'checking') return { kind: 'checking' }; return c.available ? { kind: 'free', price: c.price, premium: c.premium } : { kind: 'taken' }; } // Tilgjengelighet skannes nedover venstrekanten: fylt emerald = ledig (også // tentativt "maybe"/seed-bom – vises optimistisk), hul grå = opptatt, // pulserende = sjekker. function StatusDot({ kind }) { const base = 'h-2.5 w-2.5 shrink-0 rounded-full '; if (kind === 'free' || kind === 'maybe') return ; if (kind === 'checking') return ; return ; // opptatt: hul } function ResultRow({ r, conf, big }) { const { useI18n, useCart, nok, Btn, Spinner, CheckIcon } = NS.ui; const { t } = useI18n(); useCart(); // re-render når vognen endres (inCart-status) const st = statusFor(r, conf); const inCart = NS.store.hasDomain(r.domain); const inTransfer = NS.store.hasTransfer(r.domain); const rdapSupported = !!(NS.data.rdapServers && NS.data.rdapServers[r.tld]); // Overføringspris (inkl. 1 års fornyelse) fra katalogen – vises i pillen // og sendes til transfer-dialogen. const transferPrice = (() => { const row = NS.data.tlds.find((tt) => tt.tld === r.tld); return row ? row.transfer : 0; })(); // Opptatt domene → tilbud om overføring. Åpner en dialog som ber om EPP-koden // (autorisasjonskoden) før domenet legges i vognen. function openTransfer() { NS.bus.emit('transfer:open', { domain: r.domain, tld: r.tld, price: transferPrice }); } function add() { NS.store.add({ type: 'domain', domain: r.domain, years: 1, pricePerYear: st.price !== undefined ? st.price : r.price, premium: !!st.premium, }); NS.bus.emit('domain:added', r.domain); } return (
{r.domain.slice(0, r.domain.indexOf('.'))} {r.domain.slice(r.domain.indexOf('.'))} {(st.kind === 'free' || st.kind === 'maybe') && st.premium ? ( {t('common.premium')} ) : null}
{st.kind === 'taken' ? (
{inTransfer ? ( {t('common.inCart')} ) : ( )} {rdapSupported ? ( ) : null}
) : st.kind === 'checking' ? ( ) : inCart ? ( {t('common.inCart')} ) : ( )}
); } /* Hosting-upsell som dialog: dukker opp når et domene legges i vognen. WordPress er standardfane; full plan-info så valget blir informert. Rendres på App-nivå (lytter selv på 'domain:added'). */ function UpsellDialog() { const { useI18n, nok, Btn, CheckIcon, MonoLabel, localName } = NS.ui; const { t, lang } = useI18n(); const [domain, setDomain] = useState(null); const [group, setGroup] = useState('wordpress'); const [addedId, setAddedId] = useState(null); useEffect(() => { const open = (d) => { setDomain(d); setGroup('wordpress'); setAddedId(null); NS.bus.emit('upsell:shown'); // modalen tar over → skjul evt. checkout-toast }; // Auto-trigger når et domene legges til: hopp over hvis kunden har valgt // "ikke spør igjen" for denne ordren – men nudge fortsatt mot kassen. const autoOpen = (d) => { if (NS.store.upsellDismissed()) { NS.bus.emit('checkout:nudge', { name: d }); return; } open(d); }; const off1 = NS.bus.on('domain:added', autoOpen); const off2 = NS.bus.on('upsell:open', open); // eksplisitt fra "Legg til webhotell" – alltid return () => { off1(); off2(); }; }, []); // Lukk uten hosting-kjøp → nudge for domenet som nettopp ble lagt til. // stopAsking=true demper auto-upsellen ut resten av ordren. function dismiss(stopAsking) { if (stopAsking) NS.store.dismissUpsell(); setDomain((d) => { if (d) NS.bus.emit('checkout:nudge', { name: d }); return null; }); } useEffect(() => { if (!domain) return; const onKey = (e) => { if (e.key === 'Escape') dismiss(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [domain]); if (!domain) return null; const tabs = [ NS.data.productGroups.find((g) => g.id === 'wordpress'), NS.data.productGroups.find((g) => g.id === 'webhotell'), ]; const products = NS.data.products.filter((p) => p.group === group); function add(p) { NS.store.add({ type: 'product', productId: p.id, name: p.name, group: p.group, cycle: 'monthly', monthly: p.monthly, domain: { mode: 'cart', name: domain }, }); setAddedId(p.id); setTimeout(() => { setDomain(null); NS.bus.emit('checkout:nudge', { name: p.name }); }, 900); } return (
dismiss()} />

{t('domains.upsell', { domain })}

{t('upsell.hint')}

{tabs.map((g) => ( ))}
{products.map((p) => { const hot = !!p.badge; return (
{p.badge ? (
{localName(p.badge, lang)}
) : null}
{p.name}
{p.sub}
{nok(p.monthly)} {t('common.perMonth')}
    {(p.features[lang] || p.features.no).map((f, i) => (
  • {f}
  • ))}
); })}
); } /* "Se eier": RDAP-eieroppslag som dialog. Åpnes via bussen ('rdap:open' med domenenavn) fra en opptatt resultatrad. Henter klientside (NS.api.rdap, rett mot registret) og viser det registret faktisk gir ut – innehaver er ofte skjult av personvernhensyn. Rendres på App-nivå. */ function RdapDialog() { const { useI18n, useCart, Spinner, MonoLabel, CheckIcon } = NS.ui; const { t } = useI18n(); useCart(); // oppdater "i handlevognen"-knappen når vognen endres const [domain, setDomain] = useState(null); const [data, setData] = useState(null); // null = laster useEffect(() => NS.bus.on('rdap:open', (d) => { setDomain(d); setData(null); }), []); useEffect(() => { if (!domain) return; let stale = false; NS.api.rdap(domain).then((r) => { if (!stale) setData(r); }); const onKey = (e) => { if (e.key === 'Escape') setDomain(null); }; window.addEventListener('keydown', onKey); return () => { stale = true; window.removeEventListener('keydown', onKey); }; }, [domain]); if (!domain) return null; const close = () => setDomain(null); const fmtDate = (s) => (typeof s === 'string' && s.length >= 10 ? s.slice(0, 10) : null); function transfer() { const dot = domain.indexOf('.'); const tld = dot > 0 ? domain.slice(dot + 1) : ''; const row = NS.data.tlds.find((tt) => tt.tld === tld); close(); NS.bus.emit('transfer:open', { domain, tld, price: row ? row.transfer : 0 }); } // Navn + org.nr (når registret oppgir det) på samme linje. const withOrg = (name, orgNo) => name ? (orgNo ? name + ' · ' + t('rdap.orgNo') + ' ' + orgNo : name) : null; // [etikett, verdi, erInnehaver] – tomme felt vises som "skjult"/"—". const rows = data && data.found ? [ [t('rdap.holder'), withOrg(data.registrant, data.registrantOrgNo), true], [t('rdap.registrar'), withOrg(data.registrar, data.registrarOrgNo), false], [t('rdap.status'), (data.statuses || []).join(', ') || null, false], [t('rdap.created'), fmtDate(data.created), false], [t('rdap.updated'), fmtDate(data.updated), false], [t('rdap.expires'), fmtDate(data.expires), false], [t('rdap.nameservers'), (data.nameservers || []).join(' · ') || null, false], ] : []; return (
{t('rdap.source')}

{domain}

{data === null ? (
{t('common.checking')}
) : !data.found ? (

{data.error ? t('rdap.error') : t('rdap.none')}

) : ( <>
{rows.map(([label, value, isHolder]) => (
{label}
{value || (isHolder ? t('rdap.redacted') : '—')}
))}

{t('rdap.privacy')}

)}
{domain.endsWith('.no') ? ( {t('rdap.norid')} ) : null}
); } /* Domeneoverføring: ber om EPP-/autorisasjonskoden før domenet legges i vognen. Åpnes via bussen ('transfer:open' med {domain, tld, price}) fra en opptatt resultatrad eller fra "Se mer"-dialogen. Oppdaterer koden hvis domenet alt ligger i vognen. Rendres på App-nivå. */ function TransferDialog() { const { useI18n, useCart, nok, Btn, MonoLabel } = NS.ui; const { t } = useI18n(); useCart(); const [info, setInfo] = useState(null); // {domain, tld, price} const [epp, setEpp] = useState(''); useEffect(() => NS.bus.on('transfer:open', (i) => { const ex = NS.store.get().items.find((it) => it.type === 'transfer' && it.domain === i.domain); setInfo(i); setEpp((ex && ex.epp) || ''); // forhåndsutfyll hvis koden alt er lagt inn }), []); useEffect(() => { if (!info) return; const onKey = (e) => { if (e.key === 'Escape') setInfo(null); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [info]); if (!info) return null; const close = () => setInfo(null); function confirm() { const code = epp.trim() || undefined; const ex = NS.store.get().items.find((it) => it.type === 'transfer' && it.domain === info.domain); if (ex) NS.store.update(ex.id, { epp: code }); else NS.store.add({ type: 'transfer', domain: info.domain, tld: info.tld, price: info.price, epp: code }); close(); NS.bus.emit('checkout:nudge', { name: info.domain }); } return (
{t('transfer.label')}

{info.domain}

{t('transfer.eppHint')}

{t('transfer.eppLater')}

{t('cart.domainTransfer')} · {nok(info.price)} {t('common.addToCart')}
); } /* Hovedsøket – brukes både på forsiden (hero) og på domenesiden. */ function LiveSearch({ initialQuery }) { const { useI18n, AISearchBox, MailIcon } = NS.ui; const { t, lang } = useI18n(); const [q, setQ] = useState(initialQuery || ''); const dq = useDebounced(q, 200); const results = useSearch(dq); const conf = useConfirmations(results ? results.results : null); const chips = ['minbedrift', 'kaffebar-oslo', 'fjellhytte']; return ( {results ? (
{/* Konverteringsdetalj: vis den faktiske adressen kunden får. */}
{t('domains.emailStrip1')}{' '} {(lang === 'no' ? 'post@' : 'you@') + results.results[0].domain} {' '} {t('domains.emailStrip2')}
{results.results.map((r, i) => ( ))}
) : null}
); } // Gjenbrukbar enkelt-domene-velger for hosting-flyten ("registrer nytt"). function DomainPicker({ onPick, picked }) { const { useI18n, nok, Btn, Spinner, CheckIcon } = NS.ui; const { t } = useI18n(); const [q, setQ] = useState(''); const dq = useDebounced(q, 250); const results = useSearch(dq); const conf = useConfirmations(results ? results.results : null); if (picked) { return (
{picked.name} {nok(picked.price)}{t('common.perYear')}
); } return (
setQ(e.target.value)} placeholder={t('domains.searchPlaceholder')} autoCapitalize="none" spellCheck="false" className="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm text-navy outline-none focus:border-brand" /> {results ? (
{results.results.map((r) => { const st = statusFor(r, conf); return (
{r.domain} {st.kind === 'taken' ? ( {t('common.taken')} ) : st.kind === 'checking' ? ( ) : ( {nok(st.price)}{t('common.perYear')} onPick({ name: r.domain, price: st.price })} > {t('common.select')} )}
); })}
) : null}
); } function DomainsFlow() { const { useI18n, MonoLabel, TrustBar } = NS.ui; const { t } = useI18n(); const initialQuery = useMemo(() => { const v = sessionStorage.getItem('stworder.q') || ''; sessionStorage.removeItem('stworder.q'); return v; }, []); return (
{/* Nordlys-hero med live-søk i sentrum – domenesøk er butikkens inngang. */}
{t('domains.heroLabel')}

{t('domains.heroTitleA')}{' '} {t('domains.heroTitleB')}

{t('domains.heroSubtitle')}

); } NS.flows = NS.flows || {}; NS.flows.Domains = DomainsFlow; NS.flows.DomainPicker = DomainPicker; NS.flows.LiveSearch = LiveSearch; NS.flows.UpsellDialog = UpsellDialog; NS.flows.RdapDialog = RdapDialog; NS.flows.TransferDialog = TransferDialog;