{/* watermarks (B) — sit behind content */}
{/* small icon strip — chalk PNG icons from cutouts */}
// dossier
{useSetting('home.portrait_subject', 'SUBJECT // OBJ.001')}
{useSetting('home.portrait_name_main', '@nokk')}{useSetting('home.portrait_name_acid', '.717')}
{useSetting('home.portrait_loc', 'astana, kz · since 2019')}
обряд. engraving, anime, кибернетический мистицизм. краска под кожей — сообщение, которое останется когда сервер упадёт.')}} />
// archive
go('portfolio', { focus: idx })} />
// price index
{services.slice(0,3).map((s,i)=>(
go('book')} style={{cursor:'pointer'}}>
{s.i}
{s.price}
))}
);
}
// ─── Portfolio ─────────────────────────────────────────────
function PortfolioGrid({ limit, onSelect, works = WORKS }){
const items = limit ? works.slice(0, limit) : works;
const COLS = 4;
const galleryRef = useRef(null);
useEvenColumns(galleryRef, [items]);
useParallax(galleryRef, [items]);
const renderTile = (w, i) => (
{Array.from({length: COLS}, (_, ci) => (
{items.map((w, i) => i % COLS === ci ? renderTile(w, i) : null)}
))}
);
}
function Lightbox({ idx, items, onClose, onPrev, onNext }){
if (idx == null) return null;
const w = items[idx];
useEffect(()=>{
const k = (e) => {
if (e.key === 'Escape') onClose();
if (e.key === 'ArrowLeft') onPrev();
if (e.key === 'ArrowRight') onNext();
};
window.addEventListener('keydown', k);
return ()=> window.removeEventListener('keydown', k);
}, [idx]);
return (
{monthLbl}
{weekdays.map(d => {d})}
{days.map((d,i)=>(
))}
{legend}
);
}
function StepTime({ data, set }){
const titles = useSetting('booking.step_titles', STEP_TITLES_FALLBACK);
const rawSizes = useApi('/booking/sizes', null);
const sizeRec = rawSizes ? rawSizes.find(s => s.code === data.size) : null;
const duration = sizeRec?.duration_minutes ?? 120;
// Fetch slots for the chosen date + duration
const url = `/booking/slots?date=${encodeURIComponent(data.date || '')}&duration=${duration}`;
const rawSlots = useApi(data.date ? url : null, null);
const slots = Array.isArray(rawSlots) ? rawSlots : [];
// Auto-store duration in the form data so it ships with the payload
useEffect(() => {
if (data.duration_minutes !== duration) set({ duration_minutes: duration });
}, [duration]);
const fmtDur = (m) => m >= 60 ? `${Math.floor(m/60)}ч${m%60 ? ' ' + (m%60) + 'м' : ''}` : `${m}м`;
return (
размер {data.size} · сеанс ~ {fmtDur(duration)}
{slots.length === 0 ? (
{rawSlots === null ? 'загружаю слоты...' : 'на этот день свободных слотов нет — выбери другую дату'}
) : (
{slots.map(t => (
))}
)}
);
}
function StepContact({ data, set }){
const titles = useSetting('booking.step_titles', STEP_TITLES_FALLBACK);
return (
);
}
function StepDone({ data, go }){
const [state, setState] = useState({ status: 'sending', code: null, error: null });
useEffect(() => {
let cancelled = false;
const payload = {
type: data.type,
sketch_id: data.sketchId || null,
size: data.size,
zone: data.zone,
date: data.date,
time: data.time || null,
duration_minutes: data.duration_minutes || null,
name: data.name,
contact: data.tg,
note: data.desc || null,
};
fetch(API_BASE + '/bookings', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(payload),
})
.then(r => r.json().then(j => ({ ok: r.ok, body: j })))
.then(({ ok, body }) => {
if (cancelled) return;
if (ok && body.code) setState({ status: 'ok', code: body.code, error: null });
else setState({ status: 'fail', code: null, error: body.error || 'unknown' });
})
.catch((e) => {
if (cancelled) return;
// graceful offline fallback so the demo still tells the visitor something useful
const fallback = "NK-" + Math.floor(100000 + Math.random()*900000);
setState({ status: 'offline', code: fallback, error: e.message });
});
return () => { cancelled = true; };
}, []);
const banner = {
sending: { color: 'var(--ink-dim)', text: useSetting('booking.banner_sending', '// TRANSMITTING SIGNAL...') },
ok: { color: 'var(--acid)', text: useSetting('booking.banner_ok', '// SIGNAL TRANSMITTED') },
offline: { color: 'var(--warn)', text: useSetting('booking.banner_offline', '// OFFLINE — saved locally, follow up in tg') },
fail: { color: 'var(--blood)', text: useSetting('booking.banner_fail', '// TRANSMISSION FAILED') },
}[state.status];
const titleSending = useSetting('booking.title_sending', 'отправляю...');
const titleFail = useSetting('booking.title_fail', 'не доставлено');
const titleOk = useSetting('booking.title_ok', 'заявка отправлена');
const codeLabel = useSetting('booking.code_label', 'код брони');
const followup = useSetting('booking.followup_text', 'отвечу в течение суток. если срочно — пинай в telegram.');
const btnHome = useSetting('booking.btn_home', '[ home ]');
const btnWorks = useSetting('booking.btn_works', '[ works ]');
const addrFull = useSetting('contacts.address_full', 'выдаётся в Telegram после подтверждения');
return (
{banner.text}
{state.status === 'sending' ? titleSending : state.status === 'fail' ? titleFail : titleOk}
{state.code &&
{codeLabel} {state.code}
}
{(state.status === 'ok' || state.status === 'offline') && addrFull && (
адрес студии · {addrFull}
)}
{state.status === 'fail' &&
error: {state.error}
}
{`type : ${data.type}
sketch : ${data.sketchId || '—'}
size : ${data.size}
zone : ${data.zone}
date : ${data.date}
time : ${data.time || '—'}
duration : ${data.duration_minutes ? data.duration_minutes + ' мин' : '—'}
name : ${data.name}
contact : ${data.tg}
note : ${data.desc || '—'}`}
{followup}
);
}
// ─── Export ────────────────────────────────────────────────
window.Screens = {
HomeScreen, PortfolioScreen, AboutScreen, ServicesScreen, FaqScreen, SketchScreen, BookScreen
};