// 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 (
);
}
function TldTable() {
const { useI18n, nok } = NS.ui;
const { t } = useI18n();
const [filter, setFilter] = useState('');
const rows = NS.data.tlds.filter((r) => ('.' + r.tld).includes(filter.toLowerCase()));
return (
);
}
// 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, PageHero, SectionTitle } = NS.ui;
const { t } = useI18n();
const [showBulk, setShowBulk] = useState(false);
const initialQuery = useMemo(() => {
const v = sessionStorage.getItem('stworder.q') || '';
sessionStorage.removeItem('stworder.q');
return v;
}, []);
return (
{showBulk ? (
) : null}
);
}
NS.flows = NS.flows || {};
NS.flows.Domains = DomainsFlow;
NS.flows.DomainPicker = DomainPicker;
NS.flows.LiveSearch = LiveSearch;
NS.flows.UpsellDialog = UpsellDialog;