Онлайн-запись
Онлайн-запись
Салон красоты

Выберите услугу

Сначала выберите услугу, затем удобное время для записи

Услуги
Загрузка услуг...
Свободное время
Сначала выберите услугу
:root { --bg: #f6f7fb; --surface: #ffffff; --surface-2: #f0f2f7; --text: #181b25; --muted: #6b7280; --border: #e5e7eb; --primary: #6d4aff; --primary-hover: #5b3de0; --primary-soft: rgba(109, 74, 255, 0.1); --danger: #e11d48; --success: #16a34a; --shadow: 0 8px 24px rgba(16, 24, 40, 0.08); --radius: 18px; } * { box-sizing: border-box; } html, body { margin: 0; padding: 0; min-height: 100%; background: var(--bg); color: var(--text); font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } button, input, select, textarea { font: inherit; } button { cursor: pointer; border: none; background: none; } .app { min-height: 100vh; display: flex; flex-direction: column; max-width: 720px; margin: 0 auto; } .app-header { position: sticky; top: 0; z-index: 20; background: rgba(246, 247, 251, 0.92); backdrop-filter: blur(10px); padding: 18px 16px 14px; border-bottom: 1px solid rgba(229, 231, 235, 0.9); } .brand { font-size: 13px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--primary); } .subtitle { margin-top: 6px; font-size: 20px; font-weight: 700; line-height: 1.2; } .app-main { flex: 1; padding: 16px 16px 112px; } .screen-block { margin-bottom: 16px; } .page-title { margin: 0; font-size: 26px; line-height: 1.15; font-weight: 800; } .page-desc { margin: 10px 0 0; color: var(--muted); font-size: 15px; line-height: 1.45; } .section-title { margin-bottom: 10px; font-size: 15px; font-weight: 700; color: var(--text); } .card-list { display: grid; gap: 10px; } .card { width: 100%; text-align: left; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 14px 13px; box-shadow: var(--shadow); transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; } .card:active { transform: scale(0.99); } .card.is-selected { border-color: var(--primary); background: linear-gradient(0deg, var(--primary-soft), var(--primary-soft)), var(--surface); } .card-title { font-size: 16px; font-weight: 700; line-height: 1.3; } .card-meta { margin-top: 6px; display: flex; flex-wrap: wrap; gap: 8px; color: var(--muted); font-size: 14px; line-height: 1.35; } .card-badge { display: inline-flex; align-items: center; gap: 4px; padding: 4px 8px; border-radius: 999px; background: var(--surface-2); color: var(--text); font-size: 12px; font-weight: 600; } .slot-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } .slot-btn { min-height: 48px; border-radius: 14px; background: var(--surface); border: 1px solid var(--border); box-shadow: var(--shadow); color: var(--text); font-size: 15px; font-weight: 600; transition: transform 0.15s ease, border-color 0.15s ease, background 0.15s ease; } .slot-btn:active { transform: scale(0.98); } .slot-btn.is-selected { border-color: var(--primary); background: var(--primary); color: #ffffff; } .empty-state { padding: 16px; border-radius: var(--radius); background: var(--surface); border: 1px dashed var(--border); color: var(--muted); font-size: 14px; line-height: 1.45; } .app-footer { position: fixed; left: 0; right: 0; bottom: 0; z-index: 30; padding: 12px 16px calc(12px + env(safe-area-inset-bottom)); background: rgba(246, 247, 251, 0.94); backdrop-filter: blur(10px); border-top: 1px solid rgba(229, 231, 235, 0.9); } .app-footer-inner { max-width: 720px; margin: 0 auto; } .primary-btn { width: 100%; min-height: 54px; border-radius: 16px; background: var(--primary); color: #ffffff; font-size: 16px; font-weight: 700; box-shadow: 0 12px 24px rgba(109, 74, 255, 0.28); transition: background 0.15s ease, transform 0.15s ease, opacity 0.15s ease; } .primary-btn:hover { background: var(--primary-hover); } .primary-btn:active { transform: scale(0.99); } .primary-btn:disabled { opacity: 0.5; cursor: not-allowed; box-shadow: none; } .notice { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.4; } .status-success { color: var(--success); } .status-error { color: var(--danger); } @media (min-width: 640px) { .app-header { padding-left: 20px; padding-right: 20px; } .app-main { padding-left: 20px; padding-right: 20px; } .app-footer { padding-left: 20px; padding-right: 20px; } } @media (max-width: 380px) { .page-title { font-size: 23px; } .slot-grid { grid-template-columns: 1fr; } }
const tg = window.Telegram?.WebApp; if (tg) { tg.ready(); tg.expand(); } const state = { project: 'salon_123', apiBase: 'https://YOUR-DOMAIN.COM/api', selectedServiceId: null, selectedSlotValue: null, user: null, phone: null, services: [], slots: [], isLoadingServices: false, isLoadingSlots: false, isBooking: false, }; const els = { projectTitle: document.getElementById('projectTitle'), services: document.getElementById('services'), slots: document.getElementById('slots'), bookBtn: document.getElementById('bookBtn'), }; function parseStartParams() { const params = new URLSearchParams(window.location.search); const queryProject = params.get('project') || params.get('startapp') || params.get('start') || ''; const tgProject = tg?.initDataUnsafe?.start_param || ''; state.project = (tgProject || queryProject || 'salon_123').trim(); } function detectUser() { const tgUser = tg?.initDataUnsafe?.user || null; state.user = tgUser; } function renderProjectTitle() { if (els.projectTitle) { els.projectTitle.textContent = `Проект: ${state.project}`; } } function setBookButtonState() { if (!els.bookBtn) return; const canBook = !!state.selectedServiceId && !!state.selectedSlotValue && !state.isBooking; els.bookBtn.disabled = !canBook; els.bookBtn.textContent = state.isBooking ? 'Записываем...' : 'Записаться'; } function showAlert(message) { if (tg?.showAlert) { tg.showAlert(message); } else { alert(message); } } function escapeHtml(value) { return String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function renderServices() { if (!els.services) return; if (state.isLoadingServices) { els.services.innerHTML = '
Загрузка услуг...
'; return; } if (!state.services.length) { els.services.innerHTML = '
Услуги пока не найдены
'; return; } els.services.innerHTML = state.services .map((service) => { const selectedClass = state.selectedServiceId === service.id ? 'is-selected' : ''; const price = service.price ? `${service.price} ₽` : 'Цена уточняется'; const duration = service.duration ? `${service.duration} мин` : null; return ` `; }) .join(''); els.services.querySelectorAll('[data-service-id]').forEach((btn) => { btn.addEventListener('click', () => { const id = btn.getAttribute('data-service-id'); state.selectedServiceId = Number.isNaN(Number(id)) ? id : Number(id); state.selectedSlotValue = null; renderServices(); renderSlots(); setBookButtonState(); loadSlots(); }); }); } function renderSlots() { if (!els.slots) return; if (!state.selectedServiceId) { els.slots.innerHTML = '
Сначала выберите услугу
'; return; } if (state.isLoadingSlots) { els.slots.innerHTML = '
Загрузка свободного времени...
'; return; } if (!state.slots.length) { els.slots.innerHTML = '
Свободных слотов пока нет
'; return; } els.slots.innerHTML = `
${state.slots .map((slot) => { const value = slot.value || slot.time; const selectedClass = state.selectedSlotValue === value ? 'is-selected' : ''; return ` `; }) .join('')}
`; els.slots.querySelectorAll('[data-slot-value]').forEach((btn) => { btn.addEventListener('click', () => { state.selectedSlotValue = btn.getAttribute('data-slot-value'); renderSlots(); setBookButtonState(); }); }); } async function apiGet(path) { const url = `${state.apiBase}${path}`; const res = await fetch(url, { method: 'GET', headers: { 'Content-Type': 'application/json', }, }); if (!res.ok) { throw new Error(`GET ${path} failed with ${res.status}`); } return res.json(); } async function apiPost(path, body) { const url = `${state.apiBase}${path}`; const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(body), }); if (!res.ok) { throw new Error(`POST ${path} failed with ${res.status}`); } return res.json(); } function getMockServices() { return [ { id: 101, name: 'Маникюр', price: 1500, duration: 60 }, { id: 102, name: 'Педикюр', price: 2200, duration: 90 }, { id: 103, name: 'Окрашивание', price: 3500, duration: 120 }, ]; } function getMockSlots() { return [ { value: '2026-04-20T12:00:00', label: '20 апреля, 12:00' }, { value: '2026-04-20T14:00:00', label: '20 апреля, 14:00' }, { value: '2026-04-20T16:30:00', label: '20 апреля, 16:30' }, { value: '2026-04-21T11:00:00', label: '21 апреля, 11:00' }, ]; } async function loadServices() { state.isLoadingServices = true; renderServices(); try { const data = await apiGet(`/services?project=${encodeURIComponent(state.project)}`); state.services = Array.isArray(data) ? data : data.items || []; } catch (error) { console.warn('Services API unavailable, using mock data.', error); state.services = getMockServices(); } finally { state.isLoadingServices = false; renderServices(); setBookButtonState(); } } async function loadSlots() { if (!state.selectedServiceId) return; state.isLoadingSlots = true; renderSlots(); try { const data = await apiGet( `/slots?project=${encodeURIComponent(state.project)}&service_id=${encodeURIComponent(state.selectedServiceId)}` ); state.slots = Array.isArray(data) ? data : data.items || []; } catch (error) { console.warn('Slots API unavailable, using mock data.', error); state.slots = getMockSlots(); } finally { state.isLoadingSlots = false; renderSlots(); setBookButtonState(); } } async function handleBooking() { if (!state.selectedServiceId || !state.selectedSlotValue || state.isBooking) { return; } state.isBooking = true; setBookButtonState(); const payload = { project: state.project, service_id: state.selectedServiceId, datetime: state.selectedSlotValue, phone: state.phone, user_id: state.user?.id || null, first_name: state.user?.first_name || null, last_name: state.user?.last_name || null, username: state.user?.username || null, platform: tg ? 'telegram' : 'web', }; try { const result = await apiPost('/book', payload); if (result?.status === 'success' || result?.success === true) { showAlert('Вы успешно записаны'); if (tg?.close) { // Можно оставить открытым, но пока пусть просто покажет успешную запись } } else { throw new Error('Unexpected booking response'); } } catch (error) { console.error('Booking failed', error); showAlert('Не удалось записать. Проверь API или webhook.'); } finally { state.isBooking = false; setBookButtonState(); } } function bindEvents() { if (els.bookBtn) { els.bookBtn.addEventListener('click', handleBooking); } } function init() { parseStartParams(); detectUser(); renderProjectTitle(); renderServices(); renderSlots(); setBookButtonState(); bindEvents(); loadServices(); } init();