diff --git a/admin/css/admin.css b/admin/css/admin.css new file mode 100644 index 0000000..7753537 --- /dev/null +++ b/admin/css/admin.css @@ -0,0 +1,974 @@ +/* ============================================ + Grubflip Admin Dashboard โ€” Core Styles + Mobile-first responsive design + ============================================ */ + +/* --- 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); + font-size: var(--gf-text-base); + color: var(--gf-neutral-800); + background: var(--gf-bg-subtle); + line-height: 1.5; + min-height: 100vh; + -webkit-font-smoothing: antialiased; +} + +h1, h2, h3, h4, h5, h6 { + font-family: var(--gf-font-heading); + font-weight: var(--gf-weight-bold); + line-height: 1.2; + color: var(--gf-neutral-900); +} + +a { color: var(--gf-primary); text-decoration: none; } +a:hover { color: var(--gf-primary-dark); } + +img { max-width: 100%; height: auto; } + +/* --- Layout Shell --- */ +.admin-layout { + display: flex; + min-height: 100vh; +} + +/* --- Sidebar --- */ +.sidebar { + position: fixed; + top: 0; + left: 0; + width: var(--gf-sidebar-width); + height: 100vh; + background: var(--gf-bg-dark); + color: var(--gf-neutral-300); + display: flex; + flex-direction: column; + z-index: var(--gf-z-fixed); + transform: translateX(-100%); + transition: transform var(--gf-duration-slow) var(--gf-ease); + overflow-y: auto; +} + +.sidebar.open { + transform: translateX(0); +} + +.sidebar-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,0.5); + z-index: calc(var(--gf-z-fixed) - 1); +} + +.sidebar-overlay.visible { + display: block; +} + +.sidebar-brand { + display: flex; + align-items: center; + gap: var(--gf-space-3); + padding: var(--gf-space-6) var(--gf-space-5); + border-bottom: 1px solid rgba(255,255,255,0.08); +} + +.sidebar-brand .logo-icon { + width: 2rem; + height: 2rem; + background: var(--gf-primary); + border-radius: var(--gf-radius-md); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.1rem; + color: #fff; + font-weight: var(--gf-weight-bold); + flex-shrink: 0; +} + +.sidebar-brand span { + font-family: var(--gf-font-heading); + font-size: var(--gf-text-lg); + font-weight: var(--gf-weight-bold); + color: #fff; +} + +.sidebar-nav { + flex: 1; + padding: var(--gf-space-4) 0; +} + +.sidebar-section-title { + font-size: var(--gf-text-xs); + font-weight: var(--gf-weight-semibold); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--gf-neutral-500); + padding: var(--gf-space-4) var(--gf-space-5) var(--gf-space-2); +} + +.sidebar-link { + display: flex; + align-items: center; + gap: var(--gf-space-3); + padding: var(--gf-space-3) var(--gf-space-5); + color: var(--gf-neutral-300); + font-size: var(--gf-text-sm); + font-weight: var(--gf-weight-medium); + transition: all var(--gf-duration-fast) var(--gf-ease); + cursor: pointer; + border: none; + background: none; + width: 100%; + text-align: left; +} + +.sidebar-link:hover { + background: rgba(255,255,255,0.05); + color: #fff; +} + +.sidebar-link.active { + background: rgba(255,107,53,0.15); + color: var(--gf-primary); +} + +.sidebar-link .icon { + width: 1.25rem; + height: 1.25rem; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 1rem; +} + +.sidebar-footer { + padding: var(--gf-space-4) var(--gf-space-5); + border-top: 1px solid rgba(255,255,255,0.08); +} + +.sidebar-user { + display: flex; + align-items: center; + gap: var(--gf-space-3); +} + +.sidebar-user .avatar { + width: 2rem; + height: 2rem; + border-radius: var(--gf-radius-full); + background: var(--gf-primary); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: var(--gf-text-xs); + font-weight: var(--gf-weight-semibold); + flex-shrink: 0; +} + +.sidebar-user .user-info { + overflow: hidden; +} + +.sidebar-user .user-name { + font-size: var(--gf-text-sm); + font-weight: var(--gf-weight-medium); + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sidebar-user .user-role { + font-size: var(--gf-text-xs); + color: var(--gf-neutral-400); +} + +/* --- Main Content --- */ +.main-content { + flex: 1; + width: 100%; + min-height: 100vh; +} + +/* --- Top Bar --- */ +.topbar { + position: sticky; + top: 0; + background: var(--gf-bg); + height: var(--gf-navbar-height); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 var(--gf-space-4); + border-bottom: 1px solid var(--gf-neutral-200); + z-index: var(--gf-z-sticky); +} + +.topbar-left { + display: flex; + align-items: center; + gap: var(--gf-space-3); +} + +.menu-toggle { + display: flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + border: none; + background: none; + cursor: pointer; + border-radius: var(--gf-radius-md); + color: var(--gf-neutral-600); + font-size: 1.25rem; + transition: background var(--gf-duration-fast); +} + +.menu-toggle:hover { + background: var(--gf-neutral-100); +} + +.topbar-title { + font-family: var(--gf-font-heading); + font-size: var(--gf-text-lg); + font-weight: var(--gf-weight-semibold); +} + +.topbar-right { + display: flex; + align-items: center; + gap: var(--gf-space-2); +} + +.topbar-btn { + display: flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + border: none; + background: none; + cursor: pointer; + border-radius: var(--gf-radius-full); + color: var(--gf-neutral-600); + font-size: 1.1rem; + position: relative; + transition: background var(--gf-duration-fast); +} + +.topbar-btn:hover { + background: var(--gf-neutral-100); +} + +.topbar-btn .badge-dot { + position: absolute; + top: 0.4rem; + right: 0.4rem; + width: 0.5rem; + height: 0.5rem; + background: var(--gf-error); + border-radius: var(--gf-radius-full); + border: 2px solid var(--gf-bg); +} + +/* --- Page Content --- */ +.page-content { + padding: var(--gf-space-4); +} + +.page-header { + margin-bottom: var(--gf-space-6); +} + +.page-header h1 { + font-size: var(--gf-text-2xl); + margin-bottom: var(--gf-space-1); +} + +.page-header .subtitle { + color: var(--gf-neutral-500); + font-size: var(--gf-text-sm); +} + +/* --- Cards --- */ +.card { + background: var(--gf-bg); + border-radius: var(--gf-radius-lg); + box-shadow: var(--gf-shadow-card); + border: 1px solid var(--gf-neutral-200); + padding: var(--gf-space-6); + transition: box-shadow var(--gf-duration-normal) var(--gf-ease); +} + +.card:hover { + box-shadow: 0 4px 12px rgba(0,0,0,0.12); +} + +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--gf-space-4); +} + +.card-header h2 { + font-size: var(--gf-text-lg); + font-weight: var(--gf-weight-semibold); +} + +/* --- Stat Cards --- */ +.stats-grid { + display: grid; + grid-template-columns: 1fr; + gap: var(--gf-space-4); + margin-bottom: var(--gf-space-6); +} + +.stat-card { + background: var(--gf-bg); + border-radius: var(--gf-radius-lg); + box-shadow: var(--gf-shadow-card); + border: 1px solid var(--gf-neutral-200); + padding: var(--gf-space-5); + display: flex; + align-items: flex-start; + gap: var(--gf-space-4); +} + +.stat-icon { + width: 3rem; + height: 3rem; + border-radius: var(--gf-radius-lg); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.25rem; + flex-shrink: 0; +} + +.stat-icon.orange { background: var(--gf-primary-bg); color: var(--gf-primary); } +.stat-icon.green { background: var(--gf-secondary-bg); color: var(--gf-secondary); } +.stat-icon.yellow { background: var(--gf-accent-bg); color: var(--gf-accent-dark); } +.stat-icon.blue { background: var(--gf-info-bg); color: var(--gf-info); } + +.stat-info h3 { + font-size: var(--gf-text-xs); + font-weight: var(--gf-weight-medium); + color: var(--gf-neutral-500); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: var(--gf-space-1); +} + +.stat-info .stat-value { + font-family: var(--gf-font-heading); + font-size: var(--gf-text-2xl); + font-weight: var(--gf-weight-bold); + color: var(--gf-neutral-900); +} + +.stat-info .stat-change { + font-size: var(--gf-text-xs); + margin-top: var(--gf-space-1); +} + +.stat-change.up { color: var(--gf-success); } +.stat-change.down { color: var(--gf-error); } + +/* --- Buttons --- */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--gf-space-2); + font-family: var(--gf-font-body); + font-size: var(--gf-text-base); + font-weight: var(--gf-weight-semibold); + padding: 0.75rem 1.5rem; + border-radius: var(--gf-radius-md); + border: none; + cursor: pointer; + transition: all var(--gf-duration-normal) var(--gf-ease); + white-space: nowrap; + line-height: 1; +} + +.btn-sm { + padding: 0.5rem 1rem; + font-size: var(--gf-text-sm); +} + +.btn-primary { + background: var(--gf-primary); + color: #fff; +} + +.btn-primary:hover { background: var(--gf-primary-dark); } +.btn-primary:disabled { background: #FFB899; cursor: not-allowed; } + +.btn-secondary { + background: transparent; + color: var(--gf-primary); + border: 2px solid var(--gf-primary); +} + +.btn-secondary:hover { + background: var(--gf-primary-bg); + color: var(--gf-primary-dark); + border-color: var(--gf-primary-dark); +} + +.btn-ghost { + background: transparent; + color: var(--gf-neutral-500); + padding: 0.5rem 1rem; + font-size: var(--gf-text-sm); + font-weight: var(--gf-weight-medium); +} + +.btn-ghost:hover { + background: var(--gf-neutral-100); + color: var(--gf-neutral-700); +} + +.btn-danger { + background: var(--gf-error); + color: #fff; +} + +.btn-danger:hover { background: #c0392b; } + +.btn-success { + background: var(--gf-success); + color: #fff; +} + +.btn-success:hover { background: #27ae60; } + +/* --- Forms --- */ +.form-group { + margin-bottom: var(--gf-space-4); +} + +.form-label { + display: block; + font-size: var(--gf-text-sm); + font-weight: var(--gf-weight-medium); + color: var(--gf-neutral-700); + margin-bottom: var(--gf-space-2); +} + +.form-input, +.form-select, +.form-textarea { + width: 100%; + padding: 0.75rem 1rem; + font-family: var(--gf-font-body); + font-size: var(--gf-text-base); + color: var(--gf-neutral-800); + background: var(--gf-bg); + border: 1px solid var(--gf-neutral-300); + border-radius: var(--gf-radius-md); + transition: border-color var(--gf-duration-normal), box-shadow var(--gf-duration-normal); + line-height: 1.5; +} + +.form-input::placeholder, +.form-textarea::placeholder { + color: var(--gf-neutral-400); +} + +.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.error { + border-color: var(--gf-error); + box-shadow: 0 0 0 3px rgba(231,76,60,0.15); +} + +.form-error { + font-size: var(--gf-text-xs); + color: var(--gf-error); + margin-top: var(--gf-space-1); +} + +.form-hint { + font-size: var(--gf-text-xs); + color: var(--gf-neutral-400); + margin-top: var(--gf-space-1); +} + +.form-textarea { + resize: vertical; + min-height: 6rem; +} + +.form-row { + display: grid; + grid-template-columns: 1fr; + gap: var(--gf-space-4); +} + +/* --- Badges --- */ +.badge { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.75rem; + font-size: var(--gf-text-xs); + font-weight: var(--gf-weight-semibold); + text-transform: uppercase; + letter-spacing: 0.05em; + border-radius: var(--gf-radius-full); +} + +.badge-default { background: var(--gf-neutral-100); color: var(--gf-neutral-600); } +.badge-primary { background: var(--gf-primary-bg); color: var(--gf-primary-dark); } +.badge-success { background: var(--gf-success-bg); color: #1B8A4A; } +.badge-warning { background: var(--gf-warning-bg); color: #B87A0D; } +.badge-error { background: var(--gf-error-bg); color: #C0392B; } +.badge-info { background: var(--gf-info-bg); color: #2471A3; } +.badge-accent { background: var(--gf-accent-bg); color: #B8860B; } + +/* --- Tables --- */ +.table-wrapper { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: var(--gf-text-sm); +} + +.data-table thead { + background: var(--gf-neutral-100); +} + +.data-table th { + text-align: left; + padding: 0.75rem 1rem; + font-size: var(--gf-text-xs); + font-weight: var(--gf-weight-semibold); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--gf-neutral-600); + white-space: nowrap; +} + +.data-table td { + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--gf-neutral-200); + color: var(--gf-neutral-700); +} + +.data-table tbody tr:hover { + background: var(--gf-primary-bg); +} + +.data-table .actions { + display: flex; + gap: var(--gf-space-2); +} + +/* --- Modal --- */ +.modal-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,0.5); + z-index: var(--gf-z-modal-backdrop); + align-items: center; + justify-content: center; + padding: var(--gf-space-4); +} + +.modal-overlay.visible { + display: flex; +} + +.modal { + background: var(--gf-bg); + border-radius: var(--gf-radius-xl); + box-shadow: var(--gf-shadow-xl); + width: 100%; + max-width: 32rem; + max-height: 90vh; + overflow-y: auto; + animation: modalIn var(--gf-duration-slow) var(--gf-ease-bounce); +} + +@keyframes modalIn { + from { opacity: 0; transform: scale(0.95) translateY(1rem); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--gf-space-6) var(--gf-space-6) 0; +} + +.modal-header h2 { + font-size: var(--gf-text-xl); + font-weight: var(--gf-weight-bold); +} + +.modal-close { + width: 2rem; + height: 2rem; + border: none; + background: none; + cursor: pointer; + border-radius: var(--gf-radius-full); + color: var(--gf-neutral-400); + font-size: 1.25rem; + display: flex; + align-items: center; + justify-content: center; + transition: background var(--gf-duration-fast); +} + +.modal-close:hover { + background: var(--gf-neutral-100); + color: var(--gf-neutral-700); +} + +.modal-body { + padding: var(--gf-space-6); +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: var(--gf-space-3); + padding: 0 var(--gf-space-6) var(--gf-space-6); +} + +/* --- Toast Notifications --- */ +.toast-container { + position: fixed; + top: var(--gf-space-4); + right: var(--gf-space-4); + z-index: var(--gf-z-toast); + display: flex; + flex-direction: column; + gap: var(--gf-space-3); + max-width: 24rem; + width: calc(100% - 2rem); +} + +.toast { + padding: 1rem 1.5rem; + border-radius: var(--gf-radius-md); + box-shadow: var(--gf-shadow-lg); + font-size: var(--gf-text-sm); + display: flex; + align-items: center; + gap: var(--gf-space-3); + animation: toastIn var(--gf-duration-slow) var(--gf-ease-bounce); +} + +.toast.removing { + animation: toastOut var(--gf-duration-normal) var(--gf-ease) forwards; +} + +@keyframes toastIn { + from { opacity: 0; transform: translateX(1rem); } + to { opacity: 1; transform: translateX(0); } +} + +@keyframes toastOut { + to { opacity: 0; transform: translateX(1rem); } +} + +.toast-success { background: var(--gf-success-bg); border: 1px solid var(--gf-success); } +.toast-error { background: var(--gf-error-bg); border: 1px solid var(--gf-error); } +.toast-warning { background: var(--gf-warning-bg); border: 1px solid var(--gf-warning); } +.toast-info { background: var(--gf-info-bg); border: 1px solid var(--gf-info); } + +/* --- Empty State --- */ +.empty-state { + text-align: center; + padding: var(--gf-space-12) var(--gf-space-6); + color: var(--gf-neutral-400); +} + +.empty-state .icon { + font-size: 3rem; + margin-bottom: var(--gf-space-4); +} + +.empty-state h3 { + font-size: var(--gf-text-lg); + color: var(--gf-neutral-600); + margin-bottom: var(--gf-space-2); +} + +.empty-state p { + font-size: var(--gf-text-sm); + margin-bottom: var(--gf-space-6); +} + +/* --- Loading Spinner --- */ +.spinner { + width: 2rem; + height: 2rem; + border: 3px solid var(--gf-neutral-200); + border-top-color: var(--gf-primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.loading-overlay { + display: flex; + align-items: center; + justify-content: center; + padding: var(--gf-space-12); +} + +/* --- Tabs --- */ +.tabs { + display: flex; + gap: 0; + border-bottom: 2px solid var(--gf-neutral-200); + margin-bottom: var(--gf-space-6); + overflow-x: auto; +} + +.tab { + padding: var(--gf-space-3) var(--gf-space-4); + font-size: var(--gf-text-sm); + font-weight: var(--gf-weight-medium); + color: var(--gf-neutral-500); + border: none; + background: none; + cursor: pointer; + white-space: nowrap; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + transition: all var(--gf-duration-fast); +} + +.tab:hover { color: var(--gf-neutral-700); } + +.tab.active { + color: var(--gf-primary); + border-bottom-color: var(--gf-primary); +} + +/* --- Search --- */ +.search-box { + position: relative; +} + +.search-box .search-icon { + position: absolute; + left: 0.75rem; + top: 50%; + transform: translateY(-50%); + color: var(--gf-neutral-400); + font-size: 1rem; + pointer-events: none; +} + +.search-box .form-input { + padding-left: 2.5rem; +} + +/* --- Toggle Switch --- */ +.toggle { + position: relative; + display: inline-block; + width: 2.75rem; + height: 1.5rem; +} + +.toggle input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + inset: 0; + background: var(--gf-neutral-300); + border-radius: var(--gf-radius-full); + cursor: pointer; + transition: background var(--gf-duration-normal); +} + +.toggle-slider::before { + content: ''; + position: absolute; + width: 1.125rem; + height: 1.125rem; + left: 0.1875rem; + bottom: 0.1875rem; + background: #fff; + border-radius: 50%; + transition: transform var(--gf-duration-normal) var(--gf-ease); +} + +.toggle input:checked + .toggle-slider { + background: var(--gf-primary); +} + +.toggle input:checked + .toggle-slider::before { + transform: translateX(1.25rem); +} + +/* --- Chip / Tag --- */ +.chip { + display: inline-flex; + align-items: center; + gap: var(--gf-space-1); + padding: 0.375rem 0.75rem; + background: var(--gf-neutral-100); + color: var(--gf-neutral-600); + font-size: var(--gf-text-sm); + font-weight: var(--gf-weight-medium); + border-radius: var(--gf-radius-full); + border: none; + cursor: default; +} + +.chip-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + border: none; + background: none; + cursor: pointer; + color: var(--gf-neutral-400); + font-size: 0.75rem; + border-radius: 50%; + transition: all var(--gf-duration-fast); +} + +.chip-remove:hover { + background: var(--gf-neutral-200); + color: var(--gf-neutral-700); +} + +/* --- Utility Classes --- */ +.text-center { text-align: center; } +.text-right { text-align: right; } +.text-muted { color: var(--gf-neutral-500); } +.text-sm { font-size: var(--gf-text-sm); } +.text-xs { font-size: var(--gf-text-xs); } +.font-mono { font-family: var(--gf-font-mono); } +.mt-2 { margin-top: var(--gf-space-2); } +.mt-4 { margin-top: var(--gf-space-4); } +.mt-6 { margin-top: var(--gf-space-6); } +.mb-2 { margin-bottom: var(--gf-space-2); } +.mb-4 { margin-bottom: var(--gf-space-4); } +.mb-6 { margin-bottom: var(--gf-space-6); } +.flex { display: flex; } +.flex-col { flex-direction: column; } +.items-center { align-items: center; } +.justify-between { justify-content: space-between; } +.gap-2 { gap: var(--gf-space-2); } +.gap-3 { gap: var(--gf-space-3); } +.gap-4 { gap: var(--gf-space-4); } +.hidden { display: none !important; } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0,0,0,0); + white-space: nowrap; + border: 0; +} + +/* ========================================== + RESPONSIVE BREAKPOINTS + ========================================== */ + +/* sm: 640px+ */ +@media (min-width: 640px) { + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } + + .form-row { + grid-template-columns: repeat(2, 1fr); + } +} + +/* md: 768px+ */ +@media (min-width: 768px) { + .page-content { + padding: var(--gf-space-6); + } + + .page-header h1 { + font-size: var(--gf-text-3xl); + } +} + +/* lg: 1024px+ โ€” sidebar always visible */ +@media (min-width: 1024px) { + .sidebar { + transform: translateX(0); + } + + .sidebar-overlay { + display: none !important; + } + + .menu-toggle { + display: none; + } + + .main-content { + margin-left: var(--gf-sidebar-width); + } + + .stats-grid { + grid-template-columns: repeat(4, 1fr); + } + + .page-content { + padding: var(--gf-space-8); + } +} + +/* xl: 1280px+ */ +@media (min-width: 1280px) { + .page-content { + max-width: 80rem; + } +} diff --git a/admin/css/tokens.css b/admin/css/tokens.css new file mode 100644 index 0000000..9a4b78d --- /dev/null +++ b/admin/css/tokens.css @@ -0,0 +1,122 @@ +/* ============================================ + Grubflip Design Tokens โ€” CSS Custom Properties + Generated from brand-tokens v2.0.0 + ============================================ */ + +:root { + /* --- Colors: Primary (Warm Orange) --- */ + --gf-primary: #FF6B35; + --gf-primary-light: #FF8F66; + --gf-primary-dark: #D94E1F; + --gf-primary-bg: #FFF3ED; + + /* --- Colors: Secondary (Deep Green) --- */ + --gf-secondary: #2D6A4F; + --gf-secondary-light: #40916C; + --gf-secondary-dark: #1B4332; + --gf-secondary-bg: #EDF5F0; + + /* --- Colors: Accent (Golden Yellow) --- */ + --gf-accent: #FFBA08; + --gf-accent-light: #FFD166; + --gf-accent-dark: #E0A100; + --gf-accent-bg: #FFF8E1; + + /* --- Semantic Colors --- */ + --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; + + /* --- Neutrals --- */ + --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; + + /* --- Backgrounds --- */ + --gf-bg: #FFFFFF; + --gf-bg-subtle: #FAFAFA; + --gf-bg-muted: #F5F5F5; + --gf-bg-dark: #1A1A2E; + + /* --- Typography --- */ + --gf-font-heading: 'Plus Jakarta Sans', sans-serif; + --gf-font-body: 'Inter', sans-serif; + --gf-font-mono: 'JetBrains Mono', monospace; + + --gf-text-xs: 0.75rem; + --gf-text-sm: 0.875rem; + --gf-text-base: 1rem; + --gf-text-lg: 1.125rem; + --gf-text-xl: 1.25rem; + --gf-text-2xl: 1.5rem; + --gf-text-3xl: 1.875rem; + --gf-text-4xl: 2.25rem; + + --gf-weight-regular: 400; + --gf-weight-medium: 500; + --gf-weight-semibold: 600; + --gf-weight-bold: 700; + --gf-weight-extrabold: 800; + + /* --- Spacing --- */ + --gf-space-0: 0; + --gf-space-1: 0.25rem; + --gf-space-2: 0.5rem; + --gf-space-3: 0.75rem; + --gf-space-4: 1rem; + --gf-space-5: 1.25rem; + --gf-space-6: 1.5rem; + --gf-space-8: 2rem; + --gf-space-10: 2.5rem; + --gf-space-12: 3rem; + --gf-space-16: 4rem; + --gf-space-20: 5rem; + + /* --- Border Radius --- */ + --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; + + /* --- 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-xl: 0 20px 25px -5px rgba(0,0,0,0.1), 0 8px 10px -6px rgba(0,0,0,0.1); + --gf-shadow-card: 0 2px 8px rgba(0,0,0,0.08); + + /* --- Animation --- */ + --gf-duration-fast: 150ms; + --gf-duration-normal: 200ms; + --gf-duration-slow: 300ms; + --gf-ease: cubic-bezier(0.4, 0, 0.2, 1); + --gf-ease-out: cubic-bezier(0, 0, 0.2, 1); + --gf-ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1); + + /* --- Z-Index --- */ + --gf-z-dropdown: 1000; + --gf-z-sticky: 1020; + --gf-z-fixed: 1030; + --gf-z-modal-backdrop: 1040; + --gf-z-modal: 1050; + --gf-z-toast: 1080; + + /* --- Sidebar --- */ + --gf-sidebar-width: 16rem; + --gf-sidebar-collapsed: 4.5rem; + --gf-navbar-height: 4rem; +} diff --git a/admin/index.html b/admin/index.html new file mode 100644 index 0000000..53e9e81 --- /dev/null +++ b/admin/index.html @@ -0,0 +1,194 @@ + + + + + + Grubflip Admin โ€” Login + + + + + + + + +
+
+ + + + +
+
+ + +
+ +
+ + +
+ +
+ + Forgot password? +
+ + +
+ + +
+
+ + + + diff --git a/chat.html b/chat.html new file mode 100644 index 0000000..e774586 --- /dev/null +++ b/chat.html @@ -0,0 +1,173 @@ + + + + + + GrubFlip โ€” Chat + + + + + + + + + + +
+ + + +
+
+ + +
+
+

Marcus J.

+ Online ยท โญ 4.8 ยท 23 trades +
+
+ +
+ + +
+
+
+ ๐Ÿ• + Margherita Pizza + You +
+
+ +
+
+ ๐Ÿœ + Pad Thai + Marcus +
+
+
+ Accepted + Expires in 2h 15m +
+
+ + +
+
+ + +
+ + + + + +
+ + Trade request sent for Margherita Pizza โ†” Pad Thai +
+ + +
+ +
+

Hey! Your margherita looks amazing ๐Ÿคค I just made a fresh batch of pad thai โ€” extra peanuts and lime. Interested in a swap?

+ +
+
+ + +
+
+

Absolutely! I just pulled it out of the wood-fired oven. Still warm ๐Ÿ”ฅ

+ +
+
+ + +
+ + Marcus accepted the trade! +
+ + +
+ +
+

Awesome! Where do you want to meet? I'm near the downtown area

+ +
+
+ + +
+
+

I'm at Rosario's on 5th. Can you swing by around 6?

+ +
+
+ + +
+ +
+

Sounds good! I can meet at the corner of 5th and Main around 6pm ๐Ÿ‘

+ +
+
+ + +
+ +
+
+ +
+
+
+ +
+ + + + + + + + + diff --git a/css/chat.css b/css/chat.css new file mode 100644 index 0000000..baad62f --- /dev/null +++ b/css/chat.css @@ -0,0 +1,974 @@ +/* ============================================ + GrubFlip โ€” Chat & Messages Styles + Mobile-first responsive design + Uses brand tokens from styles.css + ============================================ */ + +/* --- Messages Inbox Page --- */ + +.messages-inbox { + padding-top: 0.5rem; + padding-bottom: 5rem; /* space for bottom nav */ +} + +.chat-search { + position: relative; + margin: 0.75rem 1rem; +} + +.chat-search__icon { + position: absolute; + left: 0.875rem; + top: 50%; + transform: translateY(-50%); + color: var(--gf-neutral-400); + pointer-events: none; +} + +.chat-search__input { + width: 100%; + padding: 0.625rem 0.875rem 0.625rem 2.5rem; + border: 1.5px solid var(--gf-neutral-200); + border-radius: var(--gf-radius-full); + background: var(--gf-bg-muted); + font-family: var(--gf-font-body); + font-size: 0.875rem; + color: var(--gf-neutral-900); + transition: border-color 0.15s ease, background 0.15s ease; +} + +.chat-search__input:focus { + outline: none; + border-color: var(--gf-primary); + background: var(--gf-bg); + box-shadow: 0 0 0 3px rgba(255, 107, 53, 0.1); +} + +.chat-search__input::placeholder { + color: var(--gf-neutral-400); +} + +/* Trade Context Banner */ +.trade-context-banner { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0 1rem 0.5rem; + padding: 0.5rem 0.75rem; + background: var(--gf-primary-bg); + border-radius: var(--gf-radius-md); + font-family: var(--gf-font-body); + font-size: 0.8125rem; + font-weight: 600; + color: var(--gf-primary-dark); +} + +.trade-context-banner svg { + color: var(--gf-primary); + flex-shrink: 0; +} + +/* Conversation List */ +.conversation-list { + list-style: none; + margin: 0; + padding: 0; +} + +.conversation-item { + border-bottom: 1px solid var(--gf-neutral-100); +} + +.conversation-item:last-child { + border-bottom: none; +} + +.conversation-item__link { + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 0.875rem 1rem; + text-decoration: none; + color: inherit; + transition: background 0.12s ease; +} + +.conversation-item__link:active { + background: var(--gf-neutral-50); +} + +/* Avatar */ +.conversation-item__avatar { + position: relative; + flex-shrink: 0; +} + +.conversation-item__avatar img { + width: 48px; + height: 48px; + border-radius: var(--gf-radius-full); + object-fit: cover; + background: var(--gf-neutral-200); +} + +.conversation-item__online { + position: absolute; + bottom: 1px; + right: 1px; + width: 12px; + height: 12px; + background: var(--gf-success); + border: 2px solid var(--gf-bg); + border-radius: var(--gf-radius-full); +} + +/* Content */ +.conversation-item__content { + flex: 1; + min-width: 0; +} + +.conversation-item__header { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 0.125rem; +} + +.conversation-item__name { + font-family: var(--gf-font-heading); + font-size: 0.9375rem; + font-weight: 700; + color: var(--gf-neutral-900); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.conversation-item--unread .conversation-item__name { + color: var(--gf-neutral-900); +} + +.conversation-item__time { + font-family: var(--gf-font-body); + font-size: 0.75rem; + color: var(--gf-neutral-400); + flex-shrink: 0; + margin-left: 0.5rem; +} + +.conversation-item--unread .conversation-item__time { + color: var(--gf-primary); + font-weight: 600; +} + +.conversation-item__preview { + font-family: var(--gf-font-body); + font-size: 0.8125rem; + color: var(--gf-neutral-500); + line-height: 1.4; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 0.25rem; +} + +.conversation-item--unread .conversation-item__preview { + color: var(--gf-neutral-700); + font-weight: 500; +} + +.conversation-item__meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.conversation-item__trade-tag { + font-family: var(--gf-font-body); + font-size: 0.6875rem; + color: var(--gf-neutral-500); + background: var(--gf-neutral-100); + padding: 0.125rem 0.5rem; + border-radius: var(--gf-radius-full); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 200px; +} + +.conversation-item__trade-tag--complete { + background: var(--gf-success-bg); + color: #1a8a4a; +} + +.conversation-item__trade-tag--expired { + background: var(--gf-neutral-100); + color: var(--gf-neutral-400); +} + +.conversation-item__badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 0.375rem; + background: var(--gf-primary); + color: #fff; + font-family: var(--gf-font-body); + font-size: 0.6875rem; + font-weight: 700; + border-radius: var(--gf-radius-full); + flex-shrink: 0; +} + + +/* ============================================ + Chat View (Individual Conversation) + ============================================ */ + +.chat-view { + display: flex; + flex-direction: column; + height: 100dvh; + height: 100vh; /* fallback */ + overflow: hidden; +} + +/* Chat Header */ +.chat-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: var(--gf-bg); + border-bottom: 1px solid var(--gf-neutral-100); + position: sticky; + top: 0; + z-index: 100; + min-height: 56px; +} + +.chat-header__back { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + color: var(--gf-neutral-700); + text-decoration: none; + border-radius: var(--gf-radius-full); + transition: background 0.12s ease; + flex-shrink: 0; +} + +.chat-header__back:active { + background: var(--gf-neutral-100); +} + +.chat-header__user { + display: flex; + align-items: center; + gap: 0.625rem; + flex: 1; + min-width: 0; +} + +.chat-header__avatar { + position: relative; + flex-shrink: 0; +} + +.chat-header__avatar img { + width: 36px; + height: 36px; + border-radius: var(--gf-radius-full); + object-fit: cover; + background: var(--gf-neutral-200); +} + +.chat-header__online { + position: absolute; + bottom: 0; + right: 0; + width: 10px; + height: 10px; + background: var(--gf-success); + border: 2px solid var(--gf-bg); + border-radius: var(--gf-radius-full); +} + +.chat-header__info { + min-width: 0; +} + +.chat-header__name { + font-family: var(--gf-font-heading); + font-size: 0.9375rem; + font-weight: 700; + color: var(--gf-neutral-900); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.chat-header__status { + font-family: var(--gf-font-body); + font-size: 0.6875rem; + color: var(--gf-neutral-400); +} + +/* Icon button (shared) */ +.btn-icon { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + background: none; + border: none; + color: var(--gf-neutral-600); + border-radius: var(--gf-radius-full); + cursor: pointer; + transition: background 0.12s ease; + flex-shrink: 0; +} + +.btn-icon:active { + background: var(--gf-neutral-100); +} + +/* Trade Context Card */ +.trade-context-card { + background: var(--gf-bg); + border-bottom: 1px solid var(--gf-neutral-100); + padding: 0.625rem 1rem; +} + +.trade-context-card__meals { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.trade-context-card__meal { + display: flex; + align-items: center; + gap: 0.375rem; + background: var(--gf-neutral-50); + padding: 0.375rem 0.625rem; + border-radius: var(--gf-radius-lg); + flex: 1; + min-width: 0; +} + +.trade-context-card__emoji { + font-size: 1.25rem; + flex-shrink: 0; +} + +.trade-context-card__label { + font-family: var(--gf-font-body); + font-size: 0.75rem; + font-weight: 600; + color: var(--gf-neutral-800); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.trade-context-card__owner { + font-family: var(--gf-font-body); + font-size: 0.625rem; + color: var(--gf-neutral-400); + margin-left: auto; + flex-shrink: 0; +} + +.trade-context-card__arrow { + color: var(--gf-primary); + flex-shrink: 0; +} + +.trade-context-card__status { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + margin-bottom: 0.5rem; + font-family: var(--gf-font-body); +} + +.trade-status { + font-size: 0.6875rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.125rem 0.5rem; + border-radius: var(--gf-radius-full); +} + +.trade-status--pending { + background: var(--gf-warning-bg); + color: #b47d00; +} + +.trade-status--accepted { + background: var(--gf-success-bg); + color: #1a8a4a; +} + +.trade-status--confirmed { + background: var(--gf-info-bg); + color: #1a6fb5; +} + +.trade-context-card__expires { + font-size: 0.6875rem; + color: var(--gf-neutral-400); +} + +.trade-context-card__actions { + display: flex; + gap: 0.5rem; + justify-content: center; +} + +.trade-context-card__actions .btn { + font-size: 0.75rem; + padding: 0.375rem 0.875rem; +} + +/* Button variants for chat */ +.btn--secondary-outline { + background: none; + border: 1.5px solid var(--gf-secondary); + color: var(--gf-secondary); + border-radius: var(--gf-radius-full); + font-family: var(--gf-font-body); + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; +} + +.btn--secondary-outline:active { + background: var(--gf-secondary-bg); +} + + +/* ============================================ + Chat Messages Area + ============================================ */ + +.chat-messages { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 0.75rem 0.75rem 1rem; + background: var(--gf-bg-subtle); + -webkit-overflow-scrolling: touch; +} + +/* Date Separator */ +.chat-date-separator { + display: flex; + align-items: center; + justify-content: center; + margin: 0.75rem 0; +} + +.chat-date-separator span { + font-family: var(--gf-font-body); + font-size: 0.6875rem; + font-weight: 600; + color: var(--gf-neutral-400); + background: var(--gf-neutral-100); + padding: 0.1875rem 0.75rem; + border-radius: var(--gf-radius-full); +} + +/* System Messages */ +.chat-system-msg { + display: flex; + align-items: center; + justify-content: center; + gap: 0.375rem; + font-family: var(--gf-font-body); + font-size: 0.6875rem; + color: var(--gf-neutral-400); + text-align: center; + margin: 0.5rem 1rem; + padding: 0.375rem 0; +} + +.chat-system-msg svg { + flex-shrink: 0; +} + +.chat-system-msg--success { + color: #1a8a4a; +} + + +/* ============================================ + Message Bubbles + ============================================ */ + +.chat-msg { + display: flex; + align-items: flex-end; + gap: 0.375rem; + margin-bottom: 0.25rem; + max-width: 85%; +} + +/* Incoming */ +.chat-msg--in { + align-self: flex-start; + margin-right: auto; +} + +.chat-msg--in .chat-msg__bubble { + background: var(--gf-bg); + color: var(--gf-neutral-800); + border-radius: var(--gf-radius-lg) var(--gf-radius-lg) var(--gf-radius-lg) 0.25rem; + box-shadow: var(--gf-shadow-sm); +} + +/* Outgoing */ +.chat-msg--out { + flex-direction: row-reverse; + align-self: flex-end; + margin-left: auto; +} + +.chat-msg--out .chat-msg__bubble { + background: var(--gf-primary); + color: #fff; + border-radius: var(--gf-radius-lg) var(--gf-radius-lg) 0.25rem var(--gf-radius-lg); +} + +.chat-msg--out .chat-msg__time { + color: rgba(255, 255, 255, 0.7); +} + +/* Avatar */ +.chat-msg__avatar { + width: 32px; + height: 32px; + border-radius: var(--gf-radius-full); + object-fit: cover; + flex-shrink: 0; + background: var(--gf-neutral-200); +} + +/* Bubble */ +.chat-msg__bubble { + padding: 0.5rem 0.75rem; + max-width: 100%; +} + +.chat-msg__bubble p { + font-family: var(--gf-font-body); + font-size: 0.875rem; + line-height: 1.45; + word-wrap: break-word; + overflow-wrap: break-word; +} + +.chat-msg__time { + display: block; + font-family: var(--gf-font-body); + font-size: 0.625rem; + color: var(--gf-neutral-400); + margin-top: 0.25rem; + text-align: right; +} + +/* Consecutive messages from same sender โ€” tighten spacing */ +.chat-msg + .chat-msg--in, +.chat-msg + .chat-msg--out { + margin-top: 0.125rem; +} + +/* Typing indicator */ +.chat-msg--typing .chat-msg__bubble { + padding: 0.625rem 0.875rem; +} + +.typing-dots { + display: flex; + gap: 0.25rem; + align-items: center; + height: 1rem; +} + +.typing-dots span { + width: 6px; + height: 6px; + background: var(--gf-neutral-300); + border-radius: var(--gf-radius-full); + animation: typingBounce 1.4s infinite ease-in-out; +} + +.typing-dots span:nth-child(2) { + animation-delay: 0.2s; +} + +.typing-dots span:nth-child(3) { + animation-delay: 0.4s; +} + +@keyframes typingBounce { + 0%, 60%, 100% { + transform: translateY(0); + opacity: 0.4; + } + 30% { + transform: translateY(-4px); + opacity: 1; + } +} + + +/* ============================================ + Chat Input Bar + ============================================ */ + +.chat-input-bar { + display: flex; + align-items: flex-end; + gap: 0.375rem; + padding: 0.5rem 0.625rem; + background: var(--gf-bg); + border-top: 1px solid var(--gf-neutral-100); + /* Safe area for notch phones */ + padding-bottom: calc(0.5rem + env(safe-area-inset-bottom, 0px)); +} + +.chat-input-bar__attach { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + background: none; + border: none; + color: var(--gf-neutral-500); + cursor: pointer; + border-radius: var(--gf-radius-full); + flex-shrink: 0; + transition: color 0.12s ease; +} + +.chat-input-bar__attach:active { + color: var(--gf-primary); + background: var(--gf-primary-bg); +} + +.chat-input-bar__field { + flex: 1; + min-width: 0; +} + +.chat-input-bar__textarea { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1.5px solid var(--gf-neutral-200); + border-radius: 1.125rem; + background: var(--gf-bg-muted); + font-family: var(--gf-font-body); + font-size: 0.875rem; + color: var(--gf-neutral-900); + line-height: 1.4; + resize: none; + max-height: 120px; + overflow-y: auto; + transition: border-color 0.15s ease; +} + +.chat-input-bar__textarea:focus { + outline: none; + border-color: var(--gf-primary); + background: var(--gf-bg); +} + +.chat-input-bar__textarea::placeholder { + color: var(--gf-neutral-400); +} + +.chat-input-bar__send { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + background: var(--gf-primary); + border: none; + color: #fff; + border-radius: var(--gf-radius-full); + cursor: pointer; + flex-shrink: 0; + transition: all 0.15s ease; +} + +.chat-input-bar__send:disabled { + background: var(--gf-neutral-200); + color: var(--gf-neutral-400); + cursor: default; +} + +.chat-input-bar__send:not(:disabled):active { + background: var(--gf-primary-dark); + transform: scale(0.94); +} + + +/* ============================================ + Quick Actions Panel + ============================================ */ + +.chat-quick-actions { + display: flex; + gap: 0.5rem; + padding: 0.75rem 1rem; + background: var(--gf-bg); + border-top: 1px solid var(--gf-neutral-100); + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.chat-quick-action { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 0.75rem; + background: var(--gf-neutral-50); + border: 1.5px solid var(--gf-neutral-200); + border-radius: var(--gf-radius-full); + font-family: var(--gf-font-body); + font-size: 0.75rem; + font-weight: 500; + color: var(--gf-neutral-700); + white-space: nowrap; + cursor: pointer; + transition: all 0.12s ease; +} + +.chat-quick-action:active { + background: var(--gf-primary-bg); + border-color: var(--gf-primary-light); + color: var(--gf-primary-dark); +} + + +/* ============================================ + Bottom Nav Badge (for unread count) + ============================================ */ + +.gf-bottom-nav__badge { + position: absolute; + top: 2px; + right: calc(50% - 20px); + min-width: 16px; + height: 16px; + padding: 0 0.25rem; + background: var(--gf-primary); + color: #fff; + font-family: var(--gf-font-body); + font-size: 0.625rem; + font-weight: 700; + border-radius: var(--gf-radius-full); + display: flex; + align-items: center; + justify-content: center; +} + + +/* ============================================ + Slide-in Menu / Navigation + ============================================ */ + +.slide-menu-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + z-index: 999; + opacity: 0; + visibility: hidden; + transition: opacity 0.25s ease, visibility 0.25s ease; +} + +.slide-menu-overlay--active { + opacity: 1; + visibility: visible; +} + +.slide-menu { + position: fixed; + top: 0; + left: 0; + bottom: 0; + width: 80%; + max-width: 320px; + background: var(--gf-bg); + z-index: 1000; + transform: translateX(-100%); + transition: transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94); + display: flex; + flex-direction: column; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +.slide-menu--active { + transform: translateX(0); +} + +.slide-menu__header { + padding: 1.25rem 1rem; + border-bottom: 1px solid var(--gf-neutral-100); +} + +.slide-menu__user { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.5rem; +} + +.slide-menu__user img { + width: 48px; + height: 48px; + border-radius: var(--gf-radius-full); + object-fit: cover; + background: var(--gf-neutral-200); +} + +.slide-menu__user-name { + font-family: var(--gf-font-heading); + font-size: 1rem; + font-weight: 700; + color: var(--gf-neutral-900); +} + +.slide-menu__user-handle { + font-family: var(--gf-font-body); + font-size: 0.75rem; + color: var(--gf-neutral-400); +} + +.slide-menu__stats { + display: flex; + gap: 1rem; + font-family: var(--gf-font-body); + font-size: 0.75rem; + color: var(--gf-neutral-500); +} + +.slide-menu__stats strong { + color: var(--gf-neutral-800); + font-weight: 700; +} + +.slide-menu__nav { + padding: 0.5rem 0; + flex: 1; +} + +.slide-menu__link { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + text-decoration: none; + font-family: var(--gf-font-body); + font-size: 0.9375rem; + font-weight: 500; + color: var(--gf-neutral-700); + transition: background 0.12s ease; +} + +.slide-menu__link:active { + background: var(--gf-neutral-50); +} + +.slide-menu__link--active { + color: var(--gf-primary); + font-weight: 600; + background: var(--gf-primary-bg); +} + +.slide-menu__link svg { + width: 22px; + height: 22px; + flex-shrink: 0; +} + +.slide-menu__divider { + height: 1px; + background: var(--gf-neutral-100); + margin: 0.375rem 0; +} + +.slide-menu__footer { + padding: 1rem; + border-top: 1px solid var(--gf-neutral-100); +} + +.slide-menu__footer-link { + font-family: var(--gf-font-body); + font-size: 0.8125rem; + color: var(--gf-neutral-400); + text-decoration: none; +} + + +/* ============================================ + Responsive โ€” Tablet & Desktop + ============================================ */ + +@media (min-width: 768px) { + .chat-msg { + max-width: 65%; + } + + .messages-inbox .container { + max-width: 640px; + margin: 0 auto; + } + + .chat-header { + padding: 0.5rem 1.5rem; + } + + .chat-messages { + padding: 1rem 1.5rem; + } + + .chat-input-bar { + padding: 0.625rem 1.5rem; + } + + .slide-menu { + max-width: 360px; + } + + .conversation-item__trade-tag { + max-width: 280px; + } +} + +@media (min-width: 1024px) { + .chat-msg { + max-width: 50%; + } + + .messages-inbox .container { + max-width: 720px; + } +} diff --git a/css/landing.css b/css/landing.css new file mode 100644 index 0000000..20f268b --- /dev/null +++ b/css/landing.css @@ -0,0 +1,709 @@ +/* ============================================ + GrubFlip Landing Page + Mobile-first ยท Brand tokens from Ava + ============================================ */ + +/* --- Tokens (from design system) --- */ +:root { + --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-bg: #FFF8E1; + + --gf-neutral-50: #FAFAFA; + --gf-neutral-100: #F5F5F5; + --gf-neutral-200: #E5E5E5; + --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-font-heading: 'Plus Jakarta Sans', sans-serif; + --gf-font-body: 'Inter', sans-serif; + + --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-xl: 0 20px 25px -5px rgba(0,0,0,0.1), 0 8px 10px -6px rgba(0,0,0,0.1); + + --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 --- */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 16px; + -webkit-text-size-adjust: 100%; + scroll-behavior: smooth; +} + +body { + font-family: var(--gf-font-body); + color: var(--gf-neutral-800); + background: #fff; + line-height: 1.6; + overflow-x: hidden; + -webkit-font-smoothing: antialiased; +} + +img { max-width: 100%; display: block; } +a { text-decoration: none; color: inherit; } + +.container { + width: 100%; + max-width: 1120px; + margin: 0 auto; + padding: 0 1.25rem; +} + +/* --- Buttons --- */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + font-family: var(--gf-font-body); + font-weight: 600; + border: none; + border-radius: var(--gf-radius-full); + cursor: pointer; + transition: all 0.2s ease; + text-decoration: none; + line-height: 1; + white-space: nowrap; +} + +.btn--sm { + padding: 0.5rem 1rem; + font-size: 0.875rem; +} + +.btn--lg { + padding: 0.875rem 1.75rem; + font-size: 1rem; +} + +.btn--primary { + background: var(--gf-primary); + color: #fff; +} +.btn--primary:hover { + background: var(--gf-primary-dark); + transform: translateY(-1px); + box-shadow: var(--gf-shadow-md); +} + +.btn--outline { + background: transparent; + color: var(--gf-neutral-700); + border: 2px solid var(--gf-neutral-200); +} +.btn--outline:hover { + border-color: var(--gf-primary); + color: var(--gf-primary); +} + +.btn--dark { + background: var(--gf-neutral-900); + color: #fff; +} +.btn--dark:hover { + background: var(--gf-neutral-700); + transform: translateY(-1px); + box-shadow: var(--gf-shadow-md); +} + +/* --- Navigation --- */ +.nav { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100; + background: rgba(255,255,255,0.92); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid rgba(0,0,0,0.06); +} + +.nav__inner { + display: flex; + align-items: center; + justify-content: space-between; + max-width: 1120px; + margin: 0 auto; + padding: 0.875rem 1.25rem; +} + +.nav__logo { + font-family: var(--gf-font-heading); + font-weight: 800; + font-size: 1.375rem; + color: var(--gf-neutral-900); +} +.nav__logo span { + color: var(--gf-primary); +} + +.nav__links { + display: flex; + align-items: center; + gap: 1.25rem; +} + +.nav__link { + font-size: 0.875rem; + font-weight: 500; + color: var(--gf-neutral-600); + transition: color 0.2s; +} +.nav__link:hover { color: var(--gf-primary); } + +.hide-mobile { display: none; } + +/* --- Hero --- */ +.hero { + padding: 7rem 0 3rem; + background: linear-gradient(180deg, var(--gf-primary-bg) 0%, #fff 100%); +} + +.hero .container { + display: flex; + flex-direction: column; + gap: 2.5rem; +} + +.hero__badge { + display: inline-block; + background: #fff; + border: 1px solid var(--gf-neutral-200); + border-radius: var(--gf-radius-full); + padding: 0.375rem 0.875rem; + font-size: 0.8125rem; + font-weight: 600; + color: var(--gf-neutral-700); + margin-bottom: 0.75rem; +} + +.hero__title { + font-family: var(--gf-font-heading); + font-weight: 800; + font-size: 2.25rem; + line-height: 1.15; + color: var(--gf-neutral-900); + letter-spacing: -0.02em; +} + +.hero__highlight { + color: var(--gf-primary); +} + +.hero__sub { + margin-top: 1rem; + font-size: 1.0625rem; + color: var(--gf-neutral-600); + line-height: 1.65; + max-width: 480px; +} + +.hero__cta { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 1.5rem; +} + +.hero__social-proof { + display: flex; + align-items: center; + gap: 0.75rem; + margin-top: 1.75rem; + font-size: 0.875rem; + color: var(--gf-neutral-500); +} + +.hero__avatars { + display: flex; +} + +.hero__avatar { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 50%; + font-size: 0.875rem; + border: 2px solid #fff; + margin-right: -0.5rem; +} +.hero__avatar:last-child { margin-right: 0; } + +.hero__social-text strong { + color: var(--gf-neutral-800); +} + +/* Phone Mockup */ +.hero__visual { + display: flex; + justify-content: center; +} + +.phone-mockup { + width: 260px; + background: var(--gf-neutral-900); + border-radius: 2rem; + padding: 0.5rem; + box-shadow: var(--gf-shadow-xl), 0 0 0 1px rgba(0,0,0,0.1); + transform: rotate(2deg); +} + +.phone-mockup__screen { + background: #fff; + border-radius: 1.625rem; + padding: 1rem; + min-height: 380px; + display: flex; + flex-direction: column; + gap: 0.625rem; +} + +.phone-mockup__header { + text-align: center; + padding: 0.5rem 0 0.75rem; +} + +.phone-mockup__logo { + font-family: var(--gf-font-heading); + font-weight: 800; + font-size: 1rem; + color: var(--gf-neutral-900); +} +.phone-mockup__logo span { color: var(--gf-primary); } + +.phone-mockup__card { + display: flex; + align-items: center; + gap: 0.625rem; + background: var(--gf-neutral-50); + border: 1px solid var(--gf-neutral-200); + border-radius: var(--gf-radius-lg); + padding: 0.75rem; + animation: cardSlideIn 0.6s ease both; +} +.phone-mockup__card:nth-child(3) { animation-delay: 0.2s; } +.phone-mockup__card:nth-child(4) { animation-delay: 0.4s; } + +.phone-mockup__card--alt { + background: var(--gf-secondary-bg); + border-color: rgba(45,106,79,0.15); +} + +.phone-mockup__food-emoji { + font-size: 1.75rem; + flex-shrink: 0; +} + +.phone-mockup__card-text { + display: flex; + flex-direction: column; + gap: 0.125rem; + flex: 1; + min-width: 0; +} + +.phone-mockup__card-text strong { + font-size: 0.8125rem; + font-weight: 600; + color: var(--gf-neutral-800); +} + +.phone-mockup__card-text small { + font-size: 0.6875rem; + color: var(--gf-neutral-400); +} + +.phone-mockup__badge { + flex-shrink: 0; + background: var(--gf-primary); + color: #fff; + font-size: 0.6875rem; + font-weight: 700; + padding: 0.25rem 0.625rem; + border-radius: var(--gf-radius-full); +} + +.phone-mockup__badge--green { + background: var(--gf-secondary); +} + +@keyframes cardSlideIn { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* --- How It Works --- */ +.how-it-works { + padding: 4rem 0; +} + +.section-title { + font-family: var(--gf-font-heading); + font-weight: 800; + font-size: 1.75rem; + color: var(--gf-neutral-900); + text-align: center; + letter-spacing: -0.02em; +} + +.section-sub { + text-align: center; + color: var(--gf-neutral-500); + margin-top: 0.5rem; + font-size: 1rem; +} + +.steps { + display: flex; + flex-direction: column; + gap: 2rem; + margin-top: 2.5rem; +} + +.step { + text-align: center; + position: relative; + padding: 1.5rem; +} + +.step__icon { + font-size: 2.5rem; + margin-bottom: 0.75rem; +} + +.step__num { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 50%; + background: var(--gf-primary); + color: #fff; + font-weight: 700; + font-size: 0.875rem; + margin-bottom: 0.75rem; +} + +.step__title { + font-family: var(--gf-font-heading); + font-weight: 700; + font-size: 1.125rem; + color: var(--gf-neutral-800); + margin-bottom: 0.5rem; +} + +.step__desc { + color: var(--gf-neutral-500); + font-size: 0.9375rem; + line-height: 1.6; + max-width: 320px; + margin: 0 auto; +} + +/* --- Features --- */ +.features { + padding: 4rem 0; + background: var(--gf-neutral-50); +} + +.features__grid { + display: grid; + grid-template-columns: 1fr; + gap: 1.25rem; + margin-top: 2.5rem; +} + +.feature-card { + background: #fff; + border: 1px solid var(--gf-neutral-200); + border-radius: var(--gf-radius-xl); + padding: 1.5rem; + transition: transform 0.2s, box-shadow 0.2s; +} +.feature-card:hover { + transform: translateY(-2px); + box-shadow: var(--gf-shadow-md); +} + +.feature-card__icon { + font-size: 2rem; + margin-bottom: 0.75rem; +} + +.feature-card__title { + font-family: var(--gf-font-heading); + font-weight: 700; + font-size: 1.0625rem; + color: var(--gf-neutral-800); + margin-bottom: 0.375rem; +} + +.feature-card__desc { + color: var(--gf-neutral-500); + font-size: 0.9375rem; + line-height: 1.55; +} + +/* --- Testimonial --- */ +.testimonial { + padding: 4rem 0; + text-align: center; +} + +.testimonial__quote { + font-family: var(--gf-font-heading); + font-size: 1.25rem; + font-weight: 600; + line-height: 1.5; + color: var(--gf-neutral-800); + max-width: 600px; + margin: 0 auto; + font-style: italic; +} + +.testimonial__author { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + margin-top: 1.5rem; +} + +.testimonial__avatar { + display: flex; + align-items: center; + justify-content: center; + width: 2.75rem; + height: 2.75rem; + background: var(--gf-accent-bg); + border-radius: 50%; + font-size: 1.25rem; +} + +.testimonial__author strong { + display: block; + font-size: 0.9375rem; + color: var(--gf-neutral-800); +} + +.testimonial__author span { + font-size: 0.8125rem; + color: var(--gf-neutral-500); +} + +/* --- Download CTA --- */ +.download { + padding: 4rem 0; + background: linear-gradient(135deg, var(--gf-primary) 0%, var(--gf-primary-dark) 100%); + text-align: center; + color: #fff; +} + +.download__title { + font-family: var(--gf-font-heading); + font-weight: 800; + font-size: 2rem; + letter-spacing: -0.02em; +} + +.download__sub { + margin-top: 0.75rem; + font-size: 1.0625rem; + opacity: 0.9; + max-width: 400px; + margin-left: auto; + margin-right: auto; +} + +.download__buttons { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.75rem; + margin-top: 2rem; +} + +.download__store-btn { + min-width: 160px; +} + +.download__store-icon { + width: 20px; + height: 20px; + flex-shrink: 0; +} + +.download__note { + margin-top: 1.5rem; + font-size: 0.875rem; + opacity: 0.75; +} +.download__note a { + color: #fff; + text-decoration: underline; + text-underline-offset: 2px; +} +.download__note a:hover { + opacity: 0.8; +} + +/* --- Footer --- */ +.footer { + padding: 2.5rem 0 1.5rem; + background: var(--gf-neutral-900); + color: var(--gf-neutral-400); +} + +.footer__inner { + display: flex; + flex-direction: column; + gap: 1.5rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid rgba(255,255,255,0.1); +} + +.footer__logo { + font-family: var(--gf-font-heading); + font-weight: 800; + font-size: 1.25rem; + color: #fff; +} +.footer__logo span { color: var(--gf-primary); } + +.footer__tagline { + font-size: 0.875rem; + margin-top: 0.25rem; +} + +.footer__links { + display: flex; + flex-wrap: wrap; + gap: 1.25rem; +} + +.footer__links a { + font-size: 0.875rem; + color: var(--gf-neutral-400); + transition: color 0.2s; +} +.footer__links a:hover { color: #fff; } + +.footer__copy { + text-align: center; + font-size: 0.8125rem; + padding-top: 1.5rem; + color: var(--gf-neutral-500); +} + +/* ============================================ + Tablet+ (640px) + ============================================ */ +@media (min-width: 640px) { + .hide-mobile { display: inline; } + + .hero__title { font-size: 2.75rem; } + + .steps { + flex-direction: row; + gap: 1.5rem; + } + .step { flex: 1; } + + .features__grid { + grid-template-columns: 1fr 1fr; + } + + .footer__inner { + flex-direction: row; + align-items: center; + justify-content: space-between; + } +} + +/* ============================================ + Desktop (960px) + ============================================ */ +@media (min-width: 960px) { + .hero .container { + flex-direction: row; + align-items: center; + gap: 4rem; + } + + .hero__content { flex: 1; } + .hero__visual { flex: 0 0 auto; } + + .hero__title { font-size: 3.25rem; } + + .hero { + padding: 8rem 0 5rem; + } + + .section-title { font-size: 2.25rem; } + + .how-it-works, + .features, + .testimonial { + padding: 5rem 0; + } + + .features__grid { + grid-template-columns: repeat(4, 1fr); + } + + .testimonial__quote { + font-size: 1.5rem; + } + + .download { + padding: 5rem 0; + } + + .download__title { font-size: 2.5rem; } + + .phone-mockup { + width: 300px; + } + + .phone-mockup__screen { + min-height: 440px; + } +} diff --git a/img/avatar-placeholder.svg b/img/avatar-placeholder.svg new file mode 100644 index 0000000..4900497 --- /dev/null +++ b/img/avatar-placeholder.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/js/app.js b/js/app.js new file mode 100644 index 0000000..8162348 --- /dev/null +++ b/js/app.js @@ -0,0 +1,237 @@ +/* ============================================ + GrubFlip โ€” Core App Module + API client, auth, utilities, toast system + ============================================ */ + +const GrubFlip = (() => { + 'use strict'; + + // --- Config --- + const API_BASE = '/api'; + let authToken = localStorage.getItem('gf_token') || null; + + // --- Auth --- + function setToken(token) { + authToken = token; + if (token) { + localStorage.setItem('gf_token', token); + } else { + localStorage.removeItem('gf_token'); + } + } + + function getToken() { + return authToken; + } + + function isLoggedIn() { + return !!authToken; + } + + // --- API Client --- + async function api(endpoint, options = {}) { + const url = `${API_BASE}${endpoint}`; + const headers = { + 'Accept': 'application/json', + ...options.headers, + }; + + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } + + // Don't set Content-Type for FormData (browser sets boundary) + if (options.body && !(options.body instanceof FormData)) { + headers['Content-Type'] = 'application/json'; + if (typeof options.body === 'object') { + options.body = JSON.stringify(options.body); + } + } + + try { + const response = await fetch(url, { + ...options, + headers, + }); + + if (response.status === 401) { + setToken(null); + window.location.href = '/grubflip/login.html'; + return null; + } + + const data = await response.json(); + + if (!response.ok) { + throw new ApiError( + data.error || data.message || `Request failed (${response.status})`, + response.status, + data + ); + } + + return data; + } catch (err) { + if (err instanceof ApiError) throw err; + throw new ApiError('Network error โ€” check your connection', 0); + } + } + + class ApiError extends Error { + constructor(message, status, data = null) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.data = data; + } + } + + // Convenience methods + api.get = (endpoint, params = {}) => { + const query = new URLSearchParams(params).toString(); + const url = query ? `${endpoint}?${query}` : endpoint; + return api(url, { method: 'GET' }); + }; + + api.post = (endpoint, body) => { + return api(endpoint, { method: 'POST', body }); + }; + + api.put = (endpoint, body) => { + return api(endpoint, { method: 'PUT', body }); + }; + + api.delete = (endpoint) => { + return api(endpoint, { method: 'DELETE' }); + }; + + api.upload = (endpoint, file, fieldName = 'file') => { + const formData = new FormData(); + formData.append(fieldName, file); + return api(endpoint, { method: 'POST', body: formData }); + }; + + // --- Toast System --- + let toastContainer = null; + + function ensureToastContainer() { + if (!toastContainer) { + toastContainer = document.createElement('div'); + toastContainer.className = 'toast-container'; + toastContainer.setAttribute('role', 'status'); + toastContainer.setAttribute('aria-live', 'polite'); + document.body.appendChild(toastContainer); + } + return toastContainer; + } + + function toast(message, type = 'default', duration = 3000) { + const container = ensureToastContainer(); + const el = document.createElement('div'); + el.className = `toast${type !== 'default' ? ` toast--${type}` : ''}`; + el.textContent = message; + container.appendChild(el); + + setTimeout(() => { + el.style.opacity = '0'; + el.style.transform = 'translateY(-0.5rem)'; + el.style.transition = 'all 0.2s'; + setTimeout(() => el.remove(), 200); + }, duration); + } + + // --- Time Helpers --- + function timeAgo(dateStr) { + const now = Date.now(); + const then = new Date(dateStr).getTime(); + const diff = Math.max(0, now - then); + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes}m ago`; + if (hours < 24) return `${hours}h ago`; + if (days < 7) return `${days}d ago`; + return new Date(dateStr).toLocaleDateString(); + } + + function timeUntil(dateStr) { + const now = Date.now(); + const then = new Date(dateStr).getTime(); + const diff = Math.max(0, then - now); + const hours = Math.floor(diff / 3600000); + const minutes = Math.floor((diff % 3600000) / 60000); + + if (diff <= 0) return 'expired'; + if (hours > 0) return `${hours}h ${minutes}m left`; + return `${minutes}m left`; + } + + // --- DOM Helpers --- + function $(selector, parent = document) { + return parent.querySelector(selector); + } + + function $$(selector, parent = document) { + return [...parent.querySelectorAll(selector)]; + } + + function el(tag, attrs = {}, children = []) { + const element = document.createElement(tag); + for (const [key, val] of Object.entries(attrs)) { + if (key === 'className') element.className = val; + else if (key === 'textContent') element.textContent = val; + else if (key === 'innerHTML') element.innerHTML = val; + else if (key.startsWith('on')) element.addEventListener(key.slice(2).toLowerCase(), val); + else if (key === 'dataset') Object.assign(element.dataset, val); + else element.setAttribute(key, val); + } + for (const child of children) { + if (typeof child === 'string') { + element.appendChild(document.createTextNode(child)); + } else if (child) { + element.appendChild(child); + } + } + return element; + } + + // --- Star rating HTML --- + function starsHTML(rating, count = null) { + const full = Math.floor(rating); + const half = rating % 1 >= 0.5 ? 1 : 0; + const empty = 5 - full - half; + let html = ''; + for (let i = 0; i < full; i++) html += 'โ˜…'; + if (half) html += 'โ˜…'; // simplified โ€” half star shown as full + for (let i = 0; i < empty; i++) html += 'โ˜†'; + if (count !== null) html += ` (${count})`; + html += ''; + return html; + } + + // --- Debounce --- + function debounce(fn, delay = 300) { + let timer; + return (...args) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), delay); + }; + } + + // --- Public API --- + return { + api, + ApiError, + setToken, + getToken, + isLoggedIn, + toast, + timeAgo, + timeUntil, + $, $$, el, + starsHTML, + debounce, + }; +})(); diff --git a/js/landing.js b/js/landing.js new file mode 100644 index 0000000..58f1723 --- /dev/null +++ b/js/landing.js @@ -0,0 +1,58 @@ +/* ============================================ + GrubFlip Landing Page โ€” JS + Smooth scroll, nav background, scroll reveal + ============================================ */ + +(function () { + 'use strict'; + + // --- Smooth scroll for anchor links --- + document.querySelectorAll('a[href^="#"]').forEach(function (link) { + link.addEventListener('click', function (e) { + var target = document.querySelector(this.getAttribute('href')); + if (target) { + e.preventDefault(); + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }); + }); + + // --- Nav background on scroll --- + var nav = document.querySelector('.nav'); + if (nav) { + var onScroll = function () { + if (window.scrollY > 20) { + nav.classList.add('nav--scrolled'); + } else { + nav.classList.remove('nav--scrolled'); + } + }; + window.addEventListener('scroll', onScroll, { passive: true }); + onScroll(); + } + + // --- Scroll reveal (IntersectionObserver) --- + var revealTargets = document.querySelectorAll( + '.step, .feature-card, .testimonial__quote, .download__title' + ); + + if ('IntersectionObserver' in window && revealTargets.length) { + revealTargets.forEach(function (el) { + el.style.opacity = '0'; + el.style.transform = 'translateY(24px)'; + el.style.transition = 'opacity 0.5s ease, transform 0.5s ease'; + }); + + var observer = new IntersectionObserver(function (entries) { + entries.forEach(function (entry) { + if (entry.isIntersecting) { + entry.target.style.opacity = '1'; + entry.target.style.transform = 'translateY(0)'; + observer.unobserve(entry.target); + } + }); + }, { threshold: 0.15 }); + + revealTargets.forEach(function (el) { observer.observe(el); }); + } +})(); diff --git a/js/post.js b/js/post.js new file mode 100644 index 0000000..8a78120 --- /dev/null +++ b/js/post.js @@ -0,0 +1,249 @@ +/* ============================================ + GrubFlip โ€” Post Meal Form + Image upload, dietary tag picker, meal + creation with preview. + ============================================ */ + +const PostMeal = (() => { + 'use strict'; + + const { api, $, $$, el, toast } = GrubFlip; + + let uploadedImage = null; // { url, thumb_url, upload_id } + let submitting = false; + + const DIETARY_TAGS = [ + 'vegan', 'vegetarian', 'gluten-free', 'dairy', 'dairy-free', + 'nut-free', 'halal', 'kosher', 'high-protein', 'low-carb', 'spicy' + ]; + + const CUISINES = [ + 'Italian', 'Mexican', 'Chinese', 'Japanese', 'Indian', 'Thai', + 'Mediterranean', 'American', 'Korean', 'Vietnamese', 'French', + 'Middle Eastern', 'Caribbean', 'Ethiopian', 'Other' + ]; + + // --- Image Upload --- + function setupImageUpload() { + const zone = $('#upload-zone'); + const fileInput = $('#image-input'); + if (!zone || !fileInput) return; + + // Click to select + zone.addEventListener('click', (e) => { + if (e.target.closest('.btn--remove')) return; + fileInput.click(); + }); + + // File selected + fileInput.addEventListener('change', () => { + if (fileInput.files.length) handleFile(fileInput.files[0]); + }); + + // Drag and drop + zone.addEventListener('dragover', (e) => { + e.preventDefault(); + zone.classList.add('dragover'); + }); + + zone.addEventListener('dragleave', () => { + zone.classList.remove('dragover'); + }); + + zone.addEventListener('drop', (e) => { + e.preventDefault(); + zone.classList.remove('dragover'); + const file = e.dataTransfer.files[0]; + if (file) handleFile(file); + }); + + // Remove button + const removeBtn = zone.querySelector('.btn--remove'); + if (removeBtn) { + removeBtn.addEventListener('click', (e) => { + e.stopPropagation(); + clearUpload(); + }); + } + } + + async function handleFile(file) { + // Validate + const validTypes = ['image/jpeg', 'image/png', 'image/webp']; + if (!validTypes.includes(file.type)) { + toast('Please upload a JPG, PNG, or WebP image', 'error'); + return; + } + + if (file.size > 20 * 1024 * 1024) { + toast('Image must be under 20MB', 'error'); + return; + } + + const zone = $('#upload-zone'); + const previewImg = $('#upload-preview-img'); + + // Show local preview immediately + const reader = new FileReader(); + reader.onload = (e) => { + previewImg.src = e.target.result; + zone.classList.add('has-preview'); + }; + reader.readAsDataURL(file); + + // Upload to server + try { + zone.style.opacity = '0.7'; + const data = await api.upload('/uploads/image', file); + uploadedImage = { + url: data.url, + thumb_url: data.thumb_url, + upload_id: data.upload_id, + }; + toast('Image uploaded!', 'success'); + } catch (err) { + toast('Upload failed โ€” ' + err.message, 'error'); + clearUpload(); + } finally { + zone.style.opacity = ''; + } + } + + function clearUpload() { + uploadedImage = null; + const zone = $('#upload-zone'); + const fileInput = $('#image-input'); + const previewImg = $('#upload-preview-img'); + + if (zone) zone.classList.remove('has-preview'); + if (fileInput) fileInput.value = ''; + if (previewImg) previewImg.src = ''; + } + + // --- Expiry Picker --- + function setupExpiryPicker() { + const select = $('#expires-select'); + if (!select) return; + + // Populate with reasonable time windows + const options = [ + { value: '2', label: '2 hours' }, + { value: '4', label: '4 hours' }, + { value: '6', label: '6 hours' }, + { value: '8', label: '8 hours' }, + { value: '12', label: '12 hours' }, + { value: '24', label: '24 hours' }, + ]; + + select.innerHTML = ''; + for (const opt of options) { + select.appendChild(el('option', { value: opt.value, textContent: opt.label })); + } + } + + // --- Form Validation --- + function validate() { + const errors = []; + const title = $('#meal-title')?.value.trim(); + const description = $('#meal-description')?.value.trim(); + const expiresHours = $('#expires-select')?.value; + + if (!title || title.length < 3) errors.push({ field: 'meal-title', msg: 'Title must be at least 3 characters' }); + if (title && title.length > 100) errors.push({ field: 'meal-title', msg: 'Title is too long (max 100 chars)' }); + if (!description) errors.push({ field: 'meal-description', msg: 'Add a short description' }); + if (!expiresHours) errors.push({ field: 'expires-select', msg: 'Set a pickup window' }); + if (!uploadedImage) errors.push({ field: 'upload-zone', msg: 'Add a photo of your meal' }); + + // Clear previous errors + $$('.form-error').forEach(el => el.remove()); + $$('.form-input, .form-textarea, .form-select').forEach(el => el.style.borderColor = ''); + + // Show errors + for (const error of errors) { + const fieldEl = $(`#${error.field}`); + if (fieldEl) { + fieldEl.style.borderColor = 'var(--gf-error)'; + const errEl = document.createElement('p'); + errEl.className = 'form-error'; + errEl.textContent = error.msg; + fieldEl.parentElement.appendChild(errEl); + } + } + + return errors.length === 0; + } + + // --- Submit --- + async function handleSubmit(e) { + e.preventDefault(); + if (submitting) return; + if (!validate()) return; + + submitting = true; + const submitBtn = $('#submit-btn'); + if (submitBtn) { + submitBtn.disabled = true; + submitBtn.textContent = 'Posting...'; + } + + // Gather selected dietary tags + const selectedTags = $$('.tag-picker__option:checked').map(cb => cb.value); + + const expiresHours = parseInt($('#expires-select').value, 10); + const expiresAt = new Date(Date.now() + expiresHours * 3600000).toISOString(); + + const payload = { + title: $('#meal-title').value.trim(), + description: $('#meal-description').value.trim(), + upload_id: uploadedImage.upload_id, + dietary_tags: selectedTags, + cuisine: $('#cuisine-select').value || null, + portion_size: $('#portion-select').value || 'single', + trade_type: $('#trade-type-select')?.value || 'swap', + expires_at: expiresAt, + }; + + try { + const result = await api.post('/meals', payload); + toast('Meal posted! ๐ŸŽ‰', 'success'); + // Redirect to feed after short delay + setTimeout(() => { + window.location.href = '/grubflip/index.html'; + }, 1000); + } catch (err) { + toast('Failed to post โ€” ' + err.message, 'error'); + } finally { + submitting = false; + if (submitBtn) { + submitBtn.disabled = false; + submitBtn.textContent = 'Post Meal'; + } + } + } + + // --- Init --- + function init() { + const form = $('#post-meal-form'); + if (!form) return; + + setupImageUpload(); + setupExpiryPicker(); + + form.addEventListener('submit', handleSubmit); + + // Character counter for description + const desc = $('#meal-description'); + const counter = $('#desc-counter'); + if (desc && counter) { + desc.addEventListener('input', () => { + const len = desc.value.length; + counter.textContent = `${len}/300`; + counter.style.color = len > 300 ? 'var(--gf-error)' : 'var(--gf-neutral-400)'; + }); + } + } + + return { init }; +})(); + +document.addEventListener('DOMContentLoaded', PostMeal.init); diff --git a/js/trades.js b/js/trades.js new file mode 100644 index 0000000..ec40ad0 --- /dev/null +++ b/js/trades.js @@ -0,0 +1,306 @@ +/* ============================================ + GrubFlip โ€” Trade Flow + Trade state machine, trade cards, actions + ============================================ */ + +const Trades = (() => { + 'use strict'; + + const { api, $, $$, el, timeAgo, toast } = GrubFlip; + + // --- State Machine Definition --- + const TRANSITIONS = { + pending: ['accepted', 'declined', 'cancelled', 'expired'], + accepted: ['confirmed', 'declined', 'cancelled'], + confirmed: ['completed', 'cancelled'], + completed: [], // terminal + declined: [], // terminal + cancelled: [], // terminal + expired: [], // terminal + }; + + const STATUS_META = { + pending: { label: 'Pending', icon: 'โณ', actionable: true }, + accepted: { label: 'Accepted', icon: '๐Ÿ‘', actionable: true }, + confirmed: { label: 'Confirmed', icon: '๐Ÿ“', actionable: true }, + completed: { label: 'Completed', icon: 'โœ…', actionable: false }, + declined: { label: 'Declined', icon: 'โŒ', actionable: false }, + cancelled: { label: 'Cancelled', icon: '๐Ÿšซ', actionable: false }, + expired: { label: 'Expired', icon: 'โฐ', actionable: false }, + }; + + let trades = []; + let activeTab = 'active'; // 'active' | 'completed' + let loading = false; + + // --- Trade Card Rendering --- + function renderTradeCard(trade, currentUserId) { + const card = el('div', { className: 'trade-card', dataset: { tradeId: trade.id } }); + + // Status bar + const statusBar = el('div', { className: 'trade-card__status-bar' }); + const meta = STATUS_META[trade.status] || STATUS_META.pending; + statusBar.appendChild(el('span', { + className: `trade-card__status trade-card__status--${trade.status}`, + textContent: `${meta.icon} ${meta.label}`, + })); + statusBar.appendChild(el('span', { + className: 'trade-card__time', + textContent: timeAgo(trade.updated_at || trade.created_at), + })); + card.appendChild(statusBar); + + // Meals being traded + const mealsSection = el('div', { className: 'trade-card__meals' }); + + // Offered meal (what the requester is offering) + const offeredMeal = el('div', { className: 'trade-card__meal' }); + offeredMeal.appendChild(el('img', { + className: 'trade-card__meal-img', + src: trade.offered_meal.image_thumb_url, + alt: trade.offered_meal.title, + loading: 'lazy', + })); + const offeredInfo = el('div', { className: 'trade-card__meal-info' }); + offeredInfo.appendChild(el('p', { className: 'trade-card__meal-title', textContent: trade.offered_meal.title })); + offeredInfo.appendChild(el('p', { className: 'trade-card__meal-user', textContent: trade.requester.display_name })); + offeredMeal.appendChild(offeredInfo); + mealsSection.appendChild(offeredMeal); + + // Swap icon + mealsSection.appendChild(el('span', { + className: 'trade-card__swap-icon', + innerHTML: ``, + })); + + // Requested meal (what the requester wants) + const requestedMeal = el('div', { className: 'trade-card__meal' }); + requestedMeal.appendChild(el('img', { + className: 'trade-card__meal-img', + src: trade.requested_meal.image_thumb_url, + alt: trade.requested_meal.title, + loading: 'lazy', + })); + const requestedInfo = el('div', { className: 'trade-card__meal-info' }); + requestedInfo.appendChild(el('p', { className: 'trade-card__meal-title', textContent: trade.requested_meal.title })); + requestedInfo.appendChild(el('p', { className: 'trade-card__meal-user', textContent: trade.owner.display_name })); + requestedMeal.appendChild(requestedInfo); + mealsSection.appendChild(requestedMeal); + + card.appendChild(mealsSection); + + // Message + if (trade.message) { + card.appendChild(el('p', { + className: 'trade-card__message', + textContent: `"${trade.message}"`, + })); + } + + // Meetup info (if confirmed) + if (trade.status === 'confirmed' && (trade.meetup_location || trade.meetup_time)) { + const meetup = el('div', { + className: 'trade-card__message', + style: 'font-style:normal; background:var(--gf-secondary-bg);', + }); + let meetupText = '๐Ÿ“ Meetup: '; + if (trade.meetup_location) meetupText += trade.meetup_location; + if (trade.meetup_time) meetupText += ` at ${new Date(trade.meetup_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; + meetup.textContent = meetupText; + card.appendChild(meetup); + } + + // Action buttons โ€” based on status and role + const actions = getActions(trade, currentUserId); + if (actions.length > 0) { + const actionsWrap = el('div', { className: 'trade-card__actions' }); + for (const action of actions) { + actionsWrap.appendChild(el('button', { + className: `btn btn--${action.style} btn--sm`, + textContent: action.label, + onClick: () => handleAction(trade.id, action.action), + })); + } + card.appendChild(actionsWrap); + } + + return card; + } + + // --- Determine Available Actions --- + function getActions(trade, currentUserId) { + const isOwner = trade.owner.id === currentUserId; + const isRequester = trade.requester.id === currentUserId; + const actions = []; + + switch (trade.status) { + case 'pending': + if (isOwner) { + actions.push({ label: 'Accept', action: 'accept', style: 'primary' }); + actions.push({ label: 'Decline', action: 'decline', style: 'ghost' }); + } + if (isRequester) { + actions.push({ label: 'Cancel Request', action: 'cancel', style: 'ghost' }); + } + break; + + case 'accepted': + actions.push({ label: 'Set Meetup', action: 'set-meetup', style: 'primary' }); + actions.push({ label: 'Cancel', action: 'cancel', style: 'ghost' }); + break; + + case 'confirmed': + actions.push({ label: 'Mark Complete', action: 'complete', style: 'secondary' }); + actions.push({ label: 'Cancel', action: 'cancel', style: 'ghost' }); + break; + } + + return actions; + } + + // --- Handle Trade Actions --- + async function handleAction(tradeId, action) { + try { + switch (action) { + case 'accept': + await api.put(`/trades/${tradeId}`, { status: 'accepted' }); + toast('Trade accepted! Set up a meetup time.', 'success'); + break; + + case 'decline': + if (!confirm('Decline this trade request?')) return; + await api.put(`/trades/${tradeId}`, { status: 'declined' }); + toast('Trade declined.', 'default'); + break; + + case 'cancel': + if (!confirm('Cancel this trade?')) return; + await api.put(`/trades/${tradeId}`, { status: 'cancelled' }); + toast('Trade cancelled.', 'default'); + break; + + case 'complete': + await api.put(`/trades/${tradeId}`, { status: 'completed' }); + toast('Trade completed! ๐ŸŽ‰', 'success'); + break; + + case 'set-meetup': + openMeetupModal(tradeId); + return; // Don't reload โ€” modal handles it + + default: + return; + } + + loadTrades(); // Refresh + } catch (err) { + toast('Action failed: ' + err.message, 'error'); + } + } + + // --- Meetup Modal --- + function openMeetupModal(tradeId) { + const overlay = $('#meetup-modal'); + if (!overlay) return; + + overlay.classList.add('open'); + + const form = $('#meetup-form'); + form.onsubmit = async (e) => { + e.preventDefault(); + const location = $('#meetup-location').value.trim(); + const time = $('#meetup-time').value; + + if (!location) { + toast('Please enter a meetup location', 'warning'); + return; + } + + try { + await api.put(`/trades/${tradeId}`, { + status: 'confirmed', + meetup_location: location, + meetup_time: time || null, + }); + toast('Meetup confirmed! ๐Ÿ“', 'success'); + overlay.classList.remove('open'); + loadTrades(); + } catch (err) { + toast('Failed to confirm meetup: ' + err.message, 'error'); + } + }; + + // Close on overlay click + overlay.addEventListener('click', (e) => { + if (e.target === overlay) overlay.classList.remove('open'); + }); + } + + // --- Load Trades --- + async function loadTrades() { + if (loading) return; + loading = true; + + const list = $('#trades-list'); + if (!list) return; + + list.innerHTML = '
'; + + try { + const data = await api.get('/trades', { status: activeTab === 'active' ? 'active' : 'completed' }); + trades = data.trades || []; + + list.innerHTML = ''; + + // TODO: get actual current user ID from auth + const currentUserId = parseInt(localStorage.getItem('gf_user_id') || '0', 10); + + if (trades.length === 0) { + list.innerHTML = ` +
+ + + +

No ${activeTab} trades

+

${activeTab === 'active' ? 'Browse the feed and request a swap!' : 'Your completed trades will show here.'}

+
`; + return; + } + + for (const trade of trades) { + list.appendChild(renderTradeCard(trade, currentUserId)); + } + } catch (err) { + toast('Failed to load trades: ' + err.message, 'error'); + list.innerHTML = ''; + } finally { + loading = false; + } + } + + // --- Tab Switching --- + function setupTabs() { + const tabs = $$('.trade-tab'); + for (const tab of tabs) { + tab.addEventListener('click', () => { + tabs.forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + activeTab = tab.dataset.tab; + loadTrades(); + }); + } + } + + // --- Init --- + function init() { + const tradesContainer = $('#trades-container'); + if (!tradesContainer) return; + + setupTabs(); + loadTrades(); + } + + return { init, renderTradeCard, loadTrades }; +})(); + +document.addEventListener('DOMContentLoaded', Trades.init); diff --git a/landing.html b/landing.html new file mode 100644 index 0000000..8e65379 --- /dev/null +++ b/landing.html @@ -0,0 +1,202 @@ + + + + + + GrubFlip โ€” Swap Meals with Restaurant Workers Near You + + + + + + + + + + + + + + + + +
+
+
+ ๐Ÿ• Built for restaurant crews +

Tired of the same
staff meal?
Flip it.

+

Trade meals with other restaurant workers nearby. Your pad thai for their pizza slice. Free, fast, and way better than eating the same thing every shift.

+ + +
+
+
+
+
+
+ +
+
+
๐Ÿ•
+
+ Margherita Slice + Marco's Pizzeria ยท 0.2 mi +
+ Swap +
+
+
๐Ÿœ
+
+ Pad Thai Bowl + Thai Garden ยท 0.4 mi +
+ Swap +
+
+
๐ŸŒฎ
+
+ Carnitas Tacos (2x) + La Cocina ยท 0.3 mi +
+ Swap +
+
+
+
+
+
+
+ + +
+
+

How It Works

+

Three steps. That's it. No accounts to verify, no fees, no BS.

+
+
+
๐Ÿ“ธ
+
1
+

Post Your Meal

+

Snap a pic of your staff meal and post it. Add what restaurant you work at and when you're free to swap.

+
+
+
๐Ÿ”„
+
2
+

Find a Flip

+

Browse meals from nearby restaurant workers. See something you want? Send a flip request. They accept, you're in.

+
+
+
๐Ÿค
+
3
+

Meet & Eat

+

Meet up between shifts, swap meals, and enjoy something different. Rate the flip and build your rep.

+
+
+
+
+ + +
+
+

Why Restaurant Workers
Love GrubFlip

+
+
+
๐Ÿ’ธ
+

100% Free

+

No money changes hands. Just meal for meal. The way it should be.

+
+
+
๐Ÿ“
+

Hyper-Local

+

Only see meals within walking distance. Built for your block, not your borough.

+
+
+
โšก
+

Shift-Friendly

+

Set your availability around your shifts. GrubFlip works on your schedule, not the other way around.

+
+
+
โญ
+

Rep System

+

Build your reputation as a reliable flipper. The more you trade, the more people trust you.

+
+
+
+
+ + +
+
+
+ "I've been eating the same chicken parm for 3 years. First week on GrubFlip I had sushi, ramen, and the best empanadas of my life. Game changer." +
+
+ ๐Ÿ‘จโ€๐Ÿณ +
+ Danny R. + Line cook, Brooklyn +
+
+
+
+ + +
+
+

Ready to flip?

+

Join 500+ restaurant workers already trading meals. Available on iOS and Android.

+ +

Or use the web app โ€” no download needed.

+
+
+ + + + + + + diff --git a/messages.html b/messages.html new file mode 100644 index 0000000..184ee45 --- /dev/null +++ b/messages.html @@ -0,0 +1,185 @@ + + + + + + GrubFlip โ€” Messages + + + + + + + + + + +
+ +
+ +
+
+ + +
+
+ + + + + +
+ + 3 active trade chats +
+ + + + +
+
+ + + + + + diff --git a/post.html b/post.html new file mode 100644 index 0000000..4b857ad --- /dev/null +++ b/post.html @@ -0,0 +1,183 @@ + + + + + + Post a Meal โ€” GrubFlip + + + + + + + + + +
+ + + +

Post a Meal

+
+
+ + +
+
+
+ + +
+ +
+
+ + + + + +

Tap to add a photo

+

JPG, PNG, or WebP ยท Max 20MB

+
+
+ Meal preview + +
+
+ +
+ + +
+ + +
+ + +
+ + +

0/300

+
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + + +
+
+
+ + + + + + + + diff --git a/trades.html b/trades.html new file mode 100644 index 0000000..7784035 --- /dev/null +++ b/trades.html @@ -0,0 +1,81 @@ + + + + + + My Trades โ€” GrubFlip + + + + + + + + + +
+ +
+ + + Post + +
+
+ + +
+ + +
+ + +
+
+
+
+
+ + + + + + + + + + +