/* Shared wizard logic — hooks and primitive components used by all 5 variations.
Exposes everything to window so other Babel scripts can use them. */
const { useState, useEffect, useRef, useMemo, useCallback } = React;
// ─── Hooks ───────────────────────────────────────────────────────────────
function useRoute() {
const [route, setRoute] = useState(() => window.parseRoute());
const setLang = useCallback((lang) => setRoute(r => ({ ...r, lang })), []);
const setRole = useCallback((role) => setRoute(r => ({ ...r, role })), []);
return { ...route, setLang, setRole };
}
function useT(lang, role) {
return window.getT ? window.getT(lang, role) : (window.I18N[lang] || window.I18N.en);
}
function useClient(id) {
return window.CLIENTS[id] || { name: id || 'friend', contact: '' };
}
// Persist wizard answers to localStorage keyed by client ID.
function useWizard(clientId, totalSteps, instanceKey = 'v1') {
const storageKey = `cf-${instanceKey}-${clientId}`;
const [state, setState] = useState(() => {
try {
const raw = localStorage.getItem(storageKey);
if (raw) return JSON.parse(raw);
} catch (e) {}
return { step: 0, answers: {}, started: false, done: false };
});
useEffect(() => {
try { localStorage.setItem(storageKey, JSON.stringify(state)); } catch (e) {}
}, [state, storageKey]);
const next = useCallback(() => setState(s => ({ ...s, step: Math.min(s.step + 1, totalSteps) })), [totalSteps]);
const back = useCallback(() => setState(s => ({ ...s, step: Math.max(s.step - 1, 0) })), []);
const setAnswer = useCallback((k, v) => setState(s => ({ ...s, answers: { ...s.answers, [k]: v } })), []);
const start = useCallback(() => setState(s => ({ ...s, started: true })), []);
const finish = useCallback(() => setState(s => ({ ...s, done: true })), []);
const reset = useCallback(() => setState({ step: 0, answers: {}, started: false, done: false }), []);
return { state, setState, next, back, setAnswer, start, finish, reset };
}
// ─── Confetti generator ────────────────────────────────────────────────
function Confetti({ count = 60 }) {
const pieces = useMemo(() => Array.from({ length: count }, (_, i) => {
const colors = [
'oklch(0.78 0.13 65)', 'oklch(0.78 0.06 150)', 'oklch(0.85 0.12 90)',
'oklch(0.7 0.16 30)', 'oklch(0.94 0.012 80)',
];
return {
left: Math.random() * 100,
delay: Math.random() * 1.2,
duration: 2.4 + Math.random() * 2,
color: colors[i % colors.length],
rot: Math.random() * 360,
shape: i % 3,
};
}), [count]);
return (
{pieces.map((p, i) => (
))}
);
}
// ─── Lang switcher ─────────────────────────────────────────────────────
function LangSwitcher({ lang, onChange }) {
return (
{['en', 'ru', 'uk'].map(l => (
))}
);
}
// ─── NPS scale ─────────────────────────────────────────────────────────
function NpsScale({ value, onChange, onAutoAdvance, t }) {
const cat = (n) => {
if (n == null) return '';
if (n <= 6) return t.q.nps.cat_detractor || (t.locale === 'ru' ? 'Критик' : t.locale === 'uk' ? 'Критик' : 'Detractor');
if (n <= 8) return t.q.nps.cat_passive || (t.locale === 'ru' ? 'Нейтрал' : t.locale === 'uk' ? 'Нейтрал' : 'Passive');
return t.q.nps.cat_promoter || (t.locale === 'ru' ? 'Промоутер' : t.locale === 'uk' ? 'Промоутер' : 'Promoter');
};
const handle = (n) => {
onChange(n);
if (onAutoAdvance) setTimeout(() => onAutoAdvance(), 480);
};
return (
{Array.from({ length: 11 }, (_, n) => (
))}
0 — {t.q.nps.scale_low}
{t.q.nps.scale_high} — 10
{value != null ? `→ ${cat(value)}` : ''}
);
}
// ─── Mood ──────────────────────────────────────────────────────────────
const MOOD_FACES = ['😞', '😕', '😐', '🙂', '😍'];
function MoodPicker({ value, onChange, onAutoAdvance, t }) {
const handle = (i) => {
onChange(i);
if (onAutoAdvance) setTimeout(() => onAutoAdvance(), 600);
};
return (
{t.q.mood.moods.map((label, i) => (
))}
);
}
// ─── Checks ────────────────────────────────────────────────────────────
function CheckGrid({ options, value = [], onChange }) {
const toggle = (opt) => {
const has = value.includes(opt);
onChange(has ? value.filter(v => v !== opt) : [...value, opt]);
};
return (
{options.map(opt => (
))}
);
}
// ─── Open question (text + fake voice + fake attachments) ─────────────
function OpenQuestion({ value = {}, onChange, t }) {
const [recording, setRecording] = useState(false);
const [recTime, setRecTime] = useState(0);
const recTimer = useRef();
const fileRef = useRef();
const text = value.text || '';
const voice = value.voice || null; // { duration: 7 }
const files = value.files || [];
useEffect(() => {
if (recording) {
setRecTime(0);
recTimer.current = setInterval(() => setRecTime(t => t + 1), 1000);
} else {
clearInterval(recTimer.current);
}
return () => clearInterval(recTimer.current);
}, [recording]);
const toggleRec = () => {
if (recording) {
setRecording(false);
onChange({ ...value, voice: { duration: recTime || 1 } });
} else {
setRecording(true);
}
};
const onFiles = (e) => {
const list = Array.from(e.target.files || []).map(f => ({ name: f.name, size: f.size }));
onChange({ ...value, files: [...files, ...list] });
e.target.value = '';
};
const fmtTime = (n) => `${Math.floor(n/60)}:${String(n%60).padStart(2, '0')}`;
const fmtSize = (b) => b < 1024 ? `${b}B` : b < 1048576 ? `${Math.round(b/1024)}KB` : `${(b/1048576).toFixed(1)}MB`;
return (
);
}
// ─── Done screen content (shared) ─────────────────────────────────────
function DoneCheckmark() {
return (
);
}
// Inline ladder for {v, label} levels — same pattern as variationCTO.
function Ladder({ levels, value, onChange }) {
return (
{levels.map(lv => (
))}
);
}
// Build a question registry: a list of {key, render(answers, set, t)} for the wizard.
// Each variation renders the title/lead/kicker; we provide just the input.
function makeQuestions(t) {
const q = t.q;
return [
{ key: 'nps', kicker: q.nps.kicker, title: q.nps.title, lead: q.nps.lead,
render: (a, set, onAuto) => set('nps', v)} onAutoAdvance={onAuto} t={t} />,
isAnswered: a => typeof a.nps === 'number',
},
{ key: 'mood', kicker: q.mood.kicker, title: q.mood.title, lead: q.mood.lead,
render: (a, set, onAuto) => set('mood', v)} onAutoAdvance={onAuto} t={t} />,
isAnswered: a => typeof a.mood === 'number',
},
{ key: 'onboard', kicker: q.onboard.kicker, title: q.onboard.title, lead: q.onboard.lead,
render: (a, set) => set('onboard', v)} />,
isAnswered: a => Array.isArray(a.onboard) && a.onboard.length > 0,
},
{ key: 'comm', kicker: q.comm.kicker, title: q.comm.title, lead: q.comm.lead,
render: (a, set) => set('comm', v)} />,
isAnswered: a => Array.isArray(a.comm) && a.comm.length > 0,
},
{ key: 'timeline', kicker: q.timeline.kicker, title: q.timeline.title, lead: q.timeline.lead,
render: (a, set) => set('timeline', v)} />,
isAnswered: a => Array.isArray(a.timeline) && a.timeline.length > 0,
},
{ key: 'change', kicker: q.change.kicker, title: q.change.title, lead: q.change.lead,
render: (a, set) => set('change', v)} />,
isAnswered: a => typeof a.change === 'number',
},
{ key: 'proactive', kicker: q.proactive.kicker, title: q.proactive.title, lead: q.proactive.lead,
render: (a, set, onAuto) => set('proactive', v)} onAutoAdvance={onAuto} t={{ ...t, q: { ...t.q, nps: { ...q.nps, scale_low: q.proactive.scale_low, scale_high: q.proactive.scale_high } } }} />,
isAnswered: a => typeof a.proactive === 'number',
},
{ key: 'quality', kicker: q.quality.kicker, title: q.quality.title, lead: q.quality.lead,
render: (a, set) => set('quality', v)} />,
isAnswered: a => Array.isArray(a.quality) && a.quality.length > 0,
},
{ key: 'impact', kicker: q.impact.kicker, title: q.impact.title, lead: q.impact.lead,
render: (a, set) => set('impact', v)} />,
isAnswered: a => typeof a.impact === 'number',
},
{ key: 'money', kicker: q.money.kicker, title: q.money.title, lead: q.money.lead,
render: (a, set, onAuto) => set('money', v)} onAutoAdvance={onAuto} t={{ ...t, q: { ...t.q, nps: { ...q.nps, scale_low: q.money.scale_low, scale_high: q.money.scale_high } } }} />,
isAnswered: a => typeof a.money === 'number',
},
{ key: 'trust', kicker: q.trust.kicker, title: q.trust.title, lead: q.trust.lead,
render: (a, set) => ({ ...it, levels: q.trust.levels }))} value={a.trust || {}} onChange={v => set('trust', v)} />,
isAnswered: a => a.trust && Object.keys(a.trust).length > 0,
},
{ key: 'pairs', kicker: q.pairs.kicker, title: q.pairs.title, lead: q.pairs.lead,
render: (a, set) => set('pairs', v)} />,
isAnswered: a => a.pairs && Object.keys(a.pairs).length > 0,
},
{ key: 'steal', kicker: q.steal.kicker, title: q.steal.title, lead: q.steal.lead,
render: (a, set) => set('steal', v)} max={3} />,
isAnswered: a => Array.isArray(a.steal) && a.steal.length > 0,
},
{ key: 'sort', kicker: q.sort.kicker, title: q.sort.title, lead: q.sort.lead,
render: (a, set) => set('sort', v)} />,
isAnswered: () => true,
},
{ key: 'risk', kicker: q.risk.kicker, title: q.risk.title, lead: q.risk.lead,
render: (a, set) => set('risk', v)} placeholder={q.risk.placeholder} severityLow={q.risk.sevLow} severityHigh={q.risk.sevHigh} />,
isAnswered: () => true,
},
{ key: 'again', kicker: q.again.kicker, title: q.again.title, lead: q.again.lead,
render: (a, set) => set('again', v)} yesLabel={q.again.yes} noLabel={q.again.no} reasonPlaceholder={q.again.reasonPlaceholder} />,
isAnswered: a => a.again && a.again.choice != null,
},
{ key: 'open', kicker: q.open.kicker, title: q.open.title, lead: q.open.lead,
render: (a, set) => set('open', v)} t={t} />,
isAnswered: () => true,
},
];
}
Object.assign(window, {
useRoute, useT, useClient, useWizard,
Confetti, LangSwitcher, NpsScale, MoodPicker, CheckGrid, OpenQuestion, DoneCheckmark,
makeQuestions, MOOD_FACES,
});