diff --git a/src/public/borne/assets/css/style.css b/src/public/borne/assets/css/style.css new file mode 100644 index 0000000..5f11afb --- /dev/null +++ b/src/public/borne/assets/css/style.css @@ -0,0 +1,1685 @@ +/* + * Wakdo — Design system for the kiosk front (Bloc 1) + * + * Tokens extracted from the school maquette PDF: + * - Brand yellow : #FFC72C (M arches / button fills / card borders active) + * - Brand red : #DA020E (nav arrows in maquette — kept for potential future use) + * - Neutral dark : #1A1A1A (headings, primary text) + * - Neutral mid : #4A4A4A (body copy) + * - Neutral light : #F5F5F5 (page backgrounds, card backgrounds) + * - White : #FFFFFF (overlays, cards) + * - Border radius : 12px (cards), 8px (buttons) + * - Card border : 2px solid #FFC72C when selected / active + * + * Kiosk target: 1080x1920 portrait (touch screen). + * Font stack: system-ui fallback — school font is not available as a web asset. + * OpenDys is loaded conditionally for accessibility (RGAA Cr 1.c.4). + */ + +/* ============================================================ + 1. CSS CUSTOM PROPERTIES (design tokens) + ============================================================ */ + +:root { + /* Brand palette */ + --color-brand-yellow: #FFC72C; + --color-brand-yellow-dk: #E6A800; /* darker shade for focus/hover contrast */ + --color-brand-red: #DA020E; + --color-brand-dark: #1A1A1A; + + /* Neutral palette */ + --color-text-primary: #1A1A1A; + --color-text-secondary: #4A4A4A; + --color-text-muted: #767676; /* min WCAG AA contrast on white */ + --color-bg-page: #F5F5F5; + --color-bg-card: #FFFFFF; + --color-border-default: #D1D1D1; + --color-border-active: #FFC72C; + + /* Typography */ + --font-family-base: system-ui, -apple-system, "Segoe UI", Arial, sans-serif; + --font-size-xs: 0.75rem; /* 12px */ + --font-size-sm: 0.875rem; /* 14px */ + --font-size-base: 1rem; /* 16px */ + --font-size-md: 1.25rem; /* 20px */ + --font-size-lg: 1.5rem; /* 24px */ + --font-size-xl: 2rem; /* 32px */ + --font-size-2xl: 2.5rem; /* 40px */ + --font-weight-normal: 400; + --font-weight-bold: 700; + + /* Spacing scale (8px base) */ + --space-1: 0.25rem; /* 4px */ + --space-2: 0.5rem; /* 8px */ + --space-3: 0.75rem; /* 12px */ + --space-4: 1rem; /* 16px */ + --space-5: 1.5rem; /* 24px */ + --space-6: 2rem; /* 32px */ + --space-8: 3rem; /* 48px */ + --space-10: 4rem; /* 64px */ + + /* Border radius */ + --radius-sm: 6px; + --radius-md: 12px; + --radius-lg: 20px; + --radius-pill: 9999px; + + /* Shadows */ + --shadow-card: 0 2px 12px rgba(0, 0, 0, 0.10); + --shadow-overlay: 0 4px 32px rgba(0, 0, 0, 0.18); + + /* Transitions */ + --transition-fast: 150ms ease; + --transition-base: 250ms ease; +} + +/* ============================================================ + 2. RESET & BASE + ============================================================ */ + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 16px; + /* touch-action on html prevents accidental pinch-zoom on kiosk */ + touch-action: manipulation; +} + +body { + font-family: var(--font-family-base); + font-size: var(--font-size-base); + color: var(--color-text-primary); + background-color: var(--color-bg-page); + line-height: 1.5; + min-height: 100vh; +} + +img { + display: block; + max-width: 100%; + height: auto; +} + +a { + color: inherit; + text-decoration: none; +} + +ul, ol { + list-style: none; +} + +button { + cursor: pointer; + font-family: inherit; + border: none; + background: none; +} + +/* ============================================================ + 3. UTILITY CLASSES + ============================================================ */ + +.sr-only { + /* Screen-reader only — visually hidden but accessible (RGAA) */ + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* ============================================================ + 4. COMPONENT — WELCOME SCREEN (index.html) + ============================================================ */ + +/* + * Layout: full-viewport, background photo, centered white card. + * The maquette shows the M logo arches as a background image (mc-landing-banner.png). + * The white card floats left-center in the maquette; on 1080x1920 portrait we center it. + */ + +.welcome { + position: relative; + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-6); + overflow: hidden; +} + +.welcome__bg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; + z-index: 0; +} + +.welcome__card { + position: relative; + z-index: 1; + background: var(--color-bg-card); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-overlay); + padding: var(--space-8) var(--space-8); + max-width: 700px; + width: 100%; +} + +.welcome__greeting { + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + margin-bottom: var(--space-5); + line-height: 1.2; +} + +.welcome__question { + font-size: var(--font-size-md); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + margin-bottom: var(--space-6); + line-height: 1.4; +} + +.welcome__choices { + display: flex; + gap: var(--space-5); + flex-wrap: wrap; +} + +.choice-btn { + flex: 1 1 200px; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-3); + padding: var(--space-5) var(--space-4); + background: var(--color-bg-card); + border: 2px solid var(--color-border-default); + border-radius: var(--radius-md); + transition: + border-color var(--transition-fast), + box-shadow var(--transition-fast), + transform var(--transition-fast); + -webkit-tap-highlight-color: transparent; /* suppress blue flash on mobile tap */ + min-height: 200px; + justify-content: center; +} + +.choice-btn:hover, +.choice-btn:focus-visible { + border-color: var(--color-border-active); + box-shadow: 0 0 0 3px rgba(255, 199, 44, 0.35); + outline: none; + /* slight lift gives tactile feedback on kiosk screen */ + transform: translateY(-2px); +} + +.choice-btn:active { + transform: translateY(0); + box-shadow: none; +} + +.choice-btn__image { + width: 120px; + height: 120px; + object-fit: contain; +} + +.choice-btn__label { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + text-align: center; +} + +/* ============================================================ + 5. COMPONENT — CATEGORIES SCREEN (categories.html) + ============================================================ */ + +/* + * Layout: header bar at top with logo, then a full-page category grid. + * The maquette shows categories as a horizontal scrollable strip at the top + * of the product-list screen. Here we present them as a full-page grid + * (intermediate screen before the product list, P5 scope). + * 3-column grid on portrait kiosk (1080px wide). + */ + +.categories-page { + min-height: 100vh; + display: flex; + flex-direction: column; + background: var(--color-bg-page); +} + +/* ---------- header ---------- */ + +.site-header { + background: var(--color-bg-card); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + padding: var(--space-4) var(--space-6); + display: flex; + align-items: center; + justify-content: space-between; + /* Sticky so the logo stays visible while user scrolls categories */ + position: sticky; + top: 0; + z-index: 100; +} + +.site-header__logo { + height: 56px; + width: auto; +} + +.site-header__back { + display: inline-flex; + align-items: center; + gap: var(--space-2); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-bold); + color: var(--color-text-secondary); + padding: var(--space-2) var(--space-4); + border: 2px solid var(--color-border-default); + border-radius: var(--radius-sm); + transition: border-color var(--transition-fast), color var(--transition-fast); +} + +.site-header__back:hover, +.site-header__back:focus-visible { + border-color: var(--color-border-active); + color: var(--color-text-primary); + outline: none; +} + +/* ---------- main content area ---------- */ + +.categories-main { + flex: 1; + padding: var(--space-6) var(--space-6) var(--space-10); +} + +.categories-main__heading { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + margin-bottom: var(--space-2); +} + +.categories-main__sub { + font-size: var(--font-size-base); + color: var(--color-text-secondary); + margin-bottom: var(--space-6); +} + +/* ---------- category grid ---------- */ + +.category-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--space-5); +} + +/* + * Each category card is a link () styled as a card. + * Large tap target (min 160px) for kiosk touch use. + */ +.category-card { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-3); + padding: var(--space-5) var(--space-4); + background: var(--color-bg-card); + border: 2px solid var(--color-border-default); + border-radius: var(--radius-md); + box-shadow: var(--shadow-card); + transition: + border-color var(--transition-fast), + box-shadow var(--transition-fast), + transform var(--transition-fast); + -webkit-tap-highlight-color: transparent; + min-height: 220px; + justify-content: center; + text-align: center; +} + +.category-card:hover, +.category-card:focus-visible { + border-color: var(--color-border-active); + box-shadow: 0 0 0 3px rgba(255, 199, 44, 0.35), var(--shadow-card); + outline: none; + transform: translateY(-3px); +} + +.category-card:active { + transform: translateY(0); + box-shadow: var(--shadow-card); +} + +.category-card__image { + width: 140px; + height: 140px; + object-fit: contain; +} + +.category-card__label { + font-size: var(--font-size-md); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + /* Capitalize first letter to match maquette label style */ + text-transform: capitalize; +} + +/* ============================================================ + 6. RESPONSIVE — kiosk portrait 1080px is primary target. + Desktop landscape / smaller tablets get a simplified layout. + ============================================================ */ + +/* Landscape / wide desktop: welcome card can be wider */ +@media (min-width: 1080px) { + .welcome__card { + max-width: 820px; + padding: var(--space-10) var(--space-10); + } + + .choice-btn__image { + width: 150px; + height: 150px; + } + + .category-grid { + /* 4 columns on very wide screens so the grid uses the space */ + grid-template-columns: repeat(4, 1fr); + } +} + +/* Narrow screens (phones, small tablets) */ +@media (max-width: 600px) { + .welcome__card { + padding: var(--space-5) var(--space-4); + } + + .welcome__greeting { + font-size: var(--font-size-xl); + } + + .welcome__question { + font-size: var(--font-size-base); + } + + .category-grid { + grid-template-columns: repeat(2, 1fr); + } + + .categories-main { + padding: var(--space-4); + } +} + +/* Portrait kiosk explicit — enforce single-column choice layout below 480px */ +@media (max-width: 480px) { + .welcome__choices { + flex-direction: column; + } +} + +/* ============================================================ + 7. SHARED COMPONENTS — header extensions + badges + buttons + ============================================================ */ + +/* Cart link in header (products / product / cart pages) */ +.site-header__cart { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 56px; + height: 56px; + border-radius: var(--radius-sm); + transition: background var(--transition-fast); + font-size: 1.6rem; + color: var(--color-text-primary); + text-decoration: none; +} + +.site-header__cart:hover, +.site-header__cart:focus-visible { + background: rgba(255, 199, 44, 0.18); + outline: none; +} + +.cart-icon { + line-height: 1; +} + +.cart-badge { + position: absolute; + top: 4px; + right: 4px; + min-width: 20px; + height: 20px; + padding: 0 4px; + border-radius: var(--radius-pill); + background: var(--color-brand-red); + color: #fff; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); + display: flex; + align-items: center; + justify-content: center; + line-height: 1; +} + +/* Mode badge — shown in header for context */ +.mode-badge { + display: inline-block; + padding: var(--space-1) var(--space-3); + background: var(--color-brand-yellow); + border-radius: var(--radius-pill); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); + color: var(--color-brand-dark); + white-space: nowrap; +} + +.site-header__mode { + /* Keeps mode badge aligned right in the header flex */ + margin-left: auto; +} + +/* Minimal header variant (confirmation page — no back button) */ +.site-header--minimal { + justify-content: center; +} + +/* Primary / secondary button primitives reused across pages */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + padding: var(--space-4) var(--space-6); + border-radius: var(--radius-md); + font-size: var(--font-size-md); + font-weight: var(--font-weight-bold); + text-decoration: none; + cursor: pointer; + border: 2px solid transparent; + transition: + background var(--transition-fast), + border-color var(--transition-fast), + box-shadow var(--transition-fast), + transform var(--transition-fast); + -webkit-tap-highlight-color: transparent; + /* Generous tap target for kiosk touch */ + min-height: 64px; +} + +.btn--primary { + background: var(--color-brand-yellow); + color: var(--color-brand-dark); +} + +.btn--primary:hover, +.btn--primary:focus-visible { + background: var(--color-brand-yellow-dk); + outline: none; + box-shadow: 0 0 0 3px rgba(255, 199, 44, 0.40); + transform: translateY(-1px); +} + +.btn--primary:active { + transform: translateY(0); + box-shadow: none; +} + +.btn--primary:disabled, +.btn--primary[aria-disabled="true"] { + background: var(--color-border-default); + color: var(--color-text-muted); + cursor: not-allowed; + transform: none; + box-shadow: none; + pointer-events: none; +} + +.btn--secondary { + background: var(--color-bg-card); + color: var(--color-text-primary); + border-color: var(--color-border-default); +} + +.btn--secondary:hover, +.btn--secondary:focus-visible { + border-color: var(--color-border-active); + outline: none; +} + +.btn--large { + font-size: var(--font-size-lg); + padding: var(--space-5) var(--space-8); + min-height: 80px; +} + +/* ============================================================ + 8. COMPONENT — PRODUCTS LIST (products.html) + ============================================================ */ + +.products-page { + min-height: 100vh; + display: flex; + flex-direction: column; + background: var(--color-bg-page); +} + +.products-main { + flex: 1; + padding: var(--space-6); +} + +.products-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-5); + gap: var(--space-4); + flex-wrap: wrap; +} + +.products-main__heading { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); +} + +.products-error { + color: var(--color-brand-red); + font-size: var(--font-size-md); + margin-bottom: var(--space-5); +} + +.products-empty { + color: var(--color-text-muted); + font-size: var(--font-size-md); + padding: var(--space-8) 0; + text-align: center; +} + +/* Product grid — 3 columns on kiosk portrait */ +.products-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--space-5); + list-style: none; + padding: 0; + margin: 0; +} + +/* + * product-card — clickable card linking to product detail. + * The is the grid child; using display:flex so image + text stack. + */ +.product-card { + display: flex; + flex-direction: column; + background: var(--color-bg-card); + border: 2px solid var(--color-border-default); + border-radius: var(--radius-md); + box-shadow: var(--shadow-card); + overflow: hidden; + transition: + border-color var(--transition-fast), + box-shadow var(--transition-fast), + transform var(--transition-fast); + -webkit-tap-highlight-color: transparent; + text-decoration: none; + color: inherit; +} + +.product-card:hover, +.product-card:focus-visible { + border-color: var(--color-border-active); + box-shadow: 0 0 0 3px rgba(255, 199, 44, 0.35), var(--shadow-card); + outline: none; + transform: translateY(-3px); +} + +.product-card:active { + transform: translateY(0); + box-shadow: var(--shadow-card); +} + +.product-card__image-wrap { + width: 100%; + aspect-ratio: 1 / 1; + background: var(--color-bg-page); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.product-card__image { + width: 100%; + height: 100%; + object-fit: contain; + padding: var(--space-3); +} + +.product-card__body { + padding: var(--space-3) var(--space-4) var(--space-4); + display: flex; + flex-direction: column; + gap: var(--space-1); + flex: 1; +} + +.product-card__name { + font-size: var(--font-size-base); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + line-height: 1.3; +} + +.product-card__price { + font-size: var(--font-size-md); + font-weight: var(--font-weight-bold); + color: var(--color-brand-dark); +} + +/* ============================================================ + 9. COMPONENT — PRODUCT DETAIL (product.html) + ============================================================ */ + +.product-page { + min-height: 100vh; + display: flex; + flex-direction: column; + background: var(--color-bg-page); +} + +.product-main { + flex: 1; + padding: var(--space-6); + max-width: 800px; + margin: 0 auto; + width: 100%; +} + +.product-error { + color: var(--color-brand-red); + font-size: var(--font-size-md); + padding: var(--space-5); +} + +.product-detail { + display: flex; + flex-direction: column; + gap: var(--space-6); +} + +.product-detail__skeleton { + /* Placeholder shown while JS loads data */ + width: 100%; + height: 300px; + background: var(--color-bg-card); + border-radius: var(--radius-md); + animation: skeleton-pulse 1.4s ease-in-out infinite; +} + +@keyframes skeleton-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +.product-detail__image-wrap { + width: 100%; + max-width: 480px; + margin: 0 auto; + background: var(--color-bg-card); + border-radius: var(--radius-md); + box-shadow: var(--shadow-card); + overflow: hidden; + aspect-ratio: 1 / 1; + display: flex; + align-items: center; + justify-content: center; +} + +.product-detail__image { + width: 100%; + height: 100%; + object-fit: contain; + padding: var(--space-5); +} + +.product-detail__info { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.product-detail__name { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + line-height: 1.2; +} + +.product-detail__price { + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-bold); + color: var(--color-brand-dark); +} + +.product-detail__composition { + background: var(--color-bg-card); + border-left: 4px solid var(--color-brand-yellow); + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; + padding: var(--space-4) var(--space-5); +} + +.product-detail__composition-title { + font-size: var(--font-size-base); + font-weight: var(--font-weight-bold); + margin-bottom: var(--space-2); + color: var(--color-text-primary); +} + +.product-detail__composition-text { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + line-height: 1.5; +} + +.product-detail__add { + width: 100%; + margin-top: var(--space-2); +} + +/* ============================================================ + 10. COMPONENT — CART (cart.html) + ============================================================ */ + +.cart-page { + min-height: 100vh; + display: flex; + flex-direction: column; + background: var(--color-bg-page); +} + +.cart-main { + flex: 1; + padding: var(--space-6); + display: flex; + flex-direction: column; + gap: var(--space-5); + max-width: 900px; + margin: 0 auto; + width: 100%; +} + +.cart-main__heading { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); +} + +.cart-empty { + text-align: center; + padding: var(--space-10) var(--space-6); + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-5); +} + +.cart-empty__message { + font-size: var(--font-size-lg); + color: var(--color-text-secondary); +} + +.cart-list { + display: flex; + flex-direction: column; + gap: var(--space-3); + list-style: none; + padding: 0; + margin: 0; +} + +/* One cart line */ +.cart-line { + display: flex; + align-items: center; + gap: var(--space-4); + background: var(--color-bg-card); + border-radius: var(--radius-md); + box-shadow: var(--shadow-card); + padding: var(--space-3) var(--space-4); +} + +.cart-line__image { + width: 72px; + height: 72px; + object-fit: contain; + flex-shrink: 0; + border-radius: var(--radius-sm); + background: var(--color-bg-page); +} + +.cart-line__info { + flex: 1; + min-width: 0; +} + +.cart-line__name { + display: block; + font-size: var(--font-size-base); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cart-line__unit-price { + display: block; + font-size: var(--font-size-sm); + color: var(--color-text-muted); +} + +/* Quantity controls */ +.cart-line__qty { + display: flex; + align-items: center; + gap: var(--space-2); + flex-shrink: 0; +} + +.qty-btn { + width: 44px; + height: 44px; + border-radius: var(--radius-sm); + border: 2px solid var(--color-border-default); + background: var(--color-bg-card); + font-size: var(--font-size-lg); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: border-color var(--transition-fast), background var(--transition-fast); + /* Large tap target for kiosk */ +} + +.qty-btn:hover, +.qty-btn:focus-visible { + border-color: var(--color-border-active); + background: rgba(255, 199, 44, 0.10); + outline: none; +} + +.qty-value { + min-width: 32px; + text-align: center; + font-size: var(--font-size-md); + font-weight: var(--font-weight-bold); +} + +.cart-line__total { + font-size: var(--font-size-md); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + flex-shrink: 0; + min-width: 80px; + text-align: right; +} + +.cart-line__remove { + background: none; + border: none; + cursor: pointer; + padding: var(--space-2); + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: background var(--transition-fast); +} + +.cart-line__remove:hover, +.cart-line__remove:focus-visible { + background: rgba(218, 2, 14, 0.10); + outline: none; +} + +/* Order summary block */ +.cart-summary { + background: var(--color-bg-card); + border-radius: var(--radius-md); + box-shadow: var(--shadow-card); + padding: var(--space-5); + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.cart-summary__line { + display: flex; + justify-content: space-between; + font-size: var(--font-size-base); + color: var(--color-text-secondary); +} + +.cart-summary__line--total { + padding-top: var(--space-3); + border-top: 2px solid var(--color-border-default); + font-size: var(--font-size-lg); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); +} + +/* Cart action row */ +.cart-actions { + display: flex; + gap: var(--space-4); + justify-content: space-between; + flex-wrap: wrap; + padding-bottom: var(--space-8); +} + +.cart-actions .btn { + flex: 1 1 200px; +} + +/* ============================================================ + 11. COMPONENT — PAYMENT (payment.html) + ============================================================ */ + +.payment-page { + min-height: 100vh; + display: flex; + flex-direction: column; + background: var(--color-bg-page); +} + +.payment-main { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + padding: var(--space-8) var(--space-6); + gap: var(--space-8); +} + +.payment-main__heading { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + text-align: center; +} + +.payment-recap { + background: var(--color-bg-card); + border-radius: var(--radius-md); + box-shadow: var(--shadow-card); + padding: var(--space-5) var(--space-6); + text-align: center; + width: 100%; + max-width: 480px; +} + +.payment-recap__mode { + font-size: var(--font-size-sm); + color: var(--color-text-muted); + margin-bottom: var(--space-1); +} + +.payment-recap__items { + font-size: var(--font-size-base); + color: var(--color-text-secondary); + margin-bottom: var(--space-2); +} + +.payment-recap__total { + font-size: var(--font-size-lg); + color: var(--color-text-primary); +} + +/* Two payment method buttons side by side */ +.payment-methods { + display: flex; + gap: var(--space-6); + flex-wrap: wrap; + justify-content: center; + width: 100%; + max-width: 640px; +} + +.payment-choice { + flex: 1 1 220px; + min-height: 220px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-4); + padding: var(--space-6); + background: var(--color-bg-card); + border: 2px solid var(--color-border-default); + border-radius: var(--radius-md); + box-shadow: var(--shadow-card); + cursor: pointer; + transition: + border-color var(--transition-fast), + box-shadow var(--transition-fast), + transform var(--transition-fast); + -webkit-tap-highlight-color: transparent; + font-family: inherit; +} + +.payment-choice:hover, +.payment-choice:focus-visible { + border-color: var(--color-border-active); + box-shadow: 0 0 0 3px rgba(255, 199, 44, 0.35), var(--shadow-card); + outline: none; + transform: translateY(-3px); +} + +.payment-choice:active { + transform: translateY(0); + box-shadow: var(--shadow-card); +} + +.payment-choice__icon { + width: 80px; + height: 80px; +} + +.payment-choice__label { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + text-align: center; +} + +/* ============================================================ + 12. COMPONENT — CONFIRMATION (confirmation.html) + ============================================================ */ + +.confirmation-page { + min-height: 100vh; + display: flex; + flex-direction: column; + background: var(--color-bg-page); +} + +.confirmation-main { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-8) var(--space-6); + gap: var(--space-8); +} + +.confirmation-banner { + background: var(--color-bg-card); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-overlay); + padding: var(--space-8); + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-5); + text-align: center; + max-width: 560px; + width: 100%; +} + +.confirmation-banner__check { + width: 96px; + height: 96px; + /* Subtle entrance animation */ + animation: check-pop 0.4s cubic-bezier(0.22, 1, 0.36, 1) both; +} + +@keyframes check-pop { + from { transform: scale(0.5); opacity: 0; } + to { transform: scale(1); opacity: 1; } +} + +.confirmation-banner__title { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); +} + +.confirmation-banner__sub { + font-size: var(--font-size-md); + color: var(--color-text-secondary); +} + +.confirmation-banner__number-block { + display: flex; + flex-direction: column; + gap: var(--space-1); + background: var(--color-bg-page); + border-radius: var(--radius-md); + padding: var(--space-4) var(--space-6); + min-width: 200px; +} + +.confirmation-banner__number-label { + font-size: var(--font-size-sm); + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.confirmation-banner__number { + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-bold); + color: var(--color-brand-dark); + letter-spacing: 0.04em; +} + +.confirmation-banner__total { + font-size: var(--font-size-base); + color: var(--color-text-secondary); +} + +.confirmation-banner__delay { + font-size: var(--font-size-base); + color: var(--color-text-secondary); +} + +.confirmation-new-order { + /* Full-width CTA on kiosk portrait */ + min-width: 320px; +} + +/* ============================================================ + 13. RESPONSIVE EXTENSIONS — new pages + ============================================================ */ + +@media (max-width: 700px) { + .products-grid { + grid-template-columns: repeat(2, 1fr); + } + + .cart-line { + flex-wrap: wrap; + gap: var(--space-3); + } + + .cart-line__info { + flex: 1 1 calc(100% - 80px); + } + + .payment-methods { + flex-direction: column; + align-items: stretch; + } +} + +@media (max-width: 480px) { + .products-grid { + grid-template-columns: 1fr 1fr; + gap: var(--space-3); + } + + .products-main { + padding: var(--space-4); + } + + .cart-main { + padding: var(--space-4); + } +} + +/* ============================================================ + 14. COMPONENT — MENU COMPOSER MODAL (product.html, type=menu) + ============================================================ */ + +/* + * Overlay dims the page content while the composer is open. + * z-index 200 sits above the sticky header (z-index 100). + */ +.composer-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-4); + /* Subtle fade-in so the overlay does not feel abrupt */ + animation: composer-fade-in var(--transition-base) both; +} + +@keyframes composer-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +/* White container centred inside the overlay */ +.composer-container { + background: var(--color-bg-card); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-overlay); + width: 100%; + max-width: 1080px; + max-height: 90vh; + display: flex; + flex-direction: column; + overflow: hidden; + /* Slide-up entrance from maquette aesthetic */ + animation: composer-slide-up var(--transition-base) both; +} + +@keyframes composer-slide-up { + from { transform: translateY(32px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +/* ---------- Header ------------------------------------------ */ + +.composer-header { + padding: var(--space-5) var(--space-6) var(--space-4); + border-bottom: 1px solid var(--color-border-default); + display: flex; + flex-direction: column; + gap: var(--space-3); + flex-shrink: 0; +} + +.composer-title { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); +} + +/* Progress indicator */ +.composer-progress { + display: flex; + align-items: center; + gap: var(--space-4); +} + +.composer-progress__text { + font-size: var(--font-size-sm); + color: var(--color-text-muted); + white-space: nowrap; + min-width: 6rem; +} + +.composer-progress__bar { + flex: 1; + height: 6px; + background: var(--color-bg-page); + border-radius: var(--radius-pill); + overflow: hidden; +} + +.composer-progress__fill { + height: 100%; + background: var(--color-brand-yellow); + border-radius: var(--radius-pill); + transition: width var(--transition-base); +} + +/* ---------- Body -------------------------------------------- */ + +.composer-body { + flex: 1; + overflow-y: auto; + padding: var(--space-5) var(--space-6); + display: flex; + flex-direction: column; + gap: var(--space-5); + /* Visible focus outline inside scrollable area */ + outline: none; +} + +.composer-step__subtitle { + font-size: var(--font-size-md); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); +} + +/* ---------- Selectable card grid ----------------------------- */ + +.composer-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: var(--space-4); + list-style: none; + padding: 0; + margin: 0; +} + +/* + * composer-card — button variant of the product card. + * Displays image + name (+ price for burgers). + * Uses aria-pressed to expose selection state to assistive tech. + */ +.composer-card { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-3) var(--space-4); + background: var(--color-bg-card); + border: 2px solid var(--color-border-default); + border-radius: var(--radius-md); + box-shadow: var(--shadow-card); + cursor: pointer; + text-align: center; + transition: + border-color var(--transition-fast), + box-shadow var(--transition-fast), + transform var(--transition-fast); + -webkit-tap-highlight-color: transparent; +} + +.composer-card:hover, +.composer-card:focus-visible { + border-color: var(--color-border-active); + box-shadow: 0 0 0 3px rgba(255, 199, 44, 0.35), var(--shadow-card); + outline: none; + transform: translateY(-2px); +} + +.composer-card:active { + transform: translateY(0); +} + +/* Selected state — mirrors aria-pressed=true */ +.composer-card--selected { + border-color: var(--color-border-active); + box-shadow: 0 0 0 3px rgba(255, 199, 44, 0.45), var(--shadow-card); + background: rgba(255, 199, 44, 0.06); +} + +.composer-card__image { + width: 100%; + aspect-ratio: 1 / 1; + object-fit: contain; + max-height: 120px; + border-radius: var(--radius-sm); +} + +.composer-card__name { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + line-height: 1.3; +} + +.composer-card__price { + font-size: var(--font-size-sm); + color: var(--color-text-muted); +} + +/* ---------- Personalisation checkboxes ----------------------- */ + +.composer-options { + border: 1px solid var(--color-border-default); + border-radius: var(--radius-md); + padding: var(--space-4) var(--space-5); + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.composer-options__legend { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-bold); + color: var(--color-text-secondary); + padding: 0 var(--space-2); +} + +.composer-option-label { + display: flex; + align-items: center; + gap: var(--space-3); + font-size: var(--font-size-base); + color: var(--color-text-primary); + cursor: pointer; +} + +.composer-option-label input[type="checkbox"] { + width: 20px; + height: 20px; + accent-color: var(--color-brand-yellow); + cursor: pointer; + flex-shrink: 0; +} + +/* ---------- Taille toggle ------------------------------------ */ + +.composer-taille { + display: flex; + gap: var(--space-3); + align-items: center; + flex-wrap: wrap; +} + +.composer-taille__btn { + flex: 1 1 160px; + padding: var(--space-3) var(--space-4); + border: 2px solid var(--color-border-default); + border-radius: var(--radius-md); + background: var(--color-bg-card); + font-size: var(--font-size-base); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + cursor: pointer; + transition: + border-color var(--transition-fast), + background var(--transition-fast), + box-shadow var(--transition-fast); + -webkit-tap-highlight-color: transparent; + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + min-height: 56px; +} + +.composer-taille__btn:hover, +.composer-taille__btn:focus-visible { + border-color: var(--color-border-active); + outline: none; + box-shadow: 0 0 0 3px rgba(255, 199, 44, 0.25); +} + +/* Active/selected taille button */ +.composer-taille__btn--active { + border-color: var(--color-brand-yellow); + background: rgba(255, 199, 44, 0.10); +} + +/* Supplement price hint inside the Grande button */ +.composer-taille__price-hint { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-normal); + color: var(--color-text-muted); + background: var(--color-bg-page); + padding: 2px var(--space-2); + border-radius: var(--radius-pill); +} + +/* ---------- Recap (step 5) ----------------------------------- */ + +.composer-recap { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0; + border: 1px solid var(--color-border-default); + border-radius: var(--radius-md); + overflow: hidden; +} + +.composer-recap__line { + display: flex; + align-items: baseline; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + font-size: var(--font-size-base); + color: var(--color-text-primary); + border-bottom: 1px solid var(--color-border-default); +} + +.composer-recap__line:last-child { + border-bottom: none; +} + +.composer-recap__icon { + color: var(--color-brand-yellow); + font-size: var(--font-size-xs); + flex-shrink: 0; +} + +.composer-recap__label { + flex: 1; + display: flex; + align-items: baseline; + gap: var(--space-2); + flex-wrap: wrap; +} + +.composer-recap__opts { + font-size: var(--font-size-sm); + color: var(--color-text-muted); +} + +.composer-recap__taille { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); +} + +.composer-recap__suppl { + font-size: var(--font-size-sm); + color: var(--color-brand-yellow-dk); + font-weight: var(--font-weight-bold); +} + +/* Totals block below the recap list */ +.composer-recap__totals { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-4) var(--space-4) 0; +} + +.composer-recap__base, +.composer-recap__suppl-total { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); +} + +.composer-recap__total-line { + font-size: var(--font-size-lg); + color: var(--color-text-primary); +} + +/* ---------- Footer ------------------------------------------ */ + +.composer-footer { + flex-shrink: 0; + padding: var(--space-4) var(--space-6); + border-top: 1px solid var(--color-border-default); + background: var(--color-bg-card); +} + +.composer-footer__row { + display: flex; + gap: var(--space-4); + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; +} + +.composer-footer__cancel { + /* Pushed to the left so it is visually separated from the nav buttons */ + margin-right: auto; +} + +/* ---------- Cart line composition display -------------------- */ + +.cart-line__composition { + list-style: none; + padding: var(--space-1) 0 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.cart-line__comp-item { + font-size: var(--font-size-xs); + color: var(--color-text-muted); + line-height: 1.4; +} + +.cart-line__comp-suppl { + font-size: var(--font-size-xs); + color: var(--color-brand-yellow-dk); + font-weight: var(--font-weight-bold); + line-height: 1.4; + padding-top: var(--space-1); +} + +/* ---------- Responsive — narrow screens --------------------- */ + +@media (max-width: 600px) { + .composer-grid { + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: var(--space-3); + } + + .composer-container { + max-height: 95vh; + } + + .composer-header, + .composer-body, + .composer-footer { + padding-left: var(--space-4); + padding-right: var(--space-4); + } + + .composer-footer__row { + flex-direction: column-reverse; + align-items: stretch; + } + + .composer-footer__cancel { + margin-right: 0; + } +} diff --git a/src/public/borne/assets/js/data.js b/src/public/borne/assets/js/data.js new file mode 100644 index 0000000..38f03b1 --- /dev/null +++ b/src/public/borne/assets/js/data.js @@ -0,0 +1,111 @@ +/* + * data.js — Data loading layer for the Wakdo kiosk. + * + * P5 reads static JSON copies in /data/ (same origin). + * In P4, swap the BASE_URL constants to point to REST API endpoints. + * The function signatures and return shapes remain unchanged so that + * page scripts need no modification when the data source changes. + * + * Category-to-slug mapping (mirrors data/categories.json id field): + * 1=menus 2=boissons 3=burgers 4=frites 5=encas + * 6=wraps 7=salades 8=desserts 9=sauces + */ + +/* --- P4 swap point ------------------------------------------------------- + * TODO(P4): replace these two paths with API endpoints, e.g.: + * const CATEGORIES_URL = '/api/categories'; + * const PRODUCTS_URL = '/api/products'; + * The rest of this file is API-agnostic. + * ----------------------------------------------------------------------- */ +const CATEGORIES_URL = 'data/categories.json'; +const PRODUCTS_URL = 'data/produits.json'; + +/** @type {Array|null} — in-memory cache to avoid repeated fetches */ +let _categoriesCache = null; + +/** @type {Object|null} */ +let _productsCache = null; + +/** + * Fetches and caches the categories list. + * @returns {Promise} + */ +export async function loadCategories() { + if (_categoriesCache) return _categoriesCache; + const res = await fetch(CATEGORIES_URL); + if (!res.ok) throw new Error(`Failed to load categories: HTTP ${res.status}`); + _categoriesCache = await res.json(); + return _categoriesCache; +} + +/** + * Fetches and caches the full products object keyed by category slug. + * @returns {Promise} + */ +export async function loadProducts() { + if (_productsCache) return _productsCache; + const res = await fetch(PRODUCTS_URL); + if (!res.ok) throw new Error(`Failed to load products: HTTP ${res.status}`); + _productsCache = await res.json(); + return _productsCache; +} + +/** + * Returns the array of products for a given category slug. + * Returns [] if the slug is not found. + * @param {string} slug — e.g. "burgers", "menus" + * @returns {Promise} + */ +export async function getProductsByCategory(slug) { + const data = await loadProducts(); + return data[slug] ?? []; +} + +/** + * Returns the category object for the given id. + * @param {number} id + * @returns {Promise} + */ +export async function getCategoryById(id) { + const cats = await loadCategories(); + return cats.find(c => c.id === id) ?? null; +} + +/** + * Finds a product by its numeric id, searching all category slates. + * Returns null if not found. + * @param {number} id + * @returns {Promise} + */ +export async function findProduct(id) { + const data = await loadProducts(); + for (const slug of Object.keys(data)) { + const found = data[slug].find(p => p.id === id); + if (found) return { ...found, categorie: slug }; + } + return null; +} + +/** + * Maps a category id integer to its slug string. + * Derived from data/categories.json — kept here as a convenience + * so page scripts can convert query-string ids without an extra fetch. + */ +export const CATEGORY_ID_TO_SLUG = { + 1: 'menus', + 2: 'boissons', + 3: 'burgers', + 4: 'frites', + 5: 'encas', + 6: 'wraps', + 7: 'salades', + 8: 'desserts', + 9: 'sauces' +}; + +/** + * Inverse of the above: slug -> id. + */ +export const CATEGORY_SLUG_TO_ID = Object.fromEntries( + Object.entries(CATEGORY_ID_TO_SLUG).map(([id, slug]) => [slug, Number(id)]) +); diff --git a/src/public/borne/assets/js/nav.js b/src/public/borne/assets/js/nav.js new file mode 100644 index 0000000..5b431b8 --- /dev/null +++ b/src/public/borne/assets/js/nav.js @@ -0,0 +1,56 @@ +/* + * nav.js — Shared navigation helpers loaded on every page. + * + * Responsibilities: + * - Inject the mode badge ("Sur place" / "A emporter") into any + * element with [data-mode-badge] on the page. + * - Sync the cart item count into any element with [data-cart-count]. + * - Handle the mode query-string on page load (welcome -> categories handoff). + * + * Import this module in every page that has a header. + */ + +import { getMode, setMode, getCartCount } from './state.js'; + +/** + * Reads ?mode= from the current URL and persists it if present. + * Called once on DOMContentLoaded so that the welcome -> categories + * navigation stores the chosen mode before any render. + */ +function syncModeFromURL() { + const params = new URLSearchParams(window.location.search); + const modeParam = params.get('mode'); + if (modeParam === 'sur-place' || modeParam === 'a-emporter') { + setMode(modeParam); + } +} + +/** + * Renders the human-readable mode label into every [data-mode-badge] element. + */ +function renderModeBadge() { + const mode = getMode(); + const label = mode === 'a-emporter' ? 'A emporter' : 'Sur place'; + document.querySelectorAll('[data-mode-badge]').forEach(el => { + el.textContent = label; + }); +} + +/** + * Updates the cart item count badge in every [data-cart-count] element. + * Called on load and after any cart mutation. + */ +export function refreshCartBadge() { + const count = getCartCount(); + document.querySelectorAll('[data-cart-count]').forEach(el => { + el.textContent = count > 0 ? String(count) : ''; + el.hidden = count === 0; + }); +} + +/* Initialise on DOM ready */ +document.addEventListener('DOMContentLoaded', () => { + syncModeFromURL(); + renderModeBadge(); + refreshCartBadge(); +}); diff --git a/src/public/borne/assets/js/page-cart.js b/src/public/borne/assets/js/page-cart.js new file mode 100644 index 0000000..5354b83 --- /dev/null +++ b/src/public/borne/assets/js/page-cart.js @@ -0,0 +1,180 @@ +/* + * page-cart.js — Shopping cart screen. + * + * Displays all cart lines with quantity controls and totals. + * Handles two item shapes: + * - Simple product: { id, type, libelle, prix_cents, quantite, image } + * - Composed menu: { ...above, composition: {...}, supplement_cents: number } + * + * Menu lines render a composition breakdown beneath the product name. + * Simple product lines render as before (no composition block). + * + * TVA: 10% (taux normal restauration, France 2024 — simplification MVP). + * TODO: verify exact applicable TVA rate with an accountant in P3. + * The real rate depends on sur-place vs a-emporter, alcohol content, etc. + * + * The total displayed is TTC (tax inclusive) because French consumer law + * requires prices shown to end-consumers to include all taxes. + */ + +import { getCart, removeFromCart, updateQuantity, getTotalCents, computeMenuLineCents, clearCart, formatPrice } from './state.js'; +import { refreshCartBadge } from './nav.js'; + +/* TVA rate used for display breakdown only — stored prices are already TTC */ +const TVA_RATE = 0.10; + +const cartList = document.getElementById('cart-list'); +const emptyBlock = document.getElementById('cart-empty'); +const summaryBlock= document.getElementById('cart-summary'); +const totalTTC = document.getElementById('total-ttc'); +const totalHT = document.getElementById('total-ht'); +const totalTVA = document.getElementById('total-tva'); +const payBtn = document.getElementById('pay-btn'); +const abandonBtn = document.getElementById('abandon-btn'); + +function renderCart() { + const items = getCart(); + refreshCartBadge(); + + if (!items.length) { + cartList.innerHTML = ''; + emptyBlock.hidden = false; + summaryBlock.hidden = true; + if (payBtn) payBtn.disabled = true; + return; + } + + emptyBlock.hidden = true; + summaryBlock.hidden = false; + if (payBtn) payBtn.disabled = false; + + cartList.innerHTML = ''; + items.forEach((item, index) => { + const isMenu = item.type === 'menu'; + const lineTotalCents = isMenu + ? computeMenuLineCents(item) + : item.prix_cents * item.quantite; + + const row = document.createElement('li'); + row.className = 'cart-line'; + row.setAttribute('aria-label', `${item.libelle}, quantite ${item.quantite}`); + + row.innerHTML = ` + ${item.libelle} +
+ ${item.libelle} + ${formatPrice(item.prix_cents)} / unite${isMenu && (item.supplement_cents ?? 0) > 0 ? ` + ${formatPrice(item.supplement_cents)} suppl.` : ''} + ${isMenu && item.composition ? renderCompositionBlock(item) : ''} +
+
+ + ${item.quantite} + +
+ ${formatPrice(lineTotalCents)} + + `; + cartList.appendChild(row); + }); + + /* Attach event listeners after render */ + cartList.querySelectorAll('.qty-btn--minus').forEach(btn => { + btn.addEventListener('click', () => { + const idx = parseInt(btn.dataset.index, 10); + const cart = getCart(); + updateQuantity(idx, cart[idx].quantite - 1); + renderCart(); + }); + }); + + cartList.querySelectorAll('.qty-btn--plus').forEach(btn => { + btn.addEventListener('click', () => { + const idx = parseInt(btn.dataset.index, 10); + const cart = getCart(); + updateQuantity(idx, cart[idx].quantite + 1); + renderCart(); + }); + }); + + cartList.querySelectorAll('.cart-line__remove').forEach(btn => { + btn.addEventListener('click', () => { + const idx = parseInt(btn.dataset.index, 10); + removeFromCart(idx); + renderCart(); + }); + }); + + /* Update totals */ + const ttcCents = getTotalCents(); + /* Back-calculate HT from TTC (prices assumed to be TTC already) */ + const htCents = Math.round(ttcCents / (1 + TVA_RATE)); + const tvaCents = ttcCents - htCents; + + if (totalTTC) totalTTC.textContent = formatPrice(ttcCents); + if (totalHT) totalHT.textContent = formatPrice(htCents); + if (totalTVA) totalTVA.textContent = formatPrice(tvaCents); +} + +/** + * Builds the composition breakdown HTML for a menu cart line. + * Renders burger (with personalisation options), accompagnement with taille, + * boisson with taille, sauce, and the supplement summary if applicable. + * + * @param {Object} item — cart item with type === 'menu' and composition object + * @returns {string} HTML string + */ +function renderCompositionBlock(item) { + const c = item.composition; + if (!c) return ''; + + const burgerOpts = c.burger.options && c.burger.options.length + ? ` (${c.burger.options.map(o => o === 'sans-oignon' ? 'sans oignon' : 'avec fromage').join(', ')})` + : ''; + + const accompTailleLabel = c.accompagnement.taille === 'G' ? ' grande' : ' normale'; + const boissonTailleLabel = c.boisson.taille === 'G' ? ' grande' : ' normale'; + + const nbGrandes = (c.accompagnement.taille === 'G' ? 1 : 0) + (c.boisson.taille === 'G' ? 1 : 0); + const supplTotal = item.supplement_cents ?? 0; + + return ` +
    +
  • + ${c.burger.libelle}${burgerOpts}
  • +
  • + ${c.accompagnement.libelle}${accompTailleLabel}
  • +
  • + ${c.boisson.libelle}${boissonTailleLabel}
  • +
  • + ${c.sauce.libelle}
  • + ${supplTotal > 0 ? `
  • Supplement ${nbGrandes} grande(s) : +${formatPrice(supplTotal)}
  • ` : ''} +
+ `; +} + +if (abandonBtn) { + abandonBtn.addEventListener('click', () => { + clearCart(); + window.location.href = 'categories.html'; + }); +} + +document.addEventListener('DOMContentLoaded', renderCart); diff --git a/src/public/borne/assets/js/page-confirmation.js b/src/public/borne/assets/js/page-confirmation.js new file mode 100644 index 0000000..9173527 --- /dev/null +++ b/src/public/borne/assets/js/page-confirmation.js @@ -0,0 +1,42 @@ +/* + * page-confirmation.js — Order confirmation screen. + * + * Generates a short order number: "WK-" + Date.now() encoded in base 36. + * This is session-unique and human-readable at the counter. + * + * Clears the cart on load so that "Nouvelle commande" starts fresh. + */ + +import { clearCart, getTotalCents, formatPrice } from './state.js'; + +const orderNumberEl = document.getElementById('order-number'); +const orderTotalEl = document.getElementById('order-total'); +const newOrderBtn = document.getElementById('new-order-btn'); + +function generateOrderNumber() { + return 'WK-' + Date.now().toString(36).toUpperCase(); +} + +document.addEventListener('DOMContentLoaded', () => { + /* Capture total before clearing */ + const totalCents = getTotalCents(); + + if (orderTotalEl) { + orderTotalEl.textContent = formatPrice(totalCents); + } + + if (orderNumberEl) { + orderNumberEl.textContent = generateOrderNumber(); + } + + /* Clear cart immediately — order is confirmed */ + clearCart(); +}); + +if (newOrderBtn) { + newOrderBtn.addEventListener('click', () => { + /* clearCart() already called on DOMContentLoaded, but guard anyway */ + clearCart(); + window.location.href = 'index.html'; + }); +} diff --git a/src/public/borne/assets/js/page-product-menu.js b/src/public/borne/assets/js/page-product-menu.js new file mode 100644 index 0000000..7ceb13e --- /dev/null +++ b/src/public/borne/assets/js/page-product-menu.js @@ -0,0 +1,702 @@ +/* + * page-product-menu.js — Multi-step menu composer for the Wakdo kiosk. + * + * Imported by page-product.js only when the loaded product has type === 'menu'. + * Keeping the composer in its own module avoids bloating page-product.js and + * makes future unit-testing of the composition logic straightforward. + * + * Steps: + * 1 — Burger selection + personalisation options (sans oignon / avec fromage) + * 2 — Accompagnement (frites or salades) + taille toggle + * 3 — Boisson + taille toggle + * 4 — Sauce + * 5 — Recap + "Ajouter au panier" + * + * Price rule: grande taille = +50 centimes per sized item (accompagnement + boisson). + * + * A11y: role=dialog, aria-modal=true, focus-trap (Tab cycles inside the modal), + * ESC closes/cancels, focus is moved to the first interactive element on each step. + */ + +import { getProductsByCategory } from './data.js'; +import { addToCart, computeMenuLineCents, formatPrice } from './state.js'; +import { refreshCartBadge } from './nav.js'; + +const SUPPLEMENT_GRANDE_CENTS = 50; +const TOTAL_STEPS = 5; + +/* ------------------------------------------------------------------ */ +/* Public entry-point — called from page-product.js */ +/* ------------------------------------------------------------------ */ + +/** + * Initialises and opens the menu composer modal. + * Fetches required category products, builds the initial state, then renders. + * + * @param {Object} menu — product object with type === 'menu' + * @param {string} returnCategory — category slug to redirect to after add/cancel + */ +export async function openMenuComposer(menu, returnCategory) { + let burgers, frites, salades, boissons, sauces; + try { + [burgers, frites, salades, boissons, sauces] = await Promise.all([ + getProductsByCategory('burgers'), + getProductsByCategory('frites'), + getProductsByCategory('salades'), + getProductsByCategory('boissons'), + getProductsByCategory('sauces') + ]); + } catch (err) { + console.error('Menu composer: failed to load category products', err); + return; + } + + const accompagnements = [...frites, ...salades]; + + /* Heuristic pre-selection: if the menu name contains a burger name, pre-select it. + * "Menu CBO" -> first burger whose nom equals "CBO". + * Fallback: first burger in the list. */ + const menuNameUpper = menu.nom.toUpperCase(); + const preselectedBurger = + burgers.find(b => menuNameUpper.includes(b.nom.toUpperCase())) ?? burgers[0] ?? null; + + /* Composer internal state — single mutable object, re-read on each render. */ + const state = { + currentStep: 1, + menu, + returnCategory, + burgers, + accompagnements, + boissons, + sauces, + /* Selections */ + burger: preselectedBurger, + burgerOptions: [], // subset of ['sans-oignon', 'avec-fromage'] + accompagnement: accompagnements[0] ?? null, + accompTaille: 'N', // 'N' or 'G' + boisson: boissons[0] ?? null, + boissonTaille: 'N', + sauce: sauces[0] ?? null + }; + + const modal = buildModalShell(menu); + document.body.appendChild(modal); + modal.removeAttribute('hidden'); + + /* Prevent background scroll while composer is open. */ + document.body.style.overflow = 'hidden'; + + renderStep(modal, state); + trapFocus(modal); + + /* ESC closes the modal and returns to product list. */ + const escHandler = (e) => { + if (e.key === 'Escape') { + cancelComposer(modal, returnCategory, escHandler); + } + }; + document.addEventListener('keydown', escHandler); +} + +/* ------------------------------------------------------------------ */ +/* Modal shell builder */ +/* ------------------------------------------------------------------ */ + +function buildModalShell(menu) { + const overlay = document.createElement('div'); + overlay.className = 'composer-overlay'; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.setAttribute('aria-labelledby', 'composer-title'); + overlay.hidden = true; + + overlay.innerHTML = ` +
+
+

${escHtml(menu.nom)}

+
+ Etape 1 / ${TOTAL_STEPS} +
+
+
+
+
+
+ +
+ +
+ `; + return overlay; +} + +/* ------------------------------------------------------------------ */ +/* Step renderer — decides which step to paint */ +/* ------------------------------------------------------------------ */ + +function renderStep(modal, state) { + const body = modal.querySelector('#composer-body'); + const footer = modal.querySelector('#composer-footer'); + const stepEl = modal.querySelector('#composer-step-indicator'); + const fillEl = modal.querySelector('#composer-progress-fill'); + + stepEl.textContent = `Etape ${state.currentStep} / ${TOTAL_STEPS}`; + fillEl.style.width = `${(state.currentStep / TOTAL_STEPS) * 100}%`; + + /* Each step renderer returns {bodyHTML, canAdvance()} and may attach + * its own event listeners after DOM insertion. */ + switch (state.currentStep) { + case 1: renderStep1(body, footer, modal, state); break; + case 2: renderStep2(body, footer, modal, state); break; + case 3: renderStep3(body, footer, modal, state); break; + case 4: renderStep4(body, footer, modal, state); break; + case 5: renderStep5(body, footer, modal, state); break; + } + + /* Move focus to the first interactive element so keyboard users and + * screen readers start at the right place after each step transition. */ + requestAnimationFrame(() => { + const first = modal.querySelector( + 'button:not([disabled]), input:not([disabled]), [tabindex="0"]' + ); + if (first) first.focus(); + }); +} + +/* ------------------------------------------------------------------ */ +/* Step 1 — Burger + personalisation options */ +/* ------------------------------------------------------------------ */ + +function renderStep1(body, footer, modal, state) { + body.innerHTML = ` +

Choisissez votre burger

+
    + ${state.burgers.map(b => ` +
  • + +
  • + `).join('')} +
+ +
+ Personnalisation + + +
+ `; + + /* Burger card selection */ + body.querySelectorAll('#burger-grid .composer-card').forEach(btn => { + btn.addEventListener('click', () => { + const id = parseInt(btn.dataset.id, 10); + state.burger = state.burgers.find(b => b.id === id) ?? state.burger; + /* Update pressed states without full re-render to preserve scroll position */ + body.querySelectorAll('#burger-grid .composer-card').forEach(b => { + const active = parseInt(b.dataset.id, 10) === state.burger.id; + b.classList.toggle('composer-card--selected', active); + b.setAttribute('aria-pressed', active ? 'true' : 'false'); + }); + }); + }); + + /* Personalisation checkboxes */ + body.querySelectorAll('input[name="burger-opt"]').forEach(cb => { + cb.addEventListener('change', () => { + state.burgerOptions = Array.from( + body.querySelectorAll('input[name="burger-opt"]:checked') + ).map(el => el.value); + }); + }); + + renderFooter(footer, modal, state, { + canAdvance: () => state.burger !== null + }); +} + +/* ------------------------------------------------------------------ */ +/* Step 2 — Accompagnement + taille toggle */ +/* ------------------------------------------------------------------ */ + +function renderStep2(body, footer, modal, state) { + body.innerHTML = ` +

Choisissez votre accompagnement

+
    + ${state.accompagnements.map(a => ` +
  • + +
  • + `).join('')} +
+ ${renderTailleToggle('accomp', state.accompTaille)} + `; + + body.querySelectorAll('#accomp-grid .composer-card').forEach(btn => { + btn.addEventListener('click', () => { + const id = parseInt(btn.dataset.id, 10); + state.accompagnement = state.accompagnements.find(a => a.id === id) ?? state.accompagnement; + body.querySelectorAll('#accomp-grid .composer-card').forEach(b => { + const active = parseInt(b.dataset.id, 10) === state.accompagnement.id; + b.classList.toggle('composer-card--selected', active); + b.setAttribute('aria-pressed', active ? 'true' : 'false'); + }); + }); + }); + + attachTailleToggle(body, 'accomp', state, 'accompTaille'); + + renderFooter(footer, modal, state, { + canAdvance: () => state.accompagnement !== null + }); +} + +/* ------------------------------------------------------------------ */ +/* Step 3 — Boisson + taille toggle */ +/* ------------------------------------------------------------------ */ + +function renderStep3(body, footer, modal, state) { + body.innerHTML = ` +

Choisissez votre boisson

+
    + ${state.boissons.map(b => ` +
  • + +
  • + `).join('')} +
+ ${renderTailleToggle('boisson', state.boissonTaille)} + `; + + body.querySelectorAll('#boisson-grid .composer-card').forEach(btn => { + btn.addEventListener('click', () => { + const id = parseInt(btn.dataset.id, 10); + state.boisson = state.boissons.find(b => b.id === id) ?? state.boisson; + body.querySelectorAll('#boisson-grid .composer-card').forEach(b => { + const active = parseInt(b.dataset.id, 10) === state.boisson.id; + b.classList.toggle('composer-card--selected', active); + b.setAttribute('aria-pressed', active ? 'true' : 'false'); + }); + }); + }); + + attachTailleToggle(body, 'boisson', state, 'boissonTaille'); + + renderFooter(footer, modal, state, { + canAdvance: () => state.boisson !== null + }); +} + +/* ------------------------------------------------------------------ */ +/* Step 4 — Sauce */ +/* ------------------------------------------------------------------ */ + +function renderStep4(body, footer, modal, state) { + body.innerHTML = ` +

Choisissez votre sauce

+
    + ${state.sauces.map(s => ` +
  • + +
  • + `).join('')} +
+ `; + + body.querySelectorAll('#sauce-grid .composer-card').forEach(btn => { + btn.addEventListener('click', () => { + const id = parseInt(btn.dataset.id, 10); + state.sauce = state.sauces.find(s => s.id === id) ?? state.sauce; + body.querySelectorAll('#sauce-grid .composer-card').forEach(b => { + const active = parseInt(b.dataset.id, 10) === state.sauce.id; + b.classList.toggle('composer-card--selected', active); + b.setAttribute('aria-pressed', active ? 'true' : 'false'); + }); + }); + }); + + renderFooter(footer, modal, state, { + canAdvance: () => state.sauce !== null + }); +} + +/* ------------------------------------------------------------------ */ +/* Step 5 — Recap + add to cart */ +/* ------------------------------------------------------------------ */ + +function renderStep5(body, footer, modal, state) { + const supplement = computeSupplement(state); + const baseItem = buildCartItem(state, supplement); + const totalLine = computeMenuLineCents(baseItem); + + const optionsText = state.burgerOptions.length + ? state.burgerOptions.map(o => o === 'sans-oignon' ? 'sans oignon' : 'avec fromage').join(', ') + : null; + + body.innerHTML = ` +

Recapitulatif de votre menu

+
    +
  • + + + ${escHtml(state.burger.nom)} + ${optionsText ? `(${escHtml(optionsText)})` : ''} + +
  • +
  • + + + ${escHtml(state.accompagnement.nom)} + ${state.accompTaille === 'G' ? 'grande' : 'normale'} + ${state.accompTaille === 'G' ? '+0,50 EUR' : ''} + +
  • +
  • + + + ${escHtml(state.boisson.nom)} + ${state.boissonTaille === 'G' ? 'grande' : 'normale'} + ${state.boissonTaille === 'G' ? '+0,50 EUR' : ''} + +
  • +
  • + + ${escHtml(state.sauce.nom)} +
  • +
+
+ Menu de base : ${formatPrice(state.menu.prix_cents ?? state.menu.prix)} + ${supplement > 0 ? `Supplement grande(s) taille(s) : +${formatPrice(supplement)}` : ''} + Total : ${formatPrice(totalLine)} +
+ `; + + footer.innerHTML = ` + + `; + + footer.querySelector('#composer-cancel').addEventListener('click', () => { + cancelComposer(modal, state.returnCategory, null); + }); + + footer.querySelector('#composer-prev').addEventListener('click', () => { + state.currentStep--; + renderStep(modal, state); + }); + + footer.querySelector('#composer-add').addEventListener('click', () => { + addToCart(baseItem); + refreshCartBadge(); + closeComposer(modal); + window.location.href = `products.html?category=${state.returnCategory}`; + }); +} + +/* ------------------------------------------------------------------ */ +/* Footer renderer (steps 1-4) */ +/* ------------------------------------------------------------------ */ + +/** + * Renders the navigation footer for steps 1 through 4. + * @param {HTMLElement} footer + * @param {HTMLElement} modal + * @param {Object} state + * @param {{ canAdvance: () => boolean }} opts + */ +function renderFooter(footer, modal, state, opts) { + const isFirst = state.currentStep === 1; + + footer.innerHTML = ` + + `; + + footer.querySelector('#composer-cancel').addEventListener('click', () => { + cancelComposer(modal, state.returnCategory, null); + }); + + if (!isFirst) { + footer.querySelector('#composer-prev').addEventListener('click', () => { + state.currentStep--; + renderStep(modal, state); + }); + } + + footer.querySelector('#composer-next').addEventListener('click', () => { + if (!opts.canAdvance()) return; + state.currentStep++; + renderStep(modal, state); + }); +} + +/* ------------------------------------------------------------------ */ +/* Taille toggle — shared between accompagnement and boisson steps */ +/* ------------------------------------------------------------------ */ + +/** + * Generates the HTML for the Normale/Grande toggle. + * @param {string} prefix — 'accomp' or 'boisson', used for IDs + * @param {'N'|'G'} currentTaille + * @returns {string} + */ +function renderTailleToggle(prefix, currentTaille) { + return ` +
+ + +
+ `; +} + +/** + * Attaches click handlers to the taille toggle buttons and keeps state in sync. + * @param {HTMLElement} body + * @param {string} prefix + * @param {Object} state + * @param {'accompTaille'|'boissonTaille'} stateKey + */ +function attachTailleToggle(body, prefix, state, stateKey) { + body.querySelectorAll('.composer-taille__btn').forEach(btn => { + btn.addEventListener('click', () => { + state[stateKey] = btn.dataset.taille; + body.querySelectorAll('.composer-taille__btn').forEach(b => { + const active = b.dataset.taille === state[stateKey]; + b.classList.toggle('composer-taille__btn--active', active); + b.setAttribute('aria-pressed', active ? 'true' : 'false'); + }); + }); + }); +} + +/* ------------------------------------------------------------------ */ +/* Cart item assembly + supplement calculation */ +/* ------------------------------------------------------------------ */ + +/** + * Counts how many grande-taille choices were made (0, 1, or 2). + * @param {Object} state + * @returns {number} centimes + */ +function computeSupplement(state) { + let suppl = 0; + if (state.accompTaille === 'G') suppl += SUPPLEMENT_GRANDE_CENTS; + if (state.boissonTaille === 'G') suppl += SUPPLEMENT_GRANDE_CENTS; + return suppl; +} + +/** + * Builds the cart item object from the current composer state. + * prix_cents is the base menu price; supplement_cents accumulates size upgrades. + * + * @param {Object} state + * @param {number} supplement + * @returns {Object} + */ +function buildCartItem(state, supplement) { + /* Support both raw produits.json field (prix) and normalised (prix_cents) */ + const prixCents = state.menu.prix_cents ?? state.menu.prix; + + return { + id: state.menu.id, + type: 'menu', + categorie: 'menus', + libelle: state.menu.nom, + prix_cents: prixCents, + quantite: 1, + image: state.menu.image, + supplement_cents: supplement, + composition: { + burger: { + id: state.burger.id, + libelle: state.burger.nom, + options: [...state.burgerOptions] + }, + accompagnement: { + id: state.accompagnement.id, + libelle: state.accompagnement.nom, + categorie: state.accompagnement.categorie ?? 'frites', + taille: state.accompTaille + }, + boisson: { + id: state.boisson.id, + libelle: state.boisson.nom, + taille: state.boissonTaille + }, + sauce: { + id: state.sauce.id, + libelle: state.sauce.nom + } + } + }; +} + +/* ------------------------------------------------------------------ */ +/* Focus trap */ +/* ------------------------------------------------------------------ */ + +/** + * Traps Tab / Shift+Tab inside the modal container. + * The handler is attached to the modal element itself; it is removed + * automatically when the modal is removed from the DOM. + */ +function trapFocus(modal) { + modal.addEventListener('keydown', (e) => { + if (e.key !== 'Tab') return; + + const focusable = Array.from(modal.querySelectorAll( + 'button:not([disabled]), input:not([disabled]), [tabindex="0"]' + )).filter(el => !el.closest('[hidden]')); + + if (!focusable.length) return; + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + }); +} + +/* ------------------------------------------------------------------ */ +/* Close helpers */ +/* ------------------------------------------------------------------ */ + +function closeComposer(modal) { + modal.remove(); + document.body.style.overflow = ''; +} + +function cancelComposer(modal, returnCategory, escHandler) { + if (escHandler) { + document.removeEventListener('keydown', escHandler); + } + closeComposer(modal); + window.location.href = `products.html?category=${returnCategory}`; +} + +/* ------------------------------------------------------------------ */ +/* Utilities */ +/* ------------------------------------------------------------------ */ + +/** + * Minimal HTML escaping to prevent XSS when injecting product names/paths + * into innerHTML. Applied to all data-derived strings. + */ +function escHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/src/public/borne/assets/js/page-product.js b/src/public/borne/assets/js/page-product.js new file mode 100644 index 0000000..d345e1f --- /dev/null +++ b/src/public/borne/assets/js/page-product.js @@ -0,0 +1,119 @@ +/* + * page-product.js — Product detail screen. + * + * Reads ?id=&category= from the query string. + * + * Branch on product type: + * - type === 'menu' → open the multi-step composer modal (page-product-menu.js). + * The standard detail layout is bypassed because a menu + * cannot be added to the cart without composition choices. + * - type === 'produit' → render the standard detail card with "Ajouter au panier". + * + * After "Ajouter au panier" (simple product): + * 1. Item added to cart via state.addToCart() + * 2. Button changes to "Ajoute !" for 1 second (visual feedback) + * 3. Redirect to products.html?category= + */ + +import { findProduct } from './data.js'; +import { addToCart, formatPrice } from './state.js'; +import { refreshCartBadge } from './nav.js'; +import { openMenuComposer } from './page-product-menu.js'; + +const params = new URLSearchParams(window.location.search); +const productId = parseInt(params.get('id'), 10); +const categorySlug = params.get('category') ?? 'menus'; + +const container = document.getElementById('product-detail'); +const errorBlock = document.getElementById('product-error'); +const backBtn = document.getElementById('back-to-products'); + +if (backBtn) { + backBtn.href = `products.html?category=${categorySlug}`; +} + +async function renderProduct() { + if (!productId) { + showError('Produit introuvable.'); + return; + } + + try { + const product = await findProduct(productId); + if (!product) { + showError('Ce produit n\'existe pas.'); + return; + } + + document.title = `Wakdo - ${product.nom}`; + + if (product.type === 'menu') { + /* Hide the standard product detail area; the composer will overlay the page. + * The container stays in the DOM so the skeleton does not flash. */ + container.hidden = true; + await openMenuComposer(product, categorySlug); + return; + } + + container.innerHTML = ` +
+ ${product.nom} +
+
+

${product.nom}

+

${formatPrice(product.prix)}

+ +
+ `; + + document.getElementById('add-to-cart-btn').addEventListener('click', () => { + addToCart({ + id: product.id, + type: product.type, + categorie: product.categorie ?? categorySlug, + libelle: product.nom, + prix_cents: product.prix, + quantite: 1, + image: product.image + }); + refreshCartBadge(); + + const btn = document.getElementById('add-to-cart-btn'); + btn.textContent = 'Ajoute !'; + btn.disabled = true; + + /* Redirect after brief confirmation pause */ + setTimeout(() => { + window.location.href = `products.html?category=${categorySlug}`; + }, 1000); + }); + + } catch (err) { + showError('Erreur lors du chargement du produit.'); + console.error('renderProduct error:', err); + } +} + +function showError(msg) { + if (errorBlock) { + errorBlock.hidden = false; + errorBlock.textContent = msg; + } + if (container) { + container.hidden = true; + } +} + +document.addEventListener('DOMContentLoaded', renderProduct); diff --git a/src/public/borne/assets/js/page-products.js b/src/public/borne/assets/js/page-products.js new file mode 100644 index 0000000..ba7df44 --- /dev/null +++ b/src/public/borne/assets/js/page-products.js @@ -0,0 +1,86 @@ +/* + * page-products.js — Products list screen. + * + * Reads ?category= from the query string, maps to a slug via + * CATEGORY_ID_TO_SLUG, then fetches the matching product array. + * On product card click, navigates to product.html?id=&category=. + */ + +import { getProductsByCategory, getCategoryById, CATEGORY_ID_TO_SLUG } from './data.js'; +import { formatPrice } from './state.js'; + +const params = new URLSearchParams(window.location.search); +const categoryId = parseInt(params.get('category'), 10) || 1; +const categorySlug = CATEGORY_ID_TO_SLUG[categoryId] ?? 'menus'; + +const grid = document.getElementById('products-grid'); +const heading = document.getElementById('products-heading'); +const backBtn = document.getElementById('back-to-categories'); +const errorBlock = document.getElementById('products-error'); + +/* Build back URL preserving mode query param if present */ +const modeParam = params.get('mode'); + +function buildBackURL() { + const base = 'categories.html'; + return modeParam ? `${base}?mode=${modeParam}` : base; +} + +if (backBtn) { + backBtn.href = buildBackURL(); +} + +async function renderProducts() { + try { + const [products, category] = await Promise.all([ + getProductsByCategory(categorySlug), + getCategoryById(categoryId) + ]); + + if (heading && category) { + /* Capitalize first letter of the category title */ + const title = category.title.charAt(0).toUpperCase() + category.title.slice(1); + heading.textContent = `Nos ${title}`; + document.title = `Wakdo - ${title}`; + } + + if (!products.length) { + grid.innerHTML = '

Aucun produit disponible dans cette categorie.

'; + return; + } + + grid.innerHTML = ''; + products.forEach(product => { + const card = document.createElement('a'); + card.className = 'product-card'; + card.href = `product.html?id=${product.id}&category=${categorySlug}`; + card.setAttribute('aria-label', `${product.nom} - ${formatPrice(product.prix)}`); + + card.innerHTML = ` +
+ ${product.nom} +
+
+ ${product.nom} + ${formatPrice(product.prix)} +
+ `; + grid.appendChild(card); + }); + + } catch (err) { + if (errorBlock) { + errorBlock.hidden = false; + errorBlock.textContent = 'Impossible de charger les produits. Veuillez reessayer.'; + } + console.error('renderProducts error:', err); + } +} + +document.addEventListener('DOMContentLoaded', renderProducts); diff --git a/src/public/borne/assets/js/state.js b/src/public/borne/assets/js/state.js new file mode 100644 index 0000000..6184848 --- /dev/null +++ b/src/public/borne/assets/js/state.js @@ -0,0 +1,174 @@ +/* + * state.js — Global client-side state for the Wakdo kiosk. + * + * Persists via localStorage so that navigation between pages does not + * lose the cart or the consumption mode. + * + * Price convention: all values stored and computed in INTEGER CENTIMES. + * Formatting for display is handled by formatPrice(). + * + * TVA note: 10% applied at display time in cart/payment pages only. + * This is a simplified rate for restaurant consumption (France 2024). + * TODO: verify exact applicable rate with an accountant in P3 — the real + * rate depends on sur-place vs a-emporter, alcohol content, etc. + */ + +const STORAGE_KEY_MODE = 'wakdo_mode'; +const STORAGE_KEY_CART = 'wakdo_cart'; + +/* --- Consumption mode ---------------------------------------------------- */ + +/** + * Returns the stored consumption mode string or null if not yet chosen. + * @returns {'sur-place'|'a-emporter'|null} + */ +export function getMode() { + return localStorage.getItem(STORAGE_KEY_MODE); +} + +/** + * Persists the consumption mode chosen on the welcome screen. + * @param {'sur-place'|'a-emporter'} mode + */ +export function setMode(mode) { + localStorage.setItem(STORAGE_KEY_MODE, mode); +} + +/* --- Cart state ---------------------------------------------------------- */ + +/** + * Returns the current cart array. + * Each item shape: + * { id, type: 'produit'|'menu', categorie, libelle, prix_cents, quantite, image } + * @returns {Array} + */ +export function getCart() { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY_CART)) || []; + } catch { + return []; + } +} + +/** + * Replaces the entire cart. + * @param {Array} items + */ +export function setCart(items) { + localStorage.setItem(STORAGE_KEY_CART, JSON.stringify(items)); +} + +/** + * Appends a product or menu to the cart. + * + * For simple products (type !== 'menu'), merges with an existing line + * of the same id — matching real kiosk behavior where two identical + * sandwiches become one line with qty 2. + * + * For composed menus, each call always creates a new line because two + * menus with identical base id may have different compositions (different + * burger options, sizes, sauces). This prevents silent composition loss. + * + * Item shapes: + * Simple: { id, type, categorie, libelle, prix_cents, quantite, image } + * Menu: { ...above, composition: {...}, supplement_cents: number } + * + * @param {Object} item + */ +export function addToCart(item) { + const cart = getCart(); + if (item.type !== 'menu') { + const existing = cart.find(c => c.id === item.id && c.type === item.type); + if (existing) { + existing.quantite += item.quantite ?? 1; + setCart(cart); + return; + } + } + cart.push({ quantite: 1, ...item }); + setCart(cart); +} + +/** + * Removes the item at the given index from the cart. + * @param {number} index + */ +export function removeFromCart(index) { + const cart = getCart(); + cart.splice(index, 1); + setCart(cart); +} + +/** + * Sets the quantity for the item at the given index. + * If qty reaches 0, the item is removed. + * @param {number} index + * @param {number} qty + */ +export function updateQuantity(index, qty) { + const cart = getCart(); + if (qty <= 0) { + cart.splice(index, 1); + } else { + cart[index].quantite = qty; + } + setCart(cart); +} + +/** + * Empties the cart completely. + */ +export function clearCart() { + localStorage.removeItem(STORAGE_KEY_CART); +} + +/* --- Totals -------------------------------------------------------------- */ + +/** + * Computes the line total in centimes for a menu item including size supplements. + * For simple product items the caller should use (prix_cents * quantite) directly. + * @param {{ prix_cents: number, supplement_cents: number, quantite: number }} item + * @returns {number} + */ +export function computeMenuLineCents(item) { + return (item.prix_cents + (item.supplement_cents ?? 0)) * item.quantite; +} + +/** + * Returns the sum of all line totals in centimes. + * Menu items include their size supplements; simple items do not carry supplements. + * @returns {number} + */ +export function getTotalCents() { + return getCart().reduce((sum, item) => { + if (item.type === 'menu') { + return sum + computeMenuLineCents(item); + } + return sum + item.prix_cents * item.quantite; + }, 0); +} + +/* --- Formatting helpers -------------------------------------------------- */ + +/** + * Formats a centimes integer into a French locale price string. + * Example: 490 -> "4,90 EUR" + * @param {number} cents + * @returns {string} + */ +export function formatPrice(cents) { + const euros = cents / 100; + return euros.toLocaleString('fr-FR', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }) + ' EUR'; +} + +/** + * Returns the item count (sum of all quantities) in the cart. + * Used to show a badge on the cart button. + * @returns {number} + */ +export function getCartCount() { + return getCart().reduce((sum, item) => sum + item.quantite, 0); +} diff --git a/src/public/borne/cart.html b/src/public/borne/cart.html new file mode 100644 index 0000000..6c49f84 --- /dev/null +++ b/src/public/borne/cart.html @@ -0,0 +1,99 @@ + + + + + + + + Wakdo - Panier + + + + + + + + +
+ +

Votre panier

+ + + + + +
    + +
+ + + + + +
+ + + Valider ma commande + +
+ +
+ + + + + diff --git a/src/public/borne/categories.html b/src/public/borne/categories.html new file mode 100644 index 0000000..64a0453 --- /dev/null +++ b/src/public/borne/categories.html @@ -0,0 +1,179 @@ + + + + + + + + Wakdo - Categories + + + + + + + + +
+

Que souhaitez-vous commander ?

+

Choisissez une categorie pour decouvrir nos produits

+ + + +
+ + + diff --git a/src/public/borne/confirmation.html b/src/public/borne/confirmation.html new file mode 100644 index 0000000..4c86263 --- /dev/null +++ b/src/public/borne/confirmation.html @@ -0,0 +1,69 @@ + + + + + + + + Wakdo - Confirmation + + + + + + + + +
+ +
+ + + + +

Commande confirmee !

+ +

Votre commande est en preparation

+ +
+ Votre numero de commande + +
+ +

+ Montant regle : +

+ +

+ Temps d'attente estime : 5 - 10 minutes +

+ +
+ + + +
+ + + + diff --git a/src/public/borne/data/categories.json b/src/public/borne/data/categories.json new file mode 100644 index 0000000..83bb4f5 --- /dev/null +++ b/src/public/borne/data/categories.json @@ -0,0 +1,11 @@ +[ + { "id": 1, "title": "menus", "slug": "menus", "image": "assets/images/categories/menus.png" }, + { "id": 2, "title": "boissons", "slug": "boissons", "image": "assets/images/categories/boissons.png" }, + { "id": 3, "title": "burgers", "slug": "burgers", "image": "assets/images/categories/burgers.png" }, + { "id": 4, "title": "frites", "slug": "frites", "image": "assets/images/categories/frites.png" }, + { "id": 5, "title": "encas", "slug": "encas", "image": "assets/images/categories/encas.png" }, + { "id": 6, "title": "wraps", "slug": "wraps", "image": "assets/images/categories/wraps.png" }, + { "id": 7, "title": "salades", "slug": "salades", "image": "assets/images/categories/salades.png" }, + { "id": 8, "title": "desserts", "slug": "desserts", "image": "assets/images/categories/desserts.png" }, + { "id": 9, "title": "sauces", "slug": "sauces", "image": "assets/images/categories/sauces.png" } +] diff --git a/src/public/borne/data/produits.json b/src/public/borne/data/produits.json new file mode 100644 index 0000000..37b2732 --- /dev/null +++ b/src/public/borne/data/produits.json @@ -0,0 +1,86 @@ +{ + "menus": [ + { "id": 1, "nom": "Menu Le 280", "prix": 880, "image": "assets/images/produits/burgers/280.png", "type": "menu" }, + { "id": 2, "nom": "Menu Big Tasty", "prix": 1060, "image": "assets/images/produits/burgers/big-tasty-1-viande.png", "type": "menu" }, + { "id": 3, "nom": "Menu Big Tasty Bacon", "prix": 1090, "image": "assets/images/produits/burgers/big-tasty-bacon-1-viande.png", "type": "menu" }, + { "id": 4, "nom": "Menu Big Mac", "prix": 800, "image": "assets/images/produits/burgers/bigmac.png", "type": "menu" }, + { "id": 5, "nom": "Menu CBO", "prix": 1090, "image": "assets/images/produits/burgers/cbo.png", "type": "menu" }, + { "id": 6, "nom": "Menu MC Chicken", "prix": 930, "image": "assets/images/produits/burgers/mcchicken.png", "type": "menu" }, + { "id": 7, "nom": "Menu MC Crispy", "prix": 720, "image": "assets/images/produits/burgers/mccrispy.png", "type": "menu" }, + { "id": 8, "nom": "Menu MC Fish", "prix": 720, "image": "assets/images/produits/burgers/mcfish.png", "type": "menu" }, + { "id": 9, "nom": "Menu Royal Bacon", "prix": 705, "image": "assets/images/produits/burgers/royalbacon.png", "type": "menu" }, + { "id": 10, "nom": "Menu Royal Cheese", "prix": 640, "image": "assets/images/produits/burgers/royalcheese.png", "type": "menu" }, + { "id": 11, "nom": "Menu Royal Deluxe", "prix": 740, "image": "assets/images/produits/burgers/royaldeluxe.png", "type": "menu" }, + { "id": 12, "nom": "Menu Signature BBQ Beef 2 viandes","prix": 1350,"image": "assets/images/produits/burgers/signature-bbq-beef-2-viandes.png", "type": "menu" }, + { "id": 13, "nom": "Menu Signature Beef BBQ", "prix": 1190, "image": "assets/images/produits/burgers/signature-beef-bbq-burger-1-viande.png", "type": "menu" } + ], + "burgers": [ + { "id": 14, "nom": "Le 280", "prix": 680, "image": "assets/images/produits/burgers/280.png", "type": "produit" }, + { "id": 15, "nom": "Big Tasty", "prix": 860, "image": "assets/images/produits/burgers/big-tasty-1-viande.png", "type": "produit" }, + { "id": 16, "nom": "Big Tasty Bacon", "prix": 890, "image": "assets/images/produits/burgers/big-tasty-bacon-1-viande.png", "type": "produit" }, + { "id": 17, "nom": "Big Mac", "prix": 600, "image": "assets/images/produits/burgers/bigmac.png", "type": "produit" }, + { "id": 18, "nom": "CBO", "prix": 890, "image": "assets/images/produits/burgers/cbo.png", "type": "produit" }, + { "id": 19, "nom": "MC Chicken", "prix": 730, "image": "assets/images/produits/burgers/mcchicken.png", "type": "produit" }, + { "id": 20, "nom": "MC Crispy", "prix": 530, "image": "assets/images/produits/burgers/mccrispy.png", "type": "produit" }, + { "id": 21, "nom": "MC Fish", "prix": 485, "image": "assets/images/produits/burgers/mcfish.png", "type": "produit" }, + { "id": 22, "nom": "Royal Bacon", "prix": 510, "image": "assets/images/produits/burgers/royalbacon.png", "type": "produit" }, + { "id": 23, "nom": "Royal Cheese", "prix": 440, "image": "assets/images/produits/burgers/royalcheese.png", "type": "produit" }, + { "id": 24, "nom": "Royal Deluxe", "prix": 540, "image": "assets/images/produits/burgers/royaldeluxe.png", "type": "produit" }, + { "id": 25, "nom": "Signature BBQ Beef 2 viandes","prix": 1140,"image": "assets/images/produits/burgers/signature-bbq-beef-2-viandes.png","type": "produit" }, + { "id": 26, "nom": "Signature Beef BBQ", "prix": 1030, "image": "assets/images/produits/burgers/signature-beef-bbq-burger-1-viande.png","type": "produit" } + ], + "boissons": [ + { "id": 27, "nom": "Coca Cola", "prix": 190, "image": "assets/images/produits/boissons/coca-cola.png", "type": "produit" }, + { "id": 28, "nom": "Coca Sans Sucres", "prix": 190, "image": "assets/images/produits/boissons/coca-sans-sucres.png", "type": "produit" }, + { "id": 29, "nom": "Eau", "prix": 100, "image": "assets/images/produits/boissons/eau.png", "type": "produit" }, + { "id": 30, "nom": "Fanta Orange", "prix": 190, "image": "assets/images/produits/boissons/fanta.png", "type": "produit" }, + { "id": 31, "nom": "Ice Tea Peche", "prix": 190, "image": "assets/images/produits/boissons/ice-tea-peche.png", "type": "produit" }, + { "id": 32, "nom": "Ice Tea Citron", "prix": 190, "image": "assets/images/produits/boissons/the-vert-citron-sans-sucres.png", "type": "produit" }, + { "id": 33, "nom": "Jus d'Orange", "prix": 210, "image": "assets/images/produits/boissons/jus-orange.png", "type": "produit" }, + { "id": 34, "nom": "Jus de Pommes Bio", "prix": 230, "image": "assets/images/produits/boissons/jus-pomme-bio.png", "type": "produit" } + ], + "frites": [ + { "id": 35, "nom": "Petite Frite", "prix": 145, "image": "assets/images/produits/frites/petite-frite.png", "type": "produit" }, + { "id": 36, "nom": "Moyenne Frite", "prix": 275, "image": "assets/images/produits/frites/moyenne-frite.png", "type": "produit" }, + { "id": 37, "nom": "Grande Frite", "prix": 350, "image": "assets/images/produits/frites/grande-frite.png", "type": "produit" }, + { "id": 38, "nom": "Potatoes", "prix": 215, "image": "assets/images/produits/frites/potatoes.png", "type": "produit" }, + { "id": 39, "nom": "Grande Potatoes", "prix": 340, "image": "assets/images/produits/frites/grande-potatoes.png", "type": "produit" } + ], + "encas": [ + { "id": 40, "nom": "Cheeseburger", "prix": 260, "image": "assets/images/produits/encas/cheeseburger.png", "type": "produit" }, + { "id": 41, "nom": "Croc MCdo", "prix": 320, "image": "assets/images/produits/encas/croc-mc-do.png", "type": "produit" }, + { "id": 42, "nom": "Nuggets x4", "prix": 420, "image": "assets/images/produits/encas/nuggets-4.png", "type": "produit" }, + { "id": 43, "nom": "Nuggets x20", "prix": 1300, "image": "assets/images/produits/encas/nuggets-20.png", "type": "produit" } + ], + "desserts": [ + { "id": 44, "nom": "Brownie", "prix": 260, "image": "assets/images/produits/desserts/brownies.png", "type": "produit" }, + { "id": 45, "nom": "Cheesecake Chocolat M&M's","prix": 310, "image": "assets/images/produits/desserts/cheesecake-choconuts-m&m-s.png", "type": "produit" }, + { "id": 46, "nom": "Cheesecake Fraise", "prix": 310, "image": "assets/images/produits/desserts/cheesecake-fraise.png", "type": "produit" }, + { "id": 47, "nom": "Cookie", "prix": 320, "image": "assets/images/produits/desserts/cookie.png", "type": "produit" }, + { "id": 48, "nom": "Donut", "prix": 260, "image": "assets/images/produits/desserts/doghnut.png", "type": "produit" }, + { "id": 49, "nom": "Macarons", "prix": 270, "image": "assets/images/produits/desserts/macarons.png", "type": "produit" }, + { "id": 50, "nom": "MC Fleury", "prix": 440, "image": "assets/images/produits/desserts/mcfleury.png", "type": "produit" }, + { "id": 51, "nom": "Muffin", "prix": 360, "image": "assets/images/produits/desserts/muffin.png", "type": "produit" }, + { "id": 52, "nom": "Sunday", "prix": 100, "image": "assets/images/produits/desserts/sunday.png", "type": "produit" } + ], + "sauces": [ + { "id": 53, "nom": "Classic Barbecue", "prix": 70, "image": "assets/images/produits/sauces/classic-barbecue.png", "type": "produit" }, + { "id": 54, "nom": "Classic Moutarde", "prix": 70, "image": "assets/images/produits/sauces/classic-moutarde.png", "type": "produit" }, + { "id": 55, "nom": "Creamy Deluxe", "prix": 70, "image": "assets/images/produits/sauces/cremy-deluxe.png", "type": "produit" }, + { "id": 56, "nom": "Ketchup", "prix": 70, "image": "assets/images/produits/sauces/ketchup.png", "type": "produit" }, + { "id": 57, "nom": "Chinoise", "prix": 70, "image": "assets/images/produits/sauces/sauce-chinoise.png", "type": "produit" }, + { "id": 58, "nom": "Curry", "prix": 70, "image": "assets/images/produits/sauces/sauce-curry.png", "type": "produit" }, + { "id": 59, "nom": "Pommes Frites", "prix": 70, "image": "assets/images/produits/sauces/sauce-pommes-frite.png", "type": "produit" } + ], + "salades": [ + { "id": 60, "nom": "Petite Salade", "prix": 330, "image": "assets/images/produits/salades/petite-salade.png", "type": "produit" }, + { "id": 61, "nom": "Cesar Classic", "prix": 880, "image": "assets/images/produits/salades/salade-classic-caesar.png","type": "produit" }, + { "id": 62, "nom": "Italienne Mozza", "prix": 880, "image": "assets/images/produits/salades/salade-italian-mozza.png", "type": "produit" } + ], + "wraps": [ + { "id": 63, "nom": "MC Wrap Chevre", "prix": 310, "image": "assets/images/produits/wraps/mcwrap-chevre.png", "type": "produit" }, + { "id": 64, "nom": "MC Wrap Poulet Bacon", "prix": 330, "image": "assets/images/produits/wraps/mcwrap-poulet-bacon.png","type": "produit" }, + { "id": 65, "nom": "Ptit Wrap Chevre", "prix": 260, "image": "assets/images/produits/wraps/ptit-wrap-chevre.png", "type": "produit" }, + { "id": 66, "nom": "Ptit Wrap Ranch", "prix": 260, "image": "assets/images/produits/wraps/ptit-wrap-ranch.png", "type": "produit" } + ] +} diff --git a/src/public/borne/index.html b/src/public/borne/index.html index 7da4921..e07ccef 100644 --- a/src/public/borne/index.html +++ b/src/public/borne/index.html @@ -3,18 +3,79 @@ + - Wakdo - borne client - + + Wakdo - Bienvenue + - Wakdo -

Wakdo - borne client

-

En construction.

-

Phase P1 - conception Merise en cours. Le front borne sera implemente en phase P5.

+ + +
+ + + + +
+

Bonjour,

+

+ Souhaitez-vous consommer votre menu sur place
+ ou preferez-vous l'emporter ? +

+ + +
+ +
+ diff --git a/src/public/borne/payment.html b/src/public/borne/payment.html new file mode 100644 index 0000000..d43d3f3 --- /dev/null +++ b/src/public/borne/payment.html @@ -0,0 +1,120 @@ + + + + + + + + Wakdo - Paiement + + + + + + + + +
+ +

Comment souhaitez-vous payer ?

+ + +
+ +
+ +
+ + + + + + + +
+ +
+ + + + + + diff --git a/src/public/borne/product.html b/src/public/borne/product.html new file mode 100644 index 0000000..60a44e6 --- /dev/null +++ b/src/public/borne/product.html @@ -0,0 +1,65 @@ + + + + + + + + Wakdo - Produit + + + + + + + + +
+ + + + + +
+ + +
+ +
+ + + + + diff --git a/src/public/borne/products.html b/src/public/borne/products.html new file mode 100644 index 0000000..dc586f9 --- /dev/null +++ b/src/public/borne/products.html @@ -0,0 +1,68 @@ + + + + + + + + Wakdo - Produits + + + + + + + + +
+ +
+ +

Nos produits

+ Sur place +
+ + + + +
    + +
+ +
+ + + + +