Add GrubFlip consumer app — feed, trades, chat, post, landing, admin login

Full vanilla HTML/CSS/JS consumer app with mobile-first responsive design.
Pages: feed (index), post meal, trades, messages inbox, chat, landing page,
and admin login skeleton. Uses Ava's design tokens throughout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sarah 2026-03-27 05:44:30 +00:00
parent 358f35c6d0
commit 05dd55e0b6
15 changed files with 4652 additions and 0 deletions

974
admin/css/admin.css Normal file
View file

@ -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;
}
}

122
admin/css/tokens.css Normal file
View file

@ -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;
}

194
admin/index.html Normal file
View file

@ -0,0 +1,194 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Grubflip Admin — Login</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/tokens.css">
<link rel="stylesheet" href="css/admin.css">
<style>
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--gf-bg-dark) 0%, #2a2a4a 100%);
padding: var(--gf-space-4);
}
.login-card {
background: var(--gf-bg);
border-radius: var(--gf-radius-xl);
box-shadow: var(--gf-shadow-xl);
width: 100%;
max-width: 26rem;
padding: var(--gf-space-8);
}
.login-brand {
text-align: center;
margin-bottom: var(--gf-space-8);
}
.login-brand .logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3.5rem;
height: 3.5rem;
background: var(--gf-primary);
border-radius: var(--gf-radius-lg);
color: #fff;
font-size: 1.5rem;
font-weight: var(--gf-weight-bold);
margin-bottom: var(--gf-space-4);
}
.login-brand h1 {
font-family: var(--gf-font-heading);
font-size: var(--gf-text-2xl);
color: var(--gf-neutral-900);
margin-bottom: var(--gf-space-1);
}
.login-brand p {
color: var(--gf-neutral-500);
font-size: var(--gf-text-sm);
}
.login-footer {
text-align: center;
margin-top: var(--gf-space-6);
font-size: var(--gf-text-sm);
color: var(--gf-neutral-400);
}
.login-footer a {
color: var(--gf-primary);
font-weight: var(--gf-weight-medium);
}
.login-error {
background: var(--gf-error-bg);
color: var(--gf-error);
padding: var(--gf-space-3) var(--gf-space-4);
border-radius: var(--gf-radius-md);
font-size: var(--gf-text-sm);
margin-bottom: var(--gf-space-4);
display: none;
}
.login-error.visible { display: block; }
.btn-login {
width: 100%;
padding: 0.875rem;
font-size: var(--gf-text-base);
}
</style>
</head>
<body>
<div class="login-page">
<div class="login-card">
<div class="login-brand">
<div class="logo">🍔</div>
<h1>Grubflip Admin</h1>
<p>Restaurant management dashboard</p>
</div>
<div id="loginError" class="login-error" role="alert"></div>
<form id="loginForm" novalidate>
<div class="form-group">
<label for="email" class="form-label">Email address</label>
<input type="email" id="email" class="form-input" placeholder="owner@restaurant.com" autocomplete="email" required>
</div>
<div class="form-group">
<label for="password" class="form-label">Password</label>
<input type="password" id="password" class="form-input" placeholder="Enter your password" autocomplete="current-password" required>
</div>
<div class="form-group flex items-center justify-between">
<label class="flex items-center gap-2 text-sm" style="cursor:pointer">
<input type="checkbox" id="remember" style="accent-color:var(--gf-primary)">
Remember me
</label>
<a href="#" class="text-sm">Forgot password?</a>
</div>
<button type="submit" class="btn btn-primary btn-login" id="loginBtn">
Sign In
</button>
</form>
<div class="login-footer">
Don't have an account? <a href="#">Contact Grubflip</a>
</div>
</div>
</div>
<script>
const loginForm = document.getElementById('loginForm');
const loginError = document.getElementById('loginError');
const loginBtn = document.getElementById('loginBtn');
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
loginError.classList.remove('visible');
const email = document.getElementById('email').value.trim();
const password = document.getElementById('password').value;
if (!email || !password) {
loginError.textContent = 'Please enter your email and password.';
loginError.classList.add('visible');
return;
}
loginBtn.disabled = true;
loginBtn.textContent = 'Signing in…';
try {
// Wire to Mike's API when ready
const resp = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.message || 'Invalid email or password.');
}
const data = await resp.json();
localStorage.setItem('gf_token', data.token);
localStorage.setItem('gf_user', JSON.stringify(data.user));
window.location.href = 'dashboard.html';
} catch (err) {
// For now, allow demo login
if (email === 'demo@grubflip.com' && password === 'demo') {
localStorage.setItem('gf_token', 'demo-token');
localStorage.setItem('gf_user', JSON.stringify({
id: 1,
name: 'Demo Restaurant',
email: 'demo@grubflip.com',
role: 'owner'
}));
window.location.href = 'dashboard.html';
return;
}
loginError.textContent = err.message;
loginError.classList.add('visible');
} finally {
loginBtn.disabled = false;
loginBtn.textContent = 'Sign In';
}
});
</script>
</body>
</html>

173
chat.html Normal file
View file

@ -0,0 +1,173 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>GrubFlip — Chat</title>
<meta name="theme-color" content="#FF6B35">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@600;700;800&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/styles.css">
<link rel="stylesheet" href="css/chat.css">
</head>
<body class="chat-view">
<!-- Chat Header -->
<header class="chat-header">
<a href="messages.html" class="chat-header__back" aria-label="Back to messages">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
</a>
<div class="chat-header__user">
<div class="chat-header__avatar">
<img src="img/avatar-placeholder.svg" alt="" width="36" height="36">
<span class="chat-header__online" aria-label="Online"></span>
</div>
<div class="chat-header__info">
<h1 class="chat-header__name">Marcus J.</h1>
<span class="chat-header__status">Online · ⭐ 4.8 · 23 trades</span>
</div>
</div>
<button class="btn-icon" aria-label="More options">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg>
</button>
</header>
<!-- Trade Context Card (pinned at top) -->
<div class="trade-context-card">
<div class="trade-context-card__meals">
<div class="trade-context-card__meal">
<span class="trade-context-card__emoji">🍕</span>
<span class="trade-context-card__label">Margherita Pizza</span>
<span class="trade-context-card__owner">You</span>
</div>
<div class="trade-context-card__arrow">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 16l-4-4m0 0l4-4m-4 4h18M17 8l4 4m0 0l-4 4m4-4H3"/></svg>
</div>
<div class="trade-context-card__meal">
<span class="trade-context-card__emoji">🍜</span>
<span class="trade-context-card__label">Pad Thai</span>
<span class="trade-context-card__owner">Marcus</span>
</div>
</div>
<div class="trade-context-card__status">
<span class="trade-status trade-status--accepted">Accepted</span>
<span class="trade-context-card__expires">Expires in 2h 15m</span>
</div>
<div class="trade-context-card__actions">
<button class="btn btn--sm btn--secondary-outline">Set Meetup</button>
<button class="btn btn--sm btn--primary">Confirm Trade</button>
</div>
</div>
<!-- Messages Area -->
<main class="chat-messages" id="chatMessages" role="log" aria-label="Chat messages" aria-live="polite">
<!-- Date Separator -->
<div class="chat-date-separator" role="separator">
<span>Today</span>
</div>
<!-- System message -->
<div class="chat-system-msg">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 16l-4-4m0 0l4-4m-4 4h18M17 8l4 4m0 0l-4 4m4-4H3"/></svg>
Trade request sent for Margherita Pizza ↔ Pad Thai
</div>
<!-- Incoming message -->
<div class="chat-msg chat-msg--in">
<img src="img/avatar-placeholder.svg" alt="" class="chat-msg__avatar" width="32" height="32">
<div class="chat-msg__bubble">
<p>Hey! Your margherita looks amazing 🤤 I just made a fresh batch of pad thai — extra peanuts and lime. Interested in a swap?</p>
<time class="chat-msg__time">4:32 PM</time>
</div>
</div>
<!-- Outgoing message -->
<div class="chat-msg chat-msg--out">
<div class="chat-msg__bubble">
<p>Absolutely! I just pulled it out of the wood-fired oven. Still warm 🔥</p>
<time class="chat-msg__time">4:34 PM</time>
</div>
</div>
<!-- System message -->
<div class="chat-system-msg chat-system-msg--success">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
Marcus accepted the trade!
</div>
<!-- Incoming message -->
<div class="chat-msg chat-msg--in">
<img src="img/avatar-placeholder.svg" alt="" class="chat-msg__avatar" width="32" height="32">
<div class="chat-msg__bubble">
<p>Awesome! Where do you want to meet? I'm near the downtown area</p>
<time class="chat-msg__time">4:36 PM</time>
</div>
</div>
<!-- Outgoing message -->
<div class="chat-msg chat-msg--out">
<div class="chat-msg__bubble">
<p>I'm at Rosario's on 5th. Can you swing by around 6?</p>
<time class="chat-msg__time">4:37 PM</time>
</div>
</div>
<!-- Incoming message with location -->
<div class="chat-msg chat-msg--in">
<img src="img/avatar-placeholder.svg" alt="" class="chat-msg__avatar" width="32" height="32">
<div class="chat-msg__bubble">
<p>Sounds good! I can meet at the corner of 5th and Main around 6pm 👍</p>
<time class="chat-msg__time">4:38 PM</time>
</div>
</div>
<!-- Typing indicator -->
<div class="chat-msg chat-msg--in chat-msg--typing" aria-label="Marcus is typing">
<img src="img/avatar-placeholder.svg" alt="" class="chat-msg__avatar" width="32" height="32">
<div class="chat-msg__bubble">
<div class="typing-dots">
<span></span><span></span><span></span>
</div>
</div>
</div>
</main>
<!-- Chat Input -->
<footer class="chat-input-bar">
<button class="chat-input-bar__attach" aria-label="Attach photo">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
</button>
<div class="chat-input-bar__field">
<textarea
class="chat-input-bar__textarea"
placeholder="Type a message..."
aria-label="Message input"
rows="1"
></textarea>
</div>
<button class="chat-input-bar__send" aria-label="Send message" disabled>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
</footer>
<!-- Quick Actions (slide up from input) -->
<div class="chat-quick-actions" id="quickActions" hidden>
<button class="chat-quick-action">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/><circle cx="12" cy="10" r="3"/></svg>
Share Location
</button>
<button class="chat-quick-action">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
Suggest Meetup Time
</button>
<button class="chat-quick-action">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
Complete Trade
</button>
</div>
</body>
</html>

974
css/chat.css Normal file
View file

@ -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;
}
}

709
css/landing.css Normal file
View file

@ -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;
}
}

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
<circle cx="24" cy="24" r="24" fill="#E5E5E5"/>
<circle cx="24" cy="18" r="8" fill="#A3A3A3"/>
<path d="M8 42c0-8.837 7.163-16 16-16s16 7.163 16 16" fill="#A3A3A3"/>
</svg>

After

Width:  |  Height:  |  Size: 252 B

237
js/app.js Normal file
View file

@ -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 = '<span class="stars" aria-label="' + rating + ' out of 5 stars">';
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 += ` <span style="color:var(--gf-neutral-400)">(${count})</span>`;
html += '</span>';
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,
};
})();

58
js/landing.js Normal file
View file

@ -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); });
}
})();

249
js/post.js Normal file
View file

@ -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 = '<option value="">Pick up within...</option>';
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);

306
js/trades.js Normal file
View file

@ -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: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="28" height="28"><path d="M7 16l-4-4m0 0l4-4m-4 4h18M17 8l4 4m0 0l-4 4m4-4H3"/></svg>`,
}));
// 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 = '<div class="spinner"></div>';
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 = `
<div class="feed__empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width:64px;height:64px;margin:0 auto 1rem;opacity:0.4">
<path d="M7 16l-4-4m0 0l4-4m-4 4h18M17 8l4 4m0 0l-4 4m4-4H3"/>
</svg>
<h3>No ${activeTab} trades</h3>
<p>${activeTab === 'active' ? 'Browse the feed and request a swap!' : 'Your completed trades will show here.'}</p>
</div>`;
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);

202
landing.html Normal file
View file

@ -0,0 +1,202 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GrubFlip — Swap Meals with Restaurant Workers Near You</title>
<meta name="description" content="GrubFlip is the meal trading app for restaurant workers. Swap staff meals, discover new flavors, save money. Join the flip.">
<meta name="theme-color" content="#FF6B35">
<meta property="og:title" content="GrubFlip — Swap Meals Near You">
<meta property="og:description" content="The meal trading app for restaurant workers. Swap staff meals, discover new flavors, save money.">
<meta property="og:type" content="website">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@600;700;800&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/landing.css">
</head>
<body>
<!-- Nav -->
<header class="nav">
<div class="nav__inner">
<a href="/" class="nav__logo">Grub<span>Flip</span></a>
<div class="nav__links">
<a href="#how-it-works" class="nav__link">How It Works</a>
<a href="#features" class="nav__link hide-mobile">Features</a>
<a href="#download" class="btn btn--primary btn--sm">Get the App</a>
</div>
</div>
</header>
<!-- Hero -->
<section class="hero">
<div class="container">
<div class="hero__content">
<span class="hero__badge">🍕 Built for restaurant crews</span>
<h1 class="hero__title">Tired of the same<br> staff meal?<br><span class="hero__highlight">Flip it.</span></h1>
<p class="hero__sub">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.</p>
<div class="hero__cta">
<a href="#download" class="btn btn--primary btn--lg">Start Flipping</a>
<a href="#how-it-works" class="btn btn--outline btn--lg">See How It Works</a>
</div>
<p class="hero__social-proof">
<span class="hero__avatars">
<span class="hero__avatar" style="background:#FF6B35;">🧑‍🍳</span>
<span class="hero__avatar" style="background:#2D6A4F;">👩‍🍳</span>
<span class="hero__avatar" style="background:#FFBA08;">👨‍🍳</span>
</span>
<span class="hero__social-text"><strong>500+</strong> restaurant workers already flipping</span>
</p>
</div>
<div class="hero__visual">
<div class="hero__phone">
<div class="phone-mockup">
<div class="phone-mockup__screen">
<div class="phone-mockup__header">
<span class="phone-mockup__logo">Grub<span>Flip</span></span>
</div>
<div class="phone-mockup__card">
<div class="phone-mockup__food-emoji">🍕</div>
<div class="phone-mockup__card-text">
<strong>Margherita Slice</strong>
<small>Marco's Pizzeria · 0.2 mi</small>
</div>
<span class="phone-mockup__badge">Swap</span>
</div>
<div class="phone-mockup__card phone-mockup__card--alt">
<div class="phone-mockup__food-emoji">🍜</div>
<div class="phone-mockup__card-text">
<strong>Pad Thai Bowl</strong>
<small>Thai Garden · 0.4 mi</small>
</div>
<span class="phone-mockup__badge phone-mockup__badge--green">Swap</span>
</div>
<div class="phone-mockup__card">
<div class="phone-mockup__food-emoji">🌮</div>
<div class="phone-mockup__card-text">
<strong>Carnitas Tacos (2x)</strong>
<small>La Cocina · 0.3 mi</small>
</div>
<span class="phone-mockup__badge">Swap</span>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- How It Works -->
<section class="how-it-works" id="how-it-works">
<div class="container">
<h2 class="section-title">How It Works</h2>
<p class="section-sub">Three steps. That's it. No accounts to verify, no fees, no BS.</p>
<div class="steps">
<div class="step">
<div class="step__icon">📸</div>
<div class="step__num">1</div>
<h3 class="step__title">Post Your Meal</h3>
<p class="step__desc">Snap a pic of your staff meal and post it. Add what restaurant you work at and when you're free to swap.</p>
</div>
<div class="step">
<div class="step__icon">🔄</div>
<div class="step__num">2</div>
<h3 class="step__title">Find a Flip</h3>
<p class="step__desc">Browse meals from nearby restaurant workers. See something you want? Send a flip request. They accept, you're in.</p>
</div>
<div class="step">
<div class="step__icon">🤝</div>
<div class="step__num">3</div>
<h3 class="step__title">Meet & Eat</h3>
<p class="step__desc">Meet up between shifts, swap meals, and enjoy something different. Rate the flip and build your rep.</p>
</div>
</div>
</div>
</section>
<!-- Features -->
<section class="features" id="features">
<div class="container">
<h2 class="section-title">Why Restaurant Workers<br>Love GrubFlip</h2>
<div class="features__grid">
<div class="feature-card">
<div class="feature-card__icon">💸</div>
<h3 class="feature-card__title">100% Free</h3>
<p class="feature-card__desc">No money changes hands. Just meal for meal. The way it should be.</p>
</div>
<div class="feature-card">
<div class="feature-card__icon">📍</div>
<h3 class="feature-card__title">Hyper-Local</h3>
<p class="feature-card__desc">Only see meals within walking distance. Built for your block, not your borough.</p>
</div>
<div class="feature-card">
<div class="feature-card__icon"></div>
<h3 class="feature-card__title">Shift-Friendly</h3>
<p class="feature-card__desc">Set your availability around your shifts. GrubFlip works on your schedule, not the other way around.</p>
</div>
<div class="feature-card">
<div class="feature-card__icon"></div>
<h3 class="feature-card__title">Rep System</h3>
<p class="feature-card__desc">Build your reputation as a reliable flipper. The more you trade, the more people trust you.</p>
</div>
</div>
</div>
</section>
<!-- Testimonial -->
<section class="testimonial">
<div class="container">
<blockquote class="testimonial__quote">
"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."
</blockquote>
<div class="testimonial__author">
<span class="testimonial__avatar">👨‍🍳</span>
<div>
<strong>Danny R.</strong>
<span>Line cook, Brooklyn</span>
</div>
</div>
</div>
</section>
<!-- CTA / Download -->
<section class="download" id="download">
<div class="container">
<h2 class="download__title">Ready to flip?</h2>
<p class="download__sub">Join 500+ restaurant workers already trading meals. Available on iOS and Android.</p>
<div class="download__buttons">
<a href="#" class="btn btn--dark btn--lg download__store-btn" aria-label="Download on the App Store">
<svg class="download__store-icon" viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/></svg>
App Store
</a>
<a href="#" class="btn btn--dark btn--lg download__store-btn" aria-label="Get it on Google Play">
<svg class="download__store-icon" viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M3.18 23.49c-.31-.16-.52-.46-.57-.82L2 3.35c-.04-.36.12-.7.42-.9L13.26 12 2.61 21.55l.57 1.94zM14.62 12L4.5 2.74l10.8 6.3-1.68 2.96zm1.56.89L6.84 21.3l10.76-6.28-1.42-2.13zM21.15 11.1l-3.87-2.27L15.2 12l2.08 3.17 3.87-2.27c.59-.34.59-1.46 0-1.8z"/></svg>
Google Play
</a>
</div>
<p class="download__note">Or use the <a href="index.html">web app</a> — no download needed.</p>
</div>
</section>
<!-- Footer -->
<footer class="footer">
<div class="container">
<div class="footer__inner">
<div class="footer__brand">
<span class="footer__logo">Grub<span>Flip</span></span>
<p class="footer__tagline">Meal trading for restaurant crews.</p>
</div>
<div class="footer__links">
<a href="#">About</a>
<a href="#">Privacy</a>
<a href="#">Terms</a>
<a href="#">Contact</a>
</div>
</div>
<p class="footer__copy">&copy; 2026 GrubFlip. Made with 🍕 for the people who make the food.</p>
</div>
</footer>
<script src="js/landing.js"></script>
</body>
</html>

185
messages.html Normal file
View file

@ -0,0 +1,185 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GrubFlip — Messages</title>
<meta name="theme-color" content="#FF6B35">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@600;700;800&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/styles.css">
<link rel="stylesheet" href="css/chat.css">
</head>
<body>
<!-- Header -->
<header class="gf-header">
<div class="gf-header__logo">Grub<span>Flip</span></div>
<div class="gf-header__actions">
<button class="btn-icon" aria-label="New message">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
</button>
</div>
</header>
<!-- Messages Inbox -->
<main class="messages-inbox">
<div class="container">
<!-- Search Bar -->
<div class="chat-search">
<svg class="chat-search__icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" class="chat-search__input" placeholder="Search conversations..." aria-label="Search conversations">
</div>
<!-- Active Trade Context Banner -->
<div class="trade-context-banner">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 16l-4-4m0 0l4-4m-4 4h18M17 8l4 4m0 0l-4 4m4-4H3"/></svg>
<span>3 active trade chats</span>
</div>
<!-- Conversation List -->
<ul class="conversation-list" role="list" aria-label="Conversations">
<!-- Unread conversation -->
<li class="conversation-item conversation-item--unread" role="listitem">
<a href="chat.html" class="conversation-item__link">
<div class="conversation-item__avatar">
<img src="img/avatar-placeholder.svg" alt="" width="48" height="48">
<span class="conversation-item__online" aria-label="Online"></span>
</div>
<div class="conversation-item__content">
<div class="conversation-item__header">
<h3 class="conversation-item__name">Marcus J.</h3>
<time class="conversation-item__time">2m</time>
</div>
<p class="conversation-item__preview">Sounds good! I can meet at the corner of 5th and Main around 6pm 👍</p>
<div class="conversation-item__meta">
<span class="conversation-item__trade-tag">
🍕 Margherita → 🍜 Pad Thai
</span>
<span class="conversation-item__badge" aria-label="2 unread messages">2</span>
</div>
</div>
</a>
</li>
<!-- Unread conversation -->
<li class="conversation-item conversation-item--unread" role="listitem">
<a href="chat.html" class="conversation-item__link">
<div class="conversation-item__avatar">
<img src="img/avatar-placeholder.svg" alt="" width="48" height="48">
</div>
<div class="conversation-item__content">
<div class="conversation-item__header">
<h3 class="conversation-item__name">Priya K.</h3>
<time class="conversation-item__time">15m</time>
</div>
<p class="conversation-item__preview">Hey! Is your butter chicken still available? I've got fresh naan and tikka that I made tonight</p>
<div class="conversation-item__meta">
<span class="conversation-item__trade-tag">
🍗 Butter Chicken → 🫓 Naan & Tikka
</span>
<span class="conversation-item__badge" aria-label="1 unread message">1</span>
</div>
</div>
</a>
</li>
<!-- Read conversation -->
<li class="conversation-item" role="listitem">
<a href="chat.html" class="conversation-item__link">
<div class="conversation-item__avatar">
<img src="img/avatar-placeholder.svg" alt="" width="48" height="48">
<span class="conversation-item__online" aria-label="Online"></span>
</div>
<div class="conversation-item__content">
<div class="conversation-item__header">
<h3 class="conversation-item__name">Alex R.</h3>
<time class="conversation-item__time">1h</time>
</div>
<p class="conversation-item__preview">Trade completed! Thanks for the amazing tacos 🌮🔥</p>
<div class="conversation-item__meta">
<span class="conversation-item__trade-tag conversation-item__trade-tag--complete">
✅ Trade completed
</span>
</div>
</div>
</a>
</li>
<!-- Read conversation -->
<li class="conversation-item" role="listitem">
<a href="chat.html" class="conversation-item__link">
<div class="conversation-item__avatar">
<img src="img/avatar-placeholder.svg" alt="" width="48" height="48">
</div>
<div class="conversation-item__content">
<div class="conversation-item__header">
<h3 class="conversation-item__name">Sofia M.</h3>
<time class="conversation-item__time">3h</time>
</div>
<p class="conversation-item__preview">I'll check if I still have some left after my shift ends at 10</p>
<div class="conversation-item__meta">
<span class="conversation-item__trade-tag">
🥘 Ramen → 🌯 Burrito Bowl
</span>
</div>
</div>
</a>
</li>
<!-- Read conversation -->
<li class="conversation-item" role="listitem">
<a href="chat.html" class="conversation-item__link">
<div class="conversation-item__avatar">
<img src="img/avatar-placeholder.svg" alt="" width="48" height="48">
</div>
<div class="conversation-item__content">
<div class="conversation-item__header">
<h3 class="conversation-item__name">Chris T.</h3>
<time class="conversation-item__time">Yesterday</time>
</div>
<p class="conversation-item__preview">No worries, maybe next time!</p>
<div class="conversation-item__meta">
<span class="conversation-item__trade-tag conversation-item__trade-tag--expired">
⏰ Trade expired
</span>
</div>
</div>
</a>
</li>
</ul>
</div>
</main>
<!-- Bottom Nav -->
<nav class="gf-bottom-nav" aria-label="Main navigation">
<a href="index.html" class="gf-bottom-nav__item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
Feed
</a>
<a href="post.html" class="gf-bottom-nav__item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
Post
</a>
<a href="messages.html" class="gf-bottom-nav__item active" aria-current="page">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
Chat
<span class="gf-bottom-nav__badge">3</span>
</a>
<a href="trades.html" class="gf-bottom-nav__item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 16l-4-4m0 0l4-4m-4 4h18M17 8l4 4m0 0l-4 4m4-4H3"/></svg>
Trades
</a>
<button class="gf-bottom-nav__item" aria-label="Profile">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
Profile
</button>
</nav>
</body>
</html>

183
post.html Normal file
View file

@ -0,0 +1,183 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Post a Meal — GrubFlip</title>
<meta name="theme-color" content="#FF6B35">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@600;700;800&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/styles.css">
</head>
<body>
<!-- Header -->
<header class="gf-header">
<a href="index.html" class="btn btn--ghost btn--icon" aria-label="Back to feed">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
</a>
<h1 style="font-size:1.125rem;font-family:var(--gf-font-heading);">Post a Meal</h1>
<div style="width:36px"></div>
</header>
<!-- Post Form -->
<main style="padding:1rem 0 5rem;">
<div class="container">
<form id="post-meal-form" novalidate>
<!-- Image Upload -->
<div class="form-group">
<label class="form-label">Photo</label>
<div class="upload-zone" id="upload-zone" role="button" tabindex="0" aria-label="Upload meal photo">
<div class="upload-zone__prompt">
<svg class="upload-zone__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
<p class="upload-zone__text">Tap to add a photo</p>
<p class="upload-zone__hint">JPG, PNG, or WebP · Max 20MB</p>
</div>
<div class="upload-zone__preview">
<img id="upload-preview-img" src="" alt="Meal preview">
<button type="button" class="btn--remove" aria-label="Remove photo">&times;</button>
</div>
</div>
<input type="file" id="image-input" accept="image/jpeg,image/png,image/webp" class="visually-hidden">
</div>
<!-- Title -->
<div class="form-group">
<label class="form-label" for="meal-title">What did you make?</label>
<input type="text" id="meal-title" class="form-input" placeholder="e.g. Chicken Parm with Penne" maxlength="100" required>
</div>
<!-- Description -->
<div class="form-group">
<label class="form-label" for="meal-description">
Description <small>(optional details)</small>
</label>
<textarea id="meal-description" class="form-textarea" placeholder="Fresh tonight, extra mozzarella. Feeds one." maxlength="300" rows="3"></textarea>
<p id="desc-counter" style="text-align:right;font-size:0.75rem;color:var(--gf-neutral-400);margin-top:0.25rem;">0/300</p>
</div>
<!-- Dietary Tags -->
<div class="form-group">
<label class="form-label">Dietary Tags <small>(select all that apply)</small></label>
<div class="tag-picker" role="group" aria-label="Dietary tags">
<input type="checkbox" class="tag-picker__option" id="tag-vegan" value="vegan">
<label class="tag-picker__label" for="tag-vegan">Vegan</label>
<input type="checkbox" class="tag-picker__option" id="tag-vegetarian" value="vegetarian">
<label class="tag-picker__label" for="tag-vegetarian">Vegetarian</label>
<input type="checkbox" class="tag-picker__option" id="tag-gluten-free" value="gluten-free">
<label class="tag-picker__label" for="tag-gluten-free">Gluten-Free</label>
<input type="checkbox" class="tag-picker__option" id="tag-dairy" value="dairy">
<label class="tag-picker__label" for="tag-dairy">Dairy</label>
<input type="checkbox" class="tag-picker__option" id="tag-dairy-free" value="dairy-free">
<label class="tag-picker__label" for="tag-dairy-free">Dairy-Free</label>
<input type="checkbox" class="tag-picker__option" id="tag-nut-free" value="nut-free">
<label class="tag-picker__label" for="tag-nut-free">Nut-Free</label>
<input type="checkbox" class="tag-picker__option" id="tag-halal" value="halal">
<label class="tag-picker__label" for="tag-halal">Halal</label>
<input type="checkbox" class="tag-picker__option" id="tag-kosher" value="kosher">
<label class="tag-picker__label" for="tag-kosher">Kosher</label>
<input type="checkbox" class="tag-picker__option" id="tag-high-protein" value="high-protein">
<label class="tag-picker__label" for="tag-high-protein">High-Protein</label>
<input type="checkbox" class="tag-picker__option" id="tag-low-carb" value="low-carb">
<label class="tag-picker__label" for="tag-low-carb">Low-Carb</label>
<input type="checkbox" class="tag-picker__option" id="tag-spicy" value="spicy">
<label class="tag-picker__label" for="tag-spicy">Spicy 🌶️</label>
</div>
</div>
<!-- Cuisine -->
<div class="form-group">
<label class="form-label" for="cuisine-select">Cuisine</label>
<select id="cuisine-select" class="form-select">
<option value="">Select cuisine type...</option>
<option value="Italian">Italian</option>
<option value="Mexican">Mexican</option>
<option value="Chinese">Chinese</option>
<option value="Japanese">Japanese</option>
<option value="Indian">Indian</option>
<option value="Thai">Thai</option>
<option value="Mediterranean">Mediterranean</option>
<option value="American">American</option>
<option value="Korean">Korean</option>
<option value="Vietnamese">Vietnamese</option>
<option value="French">French</option>
<option value="Middle Eastern">Middle Eastern</option>
<option value="Caribbean">Caribbean</option>
<option value="Ethiopian">Ethiopian</option>
<option value="Other">Other</option>
</select>
</div>
<!-- Portion + Trade Type Row -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.75rem;">
<div class="form-group">
<label class="form-label" for="portion-select">Portion</label>
<select id="portion-select" class="form-select">
<option value="single">Single serving</option>
<option value="double">Two servings</option>
<option value="family">Family size</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="trade-type-select">Trade Type</label>
<select id="trade-type-select" class="form-select">
<option value="swap">Swap</option>
<option value="gift">Gift (free)</option>
</select>
</div>
</div>
<!-- Expires -->
<div class="form-group">
<label class="form-label" for="expires-select">Available for pickup within</label>
<select id="expires-select" class="form-select" required></select>
</div>
<!-- Submit -->
<button type="submit" id="submit-btn" class="btn btn--primary btn--lg btn--block">
Post Meal
</button>
</form>
</div>
</main>
<!-- Bottom Nav -->
<nav class="gf-bottom-nav" aria-label="Main navigation">
<a href="index.html" class="gf-bottom-nav__item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
Feed
</a>
<a href="post.html" class="gf-bottom-nav__item active" aria-current="page">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
Post
</a>
<a href="trades.html" class="gf-bottom-nav__item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 16l-4-4m0 0l4-4m-4 4h18M17 8l4 4m0 0l-4 4m4-4H3"/></svg>
Trades
</a>
<button class="gf-bottom-nav__item" aria-label="Profile">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
Profile
</button>
</nav>
<script src="js/app.js"></script>
<script src="js/post.js"></script>
</body>
</html>

81
trades.html Normal file
View file

@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Trades — GrubFlip</title>
<meta name="theme-color" content="#FF6B35">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@600;700;800&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/styles.css">
</head>
<body>
<!-- Header -->
<header class="gf-header">
<div class="gf-header__logo">Grub<span>Flip</span></div>
<div class="gf-header__actions">
<a href="post.html" class="btn btn--primary btn--sm">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Post
</a>
</div>
</header>
<!-- Trade Tabs -->
<div class="trade-tabs" role="tablist" aria-label="Trade status">
<button class="trade-tab active" role="tab" data-tab="active" aria-selected="true">Active</button>
<button class="trade-tab" role="tab" data-tab="completed" aria-selected="false">History</button>
</div>
<!-- Trades List -->
<main style="padding:1rem 0 5rem;">
<div class="container" id="trades-container">
<div id="trades-list" role="feed" aria-label="Trade requests"></div>
</div>
</main>
<!-- Meetup Modal -->
<div class="modal-overlay" id="meetup-modal" role="dialog" aria-label="Set meetup details" aria-modal="true">
<div class="modal-sheet">
<div class="modal-sheet__handle"></div>
<h2 style="font-size:1.25rem;margin-bottom:1rem;">Set Meetup Details</h2>
<form id="meetup-form">
<div class="form-group">
<label class="form-label" for="meetup-location">Where to meet?</label>
<input type="text" id="meetup-location" class="form-input" placeholder="e.g. Lobby of 123 Main St" required>
</div>
<div class="form-group">
<label class="form-label" for="meetup-time">When? <small>(optional)</small></label>
<input type="datetime-local" id="meetup-time" class="form-input">
</div>
<button type="submit" class="btn btn--primary btn--lg btn--block">Confirm Meetup</button>
</form>
</div>
</div>
<!-- Bottom Nav -->
<nav class="gf-bottom-nav" aria-label="Main navigation">
<a href="index.html" class="gf-bottom-nav__item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
Feed
</a>
<a href="post.html" class="gf-bottom-nav__item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
Post
</a>
<a href="trades.html" class="gf-bottom-nav__item active" aria-current="page" style="position:relative">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 16l-4-4m0 0l4-4m-4 4h18M17 8l4 4m0 0l-4 4m4-4H3"/></svg>
Trades
</a>
<button class="gf-bottom-nav__item" aria-label="Profile">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
Profile
</button>
</nav>
<script src="js/app.js"></script>
<script src="js/trades.js"></script>
</body>
</html>