From 358f35c6d00662824446c1b536fb992d8aeb85da Mon Sep 17 00:00:00 2001 From: Sarah Date: Fri, 27 Mar 2026 05:44:12 +0000 Subject: [PATCH] =?UTF-8?q?Add=20meal=20detail=20modal=20=E2=80=94=20tap?= =?UTF-8?q?=20any=20meal=20card=20to=20see=20full=20info?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New bottom-sheet modal opens when users tap a meal card in the feed. Shows full image, description, dietary tags, user profile with rating, pickup/trade details, and a "Propose a Trade" CTA. Handles own-meal edit/delete, expired states, and message button. Mobile-first with desktop centered dialog variant. Resolves the TODO in feed.js:120. Co-Authored-By: Claude Opus 4.6 (1M context) --- css/styles.css | 1253 +++++++++++++++++++++++++++++++++++++++++++++ index.html | 509 ++---------------- js/feed.js | 295 +++++++++++ js/meal-detail.js | 299 +++++++++++ 4 files changed, 1894 insertions(+), 462 deletions(-) create mode 100644 css/styles.css create mode 100644 js/feed.js create mode 100644 js/meal-detail.js diff --git a/css/styles.css b/css/styles.css new file mode 100644 index 0000000..06c3809 --- /dev/null +++ b/css/styles.css @@ -0,0 +1,1253 @@ +/* ============================================ + GrubFlip — Global Styles + Brand tokens from Ava's design system + Mobile-first responsive design + ============================================ */ + +/* --- Custom Properties (Brand Tokens) --- */ +:root { + /* Colors */ + --gf-primary: #FF6B35; + --gf-primary-light: #FF8F66; + --gf-primary-dark: #D94E1F; + --gf-primary-bg: #FFF3ED; + + --gf-secondary: #2D6A4F; + --gf-secondary-light: #40916C; + --gf-secondary-dark: #1B4332; + --gf-secondary-bg: #EDF5F0; + + --gf-accent: #FFBA08; + --gf-accent-light: #FFD166; + --gf-accent-dark: #E0A100; + --gf-accent-bg: #FFF8E1; + + --gf-success: #2ECC71; + --gf-success-bg: #EAFAF1; + --gf-warning: #F39C12; + --gf-warning-bg: #FEF5E7; + --gf-error: #E74C3C; + --gf-error-bg: #FDEDEC; + --gf-info: #3498DB; + --gf-info-bg: #EBF5FB; + + --gf-neutral-50: #FAFAFA; + --gf-neutral-100: #F5F5F5; + --gf-neutral-200: #E5E5E5; + --gf-neutral-300: #D4D4D4; + --gf-neutral-400: #A3A3A3; + --gf-neutral-500: #737373; + --gf-neutral-600: #525252; + --gf-neutral-700: #404040; + --gf-neutral-800: #262626; + --gf-neutral-900: #171717; + + --gf-bg: #FFFFFF; + --gf-bg-subtle: #FAFAFA; + --gf-bg-muted: #F5F5F5; + + /* Typography */ + --gf-font-heading: 'Plus Jakarta Sans', sans-serif; + --gf-font-body: 'Inter', sans-serif; + --gf-font-mono: 'JetBrains Mono', monospace; + + /* Shadows */ + --gf-shadow-sm: 0 1px 2px rgba(0,0,0,0.05); + --gf-shadow-md: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1); + --gf-shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1); + --gf-shadow-card: 0 2px 8px rgba(0,0,0,0.08); + + /* Radii */ + --gf-radius-sm: 0.375rem; + --gf-radius-md: 0.5rem; + --gf-radius-lg: 0.75rem; + --gf-radius-xl: 1rem; + --gf-radius-2xl: 1.5rem; + --gf-radius-full: 9999px; +} + +/* --- Reset & Base --- */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 16px; + -webkit-text-size-adjust: 100%; +} + +body { + font-family: var(--gf-font-body); + color: var(--gf-neutral-800); + background: var(--gf-bg-subtle); + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +h1, h2, h3, h4, h5, h6 { + font-family: var(--gf-font-heading); + font-weight: 700; + line-height: 1.2; + color: var(--gf-neutral-900); +} + +a { + color: var(--gf-primary); + text-decoration: none; +} +a:hover { text-decoration: underline; } + +img { + max-width: 100%; + height: auto; + display: block; +} + +button, input, select, textarea { + font-family: inherit; + font-size: inherit; +} + +/* --- Utility Classes --- */ +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + +.container { + width: 100%; + max-width: 640px; + margin: 0 auto; + padding: 0 1rem; +} + +/* --- Top Nav / App Header --- */ +.gf-header { + background: var(--gf-bg); + border-bottom: 1px solid var(--gf-neutral-200); + position: sticky; + top: 0; + z-index: 100; + padding: 0.75rem 1rem; + display: flex; + align-items: center; + justify-content: space-between; +} + +.gf-header__logo { + font-family: var(--gf-font-heading); + font-weight: 800; + font-size: 1.5rem; + color: var(--gf-primary); +} + +.gf-header__logo span { + color: var(--gf-secondary); +} + +.gf-header__actions { + display: flex; + gap: 0.5rem; + align-items: center; +} + +/* --- Bottom Nav (Mobile) --- */ +.gf-bottom-nav { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: var(--gf-bg); + border-top: 1px solid var(--gf-neutral-200); + display: flex; + justify-content: space-around; + padding: 0.5rem 0 calc(0.5rem + env(safe-area-inset-bottom)); + z-index: 100; +} + +.gf-bottom-nav__item { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.125rem; + padding: 0.25rem 0.75rem; + border: none; + background: none; + color: var(--gf-neutral-400); + font-size: 0.625rem; + font-weight: 500; + cursor: pointer; + transition: color 0.15s; + text-decoration: none; +} + +.gf-bottom-nav__item:hover, +.gf-bottom-nav__item.active { + color: var(--gf-primary); + text-decoration: none; +} + +.gf-bottom-nav__item svg { + width: 24px; + height: 24px; +} + +.gf-bottom-nav__badge { + position: absolute; + top: -2px; + right: -4px; + background: var(--gf-error); + color: white; + font-size: 0.625rem; + font-weight: 700; + min-width: 16px; + height: 16px; + border-radius: var(--gf-radius-full); + display: flex; + align-items: center; + justify-content: center; + padding: 0 4px; +} + +/* --- Buttons --- */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.625rem 1.25rem; + border-radius: var(--gf-radius-lg); + font-weight: 600; + font-size: 0.875rem; + border: none; + cursor: pointer; + transition: all 0.15s ease; + line-height: 1.25; + white-space: nowrap; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn--primary { + background: var(--gf-primary); + color: white; +} +.btn--primary:hover:not(:disabled) { background: var(--gf-primary-dark); } + +.btn--secondary { + background: var(--gf-secondary); + color: white; +} +.btn--secondary:hover:not(:disabled) { background: var(--gf-secondary-dark); } + +.btn--outline { + background: transparent; + color: var(--gf-primary); + border: 1.5px solid var(--gf-primary); +} +.btn--outline:hover:not(:disabled) { + background: var(--gf-primary-bg); +} + +.btn--ghost { + background: transparent; + color: var(--gf-neutral-600); +} +.btn--ghost:hover:not(:disabled) { + background: var(--gf-neutral-100); +} + +.btn--sm { + padding: 0.375rem 0.75rem; + font-size: 0.75rem; + border-radius: var(--gf-radius-md); +} + +.btn--lg { + padding: 0.75rem 1.5rem; + font-size: 1rem; +} + +.btn--block { + width: 100%; +} + +.btn--icon { + padding: 0.5rem; + border-radius: var(--gf-radius-full); +} + +/* --- Cards --- */ +.card { + background: var(--gf-bg); + border-radius: var(--gf-radius-xl); + box-shadow: var(--gf-shadow-card); + overflow: hidden; +} + +/* --- Meal Card --- */ +.meal-card { + background: var(--gf-bg); + border-radius: var(--gf-radius-xl); + box-shadow: var(--gf-shadow-card); + overflow: hidden; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +.meal-card:hover { + transform: translateY(-2px); + box-shadow: var(--gf-shadow-md); +} + +.meal-card__image-wrap { + position: relative; + aspect-ratio: 4 / 3; + overflow: hidden; + background: var(--gf-neutral-100); +} + +.meal-card__image { + width: 100%; + height: 100%; + object-fit: cover; +} + +.meal-card__trade-type { + position: absolute; + top: 0.75rem; + left: 0.75rem; + background: var(--gf-primary); + color: white; + font-size: 0.6875rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.25rem 0.625rem; + border-radius: var(--gf-radius-full); +} + +.meal-card__expiry { + position: absolute; + top: 0.75rem; + right: 0.75rem; + background: rgba(0,0,0,0.65); + color: white; + font-size: 0.6875rem; + font-weight: 500; + padding: 0.25rem 0.5rem; + border-radius: var(--gf-radius-sm); + display: flex; + align-items: center; + gap: 0.25rem; +} + +.meal-card__body { + padding: 0.875rem 1rem 1rem; +} + +.meal-card__title { + font-family: var(--gf-font-heading); + font-size: 1rem; + font-weight: 700; + color: var(--gf-neutral-900); + margin-bottom: 0.25rem; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.meal-card__desc { + font-size: 0.8125rem; + color: var(--gf-neutral-500); + margin-bottom: 0.625rem; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.meal-card__tags { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + margin-bottom: 0.75rem; +} + +.meal-card__tag { + font-size: 0.6875rem; + font-weight: 500; + padding: 0.1875rem 0.5rem; + border-radius: var(--gf-radius-full); + background: var(--gf-secondary-bg); + color: var(--gf-secondary); +} + +.meal-card__tag--cuisine { + background: var(--gf-accent-bg); + color: var(--gf-accent-dark); +} + +.meal-card__footer { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 0.75rem; + border-top: 1px solid var(--gf-neutral-100); +} + +.meal-card__user { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.meal-card__avatar { + width: 32px; + height: 32px; + border-radius: var(--gf-radius-full); + object-fit: cover; + background: var(--gf-neutral-200); +} + +.meal-card__user-info { + display: flex; + flex-direction: column; +} + +.meal-card__user-name { + font-size: 0.8125rem; + font-weight: 600; + color: var(--gf-neutral-800); +} + +.meal-card__user-meta { + font-size: 0.6875rem; + color: var(--gf-neutral-400); + display: flex; + align-items: center; + gap: 0.375rem; +} + +.meal-card__distance { + font-size: 0.75rem; + color: var(--gf-neutral-500); + font-weight: 500; + display: flex; + align-items: center; + gap: 0.25rem; +} + +/* --- Feed / Grid --- */ +.feed { + padding: 1rem 0 5rem; +} + +.feed__list { + display: flex; + flex-direction: column; + gap: 1rem; + list-style: none; +} + +.feed__empty { + text-align: center; + padding: 3rem 1rem; + color: var(--gf-neutral-400); +} + +.feed__empty svg { + width: 64px; + height: 64px; + margin: 0 auto 1rem; + opacity: 0.4; +} + +/* --- Filter Bar --- */ +.filter-bar { + display: flex; + gap: 0.5rem; + padding: 0.75rem 0; + overflow-x: auto; + scrollbar-width: none; + -webkit-overflow-scrolling: touch; +} + +.filter-bar::-webkit-scrollbar { display: none; } + +.filter-chip { + flex-shrink: 0; + padding: 0.375rem 0.875rem; + border-radius: var(--gf-radius-full); + background: var(--gf-bg); + border: 1.5px solid var(--gf-neutral-200); + color: var(--gf-neutral-600); + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} + +.filter-chip:hover { + border-color: var(--gf-primary-light); + color: var(--gf-primary); +} + +.filter-chip.active { + background: var(--gf-primary); + border-color: var(--gf-primary); + color: white; +} + +/* --- Forms --- */ +.form-group { + margin-bottom: 1.25rem; +} + +.form-label { + display: block; + font-size: 0.875rem; + font-weight: 600; + color: var(--gf-neutral-700); + margin-bottom: 0.375rem; +} + +.form-label small { + font-weight: 400; + color: var(--gf-neutral-400); +} + +.form-input, +.form-select, +.form-textarea { + width: 100%; + padding: 0.625rem 0.875rem; + border: 1.5px solid var(--gf-neutral-200); + border-radius: var(--gf-radius-lg); + background: var(--gf-bg); + color: var(--gf-neutral-800); + font-size: 1rem; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.form-input:focus, +.form-select:focus, +.form-textarea:focus { + outline: none; + border-color: var(--gf-primary); + box-shadow: 0 0 0 3px rgba(255, 107, 53, 0.15); +} + +.form-input::placeholder, +.form-textarea::placeholder { + color: var(--gf-neutral-400); +} + +.form-textarea { + resize: vertical; + min-height: 80px; +} + +.form-error { + font-size: 0.75rem; + color: var(--gf-error); + margin-top: 0.25rem; +} + +/* --- Image Upload Zone --- */ +.upload-zone { + border: 2px dashed var(--gf-neutral-300); + border-radius: var(--gf-radius-xl); + padding: 2rem 1rem; + text-align: center; + cursor: pointer; + transition: all 0.15s; + background: var(--gf-bg); +} + +.upload-zone:hover, +.upload-zone.dragover { + border-color: var(--gf-primary); + background: var(--gf-primary-bg); +} + +.upload-zone__icon { + width: 48px; + height: 48px; + margin: 0 auto 0.75rem; + color: var(--gf-neutral-400); +} + +.upload-zone__text { + font-size: 0.875rem; + color: var(--gf-neutral-500); + margin-bottom: 0.25rem; +} + +.upload-zone__hint { + font-size: 0.75rem; + color: var(--gf-neutral-400); +} + +.upload-zone__preview { + position: relative; + display: none; +} + +.upload-zone__preview img { + width: 100%; + max-height: 240px; + object-fit: cover; + border-radius: var(--gf-radius-lg); +} + +.upload-zone__preview .btn--remove { + position: absolute; + top: 0.5rem; + right: 0.5rem; + background: rgba(0,0,0,0.6); + color: white; + width: 28px; + height: 28px; + border-radius: var(--gf-radius-full); + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 1rem; +} + +.upload-zone.has-preview { + padding: 0; + border-style: solid; + border-color: var(--gf-neutral-200); +} + +.upload-zone.has-preview .upload-zone__prompt { display: none; } +.upload-zone.has-preview .upload-zone__preview { display: block; } + +/* --- Dietary Tag Picker --- */ +.tag-picker { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.tag-picker__option { + display: none; +} + +.tag-picker__label { + padding: 0.375rem 0.75rem; + border-radius: var(--gf-radius-full); + border: 1.5px solid var(--gf-neutral-200); + font-size: 0.8125rem; + font-weight: 500; + color: var(--gf-neutral-600); + cursor: pointer; + transition: all 0.15s; + user-select: none; +} + +.tag-picker__label:hover { + border-color: var(--gf-secondary-light); +} + +.tag-picker__option:checked + .tag-picker__label { + background: var(--gf-secondary); + border-color: var(--gf-secondary); + color: white; +} + +/* --- Trade Cards --- */ +.trade-card { + background: var(--gf-bg); + border-radius: var(--gf-radius-xl); + box-shadow: var(--gf-shadow-card); + padding: 1rem; +} + +.trade-card__status-bar { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.trade-card__status { + font-size: 0.6875rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.25rem 0.625rem; + border-radius: var(--gf-radius-full); +} + +.trade-card__status--pending { background: var(--gf-warning-bg); color: var(--gf-warning); } +.trade-card__status--accepted { background: var(--gf-info-bg); color: var(--gf-info); } +.trade-card__status--confirmed { background: var(--gf-secondary-bg); color: var(--gf-secondary); } +.trade-card__status--completed { background: var(--gf-success-bg); color: var(--gf-success); } +.trade-card__status--declined { background: var(--gf-error-bg); color: var(--gf-error); } +.trade-card__status--cancelled { background: var(--gf-neutral-100); color: var(--gf-neutral-500); } +.trade-card__status--expired { background: var(--gf-neutral-100); color: var(--gf-neutral-400); } + +.trade-card__time { + font-size: 0.75rem; + color: var(--gf-neutral-400); + margin-left: auto; +} + +.trade-card__meals { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.trade-card__meal { + flex: 1; + display: flex; + align-items: center; + gap: 0.625rem; +} + +.trade-card__meal-img { + width: 56px; + height: 56px; + border-radius: var(--gf-radius-lg); + object-fit: cover; + background: var(--gf-neutral-100); + flex-shrink: 0; +} + +.trade-card__meal-info { + min-width: 0; +} + +.trade-card__meal-title { + font-size: 0.8125rem; + font-weight: 600; + color: var(--gf-neutral-800); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.trade-card__meal-user { + font-size: 0.6875rem; + color: var(--gf-neutral-400); +} + +.trade-card__swap-icon { + flex-shrink: 0; + width: 28px; + height: 28px; + color: var(--gf-primary); +} + +.trade-card__message { + font-size: 0.8125rem; + color: var(--gf-neutral-600); + background: var(--gf-neutral-50); + padding: 0.625rem 0.75rem; + border-radius: var(--gf-radius-lg); + margin-bottom: 0.75rem; + font-style: italic; +} + +.trade-card__actions { + display: flex; + gap: 0.5rem; +} + +.trade-card__actions .btn { + flex: 1; +} + +/* --- Trade Tabs --- */ +.trade-tabs { + display: flex; + background: var(--gf-bg); + border-bottom: 1px solid var(--gf-neutral-200); + padding: 0 1rem; +} + +.trade-tab { + flex: 1; + padding: 0.75rem 0.5rem; + text-align: center; + font-size: 0.8125rem; + font-weight: 600; + color: var(--gf-neutral-400); + border: none; + background: none; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all 0.15s; +} + +.trade-tab:hover { + color: var(--gf-neutral-600); +} + +.trade-tab.active { + color: var(--gf-primary); + border-bottom-color: var(--gf-primary); +} + +/* --- Spinner / Loading --- */ +.spinner { + width: 32px; + height: 32px; + border: 3px solid var(--gf-neutral-200); + border-top-color: var(--gf-primary); + border-radius: 50%; + animation: spin 0.6s linear infinite; + margin: 2rem auto; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.skeleton { + background: linear-gradient(90deg, var(--gf-neutral-100) 25%, var(--gf-neutral-200) 50%, var(--gf-neutral-100) 75%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; + border-radius: var(--gf-radius-md); +} + +@keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +/* --- Toast / Notifications --- */ +.toast-container { + position: fixed; + top: 1rem; + left: 50%; + transform: translateX(-50%); + z-index: 1000; + display: flex; + flex-direction: column; + gap: 0.5rem; + pointer-events: none; + width: calc(100% - 2rem); + max-width: 400px; +} + +.toast { + background: var(--gf-neutral-800); + color: white; + padding: 0.75rem 1rem; + border-radius: var(--gf-radius-lg); + font-size: 0.875rem; + font-weight: 500; + box-shadow: var(--gf-shadow-lg); + pointer-events: auto; + animation: toastIn 0.25s ease; +} + +.toast--success { background: var(--gf-secondary); } +.toast--error { background: var(--gf-error); } +.toast--warning { background: var(--gf-warning); } + +@keyframes toastIn { + from { opacity: 0; transform: translateY(-0.5rem); } + to { opacity: 1; transform: translateY(0); } +} + +/* --- Page title bar --- */ +.page-title-bar { + padding: 1.25rem 0 0.25rem; +} + +.page-title-bar h1 { + font-size: 1.5rem; +} + +.page-title-bar p { + font-size: 0.875rem; + color: var(--gf-neutral-500); + margin-top: 0.25rem; +} + +/* --- Modal / Sheet --- */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.5); + z-index: 200; + display: flex; + align-items: flex-end; + justify-content: center; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s; +} + +.modal-overlay.open { + opacity: 1; + pointer-events: auto; +} + +.modal-sheet { + background: var(--gf-bg); + border-radius: var(--gf-radius-2xl) var(--gf-radius-2xl) 0 0; + width: 100%; + max-width: 640px; + max-height: 85vh; + overflow-y: auto; + padding: 1.5rem; + transform: translateY(100%); + transition: transform 0.3s ease; +} + +.modal-overlay.open .modal-sheet { + transform: translateY(0); +} + +.modal-sheet__handle { + width: 36px; + height: 4px; + background: var(--gf-neutral-300); + border-radius: var(--gf-radius-full); + margin: 0 auto 1rem; +} + +/* --- Meal Detail Modal --- */ +.meal-detail { + padding: 0; + position: relative; +} + +.meal-detail .modal-sheet__handle { + position: absolute; + top: 0.625rem; + left: 50%; + transform: translateX(-50%); + z-index: 5; + background: rgba(255,255,255,0.7); + margin: 0; +} + +.meal-detail__close { + position: absolute; + top: 0.75rem; + right: 0.75rem; + z-index: 5; + width: 36px; + height: 36px; + border-radius: var(--gf-radius-full); + background: rgba(0,0,0,0.45); + color: white; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s; + -webkit-backdrop-filter: blur(4px); + backdrop-filter: blur(4px); +} + +.meal-detail__close:hover { + background: rgba(0,0,0,0.65); +} + +.meal-detail__image-wrap { + position: relative; + width: 100%; + aspect-ratio: 16 / 10; + overflow: hidden; + background: var(--gf-neutral-100); +} + +.meal-detail__image { + width: 100%; + height: 100%; + object-fit: cover; +} + +.meal-detail__badges { + position: absolute; + bottom: 0.75rem; + left: 0.75rem; + display: flex; + gap: 0.375rem; + flex-wrap: wrap; +} + +.meal-detail__badge { + background: var(--gf-primary); + color: white; + font-size: 0.6875rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.25rem 0.625rem; + border-radius: var(--gf-radius-full); + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +.meal-detail__badge--free { + background: var(--gf-secondary); +} + +.meal-detail__badge--expiry { + background: rgba(0,0,0,0.65); + text-transform: none; + font-weight: 500; +} + +.meal-detail__badge--expired { + background: var(--gf-error); +} + +.meal-detail__content { + padding: 1.25rem 1.25rem 1.5rem; +} + +.meal-detail__title { + font-family: var(--gf-font-heading); + font-size: 1.375rem; + font-weight: 800; + color: var(--gf-neutral-900); + line-height: 1.25; + margin-bottom: 0.5rem; +} + +.meal-detail__desc { + font-size: 0.9375rem; + color: var(--gf-neutral-600); + line-height: 1.6; + margin-bottom: 0.75rem; +} + +.meal-detail__meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.meal-detail__meta-item { + font-size: 0.75rem; + color: var(--gf-neutral-400); + font-weight: 500; + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +.meal-detail__meta-item + .meal-detail__meta-item::before { + content: '\00B7'; + margin-right: 0.125rem; + color: var(--gf-neutral-300); +} + +.meal-detail__tags { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.meal-detail__tag { + font-size: 0.75rem; + font-weight: 500; + padding: 0.25rem 0.75rem; + border-radius: var(--gf-radius-full); + background: var(--gf-secondary-bg); + color: var(--gf-secondary); +} + +.meal-detail__tag--cuisine { + background: var(--gf-accent-bg); + color: var(--gf-accent-dark); +} + +.meal-detail__divider { + height: 1px; + background: var(--gf-neutral-100); + margin: 1rem 0; +} + +.meal-detail__user { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.meal-detail__user-avatar { + width: 44px; + height: 44px; + border-radius: var(--gf-radius-full); + object-fit: cover; + background: var(--gf-neutral-200); + flex-shrink: 0; +} + +.meal-detail__user-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.meal-detail__user-name { + font-size: 0.9375rem; + font-weight: 600; + color: var(--gf-neutral-800); +} + +.meal-detail__user-stats { + font-size: 0.75rem; + color: var(--gf-neutral-400); + display: flex; + align-items: center; + gap: 0.375rem; +} + +.meal-detail__message-btn { + flex-shrink: 0; +} + +.meal-detail__details-grid { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 1.25rem; +} + +.meal-detail__detail-item { + display: flex; + align-items: flex-start; + gap: 0.75rem; + font-size: 0.875rem; +} + +.meal-detail__detail-item > svg { + flex-shrink: 0; + margin-top: 0.125rem; + color: var(--gf-neutral-400); +} + +.meal-detail__detail-item--warning > svg { + color: var(--gf-warning); +} + +.meal-detail__detail-label { + display: block; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--gf-neutral-400); + margin-bottom: 0.125rem; +} + +.meal-detail__detail-value { + display: block; + color: var(--gf-neutral-700); + line-height: 1.4; +} + +.meal-detail__actions { + padding-top: 0.25rem; +} + +.meal-detail__actions .btn + .btn { + margin-top: 0.5rem; +} + +.meal-detail__expired-notice { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 1rem; + background: var(--gf-neutral-50); + border-radius: var(--gf-radius-lg); + color: var(--gf-neutral-400); + font-size: 0.875rem; + font-weight: 500; +} + +/* Meal detail desktop adjustments */ +@media (min-width: 1024px) { + .meal-detail { + max-width: 560px; + } + + .meal-detail__image-wrap { + border-radius: var(--gf-radius-2xl) var(--gf-radius-2xl) 0 0; + } + + .meal-detail__content { + padding: 1.5rem 1.75rem 2rem; + } +} + +/* --- Star Rating --- */ +.stars { + display: inline-flex; + align-items: center; + gap: 1px; + color: var(--gf-accent); + font-size: 0.75rem; +} + +/* --- Responsive: Tablet+ --- */ +@media (min-width: 640px) { + .container { + max-width: 768px; + } + + .feed__list { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; + } + + .gf-bottom-nav { + display: none; + } +} + +@media (min-width: 1024px) { + .container { + max-width: 960px; + } + + .feed__list { + grid-template-columns: repeat(3, 1fr); + } + + .modal-overlay { + align-items: center; + } + + .modal-sheet { + border-radius: var(--gf-radius-2xl); + max-height: 80vh; + } +} diff --git a/index.html b/index.html index 92ca8b6..5c005ac 100644 --- a/index.html +++ b/index.html @@ -1,473 +1,58 @@ - - - Grubflip — Swap Employee Discounts with Nearby Restaurant Workers - - - - - - - - - + + + GrubFlip — Swap Meals Near You + + + + + + - + +
+ + +
-
- Coming Soon -

Stop eating where you work.

-

Grubflip lets restaurant employees swap their food discounts with workers at nearby spots. New food, same savings.

- Join the Waitlist → -
+ +
+
+
+
+
-
-

How It Works

-

Three steps to start flipping your grub.

-
-
-
📲
-

Sign Up

-

Create your profile and verify where you work. It takes 30 seconds.

-
-
-
🤝
-

Connect

-

Browse nearby restaurant workers who want to swap discounts with you.

-
-
-
🍕
-

Flip Your Grub

-

Use their discount, they use yours. Everybody eats something different for less.

-
-
-
- -
-

Why Grubflip?

-

Built by restaurant people, for restaurant people.

-
-
-

Save Money

-

Employee discounts at places you actually want to eat. No more paying full price.

-
-
-

New Food Daily

-

Tired of eating the same menu every shift? Try something new from down the block.

-
-
-

Meet Your Neighbors

-

Connect with other restaurant workers in your area. Build your local food network.

-
-
-

100% Free

-

Grubflip is free for restaurant employees. No fees, no catch.

-
-
-
- -
-

Ready to Flip?

-

Sign up for early access and be first in line when we launch in your area.

- -

No spam. Just a heads-up when we launch.

-

You're on the list! We'll be in touch soon.

-
- -
-

© 2026 Grubflip. A Payfrit product. All rights reserved.

-
- - + + + + + diff --git a/js/feed.js b/js/feed.js new file mode 100644 index 0000000..d6438b5 --- /dev/null +++ b/js/feed.js @@ -0,0 +1,295 @@ +/* ============================================ + GrubFlip — Feed & Meal Cards + Renders the meal feed with filtering, + pagination, and card interactions. + ============================================ */ + +const Feed = (() => { + 'use strict'; + + const { api, $, $$, el, timeUntil, timeAgo, starsHTML, debounce, toast } = GrubFlip; + + // --- State --- + let meals = []; + let pagination = { page: 1, per_page: 20, total: 0, has_more: false }; + let loading = false; + let filters = { + dietary: [], + cuisine: '', + trade_type: '', + sort: 'newest', + }; + + const DIETARY_OPTIONS = [ + 'vegan', 'vegetarian', 'gluten-free', 'dairy', 'dairy-free', + 'nut-free', 'halal', 'kosher', 'high-protein', 'low-carb', 'spicy' + ]; + + // --- Meal Card Rendering --- + function renderMealCard(meal) { + const card = el('article', { className: 'meal-card', dataset: { mealId: meal.id } }); + + // Image + const imageWrap = el('div', { className: 'meal-card__image-wrap' }); + const img = el('img', { + className: 'meal-card__image', + src: meal.image_thumb_url || meal.image_url, + alt: meal.title, + loading: 'lazy', + }); + imageWrap.appendChild(img); + + // Trade type badge + const tradeLabel = meal.trade_type === 'swap' ? 'Swap' : meal.trade_type; + imageWrap.appendChild(el('span', { + className: 'meal-card__trade-type', + textContent: tradeLabel, + })); + + // Expiry countdown + if (meal.expires_at) { + const remaining = timeUntil(meal.expires_at); + if (remaining !== 'expired') { + imageWrap.appendChild(el('span', { + className: 'meal-card__expiry', + innerHTML: ` ${remaining}`, + })); + } + } + + card.appendChild(imageWrap); + + // Body + const body = el('div', { className: 'meal-card__body' }); + + body.appendChild(el('h3', { className: 'meal-card__title', textContent: meal.title })); + body.appendChild(el('p', { className: 'meal-card__desc', textContent: meal.description })); + + // Tags + const tagsWrap = el('div', { className: 'meal-card__tags' }); + if (meal.cuisine) { + tagsWrap.appendChild(el('span', { + className: 'meal-card__tag meal-card__tag--cuisine', + textContent: meal.cuisine, + })); + } + for (const tag of (meal.dietary_tags || []).slice(0, 3)) { + tagsWrap.appendChild(el('span', { + className: 'meal-card__tag', + textContent: tag, + })); + } + body.appendChild(tagsWrap); + + // Footer — user + distance + const footer = el('div', { className: 'meal-card__footer' }); + + const userSection = el('div', { className: 'meal-card__user' }); + userSection.appendChild(el('img', { + className: 'meal-card__avatar', + src: meal.user.avatar_url, + alt: meal.user.display_name, + loading: 'lazy', + })); + + const userInfo = el('div', { className: 'meal-card__user-info' }); + userInfo.appendChild(el('span', { + className: 'meal-card__user-name', + textContent: meal.user.display_name, + })); + const metaHTML = `${starsHTML(meal.user.rating)} · ${meal.user.trade_count} trades`; + userInfo.appendChild(el('span', { + className: 'meal-card__user-meta', + innerHTML: metaHTML, + })); + userSection.appendChild(userInfo); + footer.appendChild(userSection); + + if (meal.distance_km != null) { + footer.appendChild(el('span', { + className: 'meal-card__distance', + innerHTML: ` ${meal.distance_km} km`, + })); + } + + body.appendChild(footer); + card.appendChild(body); + + // Click handler — open detail modal + card.addEventListener('click', () => { + if (typeof MealDetail !== 'undefined') { + MealDetail.open(meal); + } else { + toast(`Opening ${meal.title}...`); + } + }); + + return card; + } + + // --- Skeleton Card --- + function renderSkeleton() { + const card = el('div', { className: 'meal-card' }); + card.innerHTML = ` +
+
+
+
+
+
+
+
+
+
+
+
+
`; + return card; + } + + // --- Filter Bar --- + function renderFilterBar(container) { + const bar = el('div', { className: 'filter-bar', role: 'toolbar', 'aria-label': 'Filter meals' }); + + // Sort chip + const sortChip = el('button', { + className: 'filter-chip active', + textContent: '✨ Newest', + onClick: () => { + filters.sort = filters.sort === 'newest' ? 'nearest' : 'newest'; + sortChip.textContent = filters.sort === 'newest' ? '✨ Newest' : '📍 Nearest'; + loadMeals(true); + }, + }); + bar.appendChild(sortChip); + + // Dietary filters + for (const tag of DIETARY_OPTIONS) { + const chip = el('button', { + className: 'filter-chip', + textContent: tag, + onClick: () => { + const idx = filters.dietary.indexOf(tag); + if (idx >= 0) { + filters.dietary.splice(idx, 1); + chip.classList.remove('active'); + } else { + filters.dietary.push(tag); + chip.classList.add('active'); + } + loadMeals(true); + }, + }); + bar.appendChild(chip); + } + + container.appendChild(bar); + } + + // --- Load Meals --- + async function loadMeals(reset = false) { + if (loading) return; + loading = true; + + const listEl = $('#feed-list'); + if (!listEl) return; + + if (reset) { + pagination.page = 1; + meals = []; + listEl.innerHTML = ''; + // Show skeletons + for (let i = 0; i < 6; i++) { + listEl.appendChild(renderSkeleton()); + } + } + + try { + const params = { + page: pagination.page, + sort: filters.sort, + }; + if (filters.dietary.length) params.dietary = filters.dietary.join(','); + if (filters.cuisine) params.cuisine = filters.cuisine; + if (filters.trade_type) params.trade_type = filters.trade_type; + + const data = await api.get('/meals', params); + + if (reset) listEl.innerHTML = ''; + + meals = reset ? data.meals : [...meals, ...data.meals]; + pagination = data.pagination; + + for (const meal of data.meals) { + listEl.appendChild(renderMealCard(meal)); + } + + // Load more button + const existingMore = $('#load-more-btn'); + if (existingMore) existingMore.remove(); + + if (pagination.has_more) { + const moreBtn = el('button', { + className: 'btn btn--outline btn--block', + id: 'load-more-btn', + textContent: 'Load more meals', + onClick: () => { + pagination.page++; + loadMeals(false); + }, + }); + listEl.parentElement.appendChild(moreBtn); + } + + // Empty state + if (meals.length === 0) { + listEl.innerHTML = ` +
+ + + +

No meals found

+

Try adjusting your filters or check back later

+
`; + } + } catch (err) { + toast(err.message, 'error'); + if (reset) listEl.innerHTML = ''; + } finally { + loading = false; + } + } + + // --- Infinite Scroll --- + function setupInfiniteScroll() { + const scrollHandler = debounce(() => { + if (loading || !pagination.has_more) return; + const scrollBottom = window.innerHeight + window.scrollY; + const pageBottom = document.documentElement.offsetHeight - 200; + if (scrollBottom >= pageBottom) { + pagination.page++; + loadMeals(false); + } + }, 100); + + window.addEventListener('scroll', scrollHandler, { passive: true }); + } + + // --- Init --- + function init() { + const feedContainer = $('#feed-container'); + if (!feedContainer) return; + + renderFilterBar(feedContainer); + + const list = el('div', { className: 'feed__list', id: 'feed-list', role: 'feed', 'aria-label': 'Meal listings' }); + feedContainer.appendChild(list); + + loadMeals(true); + setupInfiniteScroll(); + } + + return { init, loadMeals, renderMealCard }; +})(); + +document.addEventListener('DOMContentLoaded', Feed.init); diff --git a/js/meal-detail.js b/js/meal-detail.js new file mode 100644 index 0000000..f05c22b --- /dev/null +++ b/js/meal-detail.js @@ -0,0 +1,299 @@ +/* ============================================ + GrubFlip — Meal Detail Modal + Shows full meal info in a bottom sheet modal. + Triggered from feed card clicks. + ============================================ */ + +const MealDetail = (() => { + 'use strict'; + + const { api, $, el, timeUntil, timeAgo, starsHTML, toast, isLoggedIn } = GrubFlip; + + let overlay = null; + let currentMeal = null; + + // --- Build Modal DOM (once) --- + function ensureModal() { + if (overlay) return overlay; + + overlay = el('div', { + className: 'modal-overlay', + role: 'dialog', + 'aria-modal': 'true', + 'aria-label': 'Meal details', + }); + + const sheet = el('div', { className: 'modal-sheet meal-detail' }); + + sheet.innerHTML = ` + + +
+ +
+
+
+

+

+
+
+
+
+
+
+
+
+ `; + + overlay.appendChild(sheet); + document.body.appendChild(overlay); + + // Close on overlay click (not sheet) + overlay.addEventListener('click', (e) => { + if (e.target === overlay) close(); + }); + + // Close button + sheet.querySelector('.meal-detail__close').addEventListener('click', close); + + // Close on Escape + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && overlay.classList.contains('open')) close(); + }); + + return overlay; + } + + // --- Populate Modal with Meal Data --- + function populate(meal) { + currentMeal = meal; + const modal = ensureModal(); + const sheet = modal.querySelector('.modal-sheet'); + + // Image + const img = sheet.querySelector('.meal-detail__image'); + img.src = meal.image_url || meal.image_thumb_url; + img.alt = meal.title; + + // Badges (trade type + expiry) + const badges = sheet.querySelector('.meal-detail__badges'); + badges.innerHTML = ''; + + const tradeLabel = meal.trade_type === 'swap' ? 'Swap' + : meal.trade_type === 'free' ? 'Free' + : meal.trade_type; + + const tradeClass = meal.trade_type === 'free' ? 'meal-detail__badge--free' : ''; + badges.innerHTML += `${tradeLabel}`; + + if (meal.expires_at) { + const remaining = timeUntil(meal.expires_at); + if (remaining !== 'expired') { + badges.innerHTML += ` + + ${remaining} + `; + } else { + badges.innerHTML += `Expired`; + } + } + + // Title + sheet.querySelector('.meal-detail__title').textContent = meal.title; + + // Description (full, no truncation) + sheet.querySelector('.meal-detail__desc').textContent = meal.description; + + // Meta line (posted time + servings) + const meta = sheet.querySelector('.meal-detail__meta'); + const metaParts = []; + if (meal.created_at) metaParts.push(`Posted ${timeAgo(meal.created_at)}`); + if (meal.servings) metaParts.push(`${meal.servings} serving${meal.servings > 1 ? 's' : ''}`); + if (meal.distance_km != null) metaParts.push(`${meal.distance_km} km away`); + meta.innerHTML = metaParts.map(p => + `${p}` + ).join(''); + + // Tags (all of them — no limit) + const tagsWrap = sheet.querySelector('.meal-detail__tags'); + tagsWrap.innerHTML = ''; + if (meal.cuisine) { + tagsWrap.innerHTML += `${meal.cuisine}`; + } + for (const tag of (meal.dietary_tags || [])) { + tagsWrap.innerHTML += `${tag}`; + } + + // User section + const userSection = sheet.querySelector('.meal-detail__user-section'); + userSection.innerHTML = ` +
+ ${meal.user.display_name} + + +
+ `; + + // Message button handler + const msgBtn = userSection.querySelector('.meal-detail__message-btn'); + msgBtn.addEventListener('click', () => { + if (!isLoggedIn()) { + toast('Sign in to message this person', 'warning'); + return; + } + // Navigate to chat with context + window.location.href = `chat.html?user=${meal.user.id}&meal=${meal.id}`; + }); + + // Details grid (pickup info, preferences, etc.) + const detailsGrid = sheet.querySelector('.meal-detail__details-grid'); + detailsGrid.innerHTML = ''; + + if (meal.pickup_location || meal.pickup_notes) { + detailsGrid.innerHTML += ` +
+ +
+ Pickup + ${meal.pickup_location || meal.pickup_notes || 'Contact for details'} +
+
+ `; + } + + if (meal.looking_for) { + detailsGrid.innerHTML += ` +
+ +
+ Looking for + ${meal.looking_for} +
+
+ `; + } + + if (meal.allergen_info) { + detailsGrid.innerHTML += ` +
+ +
+ Allergen info + ${meal.allergen_info} +
+
+ `; + } + + // Actions + const actions = sheet.querySelector('.meal-detail__actions'); + const isExpired = meal.expires_at && timeUntil(meal.expires_at) === 'expired'; + const isOwnMeal = parseInt(localStorage.getItem('gf_user_id') || '0', 10) === meal.user.id; + + actions.innerHTML = ''; + + if (isOwnMeal) { + actions.innerHTML = ` + + + `; + } else if (isExpired) { + actions.innerHTML = ` +
+ + This meal listing has expired +
+ `; + } else { + actions.innerHTML = ` + + `; + } + + // Action handlers + const proposeBtn = actions.querySelector('[data-action="propose"]'); + if (proposeBtn) { + proposeBtn.addEventListener('click', () => { + if (!isLoggedIn()) { + toast('Sign in to propose a trade', 'warning'); + return; + } + close(); + window.location.href = `trades.html?propose=${meal.id}`; + }); + } + + const editBtn = actions.querySelector('[data-action="edit"]'); + if (editBtn) { + editBtn.addEventListener('click', () => { + close(); + window.location.href = `post.html?edit=${meal.id}`; + }); + } + + const deleteBtn = actions.querySelector('[data-action="delete"]'); + if (deleteBtn) { + deleteBtn.addEventListener('click', async () => { + if (!confirm('Remove this meal listing?')) return; + try { + await api.delete(`/meals/${meal.id}`); + toast('Meal removed', 'success'); + close(); + // Refresh feed + if (typeof Feed !== 'undefined' && Feed.loadMeals) { + Feed.loadMeals(true); + } + } catch (err) { + toast(err.message, 'error'); + } + }); + } + } + + // --- Open / Close --- + function open(meal) { + populate(meal); + const modal = ensureModal(); + + // Prevent body scroll + document.body.style.overflow = 'hidden'; + + // Small delay for transition + requestAnimationFrame(() => { + modal.classList.add('open'); + }); + } + + function close() { + if (!overlay) return; + overlay.classList.remove('open'); + document.body.style.overflow = ''; + currentMeal = null; + } + + // --- Public API --- + return { open, close }; +})();