// Domeneflyt: 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, bulk-sjekk og TLD-tabell. import React, { useState, useEffect, useMemo } from 'react'; const NS = window.STWORDER; 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' }; } 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); 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.premium ? ( {t('common.premium')} ) : null}
{st.kind === 'taken' ? ( {t('common.taken')} ) : 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); }; const off1 = NS.bus.on('domain:added', open); const off2 = NS.bus.on('upsell:open', open); // fra "Legg til webhotell" i vognen return () => { off1(); off2(); }; }, []); // Lukk uten hosting-kjøp → nudge for domenet som nettopp ble lagt til. function dismiss() { 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 (

{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}
  • ))}
); })}
); } /* 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}
); } function BulkChecker() { const { useI18n, useCart, nok, Btn, Spinner, CheckIcon } = NS.ui; const { t } = useI18n(); useCart(); const [text, setText] = useState(''); const [busy, setBusy] = useState(false); const [rows, setRows] = useState(null); async function check() { setBusy(true); setRows(await NS.api.bulkCheck(text)); setBusy(false); } function addAll() { rows.filter((r) => r.available && !NS.store.hasDomain(r.domain)).forEach((r) => { NS.store.add({ type: 'domain', domain: r.domain, years: 1, pricePerYear: r.price, premium: !!r.premium }); }); NS.bus.emit('cart:open'); } const availableCount = rows ? rows.filter((r) => r.available).length : 0; return (

{t('domains.bulkHint')}