@muten/shadcn 0.0.5 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +61 -6
- package/README.md +57 -4
- package/globals.css +30 -2
- package/kit.css +2763 -0
- package/motion.css +79 -0
- package/nova.css +1387 -0
- package/package.json +10 -2
- package/registry/accordion.muten +11 -10
- package/registry/agenda.js +35 -0
- package/registry/agenda.muten +10 -0
- package/registry/alert-dialog.muten +9 -12
- package/registry/alert.muten +8 -8
- package/registry/app-shell.js +9 -0
- package/registry/app-shell.muten +12 -0
- package/registry/attachment.muten +13 -9
- package/registry/avatar.muten +24 -5
- package/registry/badge.muten +23 -8
- package/registry/band.muten +16 -0
- package/registry/bar-chart.muten +7 -0
- package/registry/bell.muten +3 -0
- package/registry/bento.muten +6 -9
- package/registry/booking.js +21 -0
- package/registry/booking.muten +13 -0
- package/registry/breadcrumb.muten +9 -7
- package/registry/button-group.muten +13 -4
- package/registry/button.muten +21 -8
- package/registry/calendar.js +28 -93
- package/registry/calendar.muten +9 -8
- package/registry/canvas-viewport.js +12 -5
- package/registry/canvas-viewport.muten +9 -9
- package/registry/card-field.muten +4 -0
- package/registry/card.muten +12 -8
- package/registry/carousel.js +5 -36
- package/registry/carousel.muten +12 -5
- package/registry/chatbot.muten +9 -0
- package/registry/checklist.muten +6 -0
- package/registry/choice-list.muten +16 -0
- package/registry/code.js +14 -12
- package/registry/code.muten +6 -7
- package/registry/collapsible.muten +14 -10
- package/registry/color-picker.js +460 -41
- package/registry/color-picker.muten +7 -6
- package/registry/combobox.js +5 -0
- package/registry/combobox.muten +6 -23
- package/registry/command-palette.muten +8 -12
- package/registry/command.js +32 -0
- package/registry/command.muten +32 -11
- package/registry/context-menu.muten +9 -8
- package/registry/count-up.muten +5 -10
- package/registry/date-field.js +5 -0
- package/registry/date-field.muten +7 -0
- package/registry/device-frame.muten +8 -0
- package/registry/dialog.muten +6 -12
- package/registry/dirty-bar.js +19 -0
- package/registry/dirty-bar.muten +13 -0
- package/registry/drawer.muten +6 -11
- package/registry/dropdown-menu.muten +37 -13
- package/registry/empty.muten +11 -7
- package/registry/feature-grid.muten +10 -12
- package/registry/feature-rows.muten +12 -8
- package/registry/field-behavior.js +33 -0
- package/registry/field-behavior.muten +9 -0
- package/registry/field.muten +26 -7
- package/registry/file-input.js +36 -21
- package/registry/file-input.muten +8 -7
- package/registry/fixed-card.muten +25 -0
- package/registry/folder-picker.js +13 -15
- package/registry/folder-picker.muten +5 -8
- package/registry/gradient-blinds.muten +6 -13
- package/registry/hover-card.muten +7 -9
- package/registry/image-upload.js +5 -0
- package/registry/image-upload.muten +8 -0
- package/registry/indicator.muten +5 -0
- package/registry/input-group.muten +27 -4
- package/registry/input-otp.js +8 -35
- package/registry/input-otp.muten +6 -6
- package/registry/input.muten +8 -7
- package/registry/inspector.muten +13 -15
- package/registry/island.js +7 -0
- package/registry/island.muten +5 -0
- package/registry/item.muten +20 -7
- package/registry/kbd.muten +3 -3
- package/registry/kit/board.js +300 -0
- package/registry/kit/collections.js +148 -0
- package/registry/kit/core.js +989 -0
- package/registry/kit/dates.js +106 -0
- package/registry/kit/menus.js +90 -0
- package/registry/kit/overlays.js +114 -0
- package/registry/kit/paint.js +211 -0
- package/registry/kit/qrcode.js +2291 -0
- package/registry/kit/structure.js +393 -0
- package/registry/kit/table.js +198 -0
- package/registry/kit/values.js +179 -0
- package/registry/kit-layer.js +2 -0
- package/registry/kit-layer.muten +5 -0
- package/registry/kit-skeleton.js +24 -0
- package/registry/kit-skeleton.muten +16 -0
- package/registry/kit-widget.js +79 -0
- package/registry/kit-widget.muten +4 -0
- package/registry/link-btn.muten +9 -7
- package/registry/live-stack.js +29 -0
- package/registry/live-stack.muten +7 -0
- package/registry/logo-cloud.muten +6 -11
- package/registry/member-list.muten +6 -0
- package/registry/menubar.muten +10 -14
- package/registry/message-scroller.js +12 -0
- package/registry/message-scroller.muten +8 -4
- package/registry/message.muten +27 -10
- package/registry/messaging.muten +11 -0
- package/registry/modal-behavior.js +56 -0
- package/registry/modal-behavior.muten +4 -0
- package/registry/modal.muten +29 -0
- package/registry/money-field.js +5 -0
- package/registry/money-field.muten +7 -0
- package/registry/nav-button.muten +14 -0
- package/registry/navigation-menu.muten +16 -14
- package/registry/navpill.muten +15 -9
- package/registry/new-appointment.js +8 -0
- package/registry/new-appointment.muten +10 -0
- package/registry/number-field.js +34 -24
- package/registry/number-field.muten +8 -6
- package/registry/order-board.js +6 -0
- package/registry/order-board.muten +21 -0
- package/registry/overlay-behavior.js +13 -0
- package/registry/overlay-behavior.muten +4 -0
- package/registry/pagination-nav.js +6 -0
- package/registry/pagination-nav.muten +8 -0
- package/registry/pagination.muten +12 -9
- package/registry/password-field.muten +3 -0
- package/registry/permission-matrix.muten +6 -0
- package/registry/phone-field.js +5 -0
- package/registry/phone-field.muten +7 -0
- package/registry/phone-shell.js +7 -0
- package/registry/phone-shell.muten +9 -0
- package/registry/pill.muten +6 -5
- package/registry/plans.muten +4 -0
- package/registry/popover.muten +16 -9
- package/registry/pricing.muten +21 -21
- package/registry/progress.muten +5 -5
- package/registry/queue.muten +3 -0
- package/registry/radio-group.muten +16 -8
- package/registry/range-slider.js +9 -24
- package/registry/range-slider.muten +4 -5
- package/registry/resizable.js +34 -27
- package/registry/resizable.muten +16 -5
- package/registry/roving-behavior.js +16 -0
- package/registry/roving-behavior.muten +3 -0
- package/registry/row-menu.js +5 -0
- package/registry/row-menu.muten +5 -0
- package/registry/save-button.muten +11 -0
- package/registry/scale-slider.js +7 -0
- package/registry/scale-slider.muten +9 -0
- package/registry/scroll-area.muten +8 -4
- package/registry/segmented-control.muten +8 -8
- package/registry/separator.muten +3 -3
- package/registry/share-button.js +7 -0
- package/registry/share-button.muten +10 -0
- package/registry/sheet.muten +6 -10
- package/registry/sidebar.muten +23 -15
- package/registry/skeleton.muten +6 -4
- package/registry/slider.js +73 -27
- package/registry/slider.muten +6 -6
- package/registry/sortable-behavior.js +43 -0
- package/registry/sortable-behavior.muten +2 -0
- package/registry/sortable-list.js +10 -26
- package/registry/sortable-list.muten +6 -7
- package/registry/sortable.muten +15 -0
- package/registry/spark.js +14 -0
- package/registry/spark.muten +3 -0
- package/registry/spinner.muten +4 -3
- package/registry/split-button.muten +12 -10
- package/registry/stamp-card.muten +7 -0
- package/registry/stat-band.muten +6 -9
- package/registry/stat.muten +23 -0
- package/registry/status-bar.muten +7 -6
- package/registry/stepper.muten +17 -14
- package/registry/store-qr.js +5 -0
- package/registry/store-qr.muten +5 -0
- package/registry/swatch.muten +8 -8
- package/registry/switch.muten +14 -6
- package/registry/table-view.muten +10 -0
- package/registry/tabs-behavior.js +27 -0
- package/registry/tabs-behavior.muten +3 -0
- package/registry/tabs.muten +19 -10
- package/registry/tag-input.js +4 -27
- package/registry/tag-input.muten +6 -6
- package/registry/text.muten +7 -0
- package/registry/time-field.js +5 -0
- package/registry/time-field.muten +6 -0
- package/registry/timeline.muten +14 -14
- package/registry/toaster.js +34 -22
- package/registry/toaster.muten +10 -8
- package/registry/toggle-group.muten +11 -6
- package/registry/toggle.muten +8 -5
- package/registry/toolbar.muten +11 -6
- package/registry/tooltip.muten +6 -9
- package/registry/tree-view.js +27 -7
- package/registry/tree-view.muten +8 -13
- package/registry/vector-field.muten +9 -8
- package/registry/virtual-list.js +5 -0
- package/registry/virtual-list.muten +6 -0
- package/registry/weekly-hours.muten +4 -0
- package/registry/wordline.muten +6 -0
- package/registry/zoom-control.muten +9 -9
- package/registry.json +868 -424
- package/themes/bloom.css +53 -0
- package/themes/editorial.css +65 -0
- package/themes/noir.css +54 -0
- package/registry/chart.js +0 -34
- package/registry/chart.muten +0 -7
- package/registry/checkbox.muten +0 -8
- package/registry/context-menu.js +0 -34
- package/registry/select.muten +0 -21
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
// kit/structure.js - the app's structure from the reference artifact, all data-driven and built from atoms:
|
|
2
|
+
// phoneShell the phone frame: big title that shrinks on scroll, back + two actions, three fixed bottom tabs
|
|
3
|
+
// share Storio's share sheet inside the adaptive modal (link or product card, Copiar, brand targets, QR)
|
|
4
|
+
// appShell Sidebar (store switcher, groups, counts, person) + page header + detail Sheet + Command (⌘K)
|
|
5
|
+
// booking week strip + hours in one piece (dot = something left, closed days, a full day's empty state)
|
|
6
|
+
// wizard «Nueva cita» in 4 steps inside the adaptive modal (Stepper, choice lists, day tiles, summary)
|
|
7
|
+
|
|
8
|
+
const list = (v) => (Array.isArray(v) ? v : []);
|
|
9
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
10
|
+
|
|
11
|
+
// ── phone shell ──
|
|
12
|
+
// i: tabs [{label, icon, badge}], rows [{tab, section, title, subtitle, value}]; on.select(label)
|
|
13
|
+
export function phoneShell(root, i, core, on) {
|
|
14
|
+
const { $, $$ } = core;
|
|
15
|
+
const tabs = list(i.tabs), rows = list(i.rows);
|
|
16
|
+
root.innerHTML = `<div class="cx-phone-frame"><div class="cx-ph-screen">
|
|
17
|
+
<div class="cx-ph-status"><span>9:41</span><span class="cx-ph-island"></span><span>100 %</span></div>
|
|
18
|
+
<header class="cx-ph-head"><div class="cx-ph-bar">
|
|
19
|
+
<button data-slot="button" class="cn-button cn-button-variant-ghost cn-button-size-icon group/button" aria-label="Atrás" title="Atrás"><svg><use href="#left"/></svg></button>
|
|
20
|
+
<b class="cx-ph-small"></b>
|
|
21
|
+
<div class="cx-hstack" style="gap:2px;width:auto;flex-wrap:nowrap"><button data-slot="button" class="cn-button cn-button-variant-ghost cn-button-size-icon group/button" aria-label="Buscar" title="Buscar"><svg><use href="#search"/></svg></button><button data-slot="button" class="cn-button cn-button-variant-ghost cn-button-size-icon group/button" aria-label="Más opciones" title="Más opciones"><svg><use href="#more"/></svg></button></div>
|
|
22
|
+
</div></header>
|
|
23
|
+
<div class="cx-ph-body cx-scroll"></div>
|
|
24
|
+
<nav class="cx-ph-nav" aria-label="Principal">${tabs.map((t, k) => `<button class="cn-button cn-button-variant-ghost cx-size-tile cx-ph-tab" data-tab="${k}" ${k === 0 ? 'aria-current="page"' : ''}>${t.badge ? `<span class="cx-ph-ico"><svg><use href="#${t.icon}"/></svg><i class="cx-ph-badge">${esc(t.badge)}</i></span>` : `<svg><use href="#${t.icon}"/></svg>`}<span>${esc(t.label)}</span></button>`).join('')}</nav>
|
|
25
|
+
</div></div>`;
|
|
26
|
+
const head = $('.cx-ph-head', root), phBody = $('.cx-ph-body', root);
|
|
27
|
+
const screen = (label) => {
|
|
28
|
+
const mine = rows.filter((r) => r.tab === label);
|
|
29
|
+
let html = '', section = null;
|
|
30
|
+
mine.forEach((r) => {
|
|
31
|
+
if (r.section && r.section !== section) { section = r.section; html += `<p class="cx-ph-sec">${esc(section)}</p>`; }
|
|
32
|
+
html += `<div class="cx-ph-row">${r.subtitle ? `<div><b>${esc(r.title)}</b><small>${esc(r.subtitle)}</small></div>` : `<b>${esc(r.title)}</b>`}<span class="cx-time">${esc(r.value)}</span></div>`;
|
|
33
|
+
});
|
|
34
|
+
return html;
|
|
35
|
+
};
|
|
36
|
+
const show = (label) => {
|
|
37
|
+
$('.cx-ph-small', root).textContent = label;
|
|
38
|
+
phBody.innerHTML = `<h1 class="cx-ph-big">${esc(label)}</h1>${screen(label)}`; phBody.scrollTop = 0; head.removeAttribute('data-compact');
|
|
39
|
+
phBody.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 160, easing: 'ease-out' });
|
|
40
|
+
};
|
|
41
|
+
phBody.addEventListener('scroll', () => head.toggleAttribute('data-compact', phBody.scrollTop > 30), { passive: true });
|
|
42
|
+
$$('.cx-ph-tab', root).forEach((t) => t.onclick = () => {
|
|
43
|
+
if (t.hasAttribute('aria-current')) { phBody.scrollTo({ top: 0, behavior: 'smooth' }); return; }
|
|
44
|
+
$$('.cx-ph-tab', root).forEach((x) => x.removeAttribute('aria-current')); t.setAttribute('aria-current', 'page');
|
|
45
|
+
const label = tabs[+t.dataset.tab].label; show(label); on.select?.(label);
|
|
46
|
+
});
|
|
47
|
+
if (tabs.length) show(tabs[0].label);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── share sheet ──
|
|
51
|
+
// i: label, variant, kind "link" | "product", name, initials, url, text, product {name, meta, price, image}
|
|
52
|
+
const enc = encodeURIComponent;
|
|
53
|
+
const targetsFor = (url, text) => [
|
|
54
|
+
{ name: 'QR', icon: 'qrc', plain: true, qr: true },
|
|
55
|
+
{ name: 'WhatsApp', icon: 'b-whatsapp', color: '#25D366', href: `https://wa.me/?text=${enc(text + ' ' + url)}` },
|
|
56
|
+
{ name: 'Instagram', icon: 'b-instagram', color: '#E4405F', copy: 'Enlace copiado. Pégalo en tu bio o en una historia de Instagram.' },
|
|
57
|
+
{ name: 'TikTok', icon: 'b-tiktok', color: '#000000', copy: 'Enlace copiado. Pégalo en tu bio de TikTok.' },
|
|
58
|
+
{ name: 'Facebook', icon: 'b-facebook', color: '#0866FF', href: `https://www.facebook.com/sharer/sharer.php?u=${enc(url)}` },
|
|
59
|
+
{ name: 'X', icon: 'b-x', color: '#000000', href: `https://x.com/intent/post?text=${enc(text)}&url=${enc(url)}` },
|
|
60
|
+
{ name: 'Telegram', icon: 'b-telegram', color: '#26A5E4', href: `https://t.me/share/url?url=${enc(url)}&text=${enc(text)}` },
|
|
61
|
+
{ name: 'Gmail', icon: 'b-gmail', color: '#EA4335', href: `https://mail.google.com/mail/?view=cm&su=${enc(text)}&body=${enc(url)}` },
|
|
62
|
+
];
|
|
63
|
+
// a row you can drag sideways with the mouse too (touch already pans); a drag never fires the tap underneath
|
|
64
|
+
const dragScroll = (row) => {
|
|
65
|
+
let d = null;
|
|
66
|
+
row.addEventListener('pointerdown', (e) => { if (e.pointerType !== 'mouse' || e.button > 0) return; d = { x: e.clientX, left: row.scrollLeft, moved: false }; });
|
|
67
|
+
addEventListener('pointermove', (e) => { if (!d) return; const dx = e.clientX - d.x; if (Math.abs(dx) > 4) { d.moved = true; row.classList.add('dragging'); } if (d.moved) row.scrollLeft = d.left - dx; });
|
|
68
|
+
addEventListener('pointerup', () => { if (!d) return; const moved = d.moved; d = null; row.classList.remove('dragging'); if (moved) row.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); }, { capture: true, once: true }); });
|
|
69
|
+
};
|
|
70
|
+
export function share(root, i, core) {
|
|
71
|
+
const { ui, $, openModal, CONTENT, body, foot, mdl, easeHeight, money0 } = core;
|
|
72
|
+
const product = i.kind === 'product' && i.product ? i.product : null;
|
|
73
|
+
const url = /^https?:\/\//.test(i.url || '') ? i.url : `https://${i.url || ''}`;
|
|
74
|
+
const shown = url.replace(/^https?:\/\//, '');
|
|
75
|
+
const text = i.text || (product ? `${product.name} en ${i.name}` : `Reserva en ${i.name}`);
|
|
76
|
+
const title = product ? 'Compartir producto' : 'Compartir enlace';
|
|
77
|
+
root.innerHTML = ui.button({ label: `${i.icon === 'none' ? '' : ui.icon('share')}${esc(i.label || 'Compartir')}`, variant: i.variant || 'default', size: 'default' });
|
|
78
|
+
const msg = (t) => { const m = $('.cx-sh-msg', body); if (m) m.textContent = t; };
|
|
79
|
+
const copy = (ok) => {
|
|
80
|
+
const fb = () => { const u = $('.cx-sh-url', body); if (u) getSelection().selectAllChildren(u); msg('Selecciona el enlace y cópialo.'); };
|
|
81
|
+
try { navigator.clipboard.writeText(url).then(() => { msg(ok); const b = $('.cx-sh-copybtn', body); if (b) { b.textContent = 'Copiado'; setTimeout(() => { b.textContent = 'Copiar'; }, 1600); } }, fb); } catch { fb(); }
|
|
82
|
+
};
|
|
83
|
+
const main = () => {
|
|
84
|
+
$('#mdl-title').textContent = title;
|
|
85
|
+
const card = product
|
|
86
|
+
? `<div class="cx-sh-prod" style="--g:${esc(product.image || 'var(--muted)')}"><span class="cx-sh-tag">${money0(Number(product.price) || 0)}</span><div class="cx-sh-pinfo"><b>${esc(product.name)}</b><span>${esc(product.meta)}</span></div></div>`
|
|
87
|
+
: `<div class="cx-sh-card"><span class="cx-sh-av">${esc(i.initials)}</span><b>${esc(i.name)}</b><span>${esc(shown)}</span></div>`;
|
|
88
|
+
body.innerHTML = `<div class="cx-sh">${card}
|
|
89
|
+
<div class="cx-sh-copy"><span class="cx-sh-url cx-num">${esc(shown)}</span><button class="cn-button cn-button-variant-default cn-button-size-sm cx-sh-copybtn" data-sh-copy>Copiar</button></div>
|
|
90
|
+
<span class="cx-sh-lbl">Compartir en</span>
|
|
91
|
+
<div class="cx-sh-targets">${targetsFor(url, text).map((t, k) => { const ico = `<span class="cx-sh-ic${t.plain ? ' plain' : ''}" style="${t.color ? `--brand:${t.color}` : ''}"><svg><use href="#${t.icon}"/></svg></span><span class="cx-sh-l">${t.name}</span>`; return t.href ? `<a class="cn-button cn-button-variant-ghost cx-size-tile-lg cx-sh-tg" href="${t.href}" target="_blank" rel="noopener" data-t="${k}">${ico}</a>` : `<button class="cn-button cn-button-variant-ghost cx-size-tile-lg cx-sh-tg" data-t="${k}">${ico}</button>`; }).join('')}</div>
|
|
92
|
+
<p class="cx-note cx-sh-msg" aria-live="polite" style="text-align:left"></p></div>`;
|
|
93
|
+
const row = $('.cx-sh-targets', body); dragScroll(row);
|
|
94
|
+
const edge = () => { row.toggleAttribute('data-end', row.scrollLeft >= row.scrollWidth - row.clientWidth - 2); row.toggleAttribute('data-scrolled', row.scrollLeft > 2); };
|
|
95
|
+
row.addEventListener('scroll', edge, { passive: true }); requestAnimationFrame(edge);
|
|
96
|
+
row.addEventListener('click', (e) => { const t = e.target.closest('[data-t]'); if (!t) return; const x = targetsFor(url, text)[+t.dataset.t]; if (x.copy) copy(x.copy); if (x.qr) easeHeight(mdl, qr); });
|
|
97
|
+
};
|
|
98
|
+
const qr = () => {
|
|
99
|
+
$('#mdl-title').textContent = 'Código QR';
|
|
100
|
+
body.innerHTML = `<div class="cx-sh cx-sh-qrview"><div class="cx-qr" style="width:15rem;max-width:72%;aspect-ratio:1;height:auto" role="img" aria-label="Código QR de ${esc(shown)}"><div class="cx-qr-mark"><span>${esc(i.initials)}</span></div></div><span class="cx-sh-qrurl cx-num">${esc(shown)}</span><button data-slot="button" class="cn-button cn-button-variant-outline cn-button-size-sm group/button" data-sh-back><svg><use href="#left"/></svg>Volver</button></div>`;
|
|
101
|
+
import('@muten/shadcn/registry/kit/qrcode.js').then(({ default: qrcode }) => {
|
|
102
|
+
const q = qrcode(0, 'H'); q.addData(url); q.make(); const n = q.getModuleCount(); let d = '';
|
|
103
|
+
for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) if (q.isDark(r, c)) d += `M${c} ${r}h1v1h-1z`;
|
|
104
|
+
$('.cx-qr', body)?.insertAdjacentHTML('afterbegin', `<svg viewBox="0 0 ${n} ${n}" shape-rendering="crispEdges" aria-hidden="true"><path d="${d}" fill="var(--qr-ink)"/></svg>`);
|
|
105
|
+
});
|
|
106
|
+
body.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 160 });
|
|
107
|
+
};
|
|
108
|
+
const kind = `share-${Math.random().toString(36).slice(2, 8)}`;
|
|
109
|
+
CONTENT[kind] = () => {
|
|
110
|
+
$('#mdl-desc').textContent = ''; main();
|
|
111
|
+
foot.innerHTML = navigator.share ? ui.button({ label: `${ui.icon('share')}Compartir en otra app`, variant: 'default', size: 'default', cls: 'cx-go', attrs: 'data-sh-native' }) : '';
|
|
112
|
+
body.dataset.share = kind;
|
|
113
|
+
};
|
|
114
|
+
root.addEventListener('click', (e) => { if (e.target.closest('button')) openModal(kind, 'auto'); });
|
|
115
|
+
document.addEventListener('click', async (e) => {
|
|
116
|
+
if (body.dataset.share !== kind || !mdl.contains(e.target)) return;
|
|
117
|
+
if (e.target.closest('[data-sh-copy]')) copy('Enlace copiado.');
|
|
118
|
+
if (e.target.closest('[data-sh-back]')) easeHeight(mdl, main);
|
|
119
|
+
if (e.target.closest('[data-sh-native]')) { try { await navigator.share({ title: text, url }); } catch {} }
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── app shell ──
|
|
124
|
+
// i: brand {name, plan}, user {name, email}, nav [{group, icon, label, count}], page,
|
|
125
|
+
// pages [{name, title, description, actions [{label, variant, icon}]}], rows [{page, id, title, description,
|
|
126
|
+
// badge, tone, amount, sheet [{icon, title, description, badge, tone, amount, photos}], timeline [{label, when}]}],
|
|
127
|
+
// commands [{icon, label, page}]; on.navigate(page) · on.open(id) · on.action(label)
|
|
128
|
+
export function appShell(root, i, core, on) {
|
|
129
|
+
const { ui, $, $$, openModal, closeModal, CONTENT, body, foot, mdl, money0, fold } = core;
|
|
130
|
+
const nav = list(i.nav), pages = list(i.pages), rows = list(i.rows), commands = list(i.commands);
|
|
131
|
+
const groups = [...new Set(nav.map((n) => n.group))].map((g) => [g, nav.filter((n) => n.group === g)]);
|
|
132
|
+
root.classList.add('cx-app'); const app = root;
|
|
133
|
+
let appPage = i.page || nav[0]?.label || '';
|
|
134
|
+
const aside = (r) => `${r.badge ? ui.badge(esc(r.badge), { tone: r.tone || 'muted' }) : ''}${r.amount != null && r.amount !== '' ? ui.text(money0(Number(r.amount)), 'strong') : ''}`;
|
|
135
|
+
const navBtn = (n) => `<li data-slot="sidebar-menu-item" class="group/menu-item cx-sb-li" style="position:relative"><button data-slot="sidebar-menu-button" data-size="default" data-active="${n.label === appPage}" ${n.label === appPage ? 'aria-current="page"' : ''} class="cn-sidebar-menu-button cn-sidebar-menu-button-variant-default cn-sidebar-menu-button-size-default peer/menu-button" data-nav="${esc(n.label)}" data-tipname="${esc(n.label)}">${ui.icon(n.icon)}<span>${esc(n.label)}</span></button>${n.count ? `<div data-slot="sidebar-menu-badge" class="cn-sidebar-menu-badge">${esc(n.count)}</div>` : ''}</li>`;
|
|
136
|
+
const pageBody = () => {
|
|
137
|
+
const mine = rows.filter((r) => r.page === appPage);
|
|
138
|
+
if (mine.length) return `<div data-slot="item-group" class="cn-item-group cx-app-list" role="list">${mine.map((r) => `<button class="cn-item cn-item-variant-outline cn-item-size-sm cx-app-row" ${r.sheet ? `data-open-row="${esc(r.id)}"` : ''}>${ui.itemMedia(ui.avatar(r.title), 'default')}<div class="cn-item-content"><div class="cn-item-title">${esc(r.title)}</div><p class="cn-item-description">${esc(r.description)}</p></div><div class="cn-item-actions">${aside(r)}</div></button>`).join('')}</div>`;
|
|
139
|
+
return `<div data-slot="empty" class="cn-empty" style="padding:48px 12px"><div class="cn-empty-header"><div class="cn-empty-media cn-empty-media-icon">${ui.icon(nav.find((n) => n.label === appPage)?.icon || 'store')}</div><div class="cn-empty-title">${esc(appPage)}</div><div class="cn-empty-description">${esc(i.empty || 'Todavía no hay nada aquí.')}</div></div></div>`;
|
|
140
|
+
};
|
|
141
|
+
const twoLine = (a, b) => `<span class="cx-sb-two">${ui.text(esc(a), 'heading')}${ui.text(esc(b), 'caption')}</span>`;
|
|
142
|
+
const render = () => {
|
|
143
|
+
const meta = pages.find((p) => p.name === appPage) || { title: appPage, description: '', actions: [] };
|
|
144
|
+
app.innerHTML = `
|
|
145
|
+
<div class="group cx-app-sb" data-slot="sidebar" data-state="${app.dataset.collapsed ? 'collapsed' : 'expanded'}" data-collapsible="${app.dataset.collapsed ? 'icon' : ''}" data-side="left" data-variant="sidebar">
|
|
146
|
+
<div data-slot="sidebar-inner" class="cn-sidebar-inner">
|
|
147
|
+
<div data-slot="sidebar-header" class="cn-sidebar-header"><ul class="cn-sidebar-menu"><li class="group/menu-item"><button data-slot="sidebar-menu-button" data-size="lg" class="cn-sidebar-menu-button cn-sidebar-menu-button-variant-default cn-sidebar-menu-button-size-lg peer/menu-button" data-tipname="${esc(i.brand?.name)}">${ui.avatar(i.brand?.short || i.brand?.name || '', { shape: 'square', size: 'default' })}${twoLine(i.brand?.name, i.brand?.plan)}${ui.icon('updown')}</button></li></ul></div>
|
|
148
|
+
<div data-slot="sidebar-content" class="cn-sidebar-content">${groups.map(([label, items]) => `<div data-slot="sidebar-group" class="cn-sidebar-group"><div data-slot="sidebar-group-label" class="cn-sidebar-group-label">${esc(label)}</div><div class="cn-sidebar-group-content"><ul data-slot="sidebar-menu" class="cn-sidebar-menu">${items.map(navBtn).join('')}</ul></div></div>`).join('')}</div>
|
|
149
|
+
<div data-slot="sidebar-footer" class="cn-sidebar-footer"><ul class="cn-sidebar-menu"><li class="group/menu-item"><button data-slot="sidebar-menu-button" data-size="lg" class="cn-sidebar-menu-button cn-sidebar-menu-button-variant-default cn-sidebar-menu-button-size-lg peer/menu-button" data-tipname="${esc(i.user?.name)}">${ui.avatar(i.user?.name || '', { size: 'default' })}${twoLine(i.user?.name, i.user?.email)}${ui.icon('more')}</button></li></ul></div>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
<div class="cn-sheet-overlay cx-app-scrim" data-app-scrim hidden></div>
|
|
153
|
+
<main data-slot="sidebar-inset" class="cn-sidebar-inset cx-app-main">
|
|
154
|
+
<header class="cx-app-top">
|
|
155
|
+
${ui.button({ icon: 'menu-lines', variant: 'ghost', size: 'icon-sm', attrs: 'data-sb-toggle aria-label="Mostrar u ocultar el menú" title="Menú (Ctrl B)"' })}
|
|
156
|
+
<div data-slot="separator" class="cn-separator cn-separator-vertical cx-app-vsep"></div>
|
|
157
|
+
<nav aria-label="Migas"><ol data-slot="breadcrumb-list" class="cn-breadcrumb-list"><li class="cn-breadcrumb-item"><a class="cn-breadcrumb-link" href="#" data-home>${esc(i.brand?.short || i.brand?.name)}</a></li><li class="cn-breadcrumb-separator" role="presentation">${ui.icon('right')}</li><li class="cn-breadcrumb-item"><span class="cn-breadcrumb-page" aria-current="page">${esc(meta.title)}</span></li></ol></nav>
|
|
158
|
+
<span class="cx-grow"></span>
|
|
159
|
+
${ui.button({ label: `${ui.icon('search')}<span class="cx-app-search-t">Buscar…</span><kbd data-slot="kbd" class="cn-kbd">⌘K</kbd>`, size: 'sm', cls: 'cx-app-search', attrs: 'data-cmd-open aria-keyshortcuts="Control+K Meta+K"' })}
|
|
160
|
+
</header>
|
|
161
|
+
<div class="cx-app-page">
|
|
162
|
+
<div class="cx-app-head"><div class="cx-vstack" style="gap:2px">${ui.text(esc(meta.title), 'title', 'h1')}${meta.description ? ui.text(esc(meta.description), 'muted', 'p') : ''}</div><div class="cx-hstack">${list(meta.actions).map((a) => ui.button({ label: `${a.icon ? ui.icon(a.icon) : ''}${esc(a.label)}`, variant: a.variant || 'outline', size: 'sm', attrs: `data-app-act="${esc(a.label)}"` })).join('')}</div></div>
|
|
163
|
+
${pageBody()}
|
|
164
|
+
</div>
|
|
165
|
+
<div class="cn-sheet-overlay cx-app-scrim" data-sheet-scrim hidden></div>
|
|
166
|
+
<aside data-slot="sheet-content" data-side="right" class="cn-sheet-content cx-app-sheet" role="dialog" aria-modal="false" aria-label="Detalle" hidden></aside>
|
|
167
|
+
</main>`;
|
|
168
|
+
syncTips();
|
|
169
|
+
};
|
|
170
|
+
const syncTips = () => $$('[data-tipname]', app).forEach((b) => { if (app.dataset.collapsed && !app.dataset.mobile) b.dataset.name = b.dataset.tipname; else delete b.dataset.name; });
|
|
171
|
+
const sheet = () => $('.cx-app-sheet', app), sheetScrim = () => $('[data-sheet-scrim]', app);
|
|
172
|
+
const openRow = (id) => {
|
|
173
|
+
const r = rows.find((x) => String(x.id) === String(id)), s = sheet(); if (!r || !s) return;
|
|
174
|
+
const photos = (x) => (list(x.photos).length ? `<div class="cx-hstack">${list(x.photos).map((bg) => ui.avatar('', { shape: 'square', size: 'lg', bg })).join('')}</div>` : '');
|
|
175
|
+
const items = list(r.sheet).map((x) => ui.item({ media: ui.itemMedia(ui.icon(x.icon || 'info')), title: esc(x.title), description: esc(x.description || ''), actions: aside(x), footer: photos(x) }));
|
|
176
|
+
const tl = list(r.timeline);
|
|
177
|
+
if (tl.length) items.push(ui.item({ media: ui.itemMedia(ui.icon('clock')), title: 'Historial', footer: `<ol class="cx-timeline">${tl.map((t, k) => `<li ${k === tl.length - 1 ? 'data-now' : ''}>${ui.text(esc(t.label), k === tl.length - 1 ? 'label' : 'body')}${ui.text(esc(t.when), 'caption')}</li>`).join('')}</ol>` }));
|
|
178
|
+
s.innerHTML = `<div data-slot="sheet-header" class="cn-sheet-header"><div class="cx-hstack" style="justify-content:space-between;flex-wrap:nowrap"><div class="cx-vstack" style="gap:2px">${ui.text(esc(r.sheetTitle || r.title), 'title', 'h2')}${ui.text(esc(r.sheetDescription || r.description), 'muted', 'p')}</div>${ui.button({ icon: 'x', variant: 'ghost', size: 'icon-sm', attrs: 'data-sheet-close aria-label="Cerrar" title="Cerrar"' })}</div></div>
|
|
179
|
+
<div class="cx-app-sheet-body"><div data-slot="item-group" class="cn-item-group">${items.join(ui.itemSeparator())}</div></div>
|
|
180
|
+
${r.primary || r.secondary ? `<div data-slot="sheet-footer" class="cn-sheet-footer">${r.secondary ? ui.button({ label: esc(r.secondary), size: 'default', cls: 'cx-exit', attrs: `data-app-act="${esc(r.secondary)}"` }) : ''}${r.primary ? ui.button({ label: `${esc(r.primary)}${ui.icon('right')}`, variant: 'default', size: 'default', cls: 'cx-go', attrs: `data-app-act="${esc(r.primary)}"` }) : ''}</div>` : ''}`;
|
|
181
|
+
s.hidden = false; sheetScrim().hidden = false;
|
|
182
|
+
requestAnimationFrame(() => { s.dataset.state = 'open'; sheetScrim().dataset.state = 'open'; });
|
|
183
|
+
$('[data-sheet-close]', s).focus({ preventScroll: true });
|
|
184
|
+
$$('[data-open-row]', app).forEach((x) => x.toggleAttribute('data-active', x.dataset.openRow === String(id)));
|
|
185
|
+
on.open?.(String(id));
|
|
186
|
+
};
|
|
187
|
+
const closeSheet = () => { const s = sheet(); if (!s || s.hidden) return; s.dataset.state = 'closed'; sheetScrim().dataset.state = 'closed'; setTimeout(() => { if (s.dataset.state === 'closed') { s.hidden = true; sheetScrim().hidden = true; } }, 220); $$('[data-open-row]', app).forEach((x) => x.removeAttribute('data-active')); };
|
|
188
|
+
const toggleSidebar = () => {
|
|
189
|
+
if (app.dataset.mobile) { app.toggleAttribute('data-sb-open'); $('[data-app-scrim]', app).hidden = !app.hasAttribute('data-sb-open'); return; }
|
|
190
|
+
if (app.dataset.collapsed) delete app.dataset.collapsed; else app.dataset.collapsed = '1';
|
|
191
|
+
const g = $('.cx-app-sb', app); g.dataset.state = app.dataset.collapsed ? 'collapsed' : 'expanded'; g.dataset.collapsible = app.dataset.collapsed ? 'icon' : ''; syncTips();
|
|
192
|
+
};
|
|
193
|
+
const go = (page) => { appPage = page; const col = app.dataset.collapsed; render(); if (col) app.dataset.collapsed = col; app.removeAttribute('data-sb-open'); on.navigate?.(page); };
|
|
194
|
+
app.addEventListener('click', (e) => {
|
|
195
|
+
if (e.target.closest('[data-sb-toggle]')) return toggleSidebar();
|
|
196
|
+
if (e.target.closest('[data-app-scrim]')) return toggleSidebar();
|
|
197
|
+
if (e.target.closest('[data-home]')) { e.preventDefault(); return go(nav[0]?.label || appPage); }
|
|
198
|
+
const n = e.target.closest('[data-nav]'); if (n) return go(n.dataset.nav);
|
|
199
|
+
const o = e.target.closest('[data-open-row]'); if (o) return openRow(o.dataset.openRow);
|
|
200
|
+
if (e.target.closest('[data-sheet-close], [data-sheet-scrim]')) return closeSheet();
|
|
201
|
+
if (e.target.closest('[data-cmd-open]')) return openCmd();
|
|
202
|
+
const a = e.target.closest('[data-app-act]'); if (a) on.action?.(a.dataset.appAct);
|
|
203
|
+
});
|
|
204
|
+
app.addEventListener('keydown', (e) => { if (e.key === 'Escape' && sheet() && !sheet().hidden) { e.stopPropagation(); closeSheet(); } });
|
|
205
|
+
document.addEventListener('keydown', (e) => {
|
|
206
|
+
if (!app.isConnected) return;
|
|
207
|
+
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); if (mdl.hidden) openCmd(); }
|
|
208
|
+
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'b' && app.contains(document.activeElement)) { e.preventDefault(); toggleSidebar(); }
|
|
209
|
+
});
|
|
210
|
+
new ResizeObserver(() => { const m = app.clientWidth < 720; if (!!app.dataset.mobile !== m) { if (m) { app.dataset.mobile = '1'; delete app.dataset.collapsed; const g = $('.cx-app-sb', app); if (g) { g.dataset.state = 'expanded'; g.dataset.collapsible = ''; } } else { delete app.dataset.mobile; app.removeAttribute('data-sb-open'); const s = $('[data-app-scrim]', app); if (s) s.hidden = true; } syncTips(); } }).observe(app);
|
|
211
|
+
|
|
212
|
+
// Command (⌘K): groups of results filtered as you type, arrows + Enter
|
|
213
|
+
const CMD = () => [
|
|
214
|
+
['Acciones', commands.map((c) => [c.icon || 'right', c.label, () => (c.page ? go(c.page) : on.action?.(c.label))])],
|
|
215
|
+
...[...new Set(rows.map((r) => r.page))].map((p) => [p, rows.filter((r) => r.page === p).map((r) => [nav.find((n) => n.label === p)?.icon || 'user', r.command || r.title, () => { go(p); if (r.sheet) { app.scrollIntoView({ block: 'center', behavior: 'smooth' }); openRow(r.id); } }])]),
|
|
216
|
+
];
|
|
217
|
+
let cmdItems = [], cmdHl = 0;
|
|
218
|
+
const kind = `cmd-${Math.random().toString(36).slice(2, 8)}`;
|
|
219
|
+
const paint = (q = '') => {
|
|
220
|
+
const t = fold(q.trim()); cmdItems = [];
|
|
221
|
+
const html = CMD().map(([g, items]) => { const hits = items.filter(([, l]) => !t || fold(l).includes(t)); if (!hits.length) return ''; return `<div data-slot="command-group" class="cn-command-group" role="group" aria-label="${esc(g)}"><div class="cx-cmd-label">${ui.text(esc(g), 'caption')}</div>${hits.map((h) => { cmdItems.push(h); const k = cmdItems.length - 1; return `<div data-slot="command-item" class="cn-command-item" role="option" id="cmd-${k}" data-ci="${k}" ${k === cmdHl ? 'data-selected="true" aria-selected="true"' : ''}>${ui.icon(h[0])}${ui.text(esc(h[1]))}</div>`; }).join('')}</div>`; }).join('');
|
|
222
|
+
$('.cx-cmd-list', body).innerHTML = html || `<div data-slot="command-empty" class="cn-command-empty">Nada con «${esc(q)}»</div>`;
|
|
223
|
+
$('.cx-cmd-in', body).setAttribute('aria-activedescendant', cmdItems.length ? `cmd-${cmdHl}` : '');
|
|
224
|
+
$(`#cmd-${cmdHl}`, body)?.scrollIntoView({ block: 'nearest' });
|
|
225
|
+
};
|
|
226
|
+
CONTENT[kind] = () => {
|
|
227
|
+
$('#mdl-title').textContent = 'Buscar'; $('#mdl-desc').textContent = '';
|
|
228
|
+
body.dataset.cmd = kind;
|
|
229
|
+
body.innerHTML = `<div data-slot="command" class="cn-command cx-cmd"><div data-slot="command-input-wrapper" class="cn-command-input-wrapper"><div data-slot="input-group" class="cn-input-group cn-command-input-group"><input data-slot="input-group-control" class="cn-command-input cn-input-group-input cx-cmd-in" placeholder="${esc(i.placeholder || 'Busca o salta a…')}" role="combobox" aria-expanded="true" aria-controls="cmd-list" autocomplete="off"><div class="cn-input-group-addon cn-input-group-addon-align-inline-start"><svg class="cn-command-input-icon"><use href="#search"/></svg></div></div></div><div data-slot="command-list" class="cn-command-list cx-cmd-list" id="cmd-list" role="listbox"></div></div>`;
|
|
230
|
+
foot.innerHTML = `<span class="cx-hstack cx-cmd-help">${ui.text('<kbd class="cn-kbd">↑</kbd><kbd class="cn-kbd">↓</kbd> moverte', 'caption')}${ui.text('<kbd class="cn-kbd">Enter</kbd> abrir', 'caption')}${ui.text('<kbd class="cn-kbd">Esc</kbd> cerrar', 'caption')}</span>`;
|
|
231
|
+
cmdHl = 0; paint();
|
|
232
|
+
setTimeout(() => $('.cx-cmd-in', body)?.focus(), 30);
|
|
233
|
+
};
|
|
234
|
+
const openCmd = () => openModal(kind, 'auto');
|
|
235
|
+
const mine = () => body.dataset.cmd === kind && !mdl.hidden;
|
|
236
|
+
const run = (k) => { const f = cmdItems[k]?.[2]; if (!f) return; closeModal(); setTimeout(f, 380); };
|
|
237
|
+
body.addEventListener('input', (e) => { if (mine() && e.target.matches('.cx-cmd-in')) { cmdHl = 0; paint(e.target.value); } });
|
|
238
|
+
body.addEventListener('keydown', (e) => {
|
|
239
|
+
if (!mine() || !e.target.matches('.cx-cmd-in')) return;
|
|
240
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); if (!cmdItems.length) return; cmdHl = (cmdHl + (e.key === 'ArrowDown' ? 1 : -1) + cmdItems.length) % cmdItems.length; paint(e.target.value); }
|
|
241
|
+
if (e.key === 'Enter') { e.preventDefault(); run(cmdHl); }
|
|
242
|
+
});
|
|
243
|
+
body.addEventListener('click', (e) => { if (!mine()) return; const it = e.target.closest('[data-ci]'); if (it) run(+it.dataset.ci); });
|
|
244
|
+
body.addEventListener('pointermove', (e) => { if (!mine()) return; const it = e.target.closest('[data-ci]'); if (it && +it.dataset.ci !== cmdHl) { cmdHl = +it.dataset.ci; $$('[data-ci]', body).forEach((x) => { const on1 = x === it; x.toggleAttribute('data-selected', on1); x.setAttribute('aria-selected', on1); }); } });
|
|
245
|
+
// the modal is shared: forget this command's marker once another content takes it
|
|
246
|
+
const watch = new MutationObserver(() => { if (mdl.hidden && body.dataset.cmd === kind) delete body.dataset.cmd; }); watch.observe(mdl, { attributes: true, attributeFilter: ['hidden'] });
|
|
247
|
+
render();
|
|
248
|
+
return { narrow: (on1) => app.toggleAttribute('data-narrow', !!on1) };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── booking picker ──
|
|
252
|
+
// i: week [{days [0..6], hours ["09:00", …]}] (what the shop offers each weekday), taken [{day, hours}] (day = days
|
|
253
|
+
// from today or "yyyy-mm-dd"; hours empty = the whole day is booked), wait (ms the server takes: shows the
|
|
254
|
+
// skeleton), cta; on.select("yyyy-mm-dd HH:MM") on «Continuar»
|
|
255
|
+
export function booking(root, i, core, on) {
|
|
256
|
+
const { $, $$, today, addDays, DAY, MON, DAYLONG } = core;
|
|
257
|
+
const key = (d) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
|
258
|
+
const iso = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
259
|
+
const dayOf = (v) => (/^-?\d+$/.test(String(v)) ? addDays(today, Number(v)) : new Date(String(v) + 'T00:00:00'));
|
|
260
|
+
const week = list(i.week), taken = new Map(list(i.taken).map((t) => [key(dayOf(t.day)), list(t.hours)]));
|
|
261
|
+
const slotsFor = (d) => {
|
|
262
|
+
const offer = week.find((w) => list(w.days).includes(d.getDay()));
|
|
263
|
+
if (!offer || d < today) return null; // closed or past
|
|
264
|
+
const busy = taken.get(key(d));
|
|
265
|
+
if (busy && !busy.length) return []; // the whole day is booked
|
|
266
|
+
let s = list(offer.hours).filter((h) => !(busy || []).includes(h));
|
|
267
|
+
if (key(d) === key(today)) { const now = new Date(); const hm = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`; s = s.filter((h) => h > hm); }
|
|
268
|
+
return s;
|
|
269
|
+
};
|
|
270
|
+
root.innerHTML = `<div class="cx-book">
|
|
271
|
+
<div class="cx-book-head">
|
|
272
|
+
<button data-slot="button" class="cn-button cn-button-variant-outline cn-button-size-icon-sm group/button" data-wk="-1" aria-label="Semana anterior" title="Semana anterior"><svg><use href="#left"/></svg></button>
|
|
273
|
+
<b class="cx-wk-label" aria-live="polite"></b>
|
|
274
|
+
<button data-slot="button" class="cn-button cn-button-variant-outline cn-button-size-icon-sm group/button" data-wk="1" aria-label="Semana siguiente" title="Semana siguiente"><svg><use href="#right"/></svg></button>
|
|
275
|
+
</div>
|
|
276
|
+
<div class="cx-week" role="group" aria-label="Día"></div>
|
|
277
|
+
<div class="cx-hours" aria-live="polite"></div>
|
|
278
|
+
<div class="cx-book-sum"><span class="cx-book-say">Elige un día y una hora</span><button data-slot="button" class="cn-button cn-button-variant-default cn-button-size-default group/button cx-book-go" disabled>${esc(i.cta || 'Continuar')}</button></div>
|
|
279
|
+
</div>`;
|
|
280
|
+
let weekStart = addDays(today, -((today.getDay() + 6) % 7)); // monday
|
|
281
|
+
let picked = { day: null, hour: null }, loadTimer = 0;
|
|
282
|
+
const weekEl = $('.cx-week', root), hours = $('.cx-hours', root), go = $('.cx-book-go', root);
|
|
283
|
+
const firstOpen = (from) => { for (let k = 0; k < 60; k++) { const d = addDays(from, k); const s = slotsFor(d); if (s && s.length) return d; } return null; };
|
|
284
|
+
const sum = () => {
|
|
285
|
+
const d = picked.day;
|
|
286
|
+
$('.cx-book-say', root).innerHTML = d ? `<b>${DAYLONG[d.getDay()]} ${d.getDate()} de ${MON[d.getMonth()]}</b>${picked.hour ? ` · <b>${picked.hour}</b>` : ' · elige una hora'}` : 'Elige un día y una hora';
|
|
287
|
+
go.disabled = !(d && picked.hour);
|
|
288
|
+
};
|
|
289
|
+
const paintWeek = () => {
|
|
290
|
+
const end = addDays(weekStart, 6);
|
|
291
|
+
$('.cx-wk-label', root).textContent = weekStart.getMonth() === end.getMonth() ? `${weekStart.getDate()} al ${end.getDate()} de ${MON[end.getMonth()]}` : `${weekStart.getDate()} de ${MON[weekStart.getMonth()]} al ${end.getDate()} de ${MON[end.getMonth()]}`;
|
|
292
|
+
$('[data-wk="-1"]', root).disabled = weekStart <= today;
|
|
293
|
+
weekEl.innerHTML = '';
|
|
294
|
+
for (let k = 0; k < 7; k++) {
|
|
295
|
+
const d = addDays(weekStart, k), s = slotsFor(d);
|
|
296
|
+
const b = document.createElement('button');
|
|
297
|
+
b.className = 'cn-button cn-button-variant-outline cx-size-tile cx-day'; b.disabled = s === null;
|
|
298
|
+
if (s && !s.length) b.dataset.full = '';
|
|
299
|
+
b.setAttribute('aria-pressed', !!picked.day && key(d) === key(picked.day));
|
|
300
|
+
b.setAttribute('aria-label', `${DAYLONG[d.getDay()]} ${d.getDate()}${s === null ? ', cerrado' : s.length ? `, ${s.length} horarios` : ', sin horarios'}`);
|
|
301
|
+
b.innerHTML = `<small>${key(d) === key(today) ? 'hoy' : DAY[d.getDay()]}</small><b>${d.getDate()}</b><i></i>`;
|
|
302
|
+
b.onclick = () => pickDay(d);
|
|
303
|
+
weekEl.appendChild(b);
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
const paintHours = (d) => {
|
|
307
|
+
const s = slotsFor(d) || [];
|
|
308
|
+
if (!s.length) {
|
|
309
|
+
const next = firstOpen(addDays(d, 1));
|
|
310
|
+
hours.innerHTML = `<div class="cx-hours-empty"><svg><use href="#caloff"/></svg><span>No quedan horarios este día.</span>${next ? `<button data-slot="button" class="cn-button cn-button-variant-outline cn-button-size-sm group/button" data-jump>Ver el ${DAYLONG[next.getDay()].toLowerCase()} ${next.getDate()}</button>` : ''}</div>`;
|
|
311
|
+
if (next) $('[data-jump]', hours).onclick = () => { if (next > addDays(weekStart, 6)) weekStart = addDays(next, -((next.getDay() + 6) % 7)); pickDay(next); };
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const am = s.filter((h) => h < '13:00'), pm = s.filter((h) => h >= '13:00');
|
|
315
|
+
const grid = (label, hs) => hs.length ? `<h4>${label}</h4><div class="cx-hour-grid">${hs.map((h) => `<button class="cn-button cn-button-variant-outline cn-button-size-sm cx-hour" aria-pressed="${h === picked.hour}">${h}</button>`).join('')}</div>` : '';
|
|
316
|
+
hours.innerHTML = `<div class="cx-panel" style="display:flex;flex-direction:column;gap:10px">${grid('Mañana', am)}${grid('Tarde', pm)}</div>`;
|
|
317
|
+
$$('.cx-hour', hours).forEach((b) => b.onclick = () => { picked.hour = b.textContent; $$('.cx-hour', hours).forEach((x) => x.setAttribute('aria-pressed', x === b)); sum(); });
|
|
318
|
+
};
|
|
319
|
+
const skeleton = () => { hours.innerHTML = `<h4 style="visibility:hidden">Mañana</h4><div class="cx-hour-grid">${'<div data-slot="skeleton" class="cn-skeleton cx-hour-skel"></div>'.repeat(8)}</div>`; };
|
|
320
|
+
function pickDay(d) {
|
|
321
|
+
if (!d) return;
|
|
322
|
+
picked = { day: d, hour: null }; paintWeek(); sum();
|
|
323
|
+
const wait = Number(i.wait) || 0;
|
|
324
|
+
clearTimeout(loadTimer); if (wait) { skeleton(); loadTimer = setTimeout(() => paintHours(d), wait); } else paintHours(d);
|
|
325
|
+
}
|
|
326
|
+
$('[data-wk="-1"]', root).onclick = () => { weekStart = addDays(weekStart, -7); paintWeek(); };
|
|
327
|
+
$('[data-wk="1"]', root).onclick = () => { weekStart = addDays(weekStart, 7); paintWeek(); };
|
|
328
|
+
go.onclick = () => { if (picked.day && picked.hour) on.select?.(`${iso(picked.day)} ${picked.hour}`); };
|
|
329
|
+
paintWeek(); pickDay(firstOpen(today));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ── «Nueva cita» in 4 steps ──
|
|
333
|
+
// i: clients [{name, description}], services [{name, duration, price}], hours ["09:00", …], closed [0] (weekdays),
|
|
334
|
+
// note (under the summary); opened by start(); on.create(json {client, service, day, hour}) · on.close()
|
|
335
|
+
export function wizard(core, i, on) {
|
|
336
|
+
const { ui, $, $$, openModal, closeModal, CONTENT, body, foot, mdl, easeHeight, today, addDays, DAY, MON, DAYLONG, money0 } = core;
|
|
337
|
+
const STEPS = ['Cliente', 'Servicio', 'Día y hora', 'Confirmar'];
|
|
338
|
+
const clients = list(i.clients), services = list(i.services), hoursList = list(i.hours), closed = list(i.closed);
|
|
339
|
+
const iso = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
340
|
+
const days = () => Array.from({ length: 10 }, (_, k) => addDays(today, k + 1)).filter((d) => !closed.includes(d.getDay())).slice(0, 5);
|
|
341
|
+
let wiz = null, q = '';
|
|
342
|
+
const kind = `wizard-${Math.random().toString(36).slice(2, 8)}`;
|
|
343
|
+
const valid = () => [wiz.client != null, wiz.service != null, wiz.day != null && wiz.hour != null, true][wiz.at];
|
|
344
|
+
const clientRows = () => clients.map((c, k) => [c, k]).filter(([c]) => !q || core.fold(`${c.name} ${c.description}`).includes(core.fold(q)))
|
|
345
|
+
.map(([c, k]) => ui.choice({ media: ui.itemMedia(ui.avatar(c.name), 'default'), title: esc(c.name), description: esc(c.description), checked: wiz.client === k, attrs: `data-v="${k}"` })).join('') || ui.text(`Nadie con «${esc(q)}»`, 'muted', 'p');
|
|
346
|
+
const stepBody = () => {
|
|
347
|
+
const at = wiz.at;
|
|
348
|
+
if (at === 0) return `<div class="cx-vstack" style="gap:12px">${ui.field({ id: 'wz-q', label: 'Busca o elige', control: ui.input({ id: 'wz-q', placeholder: 'Nombre o teléfono', value: esc(q) }) })}<div class="cx-vstack" role="radiogroup" aria-label="Cliente" data-wz="client">${clientRows()}</div></div>`;
|
|
349
|
+
if (at === 1) return `<div class="cx-vstack" role="radiogroup" aria-label="Servicio" data-wz="service">${services.map((s, k) => ui.choice({ media: ui.itemMedia(ui.icon(s.icon || 'scissors')), title: esc(s.name), description: esc(s.duration), aside: ui.text(money0(Number(s.price) || 0), 'strong'), checked: wiz.service === k, attrs: `data-v="${k}"` })).join('')}</div>`;
|
|
350
|
+
if (at === 2) return `<div class="cx-vstack" style="gap:14px">${ui.text('Día', 'label')}<div class="cx-wz-days" role="group" aria-label="Día">${days().map((d, k) => `<button class="cn-button cn-button-variant-outline cx-size-tile cx-day" aria-pressed="${wiz.day === k}" data-wz-day="${k}"><small>${DAY[d.getDay()]}</small><b>${d.getDate()}</b><i></i></button>`).join('')}</div>${ui.text('Hora', 'label')}<div class="cx-hour-grid" role="group" aria-label="Hora">${hoursList.map((h) => `<button class="cn-button cn-button-variant-outline cn-button-size-sm cx-hour" aria-pressed="${wiz.hour === h}" data-wz-hour="${h}">${h}</button>`).join('')}</div></div>`;
|
|
351
|
+
const c = clients[wiz.client], s = services[wiz.service], d = days()[wiz.day];
|
|
352
|
+
return `<div data-slot="item-group" class="cn-item-group cx-wz-sum">${[
|
|
353
|
+
ui.item({ media: ui.itemMedia(ui.avatar(c.name), 'default'), title: esc(c.name), description: 'Cliente' }),
|
|
354
|
+
ui.item({ media: ui.itemMedia(ui.icon(s.icon || 'scissors')), title: esc(s.name), description: esc(s.duration), actions: ui.text(money0(Number(s.price) || 0), 'strong') }),
|
|
355
|
+
ui.item({ media: ui.itemMedia(ui.icon('cal')), title: `${DAYLONG[d.getDay()]} ${d.getDate()} de ${MON[d.getMonth()]}`, description: wiz.hour }),
|
|
356
|
+
].join(ui.itemSeparator())}</div>${i.note ? ui.text(esc(i.note), 'caption', 'p') : ''}`;
|
|
357
|
+
};
|
|
358
|
+
const paintFoot = () => {
|
|
359
|
+
const last = wiz.at === STEPS.length - 1;
|
|
360
|
+
foot.innerHTML = ui.button({ label: wiz.at === 0 ? 'Cancelar' : 'Atrás', size: 'default', attrs: wiz.at === 0 ? 'data-dismiss' : 'data-wz-back', cls: 'cx-exit' })
|
|
361
|
+
+ ui.button({ label: last ? 'Crear cita' : 'Siguiente<svg><use href="#right"/></svg>', variant: 'default', size: 'default', attrs: `data-wz-next ${valid() ? '' : 'disabled'}`, cls: 'cx-go' });
|
|
362
|
+
};
|
|
363
|
+
const render = (dir = 0) => {
|
|
364
|
+
$('#mdl-title').textContent = i.title || 'Nueva cita';
|
|
365
|
+
$('#mdl-desc').innerHTML = ui.stepper(STEPS, wiz.at);
|
|
366
|
+
const paint = () => { body.innerHTML = stepBody(); };
|
|
367
|
+
if (dir) { easeHeight(mdl, paint); body.animate([{ opacity: 0, transform: `translateX(${dir * 14}px)` }, { opacity: 1, transform: 'none' }], { duration: 200, easing: 'cubic-bezier(.22, 1, .36, 1)' }); } else paint();
|
|
368
|
+
paintFoot();
|
|
369
|
+
};
|
|
370
|
+
CONTENT[kind] = () => { wiz = { at: 0, client: null, service: null, day: null, hour: null }; q = ''; body.dataset.wizard = kind; render(); };
|
|
371
|
+
const mine = () => wiz && body.dataset.wizard === kind && !mdl.hidden;
|
|
372
|
+
const pick = (attr, value, el, scope) => { $$(`[${attr}]`, scope).forEach((x) => x.setAttribute('aria-pressed', x === el)); $('[data-wz-next]', foot).disabled = !valid(); };
|
|
373
|
+
body.addEventListener('input', (e) => { if (!mine() || e.target.id !== 'wz-q') return; q = e.target.value; $('[data-wz="client"]', body).innerHTML = clientRows(); });
|
|
374
|
+
document.addEventListener('click', (e) => {
|
|
375
|
+
if (!mine() || !mdl.contains(e.target)) return;
|
|
376
|
+
const choice = e.target.closest('.cx-choice'), group = choice?.closest('[data-wz]');
|
|
377
|
+
if (group) { wiz[group.dataset.wz] = +choice.dataset.v; $$('.cx-choice', group).forEach((x) => { const on1 = x === choice; x.setAttribute('aria-checked', on1); x.tabIndex = on1 ? 0 : -1; }); $('[data-wz-next]', foot).disabled = !valid(); return; }
|
|
378
|
+
const day = e.target.closest('[data-wz-day]'); if (day) { wiz.day = +day.dataset.wzDay; pick('data-wz-day', wiz.day, day, body); return; }
|
|
379
|
+
const hour = e.target.closest('[data-wz-hour]'); if (hour) { wiz.hour = hour.dataset.wzHour; pick('data-wz-hour', wiz.hour, hour, body); return; }
|
|
380
|
+
if (e.target.closest('[data-wz-back]')) { wiz.at--; render(-1); return; }
|
|
381
|
+
const next = e.target.closest('[data-wz-next]');
|
|
382
|
+
if (next && !next.disabled) {
|
|
383
|
+
if (wiz.at < STEPS.length - 1) { wiz.at++; render(1); return; }
|
|
384
|
+
const c = clients[wiz.client], s = services[wiz.service], d = days()[wiz.day];
|
|
385
|
+
next.innerHTML = '<svg class="animate-spin"><use href="#loader"/></svg>Creando…'; next.disabled = true;
|
|
386
|
+
on.create?.(JSON.stringify({ client: c.name, service: s.name, day: iso(d), hour: wiz.hour }));
|
|
387
|
+
setTimeout(() => { closeModal(); wiz = null; }, 700);
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
// closing by Esc, the overlay or «Cancelar» tells the page, so its own «open» state can go back to false
|
|
391
|
+
new MutationObserver(() => { if (mdl.hidden && body.dataset.wizard === kind) { delete body.dataset.wizard; wiz = null; on.close?.(); } }).observe(mdl, { attributes: true, attributeFilter: ['hidden'] });
|
|
392
|
+
return { start: () => openModal(kind, 'auto') };
|
|
393
|
+
}
|