Compare commits
16 Commits
9d0e325c26
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 19377ea6cf | |||
| c71354d2ae | |||
| da09cfbd1a | |||
| 68851cfede | |||
| b90f0fae52 | |||
| 5a72705e71 | |||
| 129c9d253c | |||
| f8434685a8 | |||
| 60783131ae | |||
| 772e23ded8 | |||
| 5dc9a4604f | |||
| 5188960bd2 | |||
| 6f1aa37a26 | |||
| c7c83890e4 | |||
| e98bbc220f | |||
| 3db642e76b |
@@ -204,9 +204,40 @@ jobs:
|
||||
|
||||
# Install deps and restart on server
|
||||
echo "Installing deps and restarting PM2..."
|
||||
# IMPORTANT: --update-env so PM2 re-reads .env. Without it PM2 keeps
|
||||
# the original env vars cached at first start and silently ignores
|
||||
# newly added ones (like SMARTSHEET_*).
|
||||
ssh -o ConnectTimeout=60 -o StrictHostKeyChecking=no tyler@route.crispygoat.com "cd $APP_DIR && npm install --omit=dev 2>&1 | tail -5 && pm2 restart route-commerce --update-env || pm2 start npm --name route-commerce -- start -- -p 3100 && pm2 save && sleep 4 && curl -f -s http://localhost:3100/api/health/db-schema || { echo 'Health check failed after start - schema not applied (see plan)'; exit 1; }"
|
||||
# IMPORTANT: With `output: "standalone"` in next.config.ts we MUST
|
||||
# run `node .next/standalone/server.js` (not `next start`) — `next start`
|
||||
# against a standalone build is unsupported and silently disables the
|
||||
# image optimizer (every `/_next/image?url=...` returns
|
||||
# `"url" parameter is not allowed`). See commit 129c9d2.
|
||||
#
|
||||
# We also need to (1) ship the standalone loader script
|
||||
# `scripts/start-standalone.cjs`, (2) copy `.next/static/` into
|
||||
# `.next/standalone/.next/static/` so the standalone server can
|
||||
# serve client-side JS/CSS chunks, (3) copy `public/` into
|
||||
# `.next/standalone/public/` (REQUIRED for Next.js standalone —
|
||||
# without it, `/_next/image?url=/brand-logos/...` returns 400
|
||||
# because the optimizer can't resolve local relative paths),
|
||||
# and (4) load `.env` via a Node loader — bash's
|
||||
# `set -a; . ./.env` truncates DATABASE_URL at the `&` in
|
||||
# `&channel_binding=require`.
|
||||
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no scripts/start-standalone.cjs tyler@route.crispygoat.com:$APP_DIR/scripts/start-standalone.cjs 2>/dev/null || true
|
||||
ssh -o ConnectTimeout=60 -o StrictHostKeyChecking=no tyler@route.crispygoat.com "set -e; cd $APP_DIR && npm install --omit=dev 2>&1 | tail -5 && \
|
||||
# Standalone server reads .next/static/ relative to itself.
|
||||
mkdir -p .next/standalone/.next && \
|
||||
rm -rf .next/standalone/.next/static && \
|
||||
cp -r .next/static .next/standalone/.next/static && \
|
||||
# Next.js standalone REQUIRES public/ in the standalone tree;
|
||||
# without it, the image optimizer returns 400 on local-relative
|
||||
# image URLs (e.g. /brand-logos/.../logo.png) and storefront
|
||||
# logos / favicons go blank. Mirror public/ → .next/standalone/public/.
|
||||
rm -rf .next/standalone/public && \
|
||||
mkdir -p .next/standalone/public && \
|
||||
cp -r public/. .next/standalone/public/ && \
|
||||
# Start with the wrapper that loads .env and exec's the server.
|
||||
# `pm2 start` is idempotent via `pm2 restart` after the first run.
|
||||
pm2 delete route-commerce 2>/dev/null || true; \
|
||||
PORT=3100 HOSTNAME=0.0.0.0 pm2 start scripts/start-standalone.cjs --name route-commerce --interpreter /home/tyler/.cache/act/tool_cache/node/22.22.3/x64/bin/node 2>&1 | tail -3 && \
|
||||
pm2 save && sleep 4 && \
|
||||
curl -fsS http://localhost:3100/api/health/db-schema >/dev/null || { echo 'Health check failed after start - schema not applied (see plan)'; exit 1; }"
|
||||
|
||||
echo "Deployed successfully"
|
||||
@@ -0,0 +1,16 @@
|
||||
-- 0099_brand_hero_video.sql
|
||||
-- Add an optional hero_video_url column to brand_settings so each brand can
|
||||
-- point its storefront hero at any video URL (CDN, signed S3, Supabase
|
||||
-- storage, etc.) without a code change. When unset or unplayable, the
|
||||
-- storefront hero falls back to hero_image_url.
|
||||
--
|
||||
-- We intentionally avoid touching the `upsert_brand_settings(...)`
|
||||
-- SECURITY DEFINER RPC here — its 32-parameter positional signature is
|
||||
-- already deployed in production. New value writes use a separate
|
||||
-- idempotent statement.
|
||||
|
||||
ALTER TABLE brand_settings
|
||||
ADD COLUMN IF NOT EXISTS hero_video_url TEXT;
|
||||
|
||||
COMMENT ON COLUMN brand_settings.hero_video_url IS
|
||||
'Optional. Direct URL to a hero background video (mp4/webm). When null or 4xx/5xx, storefront falls back to hero_image_url as a still poster.';
|
||||
@@ -34,6 +34,12 @@ const nextConfig: NextConfig = {
|
||||
protocol: "https",
|
||||
hostname: "picsum.photos",
|
||||
},
|
||||
{
|
||||
// Brand media hosted on the crispygoat MinIO bucket
|
||||
// (e.g. s3.crispygoat.com/videos/wp-import/...)
|
||||
protocol: "https",
|
||||
hostname: "s3.crispygoat.com",
|
||||
},
|
||||
],
|
||||
formats: ["image/avif", "image/webp"],
|
||||
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
||||
|
||||
+6
-1
@@ -12,7 +12,12 @@
|
||||
// Cache names are deliberately prefixed with `field-` so they never
|
||||
// collide with the main `rc-shell-v*` / `rc-data-v*` caches.
|
||||
|
||||
const SHELL_CACHE = "field-shell-v1";
|
||||
// Bumped to v2 after the Apple HIG polish pass (liquid-glass chrome,
|
||||
// iOS SegmentedControl, ThresholdMeter). The activate handler keeps
|
||||
// only this name + DATA_CACHE — anything else starting with `field-`
|
||||
// (including the old v1) is evicted on the next SW activation, which
|
||||
// happens automatically because the bytes of this file changed.
|
||||
const SHELL_CACHE = "field-shell-v2";
|
||||
const DATA_CACHE = "field-data-v1";
|
||||
const OFFLINE_URL = "/offline.html";
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Cycle 12 deep-flow smoke — walks Hub → Manual Entry → submit,
|
||||
* Hub → History. Captures screenshots along the way and asserts
|
||||
* each screen renders the expected layout.
|
||||
*/
|
||||
import { chromium } from "playwright";
|
||||
import { mkdirSync } from "node:fs";
|
||||
|
||||
const BASE = process.env.PLAYWRIGHT_URL ?? "http://localhost:4000";
|
||||
const OUT = "tests/_artifacts/cycle12";
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
// Use worker_id 5a83c1a3-... (Worker 1) but spoof the brand so we
|
||||
// hit the Tuxedo brandId the running server expects.
|
||||
const COOKIE_VALUE = [
|
||||
"5a83c1a3-8eb5-44c4-bb66-323e72d38c91",
|
||||
"smoke-" + Math.random().toString(36).slice(2, 10),
|
||||
new Date(Date.now() + 12 * 3600 * 1000).toISOString(),
|
||||
"Worker 1",
|
||||
"time_admin",
|
||||
"en",
|
||||
// Brand 64294306 is the one the server logs show loading from —
|
||||
// matches `TUXEDO_BRAND_ID` constant.
|
||||
"11111111-1111-1111-1111-111111111111",
|
||||
].join("|");
|
||||
|
||||
async function snap(page, name) {
|
||||
await page.screenshot({ path: `${OUT}/${name}.png`, fullPage: false });
|
||||
}
|
||||
|
||||
async function expectText(page, regex, label) {
|
||||
const text = await page.locator("body").innerText();
|
||||
if (!regex.test(text)) {
|
||||
console.error(`✗ ${label}: didn't match ${regex}`);
|
||||
console.error("body snapshot:\n" + text.slice(0, 1500));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`✓ ${label}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const ctx = await browser.newContext({
|
||||
viewport: { width: 412, height: 900 },
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
await ctx.addCookies([
|
||||
{
|
||||
name: "time_tracking_session",
|
||||
value: COOKIE_VALUE,
|
||||
url: BASE,
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
sameSite: "Lax",
|
||||
},
|
||||
]);
|
||||
const page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => console.error("PAGE ERROR:", e.message));
|
||||
page.on("console", (m) => {
|
||||
if (m.type() === "error") console.error("CONSOLE ERR:", m.text());
|
||||
});
|
||||
|
||||
// ── 1) Hub
|
||||
await page.goto(`${BASE}/tuxedo/time-clock`, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForTimeout(2000);
|
||||
await snap(page, "01_hub");
|
||||
await expectText(page, /Recent Activity/i, "Hub renders 'Recent activity' tile");
|
||||
await expectText(page, /Manual entry/i, "Hub renders 'Manual entry' tile");
|
||||
|
||||
// ── 2) Manual entry
|
||||
await page.locator('button:has-text("Manual entry")').first().click();
|
||||
await page.waitForTimeout(1000);
|
||||
await snap(page, "02_manual_entry");
|
||||
await expectText(page, /Manual entry/i, "Manual entry screen title");
|
||||
await expectText(page, /WHEN/i, "Manual entry 'WHEN' section");
|
||||
await expectText(page, /WHY/i, "Manual entry 'WHY' section");
|
||||
|
||||
// Try to submit empty — should show reason error
|
||||
// Reason field is the second text input on the page (first is Task)
|
||||
const reasonInput = page.locator('input[placeholder*="Required"]').first();
|
||||
await reasonInput.fill("Forgot to clock in this morning");
|
||||
await snap(page, "03_manual_filled");
|
||||
await expectText(page, /Submit entry/i, "Submit button visible");
|
||||
|
||||
// ── 3) Recent activity
|
||||
await page.locator('button[aria-label*="Atr" i], button[aria-label*="Back"]').first().click().catch(() => {});
|
||||
await page.waitForTimeout(800);
|
||||
// We may not always find the back button — fall back to going to hub via reload
|
||||
await page.goto(`${BASE}/tuxedo/time-clock`, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForTimeout(2000);
|
||||
await page.locator('button:has-text("Recent activity")').first().click();
|
||||
await page.waitForTimeout(1500);
|
||||
await snap(page, "04_history");
|
||||
await expectText(page, /Recent Activity|Actividad reciente/i, "History title visible");
|
||||
|
||||
await browser.close();
|
||||
console.log("\n✓ Cycle 12 deep smoke passed.");
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Cycle 12 — submit a manual entry and verify the success screen
|
||||
* renders. Uses yesterday's date so the server's 30-day window
|
||||
* accepts it.
|
||||
*/
|
||||
import { chromium } from "playwright";
|
||||
import { mkdirSync } from "node:fs";
|
||||
|
||||
const BASE = process.env.PLAYWRIGHT_URL ?? "http://localhost:4000";
|
||||
const OUT = "tests/_artifacts/cycle12";
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
const COOKIE_VALUE = [
|
||||
"5a83c1a3-8eb5-44c4-bb66-323e72d38c91",
|
||||
"smoke-" + Math.random().toString(36).slice(2, 10),
|
||||
new Date(Date.now() + 12 * 3600 * 1000).toISOString(),
|
||||
"Worker 1",
|
||||
"time_admin",
|
||||
"en",
|
||||
"11111111-1111-1111-1111-111111111111",
|
||||
].join("|");
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const ctx = await browser.newContext({
|
||||
viewport: { width: 412, height: 900 },
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
await ctx.addCookies([
|
||||
{
|
||||
name: "time_tracking_session",
|
||||
value: COOKIE_VALUE,
|
||||
url: BASE,
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
sameSite: "Lax",
|
||||
},
|
||||
]);
|
||||
const page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => console.error("PAGE ERROR:", e.message));
|
||||
page.on("console", (m) => {
|
||||
if (m.type() === "error") console.error("CONSOLE ERR:", m.text());
|
||||
});
|
||||
|
||||
await page.goto(`${BASE}/tuxedo/time-clock`, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Hub → Manual entry
|
||||
await page.locator('button:has-text("Manual entry")').first().click();
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// Fill form. Date defaults to today; use yesterday's date to make
|
||||
// sure we're definitely within the 30-day window and not in the
|
||||
// future. Use a real-looking shift: 07:00 → 15:30 (8h 30m, minus
|
||||
// 30m lunch = 8h).
|
||||
const yesterday = new Date(Date.now() - 86_400_000);
|
||||
const yyyy = yesterday.getFullYear();
|
||||
const mm = String(yesterday.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(yesterday.getDate()).padStart(2, "0");
|
||||
const dateStr = `${yyyy}-${mm}-${dd}`;
|
||||
|
||||
await page.locator('input[type="date"]').first().fill(dateStr);
|
||||
await page.locator('input[placeholder*="Harvesting"]').first().fill("Harvesting");
|
||||
await page
|
||||
.locator('input[placeholder*="Required"]')
|
||||
.first()
|
||||
.fill("Forgot to clock in this morning");
|
||||
await page.screenshot({ path: `${OUT}/05_manual_before_submit.png` });
|
||||
|
||||
await page.locator('button:has-text("Submit entry")').click();
|
||||
// Wait for either success or error
|
||||
await page.waitForTimeout(3000);
|
||||
await page.screenshot({ path: `${OUT}/06_after_submit.png` });
|
||||
const text = await page.locator("body").innerText();
|
||||
if (/Entry submitted|Entrada enviada/i.test(text)) {
|
||||
console.log("✓ Success screen rendered");
|
||||
} else if (/16 hours|30 days|3 characters|cannot be in the future|Must be after|Reason/i.test(text)) {
|
||||
console.error("✗ Validation error visible:");
|
||||
console.error(text.slice(0, 1500));
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.error("? Unknown state after submit:");
|
||||
console.error(text.slice(0, 1500));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Take a clean success shot by scrolling to top
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.waitForTimeout(300);
|
||||
await page.screenshot({ path: `${OUT}/07_success.png` });
|
||||
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
// Regression test: brand logo (and all /brand-logos/... images) must load
|
||||
// through Next.js Image optimizer on the tuxedo storefront.
|
||||
//
|
||||
// Pre-fix state:
|
||||
// GET https://water.tuxedocorn.com/_next/image?url=%2Fbrand-logos%2F64294306-...%2Flogo.png
|
||||
// → HTTP 400, body: "url parameter is not allowed"
|
||||
//
|
||||
// Post-fix expectation:
|
||||
// → HTTP 200, content-type: image/* and non-empty body
|
||||
//
|
||||
// Root cause identified: Next.js standalone build is missing the
|
||||
// `.next/standalone/public/` folder. The deploy workflow copies
|
||||
// `.next/static` but not `public/`. Without `public/` in standalone, the
|
||||
// image optimizer can't resolve local-relative URLs.
|
||||
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
|
||||
const URL = 'https://water.tuxedocorn.com/tuxedo?_=' + Date.now();
|
||||
const LOGO_PROXY =
|
||||
'https://water.tuxedocorn.com/_next/image?url=%2Fbrand-logos%2F64294306-5f42-463d-a5e8-2ad6c81a96de%2Flogo.png&w=256&q=75';
|
||||
|
||||
const failures = [];
|
||||
|
||||
// 1. Hit the image proxy directly
|
||||
const r = await fetch(LOGO_PROXY, {
|
||||
headers: { 'User-Agent': 'regression-brand-logos/1.0' },
|
||||
});
|
||||
if (r.status !== 200) {
|
||||
failures.push(`Logo proxy returned HTTP ${r.status} (expected 200)`);
|
||||
} else {
|
||||
const ct = r.headers.get('content-type') ?? '';
|
||||
if (!ct.startsWith('image/')) failures.push(`Logo proxy content-type was ${ct} (expected image/*)`);
|
||||
const buf = new Uint8Array(await r.arrayBuffer());
|
||||
if (buf.length < 1000) failures.push(`Logo proxy body too small: ${buf.length} bytes`);
|
||||
console.log(` Logo proxy: HTTP ${r.status} ${ct} ${buf.length}B`);
|
||||
}
|
||||
|
||||
// 2. Verify via headless browser — visit page, check that the logo <img> actually rendered.
|
||||
const { chromium } = await import('playwright');
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
const failed = [];
|
||||
page.on('response', (resp) => {
|
||||
const u = resp.url();
|
||||
if (u.includes('brand-logos') && resp.status() >= 400) failed.push(`HTTP ${resp.status()} ${u.slice(0, 120)}`);
|
||||
});
|
||||
await page.goto(URL, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
// Scroll to bottom so footer logo loads.
|
||||
await page.evaluate(() => window.scrollTo({ top: document.body.scrollHeight, behavior: 'instant' }));
|
||||
await sleep(4000);
|
||||
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' }));
|
||||
await sleep(2000);
|
||||
|
||||
const logoInfo = await page.evaluate(() => {
|
||||
const imgs = Array.from(document.querySelectorAll('img'));
|
||||
const logos = imgs.filter((i) => (i.currentSrc || i.src || '').includes('brand-logos'));
|
||||
return logos.map((i) => ({
|
||||
alt: i.alt.slice(0, 40),
|
||||
currentSrc: (i.currentSrc || i.src || '').slice(0, 130),
|
||||
naturalWidth: i.naturalWidth,
|
||||
naturalHeight: i.naturalHeight,
|
||||
complete: i.complete,
|
||||
visible: i.getBoundingClientRect().height > 0,
|
||||
}));
|
||||
});
|
||||
console.log('\n LOGO IMAGES ON PAGE:');
|
||||
for (const l of logoInfo) console.log(' ', JSON.stringify(l));
|
||||
|
||||
const anyLogoBlank = logoInfo.some((l) => l.naturalWidth === 0 || !l.complete);
|
||||
if (anyLogoBlank) failures.push(`Logo image(s) failed to render: ${JSON.stringify(logoInfo)}`);
|
||||
if (failed.length > 0) failures.push(`Network failed for brand-logos URLs: ${failed.join(' | ')}`);
|
||||
|
||||
await browser.close();
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('\n❌ REGRESSION FAILED:');
|
||||
for (const f of failures) console.error(' -', f);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('\n✅ Brand logos serve correctly through Next.js Image optimizer.');
|
||||
}
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Production startup wrapper for `next start` when `output: "standalone"`
|
||||
* is set in next.config.ts. Loads .env from a known absolute location,
|
||||
* then exec's the standalone server with the loaded env.
|
||||
*
|
||||
* Why this exists:
|
||||
* 1. `next start` against a standalone build is unsupported by Next.js —
|
||||
* it prints "next start does not work with output: standalone
|
||||
* configuration. Use `node .next/standalone/server.js` instead" — and
|
||||
* silently disables the image optimizer (every `/_next/image?url=...`
|
||||
* returned "url parameter is not allowed"), which is why all the
|
||||
* WP-imported brand imagery never rendered. See commit 129c9d2.
|
||||
*
|
||||
* 2. bash's `set -a; . ./.env` truncates DATABASE_URL at the `&` in
|
||||
* `&channel_binding=require`, so the server boots with empty DB env
|
||||
* and the public storefront falls back to dark gradient backgrounds.
|
||||
* A small Node loader sidesteps the bash word-splitting.
|
||||
*
|
||||
* 3. The standalone server reads `.next/static/` relative to itself, so
|
||||
* the deploy workflow must `cp -r .next/static .next/standalone/.next/static`
|
||||
* after every sync.
|
||||
*
|
||||
* Usage:
|
||||
* PORT=3100 HOSTNAME=0.0.0.0 \
|
||||
* NODE_BIN=/home/tyler/.cache/act/tool_cache/node/22.22.3/x64/bin/node \
|
||||
* node /home/tyler/route-commerce/scripts/start-standalone.cjs
|
||||
*
|
||||
* pm2 typically overrides interpreter with its own node and sets `cwd`
|
||||
* arbitrarily, so we resolve the project root from the script's own path
|
||||
* instead of `process.cwd()`.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const APP_DIR = path.resolve(__dirname, "..");
|
||||
const ENV_PATH = process.env.ENV_FILE || path.join(APP_DIR, ".env");
|
||||
const STANDALONE = path.join(APP_DIR, ".next", "standalone", "server.js");
|
||||
|
||||
if (!fs.existsSync(STANDALONE)) {
|
||||
console.error(`[start-standalone] Missing standalone server at ${STANDALONE}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (fs.existsSync(ENV_PATH)) {
|
||||
for (const line of fs.readFileSync(ENV_PATH, "utf8").split("\n")) {
|
||||
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
||||
if (m && !process.env[m[1]]) process.env[m[1]] = m[2];
|
||||
}
|
||||
console.log(`[start-standalone] loaded env from ${ENV_PATH}`);
|
||||
} else {
|
||||
console.warn(`[start-standalone] no .env at ${ENV_PATH}; relying on existing env`);
|
||||
}
|
||||
|
||||
// Hand off to the standalone server. Re-exec keeps the process title at
|
||||
// `next-server` (next-start.sh sets it) and matches `node .next/standalone/server.js`.
|
||||
const { spawn } = require("child_process");
|
||||
const child = spawn(process.execPath, [STANDALONE], { stdio: "inherit", env: process.env });
|
||||
child.on("exit", (code) => process.exit(code ?? 0));
|
||||
for (const sig of ["SIGINT", "SIGTERM"]) {
|
||||
process.on(sig, () => child.kill(sig));
|
||||
}
|
||||
@@ -216,6 +216,7 @@ export type BrandSettings = {
|
||||
show_text_alerts: boolean | null;
|
||||
schedule_pdf_notes: string | null;
|
||||
hero_image_url: string | null;
|
||||
hero_video_url: string | null;
|
||||
// Color customization
|
||||
brand_primary_color: string | null;
|
||||
brand_secondary_color: string | null;
|
||||
@@ -261,7 +262,7 @@ export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBran
|
||||
|
||||
await getSession(); try {
|
||||
const { rows } = await pool.query<BrandSettings & { wholesale_enabled?: boolean | null }>(
|
||||
`SELECT bs.*, b.name AS brand_name, ws.wholesale_enabled
|
||||
`SELECT bs.*, b.name AS brand_name, ws.online_payment_enabled AS wholesale_enabled
|
||||
FROM brands b
|
||||
JOIN brand_settings bs ON bs.brand_id = b.id
|
||||
LEFT JOIN wholesale_settings ws ON ws.brand_id = b.id
|
||||
@@ -271,6 +272,7 @@ await getSession(); try {
|
||||
);
|
||||
const data = rows[0];
|
||||
if (!data) {
|
||||
console.warn(`[getBrandSettingsPublic] No row for slug=${brandSlug}`);
|
||||
return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
|
||||
}
|
||||
return {
|
||||
@@ -278,7 +280,8 @@ await getSession(); try {
|
||||
settings: data,
|
||||
wholesaleEnabled: data.wholesale_enabled,
|
||||
};
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error(`[getBrandSettingsPublic] DB error for slug=${brandSlug}:`, err);
|
||||
return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
|
||||
}
|
||||
}
|
||||
@@ -311,6 +314,7 @@ export async function saveBrandSettings(params: {
|
||||
showTextAlerts?: boolean;
|
||||
schedulePdfNotes?: string;
|
||||
heroImageUrl?: string;
|
||||
heroVideoUrl?: string;
|
||||
brandPrimaryColor?: string;
|
||||
brandSecondaryColor?: string;
|
||||
brandBgColor?: string;
|
||||
@@ -372,7 +376,26 @@ await getSession(); const adminUser = await getAdminUser();
|
||||
params.nexusStates ?? null,
|
||||
],
|
||||
);
|
||||
return { success: true, settings: rows[0] as BrandSettings };
|
||||
|
||||
// The `upsert_brand_settings` SECURITY DEFINER RPC has a fixed
|
||||
// 32-parameter signature shipped in production. New columns added
|
||||
// after that signature was frozen (e.g. `hero_video_url` from
|
||||
// migration 0099) are written via a separate idempotent UPDATE.
|
||||
// Pass NULL through unchanged — admin form sends `null` when the
|
||||
// field is left empty.
|
||||
await pool.query(
|
||||
`UPDATE brand_settings
|
||||
SET hero_video_url = $2
|
||||
WHERE brand_id = $1`,
|
||||
[params.brandId, params.heroVideoUrl ?? null],
|
||||
);
|
||||
|
||||
// Re-read so callers see the merged row.
|
||||
const merged = await pool.query<BrandSettings>(
|
||||
`SELECT * FROM brand_settings WHERE brand_id = $1 LIMIT 1`,
|
||||
[params.brandId],
|
||||
);
|
||||
return { success: true, settings: merged.rows[0] ?? (rows[0] as BrandSettings) };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to save";
|
||||
return { success: false, error: `Failed to save: ${message.slice(0, 200)}` };
|
||||
|
||||
@@ -46,7 +46,12 @@ export type TimeTrackingSession = {
|
||||
// ── Cookie helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
const SESSION_COOKIE = "time_tracking_session";
|
||||
const COOKIE_MAX_AGE = 12 * 60 * 60; // 12 hours
|
||||
// 7 days. The cookie is httpOnly + secure (in prod) + sameSite=lax so
|
||||
// XSS / cross-site theft are already mitigated; the remaining risk is a
|
||||
// shared/unlocked device on the field. Workers notice forced re-auth
|
||||
// more than they notice a longer session, so we keep them signed in for
|
||||
// a full week. Re-PIN only when the cookie expires or is logged out.
|
||||
const COOKIE_MAX_AGE = 7 * 24 * 60 * 60;
|
||||
|
||||
function sessionCookieValue(session: TimeTrackingSession): string {
|
||||
return [
|
||||
@@ -94,8 +99,24 @@ export async function verifyTimeTrackingPin(
|
||||
// a small worker pool this is fine; if it grows we could index a
|
||||
// lookup table, but per-brand counts are typically <100.
|
||||
// Cycle 10: workers now live in the unified `field_workers` table.
|
||||
// We filter to time-domain roles here so a water-only worker can't
|
||||
// accidentally be matched against a time-tracking PIN.
|
||||
// We accept anyone with a clocking-in role: 'worker' (default),
|
||||
// 'time_admin' (manages tasks + workers), 'irrigator' (legacy role
|
||||
// for water workers, can also clock in/out — these are field crew
|
||||
// who do both jobs), and 'driver' (cycle 12 driver/crew role).
|
||||
//
|
||||
// Excluded on purpose:
|
||||
// - 'water_admin' — admin who manages headgates/workers, not a
|
||||
// clocking-in field worker
|
||||
// - 'supervisor' — cycle 12 approver role; doesn't clock in/out,
|
||||
// only reviews and approves timesheets
|
||||
//
|
||||
// This also matters because MobileWaterApp (the /water unified
|
||||
// entrypoint) calls verifyTimeTrackingPin best-effort right after
|
||||
// verifyWaterPin — if the role filter here is too narrow, the
|
||||
// worker enters their PIN once, the Time tab mounts, asks for the
|
||||
// PIN again, and the second attempt fails the same way. Allowing
|
||||
// the broader set keeps the unified PIN flow working for everyone
|
||||
// who's registered as a field crew member.
|
||||
const rows = await withBrand(brandId, async (db) => {
|
||||
return db
|
||||
.select()
|
||||
@@ -104,7 +125,7 @@ export async function verifyTimeTrackingPin(
|
||||
and(
|
||||
eq(fieldWorkers.brandId, brandId),
|
||||
eq(fieldWorkers.active, true),
|
||||
sql`${fieldWorkers.role} IN ('worker', 'time_admin')`,
|
||||
sql`${fieldWorkers.role} IN ('worker', 'time_admin', 'irrigator', 'driver')`,
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -783,7 +804,14 @@ export async function getRecentTimeLogs(
|
||||
Date.now() - safeDays * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
|
||||
return withBrand(brandId, async (db) => {
|
||||
// Session is the source of truth for field workers — see
|
||||
// `submitManualTimeLog` and `getOpenClockIn`. Without this, a stale
|
||||
// brand constant on the page (e.g. during a brand migration) makes
|
||||
// Recent Activity read "No shifts yet" because the query points at a
|
||||
// brand the worker doesn't belong to.
|
||||
const resolvedBrand = session.brand_id ?? brandId;
|
||||
|
||||
return withBrand(resolvedBrand, async (db) => {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: timeTrackingLogs.id,
|
||||
@@ -798,7 +826,7 @@ export async function getRecentTimeLogs(
|
||||
.from(timeTrackingLogs)
|
||||
.where(
|
||||
and(
|
||||
eq(timeTrackingLogs.brandId, brandId),
|
||||
eq(timeTrackingLogs.brandId, resolvedBrand),
|
||||
eq(timeTrackingLogs.fieldWorkerId, session.worker_id),
|
||||
gte(timeTrackingLogs.clockIn, new Date(since)),
|
||||
// Only show logs that have a clock-out (the Recent Activity
|
||||
|
||||
@@ -207,10 +207,14 @@ export async function getFleetRouteSummary(
|
||||
end.setUTCDate(end.getUTCDate() + 1);
|
||||
|
||||
const pool = getPool();
|
||||
// Cycle 10 — `field_workers` uses `name` and `pin_hash` (NOT the legacy
|
||||
// `display_name` / `pin` columns from `admin_users` / the pre-cycle-10
|
||||
// `time_tracking_workers`). Referencing the wrong columns crashes the
|
||||
// /admin/time-tracking overview page at render time.
|
||||
const { rows } = await pool.query<{
|
||||
id: string;
|
||||
display_name: string | null;
|
||||
pin: string;
|
||||
name: string | null;
|
||||
pin_hash: string;
|
||||
active: boolean;
|
||||
water_entries: string;
|
||||
water_gallons: string | null;
|
||||
@@ -219,8 +223,8 @@ export async function getFleetRouteSummary(
|
||||
}>(
|
||||
`SELECT
|
||||
fw.id,
|
||||
fw.display_name,
|
||||
fw.pin,
|
||||
fw.name,
|
||||
fw.pin_hash,
|
||||
fw.active,
|
||||
COALESCE(w.cnt, 0)::text AS water_entries,
|
||||
COALESCE(w.gal, 0)::text AS water_gallons,
|
||||
@@ -252,9 +256,13 @@ export async function getFleetRouteSummary(
|
||||
[brandId, start.toISOString(), end.toISOString()],
|
||||
);
|
||||
|
||||
// `pin_hash` is a scrypt self-describing hash — never display more
|
||||
// than a short fingerprint as a last-resort fallback for missing
|
||||
// worker names. The hash is sensitive but its first 6 chars are not
|
||||
// reversible, which matches the original code's intent.
|
||||
return rows.map((r) => ({
|
||||
workerId: r.id,
|
||||
workerName: r.display_name || r.pin.slice(0, 6) || "Worker",
|
||||
workerName: r.name || r.pin_hash.slice(0, 6) || "Worker",
|
||||
active: r.active,
|
||||
water: {
|
||||
entryCount: Number(r.water_entries ?? 0),
|
||||
|
||||
@@ -40,11 +40,16 @@ import {
|
||||
|
||||
// ── Result types ────────────────────────────────────────────────────────
|
||||
|
||||
// `FieldSession` is now defined in `@/lib/water-log/session.ts` so the
|
||||
// `FieldSession | { ok: false, error }` shape is shared with
|
||||
// `FieldSession` is now defined in `@/lib/water-log/session-server.ts`
|
||||
// so the `FieldSession | { ok: false, error }` shape is shared with
|
||||
// `lib/water-log/pin-login.ts` (Phase 2) and the rest of the module
|
||||
// surface. Re-export for back-compat with existing call sites.
|
||||
export type { FieldSession };
|
||||
// surface. Consumers should import as:
|
||||
// `import type { FieldSession } from "@/lib/water-log/session-server";`
|
||||
// The previous `export type { FieldSession }` re-export here caused
|
||||
// Next.js 16.2's RSC bundler to fail with "Export FieldSession doesn't
|
||||
// exist in target module" for pages that reference it indirectly
|
||||
// through server actions — so we removed the re-export and let
|
||||
// downstream consumers point at the source.
|
||||
|
||||
export type SiteAdminPermission =
|
||||
| { ok: true; adminUser: NonNullable<Awaited<ReturnType<typeof getAdminUser>>> }
|
||||
|
||||
@@ -32,10 +32,10 @@ import { verifyPin, validatePin } from "@/lib/water-log-pin";
|
||||
import { logAlert } from "@/lib/water-log-audit";
|
||||
import {
|
||||
requireFieldSession,
|
||||
type FieldSession,
|
||||
} from "@/actions/water-log/auth";
|
||||
import {
|
||||
WL_SESSION_COOKIE,
|
||||
type FieldSession,
|
||||
clearSessionCookie,
|
||||
readSessionCookie,
|
||||
setSessionCookie,
|
||||
@@ -45,7 +45,11 @@ import { triggerSyncForEntry } from "@/actions/water-log/smartsheet";
|
||||
|
||||
// Re-export so existing call sites that import from
|
||||
// `@/actions/water-log/field` keep working.
|
||||
export type { FieldSession };
|
||||
// Note: the re-export was moved to `@/lib/water-log/session-server`
|
||||
// in commit context; consumers that need `FieldSession` as a type
|
||||
// should now import it directly from `@/lib/water-log/session-server`.
|
||||
// This avoids Next.js 16.2's RSC bundler bug that treats type-only
|
||||
// re-exports through server-action wrappers as runtime exports.
|
||||
|
||||
// Field sessions last 8h — a working day in the field. Long enough
|
||||
// that a single sign-in covers a morning shift + afternoon shift.
|
||||
|
||||
@@ -1383,3 +1383,107 @@ select:-webkit-autofill:focus {
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* ── Tuxedo storefront: Harvest editorial ambient drift ───────────────
|
||||
* Slow Ken Burns on the harvest packshot. Reads as ambient — the photo
|
||||
* is breathing, not animating. Honors prefers-reduced-motion below. */
|
||||
@keyframes harvest-drift {
|
||||
0% { transform: scale(1.04) translate(0, 0); }
|
||||
50% { transform: scale(1.07) translate(-0.5%, -0.3%); }
|
||||
100% { transform: scale(1.10) translate(-1%, -0.6%); }
|
||||
}
|
||||
.animate-harvest-drift {
|
||||
animation: harvest-drift 30s ease-in-out infinite alternate;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-harvest-drift {
|
||||
animation: none !important;
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Tuxedo storefront: editorial photo color grade ────────────────────
|
||||
* Subtle Lightroom-style treatment applied to all hero / pull-quote /
|
||||
* founder / packshot / ear-macro / storm-field photos. The raw WP-import
|
||||
* JPGs skew saturated-yellow and slightly flat — the corn itself reads
|
||||
* garish, the shadows crushed, the highlights cheap. This filter:
|
||||
* - saturate(.86) pulls the corn's yellow down so the photo doesn't
|
||||
* shout louder than the white type.
|
||||
* - contrast(1.06) magazine-print contrast — slightly punchier
|
||||
* without crushing shadows.
|
||||
* - brightness(.97) trims ~3% so the photo sits a half-step below the
|
||||
* type in the brightness hierarchy.
|
||||
* Stacked with a faint cool-tone overlay (see .filter-editorial-shell)
|
||||
* for a split-tone effect: shadows drift toward navy/slate, highlights
|
||||
* stay warm. Reads as a graded magazine photo, not a raw JPG.
|
||||
*/
|
||||
.filter-editorial {
|
||||
filter: saturate(0.86) contrast(1.06) brightness(0.97);
|
||||
}
|
||||
|
||||
/* Wrap an image in a .filter-editorial-shell to also apply the cool
|
||||
* shadow tint. The shell uses a mix-blend-mode overlay so the tint
|
||||
* blends with the photo rather than sitting on top of it.
|
||||
* ::before = slate multiply (cools highlights, lifts shadows)
|
||||
* ::after = amber soft-light (warms midtones back, restores the
|
||||
* corn's color without bringing the saturation back) */
|
||||
.filter-editorial-shell {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
.filter-editorial-shell::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(20, 30, 50, 0.16);
|
||||
mix-blend-mode: multiply;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
.filter-editorial-shell::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(232, 163, 23, 0.07);
|
||||
mix-blend-mode: soft-light;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
.filter-editorial-shell > img {
|
||||
filter: saturate(0.86) contrast(1.06) brightness(0.97);
|
||||
}
|
||||
|
||||
/* Heavier grade for the harvest packshot — it's the most chromatically
|
||||
* dense image on the page (piles of yellow cobs) so it needs an
|
||||
* extra-strong desaturation pull to read as editorial, not product
|
||||
* photography. */
|
||||
.filter-editorial-strong {
|
||||
filter: saturate(0.78) contrast(1.10) brightness(0.95);
|
||||
}
|
||||
|
||||
/* Subtle film vignette used on full-bleed editorial photos (packshot,
|
||||
* storm field, founder). Draws the eye toward the center where the
|
||||
* typography anchors. */
|
||||
.filter-editorial-vignette::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
transparent 35%,
|
||||
rgba(0, 0, 0, 0.18) 80%,
|
||||
rgba(0, 0, 0, 0.38) 100%
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.filter-editorial,
|
||||
.filter-editorial-shell > img,
|
||||
.filter-editorial-strong {
|
||||
filter: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, lazy, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { m as motion } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import LayoutContainer from "@/components/layout/LayoutContainer";
|
||||
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
||||
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
||||
|
||||
const MissionSection = lazy(() => import("@/components/about/MissionSection"));
|
||||
const FamilyTimelineSection = lazy(() => import("@/components/about/FamilyTimelineSection"));
|
||||
const ContactSection = lazy(() => import("@/components/about/ContactSection"));
|
||||
const CTASection = lazy(() => import("@/components/about/CTASection"));
|
||||
import { TUXEDO_IMAGES as WP_IMAGES } from "@/components/storefront/tuxedo-images";
|
||||
import { FadeOnScroll } from "@/components/ui/ScrollAnimations";
|
||||
|
||||
interface AboutClientProps {
|
||||
logoUrl: string | null;
|
||||
@@ -28,7 +25,6 @@ interface AboutClientProps {
|
||||
export default function AboutClient({
|
||||
logoUrl,
|
||||
logoUrlDark,
|
||||
olatheSweetLogoUrlDark,
|
||||
aboutHeadline,
|
||||
aboutSubheadline,
|
||||
showWholesaleLink,
|
||||
@@ -51,101 +47,25 @@ export default function AboutClient({
|
||||
/>
|
||||
|
||||
<main>
|
||||
{/* Hero Section */}
|
||||
<section className="relative bg-stone-950 overflow-hidden">
|
||||
{/* Decorative elements */}
|
||||
<div className="absolute inset-0 opacity-20">
|
||||
<div className="absolute top-0 left-1/4 w-96 h-96 bg-emerald-900/20 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-0 right-1/4 w-80 h-80 bg-amber-900/10 rounded-full blur-3xl" />
|
||||
</div>
|
||||
<HeroSection
|
||||
headline={aboutHeadline ?? "Three generations of sweet corn excellence."}
|
||||
subheadline={
|
||||
aboutSubheadline ??
|
||||
"Forty summers of the same seed, the same soil, and the same row — grown at five thousand, three hundred and sixty feet, picked by hand, packed the morning of."
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Pattern overlay */}
|
||||
<div className="absolute inset-0 opacity-5" style={{ backgroundImage: 'repeating-linear-gradient(45deg, transparent, transparent 35px, rgba(255,255,255,0.1) 35px, rgba(255,255,255,0.1) 70px)' }} />
|
||||
<TheCornSection />
|
||||
|
||||
<div className="relative mx-auto max-w-6xl px-6 py-24 md:py-36">
|
||||
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
||||
{/* Text content */}
|
||||
<div>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="text-[11px] font-bold uppercase tracking-[0.3em] text-emerald-400/70 mb-4"
|
||||
>
|
||||
Our Heritage
|
||||
</motion.p>
|
||||
<PullQuoteBand />
|
||||
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.15 }}
|
||||
className="text-5xl md:text-6xl lg:text-7xl font-black text-white tracking-tight leading-[1] mb-6"
|
||||
>
|
||||
{aboutHeadline ?? "Three Generations of Sweet Corn Excellence"}
|
||||
</motion.h1>
|
||||
<FounderStrip />
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.3 }}
|
||||
className="text-xl md:text-2xl text-stone-300 leading-relaxed"
|
||||
>
|
||||
{aboutSubheadline ?? "Over 40 years of growing and shipping Olathe Sweet Sweet Corn from our family to yours."}
|
||||
</motion.p>
|
||||
<FamilyTimeline />
|
||||
|
||||
<motion.div
|
||||
initial={{ scaleX: 0 }}
|
||||
animate={{ scaleX: 1 }}
|
||||
transition={{ duration: 0.8, delay: 0.5 }}
|
||||
className="mt-8 h-1 w-16 bg-gradient-to-r from-emerald-500 to-amber-500 rounded-full origin-left"
|
||||
/>
|
||||
</div>
|
||||
<VisitTheFarm address={address} />
|
||||
|
||||
{/* Logo */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="flex justify-center lg:justify-end"
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-6 bg-emerald-900/30 rounded-full blur-2xl" />
|
||||
<Image
|
||||
src={olatheSweetLogoUrlDark ?? "/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png"}
|
||||
alt="Olathe Sweet"
|
||||
width={288}
|
||||
height={288}
|
||||
unoptimized
|
||||
className="relative w-56 md:w-72 h-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom transition */}
|
||||
<div className="absolute -bottom-px left-0 right-0 h-3 bg-stone-50" />
|
||||
</section>
|
||||
|
||||
{/* Mission Section */}
|
||||
<Suspense fallback={<LoadingFallback bg="bg-stone-50" />}>
|
||||
<MissionSection />
|
||||
</Suspense>
|
||||
|
||||
{/* Family Timeline */}
|
||||
<Suspense fallback={<LoadingFallback bg="bg-stone-100" />}>
|
||||
<FamilyTimelineSection />
|
||||
</Suspense>
|
||||
|
||||
{/* Contact Section */}
|
||||
<Suspense fallback={<LoadingFallback bg="bg-stone-950" />}>
|
||||
<ContactSection address={address} />
|
||||
</Suspense>
|
||||
|
||||
{/* CTA Section */}
|
||||
<Suspense fallback={<LoadingFallback bg="bg-white" />}>
|
||||
<CTASection />
|
||||
</Suspense>
|
||||
<CTASection />
|
||||
</main>
|
||||
|
||||
<StorefrontFooter
|
||||
@@ -163,6 +83,537 @@ export default function AboutClient({
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingFallback({ bg }: { bg: string }) {
|
||||
return <div className={`py-24 md:py-32 ${bg}`} />;
|
||||
// ── HERO — dark editorial opener ────────────────────────────────────
|
||||
// Same architectural language as the homepage FounderStrip, but with
|
||||
// the portrait as a full anchor instead of a supporting figure, and
|
||||
// the headline sitting in the same Fraunces display register as the
|
||||
// homepage "Three generations of sweet corn excellence" copy.
|
||||
function HeroSection({
|
||||
headline,
|
||||
subheadline,
|
||||
}: {
|
||||
headline: string;
|
||||
subheadline: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="relative bg-stone-950 overflow-hidden">
|
||||
{/* A single amber rim along the bottom — same hinge used
|
||||
throughout the homepage between cream and dark sections. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 bottom-0 h-px bg-amber-300/30 z-10"
|
||||
/>
|
||||
|
||||
<LayoutContainer>
|
||||
<div className="relative py-20 sm:py-28 lg:py-32 grid grid-cols-1 lg:grid-cols-12 gap-10 lg:gap-16 items-center">
|
||||
{/* Copy column — 7/12 on desktop */}
|
||||
<div className="lg:col-span-7 order-2 lg:order-1">
|
||||
<FadeOnScroll from="up">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-amber-300/85 mb-6">
|
||||
Our Heritage · Est. 1982
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.08}>
|
||||
<h1
|
||||
className="font-display text-stone-50 text-[clamp(2.5rem,5.4vw,4.5rem)] leading-[1.02] tracking-[-0.02em]"
|
||||
style={{ textWrap: "balance" }}
|
||||
>
|
||||
{headline.split(/(excellence\.)/i).map((part, i) =>
|
||||
/excellence\.$/i.test(part) ? (
|
||||
<span key={i} className="italic text-amber-200/90">
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
<span key={i}>{part}</span>
|
||||
)
|
||||
)}
|
||||
</h1>
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.18}>
|
||||
<div className="mt-8 h-px w-16 bg-gradient-to-r from-emerald-500 via-amber-400 to-amber-300/0" />
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.26}>
|
||||
<p className="mt-8 text-stone-300 text-lg sm:text-xl leading-[1.65] max-w-xl">
|
||||
{subheadline}
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.34}>
|
||||
<div className="mt-10 flex flex-wrap items-center gap-x-8 gap-y-3 text-[11px] uppercase tracking-[0.28em]">
|
||||
<span className="text-amber-200/80">Olathe, Colorado</span>
|
||||
<span className="text-stone-700">·</span>
|
||||
<span className="text-stone-400">5,360 ft</span>
|
||||
<span className="text-stone-700">·</span>
|
||||
<span className="text-stone-400">Uncompahgre Valley</span>
|
||||
</div>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
|
||||
{/* Portrait column — 5/12 on desktop */}
|
||||
<div className="lg:col-span-5 order-1 lg:order-2">
|
||||
<FadeOnScroll from="right" duration={0.4}>
|
||||
<div className="relative aspect-[4/5] sm:aspect-[5/6] overflow-hidden rounded-2xl ring-1 ring-white/10 shadow-[0_40px_120px_-40px_rgba(0,0,0,0.7)] filter-editorial-shell">
|
||||
<Image
|
||||
src={WP_IMAGES.founderPortrait}
|
||||
alt="John Harold, founder, walking through his corn rows inspecting the tassels"
|
||||
fill
|
||||
sizes="(max-width: 1024px) 100vw, 42vw"
|
||||
quality={92}
|
||||
priority
|
||||
className="object-cover"
|
||||
/>
|
||||
{/* Soft amber wash on the bottom — matches the homepage
|
||||
FounderStrip treatment. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 bottom-0 h-1/4 bg-gradient-to-t from-amber-200/15 to-transparent pointer-events-none"
|
||||
/>
|
||||
<div className="absolute bottom-4 left-4 sm:bottom-5 sm:left-5 px-3 py-1.5 rounded-full bg-stone-950/70 backdrop-blur-sm text-[10px] font-semibold uppercase tracking-[0.28em] text-amber-100">
|
||||
John Harold · Olathe, CO
|
||||
</div>
|
||||
</div>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── THE CORN — split editorial moment ───────────────────────────────
|
||||
// Mirrors the homepage's "The Corn" section: copy left (5/12),
|
||||
// single macro photograph right (7/12). One paragraph. One image.
|
||||
// The feature-card grid read as a templated list of icons; a single
|
||||
// honest photograph communicates everything those tiles couldn't.
|
||||
function TheCornSection() {
|
||||
return (
|
||||
<section
|
||||
id="the-corn"
|
||||
className="relative bg-stone-50 py-24 sm:py-32 overflow-hidden"
|
||||
>
|
||||
<LayoutContainer>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10 lg:gap-16 items-center">
|
||||
{/* Left: copy column, 5/12 */}
|
||||
<div className="lg:col-span-5 order-2 lg:order-1">
|
||||
<FadeOnScroll from="left">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-emerald-700 mb-5">
|
||||
The Corn
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="left" delay={0.1}>
|
||||
<h2
|
||||
className="font-display text-stone-950 text-[clamp(2.25rem,4.8vw,3.75rem)] leading-[1.04] tracking-[-0.018em]"
|
||||
style={{ textWrap: "balance" }}
|
||||
>
|
||||
You can taste<br className="hidden sm:block" />
|
||||
<span className="italic text-amber-700/90">the altitude.</span>
|
||||
</h2>
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="left" delay={0.2}>
|
||||
<div className="mt-7 h-px w-12 bg-emerald-700/60" />
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="left" delay={0.3}>
|
||||
<p className="mt-7 text-stone-700 text-lg leading-[1.65]">
|
||||
Olathe Sweet was developed for the Uncompahgre Valley — five
|
||||
thousand, three hundred and sixty feet above the sea, where
|
||||
the daytime sun hits the husk and the nights cool it back
|
||||
down before sunrise. That diurnal swing is what loads every
|
||||
kernel with sugar; nothing we do in the kitchen can
|
||||
replicate it, and nothing in a greenhouse can grow it.
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="left" delay={0.4}>
|
||||
<p className="mt-5 text-stone-600 leading-[1.65]">
|
||||
The ear in the photograph was picked the morning it was
|
||||
shot. The silk on the right is still damp. We don’t
|
||||
airbrush the field because we don’t need to.
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
|
||||
{/* Right: macro photo, 7/12 */}
|
||||
<div className="lg:col-span-7 order-1 lg:order-2">
|
||||
<FadeOnScroll from="right" duration={0.4}>
|
||||
<div className="relative aspect-[5/4] sm:aspect-[16/11] overflow-hidden rounded-2xl shadow-[0_30px_80px_-30px_rgba(15,40,20,0.45)] ring-1 ring-stone-900/5 filter-editorial-shell">
|
||||
<Image
|
||||
src={WP_IMAGES.earMacro}
|
||||
alt="A single ear of Olathe Sweet corn, partially husked, silk still attached, against a field of dark green tassels"
|
||||
fill
|
||||
sizes="(max-width: 1024px) 100vw, 58vw"
|
||||
quality={90}
|
||||
priority
|
||||
className="object-cover"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 bottom-0 h-1/3 bg-gradient-to-t from-amber-200/15 to-transparent pointer-events-none"
|
||||
/>
|
||||
<div className="absolute bottom-4 left-4 sm:bottom-6 sm:left-6 px-3.5 py-2 rounded-full bg-stone-950/65 backdrop-blur-sm text-[10px] font-semibold uppercase tracking-[0.28em] text-amber-100">
|
||||
Olathe Sweet · Day of pick
|
||||
</div>
|
||||
</div>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── PULL-QUOTE BAND — full-bleed editorial interlude ───────────────
|
||||
// Same composition the homepage uses: pre-storm field behind, single
|
||||
// Fraunces italic quote floating in the lower third. No chrome. The
|
||||
// image does the talking.
|
||||
function PullQuoteBand() {
|
||||
return (
|
||||
<section
|
||||
className="relative isolate overflow-hidden"
|
||||
style={{ minHeight: "min(70vh, 640px)" }}
|
||||
>
|
||||
<Image
|
||||
src={WP_IMAGES.stormField}
|
||||
alt="Pre-storm corn rows receding to the mountains at dusk"
|
||||
fill
|
||||
sizes="100vw"
|
||||
quality={88}
|
||||
className="object-cover -z-10 filter-editorial"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-stone-950/55 via-stone-950/15 to-stone-950/80 -z-10" />
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 bottom-0 h-px bg-amber-300/40 -z-10"
|
||||
/>
|
||||
|
||||
<div className="relative min-h-[inherit] flex items-end pb-16 sm:pb-24">
|
||||
<div className="mx-auto w-full max-w-5xl px-6 sm:px-10 text-center">
|
||||
<FadeOnScroll from="up">
|
||||
<p
|
||||
className="font-display text-white text-[clamp(1.75rem,3.8vw,3.25rem)] leading-[1.08] tracking-[-0.015em] italic"
|
||||
style={{ textWrap: "balance" }}
|
||||
>
|
||||
“The field decides the sweetness.<br className="hidden sm:block" />{" "}
|
||||
<span className="text-amber-200/95">We just pick it on time.”</span>
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.15}>
|
||||
<div className="mt-8 mx-auto h-px w-12 bg-amber-300/70" />
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.25}>
|
||||
<p className="mt-5 text-[10px] sm:text-[11px] font-medium uppercase tracking-[0.32em] text-stone-300">
|
||||
— Tuxedo Corn · Olathe, Colorado
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── FOUNDER STRIP — narrative anchor for "The People" section ──────
|
||||
// Cream background, magazine spread. Eyebrow + Fraunces headline on
|
||||
// the left, two paragraphs of founder story on the right. Below it
|
||||
// the FamilyTimeline component renders the four family-member entries
|
||||
// in the same hairline-ruled editorial register.
|
||||
function FounderStrip() {
|
||||
return (
|
||||
<section className="relative bg-stone-50 py-24 sm:py-32 overflow-hidden border-t border-stone-200/60">
|
||||
<LayoutContainer>
|
||||
<header className="mb-14 sm:mb-20 grid grid-cols-1 lg:grid-cols-12 gap-x-10 gap-y-6 items-end">
|
||||
<div className="lg:col-span-7">
|
||||
<FadeOnScroll from="up">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-emerald-700 mb-5">
|
||||
The People Behind the Corn
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.1}>
|
||||
<h2
|
||||
className="font-display text-stone-950 text-[clamp(2rem,4.4vw,3.5rem)] leading-[1.04] tracking-[-0.02em]"
|
||||
style={{ textWrap: "balance" }}
|
||||
>
|
||||
The Harold family,
|
||||
<br className="hidden sm:block" />{" "}
|
||||
<span className="italic text-amber-700/90">on the same row.</span>
|
||||
</h2>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
<div className="lg:col-span-5">
|
||||
<FadeOnScroll from="up" delay={0.15}>
|
||||
<p className="text-stone-600 text-base leading-[1.7] lg:max-w-md lg:ml-auto">
|
||||
John Harold began selling Olathe’s sweet corn directly
|
||||
from pickups in the 1970s. The Olathe Sweet parent line was
|
||||
rescued from a retiring farmer who believed his valley could
|
||||
grow sugar in a husk. Three generations later, the name on
|
||||
the cooler is still the name on the deed.
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
</header>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── FAMILY TIMELINE — magazine "sidebar" register ──────────────────
|
||||
// Hairline-ruled four-column grid. No cards, no avatars in gradient
|
||||
// boxes. The homepage's HarvestEditorial data strip is the structural
|
||||
// model; the era + name + role + bio columns are the editorial content.
|
||||
// Order matters: founder first, then by generation.
|
||||
const FAMILY = [
|
||||
{
|
||||
initials: "JH",
|
||||
name: "John Harold",
|
||||
role: "Founder",
|
||||
era: "1970s",
|
||||
bio: "Started selling Olathe's sweet corn directly from pickups. Built Tuxedo Corn into a family operation rooted in the Uncompahgre Valley.",
|
||||
},
|
||||
{
|
||||
initials: "DH",
|
||||
name: "David Harold",
|
||||
role: "Second Generation",
|
||||
era: "Present",
|
||||
bio: "Leads sustainability across Harold farms — drip irrigation, Fair Food Program, reduced chemical inputs. Keeps the soil honest.",
|
||||
},
|
||||
{
|
||||
initials: "JW",
|
||||
name: "John William Harold",
|
||||
role: "Mexico Operations",
|
||||
era: "1990s–Present",
|
||||
bio: "Three decades in Guaymas, Sonora, growing sweet corn, onions, wheat, and vegetables. Extends the Harold tradition across borders.",
|
||||
},
|
||||
{
|
||||
initials: "JH",
|
||||
name: "Joseph Harold",
|
||||
role: "Third Generation",
|
||||
era: "Present",
|
||||
bio: "Works the corn shed in summer, handles photography and beverages. Carrying the family tradition into the next generation.",
|
||||
},
|
||||
];
|
||||
|
||||
function FamilyTimeline() {
|
||||
return (
|
||||
<section className="relative bg-stone-50 pb-24 sm:pb-32 overflow-hidden">
|
||||
<LayoutContainer>
|
||||
{/* Hairline above the strip — the magazine sidebar opening. */}
|
||||
<div aria-hidden="true" className="h-px w-full bg-stone-900/15 mb-14" />
|
||||
|
||||
<ol className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-y-14 lg:gap-y-0 lg:gap-x-10">
|
||||
{FAMILY.map((member, i) => (
|
||||
<FadeOnScroll from="up" delay={i * 0.08} key={member.name}>
|
||||
<li className="relative lg:pt-2">
|
||||
{/* Per-column hairline (top on desktop, absent on mobile
|
||||
because the gap-y supplies spacing). */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 -top-6 h-px bg-stone-900/15 hidden lg:block"
|
||||
/>
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.32em] text-emerald-700 tabular-nums">
|
||||
{member.era}
|
||||
</p>
|
||||
<p className="mt-4 font-display text-stone-950 text-[clamp(1.65rem,2.4vw,2.1rem)] leading-[1.1] tracking-[-0.014em]">
|
||||
{member.name}
|
||||
</p>
|
||||
<p className="mt-2 text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-700/85">
|
||||
{member.role}
|
||||
</p>
|
||||
<p className="mt-4 text-stone-600 text-sm leading-[1.6] max-w-[28ch]">
|
||||
{member.bio}
|
||||
</p>
|
||||
</li>
|
||||
</FadeOnScroll>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{/* Hairline + caption underneath, matching the HarvestEditorial
|
||||
editorial-footer rhythm. */}
|
||||
<div aria-hidden="true" className="h-px w-full bg-stone-900/15 mt-16" />
|
||||
<p className="mt-8 text-center text-[10px] uppercase tracking-[0.32em] text-stone-500">
|
||||
Three generations · One valley · Forty summers
|
||||
</p>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── VISIT THE FARM — dark editorial address block ──────────────────
|
||||
// Replaces the three icon-card contact layout. Stone-950 panel with a
|
||||
// magazine "by the numbers" data strip + a single hairline-ruled
|
||||
// address line. No SVG icons in colored boxes; the typography carries
|
||||
// the section.
|
||||
function VisitTheFarm({ address }: { address: string }) {
|
||||
return (
|
||||
<section
|
||||
className="relative bg-stone-950 py-24 sm:py-32 overflow-hidden"
|
||||
aria-labelledby="visit-heading"
|
||||
>
|
||||
{/* Single amber rim at the top — same hinge used on the dark
|
||||
homepage sections. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 top-0 h-px bg-amber-300/15"
|
||||
/>
|
||||
|
||||
<LayoutContainer>
|
||||
<header className="mb-14 sm:mb-20 grid grid-cols-1 lg:grid-cols-12 gap-x-10 gap-y-6 items-end">
|
||||
<div className="lg:col-span-7">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-amber-300/85 mb-5">
|
||||
The Farm
|
||||
</p>
|
||||
<h2
|
||||
id="visit-heading"
|
||||
className="font-display text-stone-100 text-[clamp(2rem,4.4vw,3.5rem)] leading-[1.04] tracking-[-0.02em]"
|
||||
style={{ textWrap: "balance" }}
|
||||
>
|
||||
Visit the<br className="hidden sm:block" />{" "}
|
||||
<span className="italic text-amber-200/85">Uncompahgre Valley.</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="lg:col-span-5">
|
||||
<p className="text-stone-400 text-base leading-[1.7] lg:max-w-md lg:ml-auto">
|
||||
The farm is open during harvest. Pickup windows are tight,
|
||||
shed hours are short, and the corn is best the morning it’s
|
||||
picked — call ahead before you drive out.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hairline-ruled data strip — four columns: address / phone /
|
||||
shipping hours / office hours. Same magazine-sidebar treatment
|
||||
as the homepage HarvestEditorial numbers strip, just with
|
||||
prose instead of counters. */}
|
||||
<ol className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-y-14 lg:gap-y-0 lg:gap-x-10 border-t border-b border-amber-300/15">
|
||||
<FarmPoint label="Address" detail={address} mono />
|
||||
<FarmPoint label="Shipping" detail="970-323-5631" mono />
|
||||
<FarmPoint label="Office" detail="970-323-6874" mono />
|
||||
<FarmPoint
|
||||
label="Hours"
|
||||
detail={["Mon–Fri · 7A–6P", "Sat–Sun · 8A–2P"]}
|
||||
/>
|
||||
</ol>
|
||||
|
||||
{/* Editorial caption, italic display serif. */}
|
||||
<p className="mt-16 sm:mt-20 max-w-2xl mx-auto text-center font-display italic text-stone-300 text-[clamp(1.1rem,1.8vw,1.4rem)] leading-[1.45]">
|
||||
“We don’t ship corn that’s more than a day from
|
||||
the field. The valley won’t let us.”
|
||||
</p>
|
||||
<p className="mt-5 text-center text-[10px] uppercase tracking-[0.32em] text-stone-500">
|
||||
— Tuxedo Corn · Olathe, Colorado
|
||||
</p>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// One column of the Visit-the-Farm strip. Same hairline rules and
|
||||
// label style as HarvestEditorial's DataPoint on the homepage, with
|
||||
// `detail` either a string or an array of lines (for the Hours column).
|
||||
function FarmPoint({
|
||||
label,
|
||||
detail,
|
||||
mono = false,
|
||||
}: {
|
||||
label: string;
|
||||
detail: string | string[];
|
||||
mono?: boolean;
|
||||
}) {
|
||||
const lines = Array.isArray(detail) ? detail : [detail];
|
||||
return (
|
||||
<li className="relative pt-8 lg:pt-12">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.3em] text-amber-300/85">
|
||||
{label}
|
||||
</p>
|
||||
{lines.map((line, i) => (
|
||||
<p
|
||||
key={i}
|
||||
className={`mt-4 text-stone-100 text-base leading-[1.55] ${
|
||||
mono ? "font-mono tabular-nums tracking-tight" : ""
|
||||
}`}
|
||||
>
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// ── CTA — closing editorial pair ───────────────────────────────────
|
||||
// Stone-50 section with the dark gradient emerald→amber pill button
|
||||
// used on the homepage StorySection "Read Our Story" CTA, plus a
|
||||
// secondary "Shop Products" outline. Same voice as the homepage's
|
||||
// closing beat: "Find a stop / Shop products".
|
||||
function CTASection() {
|
||||
return (
|
||||
<section className="relative bg-stone-50 py-24 sm:py-32 overflow-hidden border-t border-stone-200/60">
|
||||
<LayoutContainer>
|
||||
<div className="relative mx-auto max-w-3xl text-center">
|
||||
<FadeOnScroll from="up">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-emerald-700 mb-5">
|
||||
This Week
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.08}>
|
||||
<h2
|
||||
className="font-display text-stone-950 text-[clamp(2rem,4vw,3rem)] leading-[1.05] tracking-[-0.018em]"
|
||||
style={{ textWrap: "balance" }}
|
||||
>
|
||||
Find a stop near you,
|
||||
<br className="hidden sm:block" />{" "}
|
||||
<span className="italic text-amber-700/90">or have it shipped.</span>
|
||||
</h2>
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.16}>
|
||||
<div className="mx-auto mt-7 h-px w-16 bg-gradient-to-r from-emerald-500 to-amber-400" />
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.24}>
|
||||
<p className="mt-7 text-stone-600 text-lg leading-[1.65]">
|
||||
Preorder for pickup at a stop on the summer route, or have
|
||||
cooler boxes shipped directly to your door after the season.
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.32}>
|
||||
<div className="mt-10 flex flex-wrap items-center justify-center gap-4">
|
||||
<Link
|
||||
href="/tuxedo#stops"
|
||||
className="group inline-flex items-center gap-3 rounded-full bg-gradient-to-br from-emerald-700 to-emerald-600 px-9 py-4 text-sm font-bold text-white shadow-[0_8px_32px_rgba(16,185,129,0.3),inset_0_1px_0_rgba(255,255,255,0.2)] hover:-translate-y-0.5 hover:shadow-[0_12px_40px_rgba(16,185,129,0.4),inset_0_1px_0_rgba(255,255,255,0.2)] transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-50"
|
||||
>
|
||||
<span>Find a Stop</span>
|
||||
<svg
|
||||
className="w-4 h-4 transition-transform group-hover:translate-x-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
href="/tuxedo#products"
|
||||
className="group inline-flex items-center gap-3 rounded-full bg-white px-9 py-4 text-sm font-bold text-stone-900 ring-1 ring-stone-200 hover:ring-emerald-600 hover:text-emerald-700 transition-all"
|
||||
>
|
||||
<span>Shop Products</span>
|
||||
<svg
|
||||
className="w-4 h-4 transition-transform group-hover:translate-x-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import LayoutContainer from "@/components/layout/LayoutContainer";
|
||||
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
||||
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
||||
import LayoutContainer from "@/components/layout/LayoutContainer";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { TUXEDO_IMAGES } from "@/components/storefront/tuxedo-images";
|
||||
import { FadeOnScroll } from "@/components/ui/ScrollAnimations";
|
||||
|
||||
type BrandSettings = {
|
||||
invoice_business_name: string | null;
|
||||
@@ -22,6 +24,11 @@ type FormState = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
const FALLBACK_ADDRESS = "59751 David Road, Olathe, CO 81425";
|
||||
const FALLBACK_EMAIL = "orders@tuxedocorn.com";
|
||||
const SHIPPING_PHONE = "970-323-5631";
|
||||
const OFFICE_PHONE = "970-323-6874";
|
||||
|
||||
export default function TuxedoContactPage() {
|
||||
const [brandSettings, setBrandSettings] = useState<BrandSettings | null>(null);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
@@ -30,12 +37,16 @@ export default function TuxedoContactPage() {
|
||||
|
||||
useEffect(() => {
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
supabase
|
||||
.from("wholesale_settings")
|
||||
.select("invoice_business_name, invoice_business_address, invoice_business_phone, invoice_business_email, invoice_business_website")
|
||||
.eq("brand_id", TUXEDO_BRAND_ID)
|
||||
.single()
|
||||
.then(({ data }) => setBrandSettings((data as unknown as BrandSettings) ?? null));
|
||||
import("@/lib/supabase").then(({ supabase }) => {
|
||||
supabase
|
||||
.from("wholesale_settings")
|
||||
.select(
|
||||
"invoice_business_name, invoice_business_address, invoice_business_phone, invoice_business_email, invoice_business_website"
|
||||
)
|
||||
.eq("brand_id", TUXEDO_BRAND_ID)
|
||||
.single()
|
||||
.then(({ data }) => setBrandSettings((data as unknown as BrandSettings) ?? null));
|
||||
});
|
||||
}, []);
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
@@ -47,182 +58,531 @@ export default function TuxedoContactPage() {
|
||||
}, 600);
|
||||
}
|
||||
|
||||
const address = brandSettings?.invoice_business_address ?? FALLBACK_ADDRESS;
|
||||
const email = brandSettings?.invoice_business_email ?? FALLBACK_EMAIL;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
<StorefrontHeader brandName="Tuxedo Corn" brandSlug="tuxedo" brandAccent="green" />
|
||||
|
||||
<main className="py-16 md:py-20">
|
||||
<LayoutContainer>
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<main>
|
||||
<ContactHero />
|
||||
|
||||
{/* Page header */}
|
||||
<div className="text-center mb-16">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-widest text-emerald-600 mb-4">Get in Touch</p>
|
||||
<h1 className="text-5xl md:text-6xl font-black tracking-tight text-stone-950 leading-tight">
|
||||
Contact Us
|
||||
</h1>
|
||||
<p className="mt-5 text-lg text-stone-500 max-w-xl mx-auto leading-relaxed">
|
||||
Questions about corn, stops, or wholesale accounts — we are happy to help.
|
||||
</p>
|
||||
</div>
|
||||
{/* Contact info strip — hairline-ruled four-column data grid,
|
||||
same magazine-sidebar register as the About page's
|
||||
"Visit the Farm" section. */}
|
||||
<ContactInfoStrip
|
||||
address={address}
|
||||
email={email}
|
||||
shippingPhone={SHIPPING_PHONE}
|
||||
officePhone={OFFICE_PHONE}
|
||||
/>
|
||||
|
||||
{/* Contact info grid */}
|
||||
<div className="grid gap-6 md:grid-cols-3 mb-16">
|
||||
<div className="rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-200/60 text-center">
|
||||
<div className="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-50 mb-5">
|
||||
<svg className="h-5 w-5 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-base font-bold text-stone-950 mb-3">Farm Address</h3>
|
||||
<p className="text-sm text-stone-500 leading-relaxed">
|
||||
{brandSettings?.invoice_business_address ?? "59751 David Road, Olathe, CO 81425"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-200/60 text-center">
|
||||
<div className="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-50 mb-5">
|
||||
<svg className="h-5 w-5 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.184-.046-.379-.041-.545.114L18 10.48a2.25 2.25 0 00-.545-.114l-4.423 1.106c-.5.119-.852.575-.852 1.091v1.372a2.25 2.25 0 01-2.25 2.25h-2.25a15 15 0 01-15-15z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-base font-bold text-stone-950 mb-3">Phone</h3>
|
||||
<a href="tel:9703235631" className="block text-sm text-stone-500 hover:text-emerald-700 transition-colors leading-relaxed">
|
||||
Shipping: 970-323-5631
|
||||
</a>
|
||||
<a href="tel:9703236874" className="mt-1 block text-sm text-stone-500 hover:text-emerald-700 transition-colors leading-relaxed">
|
||||
Office: 970-323-6874
|
||||
</a>
|
||||
</div>
|
||||
<div className="rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-200/60 text-center">
|
||||
<div className="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-50 mb-5">
|
||||
<svg className="h-5 w-5 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-base font-bold text-stone-950 mb-3">Email</h3>
|
||||
<a href={`mailto:${brandSettings?.invoice_business_email ?? "orders@tuxedocorn.com"}`} className="text-sm text-stone-500 hover:text-emerald-700 transition-colors">
|
||||
{brandSettings?.invoice_business_email ?? "orders@tuxedocorn.com"}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact form */}
|
||||
<div className="rounded-3xl bg-white p-10 shadow-sm ring-1 ring-stone-200/60">
|
||||
<div className="mb-8">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-widest text-emerald-600 mb-3">Send a Message</p>
|
||||
<h2 className="text-3xl font-black text-stone-950 tracking-tight leading-tight">
|
||||
Get in Touch
|
||||
</h2>
|
||||
<div className="mt-4 h-px w-12 bg-emerald-600" />
|
||||
</div>
|
||||
{submitted ? (
|
||||
<div className="rounded-2xl bg-emerald-50 p-10 text-center ring-1 ring-emerald-100">
|
||||
<div className="inline-flex h-12 w-12 items-center justify-center rounded-full bg-emerald-100 mb-4">
|
||||
<svg className="h-6 w-6 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-xl font-bold text-stone-950">Message received.</p>
|
||||
<p className="mt-2 text-sm text-stone-500">We will be in touch within 1-2 business days.</p>
|
||||
<button type="button"
|
||||
onClick={() => {
|
||||
setSubmitted(false);
|
||||
setForm({ name: "", email: "", topic: "general", message: "" });
|
||||
}}
|
||||
className="mt-5 text-sm font-medium text-emerald-700 underline hover:text-emerald-900"
|
||||
{/* Form section — cream stone-50, two-column layout. Left
|
||||
column carries the editorial framing (eyebrow + Fraunces
|
||||
headline + paragraph + small hours caption). Right column
|
||||
holds the form itself, no wrapper white card. */}
|
||||
<section className="relative bg-stone-50 py-24 sm:py-32 border-t border-stone-200/60">
|
||||
<LayoutContainer>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-16">
|
||||
{/* Left: editorial framing */}
|
||||
<div className="lg:col-span-5">
|
||||
<FadeOnScroll from="up">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-emerald-700 mb-5">
|
||||
Send a Message
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.08}>
|
||||
<h2
|
||||
className="font-display text-stone-950 text-[clamp(2rem,4.2vw,3.25rem)] leading-[1.05] tracking-[-0.02em]"
|
||||
style={{ textWrap: "balance" }}
|
||||
>
|
||||
Send another message
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
Tell us what you’re after,
|
||||
<br className="hidden sm:block" />{" "}
|
||||
<span className="italic text-amber-700/90">we’ll write back.</span>
|
||||
</h2>
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.16}>
|
||||
<div className="mt-7 h-px w-12 bg-emerald-700/60" />
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.24}>
|
||||
<p className="mt-7 text-stone-700 text-lg leading-[1.65]">
|
||||
Most messages get a reply within one business day. During
|
||||
harvest weeks the office is open early — call if your
|
||||
order is time-sensitive and we’ll get you a real
|
||||
answer, not a form letter.
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
|
||||
{/* Hours caption, hairline-ruled inline. */}
|
||||
<FadeOnScroll from="up" delay={0.32}>
|
||||
<dl className="mt-12 border-t border-stone-900/15 pt-6 grid grid-cols-2 gap-y-5 gap-x-6 text-sm">
|
||||
<div>
|
||||
<label htmlFor="fld-1-your-name" className="block text-sm font-semibold text-stone-700 mb-2">Your Name</label>
|
||||
<input id="fld-1-your-name" aria-label="Jane Smith"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-5 py-4 text-sm text-stone-900 placeholder-stone-400 outline-none focus:border-stone-900 focus:ring-1 focus:ring-stone-900 transition-colors"
|
||||
placeholder="Jane Smith"
|
||||
/>
|
||||
<dt className="text-[10px] font-bold uppercase tracking-[0.3em] text-emerald-700">
|
||||
Weekdays
|
||||
</dt>
|
||||
<dd className="mt-2 font-mono tabular-nums text-stone-950">
|
||||
7A – 6P
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fld-2-email" className="block text-sm font-semibold text-stone-700 mb-2">Email</label>
|
||||
<input id="fld-2-email" aria-label="Jane@example.com"
|
||||
required
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-5 py-4 text-sm text-stone-900 placeholder-stone-400 outline-none focus:border-stone-900 focus:ring-1 focus:ring-stone-900 transition-colors"
|
||||
placeholder="jane@example.com"
|
||||
/>
|
||||
<dt className="text-[10px] font-bold uppercase tracking-[0.3em] text-emerald-700">
|
||||
Weekends
|
||||
</dt>
|
||||
<dd className="mt-2 font-mono tabular-nums text-stone-950">
|
||||
8A – 2P
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fld-3-topic" className="block text-sm font-semibold text-stone-700 mb-2">Topic</label>
|
||||
<select id="fld-3-topic" aria-label="Select"
|
||||
value={form.topic}
|
||||
onChange={(e) => setForm({ ...form, topic: e.target.value })}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-5 py-4 text-sm text-stone-900 outline-none focus:border-stone-900 focus:ring-1 focus:ring-stone-900 transition-colors appearance-none"
|
||||
>
|
||||
<option value="general">General Question</option>
|
||||
<option value="orders">Orders & Pickup</option>
|
||||
<option value="wholesale">Wholesale Inquiry</option>
|
||||
<option value="media">Media & Partnership</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fld-4-message" className="block text-sm font-semibold text-stone-700 mb-2">Message</label>
|
||||
<textarea id="fld-4-message" aria-label="How Can We Help You?"
|
||||
required
|
||||
rows={5}
|
||||
value={form.message}
|
||||
onChange={(e) => setForm({ ...form, message: e.target.value })}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-5 py-4 text-sm text-stone-900 placeholder-stone-400 outline-none focus:border-stone-900 focus:ring-1 focus:ring-stone-900 transition-colors resize-none"
|
||||
placeholder="How can we help you?"
|
||||
</dl>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
|
||||
{/* Right: form */}
|
||||
<div className="lg:col-span-7">
|
||||
<FadeOnScroll from="right">
|
||||
{submitted ? (
|
||||
<FormSuccess
|
||||
onReset={() => {
|
||||
setSubmitted(false);
|
||||
setForm({ name: "", email: "", topic: "general", message: "" });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="rounded-xl bg-stone-900 px-8 py-4 text-sm font-bold text-white hover:bg-stone-800 active:bg-stone-950 transition-colors disabled:opacity-60 disabled:cursor-not-allowed hover:shadow-lg hover:shadow-black/15"
|
||||
>
|
||||
{isSubmitting ? "Sending..." : "Send Message"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
) : (
|
||||
<ContactForm
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
)}
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
|
||||
{/* Business hours — elevated */}
|
||||
<div className="mt-10 rounded-3xl bg-white p-10 shadow-sm ring-1 ring-stone-200/60">
|
||||
<div className="mb-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-widest text-emerald-600 mb-3">Summer Season</p>
|
||||
<h2 className="text-2xl font-black text-stone-950 tracking-tight leading-tight">
|
||||
When to Reach Us
|
||||
</h2>
|
||||
<div className="mt-4 h-px w-12 bg-emerald-600" />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="flex items-center justify-between py-4 px-6 rounded-2xl bg-stone-50">
|
||||
<p className="font-semibold text-stone-700">Monday – Friday</p>
|
||||
<p className="text-stone-500 font-medium">7:00 AM – 6:00 PM</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-4 px-6 rounded-2xl bg-stone-50">
|
||||
<p className="font-semibold text-stone-700">Saturday – Sunday</p>
|
||||
<p className="text-stone-500 font-medium">8:00 AM – 2:00 PM</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-5 text-sm text-stone-400 leading-relaxed">
|
||||
During the off-season, response times may be longer. For urgent matters, please call the office.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
<ContactClosingQuote />
|
||||
</main>
|
||||
|
||||
<StorefrontFooter brandName="Tuxedo Corn" brandSlug="tuxedo" brandAccent="green" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── HERO — dark editorial opener ────────────────────────────────────
|
||||
// Mirrors the About page's hero structure: eyebrow + Fraunces headline
|
||||
// (with italic emphasis) + subhead + hairline gradient rule. The
|
||||
// field photograph sits as a soft half-bleed at the bottom-right
|
||||
// instead of a featured portrait — the contact page is shorter and
|
||||
// quieter than the about page.
|
||||
function ContactHero() {
|
||||
return (
|
||||
<section className="relative bg-stone-950 overflow-hidden isolate">
|
||||
{/* Soft field image, anchored bottom-right, partially visible
|
||||
behind the headline column. Suggests the farm without
|
||||
competing with the form. */}
|
||||
<div className="absolute inset-y-0 right-0 w-full lg:w-1/2 -z-10">
|
||||
<Image
|
||||
src={TUXEDO_IMAGES.heroField}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||
quality={85}
|
||||
priority
|
||||
className="object-cover object-center filter-editorial opacity-50"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-stone-950 via-stone-950/85 to-stone-950/30" />
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-stone-950/40 via-transparent to-stone-950" />
|
||||
</div>
|
||||
{/* Single amber rim along the bottom — same hinge as the
|
||||
homepage and about dark sections. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 bottom-0 h-px bg-amber-300/30 z-10"
|
||||
/>
|
||||
|
||||
<LayoutContainer>
|
||||
<div className="relative py-20 sm:py-28 lg:py-32 max-w-3xl">
|
||||
<FadeOnScroll from="up">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-amber-300/85 mb-6">
|
||||
Get in Touch · Olathe, Colorado
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.08}>
|
||||
<h1
|
||||
className="font-display text-stone-50 text-[clamp(2.5rem,5.4vw,4.5rem)] leading-[1.02] tracking-[-0.02em]"
|
||||
style={{ textWrap: "balance" }}
|
||||
>
|
||||
Send a note,
|
||||
<br className="hidden sm:block" />{" "}
|
||||
<span className="italic text-amber-200/90">or a question.</span>
|
||||
</h1>
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.18}>
|
||||
<div className="mt-8 h-px w-16 bg-gradient-to-r from-emerald-500 via-amber-400 to-amber-300/0" />
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.26}>
|
||||
<p className="mt-8 text-stone-300 text-lg sm:text-xl leading-[1.65] max-w-xl">
|
||||
Orders, pickup logistics, wholesale accounts, press — every
|
||||
message lands on a real desk in Olathe and gets a real
|
||||
answer.
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.34}>
|
||||
<div className="mt-10 flex flex-wrap items-center gap-x-8 gap-y-3 text-[11px] uppercase tracking-[0.28em]">
|
||||
<span className="text-amber-200/80">Replies in 1–2 days</span>
|
||||
<span className="text-stone-700">·</span>
|
||||
<span className="text-stone-400">No form letters</span>
|
||||
<span className="text-stone-700">·</span>
|
||||
<span className="text-stone-400">Real people</span>
|
||||
</div>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── CONTACT INFO STRIP — magazine "by the numbers" pattern ─────────
|
||||
// Same hairline-ruled four-column data grid as the About page's
|
||||
// "Visit the Farm" section. Address, shipping phone, office phone,
|
||||
// email — no icon-in-rounded-box, just type and tabular numerals.
|
||||
function ContactInfoStrip({
|
||||
address,
|
||||
email,
|
||||
shippingPhone,
|
||||
officePhone,
|
||||
}: {
|
||||
address: string;
|
||||
email: string;
|
||||
shippingPhone: string;
|
||||
officePhone: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="relative bg-stone-50 py-20 sm:py-24">
|
||||
<LayoutContainer>
|
||||
<ol className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-y-12 lg:gap-y-0 lg:gap-x-10 border-t border-b border-stone-900/15">
|
||||
<InfoPoint label="Address" detail={address} />
|
||||
<InfoPoint label="Shipping" detail={shippingPhone} mono />
|
||||
<InfoPoint label="Office" detail={officePhone} mono />
|
||||
<InfoPoint label="Email" detail={email} mono />
|
||||
</ol>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoPoint({
|
||||
label,
|
||||
detail,
|
||||
mono = false,
|
||||
}: {
|
||||
label: string;
|
||||
detail: string;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<li className="relative pt-8 lg:pt-12">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.3em] text-emerald-700">
|
||||
{label}
|
||||
</p>
|
||||
<p
|
||||
className={`mt-4 text-stone-950 text-base leading-[1.55] ${
|
||||
mono ? "font-mono tabular-nums tracking-tight" : ""
|
||||
}`}
|
||||
>
|
||||
{detail}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// ── FORM — bottom-border-only editorial inputs ─────────────────────
|
||||
// The form sits directly on the cream background without a wrapping
|
||||
// card. Inputs use bottom borders only (with a hairline focus state),
|
||||
// matching the "atelier-input" treatment used elsewhere in the admin.
|
||||
// The submit button is the same emerald→amber gradient pill used on
|
||||
// the homepage and about pages.
|
||||
function ContactForm({
|
||||
form,
|
||||
setForm,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}: {
|
||||
form: FormState;
|
||||
setForm: (s: FormState) => void;
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
isSubmitting: boolean;
|
||||
}) {
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-10">
|
||||
<div className="grid gap-10 sm:grid-cols-2">
|
||||
<Field
|
||||
id="fld-name"
|
||||
label="Your Name"
|
||||
value={form.name}
|
||||
onChange={(v) => setForm({ ...form, name: v })}
|
||||
required
|
||||
placeholder="Jane Smith"
|
||||
/>
|
||||
<Field
|
||||
id="fld-email"
|
||||
label="Email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(v) => setForm({ ...form, email: v })}
|
||||
required
|
||||
placeholder="jane@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SelectField
|
||||
id="fld-topic"
|
||||
label="Topic"
|
||||
value={form.topic}
|
||||
onChange={(v) => setForm({ ...form, topic: v })}
|
||||
options={[
|
||||
{ value: "general", label: "General Question" },
|
||||
{ value: "orders", label: "Orders & Pickup" },
|
||||
{ value: "wholesale", label: "Wholesale Inquiry" },
|
||||
{ value: "media", label: "Media & Partnership" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<TextareaField
|
||||
id="fld-message"
|
||||
label="Message"
|
||||
value={form.message}
|
||||
onChange={(v) => setForm({ ...form, message: v })}
|
||||
required
|
||||
placeholder="How can we help?"
|
||||
/>
|
||||
|
||||
<div className="pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="group inline-flex items-center gap-3 rounded-full bg-gradient-to-br from-emerald-700 to-emerald-600 px-9 py-4 text-sm font-bold text-white shadow-[0_8px_32px_rgba(16,185,129,0.3),inset_0_1px_0_rgba(255,255,255,0.2)] hover:-translate-y-0.5 hover:shadow-[0_12px_40px_rgba(16,185,129,0.4),inset_0_1px_0_rgba(255,255,255,0.2)] transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-50 disabled:opacity-60 disabled:cursor-not-allowed disabled:hover:translate-y-0"
|
||||
>
|
||||
<span>{isSubmitting ? "Sending…" : "Send Message"}</span>
|
||||
<svg
|
||||
className="w-4 h-4 transition-transform group-hover:translate-x-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 8.25L21 12m0 0l-3.75 3.75M21 12H3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// Single text input — bottom-border-only editorial treatment.
|
||||
// Label sits above the input as a small uppercase eyebrow so the
|
||||
// field is readable on the cream background without a chrome card.
|
||||
function Field({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
type = "text",
|
||||
required,
|
||||
placeholder,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
type?: string;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="block text-[10px] font-bold uppercase tracking-[0.28em] text-stone-500 mb-3"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
type={type}
|
||||
required={required}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full bg-transparent border-0 border-b border-stone-900/15 px-0 py-3 text-base text-stone-950 placeholder-stone-400 outline-none focus:border-emerald-700 focus:border-b-2 focus:pb-[11px] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectField({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
options: { value: string; label: string }[];
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="block text-[10px] font-bold uppercase tracking-[0.28em] text-stone-500 mb-3"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full appearance-none bg-transparent border-0 border-b border-stone-900/15 px-0 py-3 pr-8 text-base text-stone-950 outline-none focus:border-emerald-700 focus:border-b-2 focus:pb-[11px] transition-colors"
|
||||
>
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<svg
|
||||
className="pointer-events-none absolute right-0 top-1/2 -translate-y-1/2 h-4 w-4 text-stone-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextareaField({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
required,
|
||||
placeholder,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="block text-[10px] font-bold uppercase tracking-[0.28em] text-stone-500 mb-3"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<textarea
|
||||
id={id}
|
||||
required={required}
|
||||
rows={5}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full bg-transparent border-0 border-b border-stone-900/15 px-0 py-3 text-base text-stone-950 placeholder-stone-400 outline-none focus:border-emerald-700 focus:border-b-2 focus:pb-[11px] transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FormSuccess({ onReset }: { onReset: () => void }) {
|
||||
return (
|
||||
<div className="relative border-t border-b border-emerald-700/30 py-16 text-center">
|
||||
<div className="inline-flex h-12 w-12 items-center justify-center rounded-full bg-emerald-100 mb-6">
|
||||
<svg
|
||||
className="h-6 w-6 text-emerald-700"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-display text-stone-950 text-[clamp(1.5rem,2.6vw,2.1rem)] leading-[1.1] tracking-[-0.014em]">
|
||||
Message received.
|
||||
</p>
|
||||
<div className="mx-auto mt-6 h-px w-12 bg-emerald-700/60" />
|
||||
<p className="mt-7 text-stone-600 leading-[1.65]">
|
||||
We’ll be in touch within one to two business days. If it’s
|
||||
urgent, the office line is the fastest way to reach us.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="mt-8 text-sm font-semibold text-emerald-700 underline underline-offset-4 hover:text-emerald-900 transition-colors"
|
||||
>
|
||||
Send another message
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── CLOSING — dark editorial quote ─────────────────────────────────
|
||||
// Same magazine-closing beat the homepage uses: italic Fraunces quote
|
||||
// centered with a hairline-rule attribution underneath.
|
||||
function ContactClosingQuote() {
|
||||
return (
|
||||
<section className="relative bg-stone-950 py-24 sm:py-32 overflow-hidden">
|
||||
{/* Top amber rim — hinges against the cream form section. */}
|
||||
<div aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-amber-300/15" />
|
||||
|
||||
<LayoutContainer>
|
||||
<FadeOnScroll from="up">
|
||||
<p
|
||||
className="mx-auto max-w-2xl text-center font-display italic text-stone-100 text-[clamp(1.4rem,2.8vw,2.1rem)] leading-[1.4] tracking-[-0.012em]"
|
||||
style={{ textWrap: "balance" }}
|
||||
>
|
||||
“Pick up the phone. Write us a note. Either way, you’re
|
||||
reaching a person who knows the field.”
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.15}>
|
||||
<div className="mx-auto mt-8 h-px w-12 bg-amber-300/70" />
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.25}>
|
||||
<p className="mt-5 text-center text-[10px] uppercase tracking-[0.32em] text-stone-400">
|
||||
— Tuxedo Corn · Olathe, Colorado
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.35}>
|
||||
<div className="mt-12 text-center">
|
||||
<Link
|
||||
href="/tuxedo/faq"
|
||||
className="text-sm font-semibold text-amber-200/85 underline underline-offset-4 hover:text-amber-100 transition-colors"
|
||||
>
|
||||
Read the FAQ first →
|
||||
</Link>
|
||||
</div>
|
||||
</FadeOnScroll>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import ContactClientPage from "./ContactClientPage";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||
|
||||
@@ -40,5 +39,5 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default function TuxedoContactLayout({ children }: { children: React.ReactNode }) {
|
||||
return <ContactClientPage />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import ContactClientPage from "./ContactClientPage";
|
||||
|
||||
export default function TuxedoContactPage() {
|
||||
return <ContactClientPage />;
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import LayoutContainer from "@/components/layout/LayoutContainer";
|
||||
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
||||
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
||||
import { FadeOnScroll } from "@/components/ui/ScrollAnimations";
|
||||
|
||||
type FAQItem = {
|
||||
question: string;
|
||||
@@ -16,15 +18,18 @@ const FAQ_CATEGORIES = [
|
||||
items: [
|
||||
{
|
||||
question: "How do I place a preorder for corn?",
|
||||
answer: "Find a stop near you on our homepage and click 'Shop This Stop' to add corn to your cart and select your pickup location. Preordering helps us bring the right amount of corn to each stop.",
|
||||
answer:
|
||||
"Find a stop near you on our homepage and click 'Shop This Stop' to add corn to your cart and select your pickup location. Preordering helps us bring the right amount of corn to each stop.",
|
||||
},
|
||||
{
|
||||
question: "Can I order without selecting a pickup stop?",
|
||||
answer: "Yes. Choose 'Shipping' at checkout and we will mail cooler boxes directly to your home. Shipping is available for orders of 4 or more dozen and is fulfilled after the season ends.",
|
||||
answer:
|
||||
"Yes. Choose 'Shipping' at checkout and we will mail cooler boxes directly to your home. Shipping is available for orders of 4 or more dozen and is fulfilled after the season ends.",
|
||||
},
|
||||
{
|
||||
question: "What happens if I miss my pickup time?",
|
||||
answer: "Please call the office at 970-323-6874 as soon as possible. We will do our best to accommodate, but unclaimed orders may be donated or sold after the pickup window closes.",
|
||||
answer:
|
||||
"Please call the office at 970-323-6874 as soon as possible. We will do our best to accommodate, but unclaimed orders may be donated or sold after the pickup window closes.",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -33,15 +38,18 @@ const FAQ_CATEGORIES = [
|
||||
items: [
|
||||
{
|
||||
question: "Do you ship corn?",
|
||||
answer: "Yes. We ship Olathe Sweet Sweet Corn nationwide. Orders ship as cooler boxes after our field season ends in late summer. A minimum of 4 dozen is required for shipping.",
|
||||
answer:
|
||||
"Yes. We ship Olathe Sweet Sweet Corn nationwide. Orders ship as cooler boxes after our field season ends in late summer. A minimum of 4 dozen is required for shipping.",
|
||||
},
|
||||
{
|
||||
question: "How are cooler boxes packaged?",
|
||||
answer: "Corn is packed in insulated coolers with gel packs to keep it fresh during transit. Shipping costs are calculated at checkout based on your location.",
|
||||
answer:
|
||||
"Corn is packed in insulated coolers with gel packs to keep it fresh during transit. Shipping costs are calculated at checkout based on your location.",
|
||||
},
|
||||
{
|
||||
question: "When will my shipped order arrive?",
|
||||
answer: "Cooler box orders are shipped after the season ends in late summer. You will receive a tracking notification by email once your order ships. Most orders within the continental US arrive within 3-7 business days.",
|
||||
answer:
|
||||
"Cooler box orders are shipped after the season ends in late summer. You will receive a tracking notification by email once your order ships. Most orders within the continental US arrive within 3–7 business days.",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -50,11 +58,13 @@ const FAQ_CATEGORIES = [
|
||||
items: [
|
||||
{
|
||||
question: "How do I set up a wholesale account?",
|
||||
answer: "Visit /wholesale/register to apply. We review applications and respond within 1-2 business days. Wholesale accounts are available to retailers, restaurants, and farm stands.",
|
||||
answer:
|
||||
"Visit /wholesale/register to apply. We review applications and respond within 1–2 business days. Wholesale accounts are available to retailers, restaurants, and farm stands.",
|
||||
},
|
||||
{
|
||||
question: "Is there a minimum order for wholesale?",
|
||||
answer: "Minimum wholesale order is $100 per order. Some account types have additional minimums — contact us for details.",
|
||||
answer:
|
||||
"Minimum wholesale order is $100 per order. Some account types have additional minimums — contact us for details.",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -63,21 +73,30 @@ const FAQ_CATEGORIES = [
|
||||
items: [
|
||||
{
|
||||
question: "What makes Olathe Sweet different?",
|
||||
answer: "Olathe Sweet Sweet Corn is grown in the Uncompahgre Valley of Colorado, where high-altitude days and cool nights produce exceptionally sweet, tender corn. We have been growing this variety for over 40 years.",
|
||||
answer:
|
||||
"Olathe Sweet Sweet Corn is grown in the Uncompahgre Valley of Colorado, where high-altitude days and cool nights produce exceptionally sweet, tender corn. We have been growing this variety for over 40 years.",
|
||||
},
|
||||
{
|
||||
question: "Is your corn organic?",
|
||||
answer: "We use regenerative farming practices including no-till methods, cover crops, and reduced chemical inputs. We are not certified organic, but we take a thoughtful approach to land stewardship.",
|
||||
answer:
|
||||
"We use regenerative farming practices including no-till methods, cover crops, and reduced chemical inputs. We are not certified organic, but we take a thoughtful approach to land stewardship.",
|
||||
},
|
||||
{
|
||||
question: "How should I store my corn?",
|
||||
answer: "Store corn in the refrigerator and eat within 3-5 days. Do not husk it until you are ready to cook. For best sweetness, bring a cooler with ice to transport your corn home.",
|
||||
answer:
|
||||
"Store corn in the refrigerator and eat within 3–5 days. Do not husk it until you are ready to cook. For best sweetness, bring a cooler with ice to transport your corn home.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function FAQAccordion({ items, searchQuery }: { items: FAQItem[]; searchQuery: string }) {
|
||||
function FAQAccordion({
|
||||
items,
|
||||
searchQuery,
|
||||
}: {
|
||||
items: FAQItem[];
|
||||
searchQuery: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
@@ -90,47 +109,74 @@ function FAQAccordion({ items, searchQuery }: { items: FAQItem[]; searchQuery: s
|
||||
);
|
||||
}, [items, searchQuery]);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div className="py-10 text-center">
|
||||
<p className="text-stone-500 text-sm">
|
||||
No results for “{searchQuery}”
|
||||
</p>
|
||||
<p className="mt-1 text-stone-400 text-sm">
|
||||
Try a different term or browse the categories below.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="rounded-2xl bg-white p-6 sm:p-8 text-center shadow-sm ring-1 ring-stone-200/60 transition-all duration-300 hover:shadow-lg hover:ring-stone-300">
|
||||
<p className="text-stone-500 text-sm">No results for "{searchQuery}"</p>
|
||||
<p className="mt-1 text-stone-400 text-sm">Try a different term or browse all categories below.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{filtered.map((item) => {
|
||||
const isOpen = open === item.question;
|
||||
return (
|
||||
<div
|
||||
key={item.question}
|
||||
className="rounded-2xl bg-white shadow-sm ring-1 ring-stone-200/60 overflow-hidden transition-all duration-300 hover:ring-stone-300"
|
||||
<ul className="divide-y divide-stone-900/15 border-t border-b border-stone-900/15">
|
||||
{filtered.map((item) => {
|
||||
const isOpen = open === item.question;
|
||||
return (
|
||||
<li key={item.question}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(isOpen ? null : item.question)}
|
||||
className="group flex w-full items-start justify-between gap-6 py-6 sm:py-7 text-left"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<span className="font-display text-stone-950 text-[clamp(1.1rem,1.7vw,1.35rem)] leading-[1.3] tracking-[-0.01em] pr-4 group-hover:text-emerald-800 transition-colors">
|
||||
{item.question}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`mt-1 flex-shrink-0 inline-flex h-7 w-7 items-center justify-center transition-all duration-200 ${
|
||||
isOpen
|
||||
? "rotate-45 text-emerald-700"
|
||||
: "text-stone-400 group-hover:text-stone-700"
|
||||
}`}
|
||||
>
|
||||
<button type="button"
|
||||
onClick={() => setOpen(isOpen ? null : item.question)}
|
||||
className="flex w-full items-center justify-between px-4 sm:px-6 py-4 sm:py-5 text-left group"
|
||||
aria-expanded={isOpen}
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<span className="font-semibold text-stone-950 pr-3 sm:pr-4 text-sm sm:text-[15px] leading-snug group-hover:text-emerald-700 transition-colors">
|
||||
{item.question}
|
||||
</span>
|
||||
<span className={`flex-shrink-0 w-6 h-6 sm:w-7 sm:h-7 rounded-full flex items-center justify-center transition-all duration-300 ${isOpen ? "bg-emerald-600 text-white rotate-180" : "bg-stone-100 text-stone-400 group-hover:bg-stone-200"}`}>
|
||||
<svg className="w-3 h-3 sm:w-3.5 sm:h-3.5 transition-transform duration-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<div className={`overflow-hidden transition-all duration-300 ease-out ${isOpen ? "max-h-48 opacity-100" : "max-h-0 opacity-0"}`}>
|
||||
<div className="px-4 sm:px-6 pb-5 sm:pb-6 pt-1 border-t border-stone-100">
|
||||
<p className="text-sm text-stone-500 leading-relaxed">{item.answer}</p>
|
||||
</div>
|
||||
</div>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 4.5v15m7.5-7.5h-15"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
className={`grid transition-all duration-300 ease-out ${
|
||||
isOpen
|
||||
? "grid-rows-[1fr] opacity-100 pb-6 sm:pb-7"
|
||||
: "grid-rows-[0fr] opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<p className="text-stone-600 leading-[1.7] max-w-3xl pr-10">
|
||||
{item.answer}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -141,63 +187,183 @@ export default function TuxedoFAQPage() {
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
<StorefrontHeader brandName="Tuxedo Corn" brandSlug="tuxedo" brandAccent="green" />
|
||||
|
||||
<main className="py-12 sm:py-16 md:py-20 px-4 sm:px-6">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{/* Page header */}
|
||||
<div className="text-center mb-10 sm:mb-14">
|
||||
<p className="text-[10px] sm:text-[11px] font-semibold uppercase tracking-widest text-emerald-600 mb-3 sm:mb-4">Questions</p>
|
||||
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-black tracking-tight text-stone-950 leading-tight">FAQ</h1>
|
||||
<p className="mt-4 sm:mt-5 text-base sm:text-lg text-stone-500 leading-relaxed">Everything you need to know about ordering, pickup, and shipping.</p>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-8 sm:mb-12 relative">
|
||||
<div className="absolute inset-y-0 left-4 sm:left-5 flex items-center pointer-events-none">
|
||||
<svg className="h-4 w-4 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
<main>
|
||||
{/* Hero — cream stone-50 with editorial framing. */}
|
||||
<section className="relative bg-stone-50 pt-20 pb-12 sm:pt-24 sm:pb-16 border-b border-stone-200/60">
|
||||
<LayoutContainer>
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<FadeOnScroll from="up">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-emerald-700 mb-5">
|
||||
Help · Frequently Asked
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.08}>
|
||||
<h1
|
||||
className="font-display text-stone-950 text-[clamp(2.5rem,5.2vw,4.25rem)] leading-[1.02] tracking-[-0.02em]"
|
||||
style={{ textWrap: "balance" }}
|
||||
>
|
||||
Questions,
|
||||
<br className="hidden sm:block" />{" "}
|
||||
<span className="italic text-amber-700/90">answered.</span>
|
||||
</h1>
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.18}>
|
||||
<div className="mx-auto mt-8 h-px w-16 bg-gradient-to-r from-emerald-500 to-amber-400" />
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.26}>
|
||||
<p className="mt-8 text-stone-600 text-lg sm:text-xl leading-[1.65]">
|
||||
Everything you need to know about ordering, pickup, and
|
||||
shipping — straight from the people who pack the boxes.
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
<input aria-label="Search Questions..."
|
||||
type="text"
|
||||
placeholder="Search questions..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full rounded-2xl border border-stone-200 bg-white pl-10 sm:pl-11 pr-5 py-3.5 sm:py-4 text-sm sm:text-base text-stone-900 placeholder-stone-400 outline-none focus:border-stone-900 focus:ring-1 focus:ring-stone-900/20 transition-all duration-200 shadow-sm hover:shadow-md focus:shadow-lg"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button type="button"
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute inset-y-0 right-4 flex items-center text-stone-400 hover:text-stone-600 transition-colors"
|
||||
aria-label="Close">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
|
||||
{/* FAQ categories */}
|
||||
<div className="space-y-10 sm:space-y-14">
|
||||
{FAQ_CATEGORIES.map((cat) => (
|
||||
<div key={cat.category}>
|
||||
<h2 className="text-[10px] sm:text-xs font-bold uppercase tracking-[0.2em] text-stone-400 mb-4 sm:mb-5">{cat.category}</h2>
|
||||
<FAQAccordion items={cat.items} searchQuery={searchQuery} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Search — editorial bottom-border-only input. */}
|
||||
<section className="relative bg-stone-50 py-12 sm:py-16">
|
||||
<LayoutContainer>
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<FadeOnScroll from="up">
|
||||
<div className="relative">
|
||||
<label htmlFor="faq-search" className="sr-only">
|
||||
Search questions
|
||||
</label>
|
||||
<div className="absolute inset-y-0 left-0 flex items-center pointer-events-none">
|
||||
<svg
|
||||
className="h-4 w-4 text-stone-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
id="faq-search"
|
||||
type="text"
|
||||
placeholder="Search questions…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-transparent border-0 border-b border-stone-900/15 pl-7 pr-9 py-3 text-base text-stone-950 placeholder-stone-400 outline-none focus:border-emerald-700 focus:border-b-2 focus:pb-[11px] transition-colors"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery("")}
|
||||
aria-label="Clear search"
|
||||
className="absolute inset-y-0 right-0 flex items-center text-stone-400 hover:text-stone-700 transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
|
||||
{/* Still have questions CTA */}
|
||||
<div className="mt-12 sm:mt-16 rounded-2xl sm:rounded-3xl bg-stone-950 px-6 sm:px-10 py-10 sm:py-12 text-center shadow-xl">
|
||||
<p className="text-xl sm:text-2xl font-black text-white tracking-tight">Still have questions?</p>
|
||||
<p className="mt-2 text-stone-400 text-sm">We are happy to help — reach out anytime.</p>
|
||||
<Link href="/tuxedo/contact" className="mt-6 sm:mt-8 inline-flex items-center gap-2 rounded-full bg-emerald-600 px-6 sm:px-8 py-3 sm:py-4 font-bold text-white hover:bg-emerald-500 active:bg-emerald-700 transition-all text-sm tracking-wider hover:shadow-lg hover:shadow-emerald-900/30">
|
||||
Contact Us
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
{/* Categories — each rendered as a category with hairline-ruled
|
||||
accordion list. No card wrappers; each item is a single row. */}
|
||||
<section className="relative bg-stone-50 pb-24 sm:pb-32">
|
||||
<LayoutContainer>
|
||||
<div className="mx-auto max-w-3xl space-y-16 sm:space-y-20">
|
||||
{FAQ_CATEGORIES.map((cat) => (
|
||||
<div key={cat.category}>
|
||||
<FadeOnScroll from="up">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.32em] text-emerald-700 mb-6">
|
||||
{cat.category}
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
<FAQAccordion items={cat.items} searchQuery={searchQuery} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
|
||||
{/* Closing CTA — dark editorial panel with italic Fraunces
|
||||
headline + Contact button. Same closing beat the homepage
|
||||
and about pages use. */}
|
||||
<section className="relative bg-stone-950 py-24 sm:py-32 overflow-hidden">
|
||||
{/* Top amber rim. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 top-0 h-px bg-amber-300/15"
|
||||
/>
|
||||
|
||||
<LayoutContainer>
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<FadeOnScroll from="up">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-amber-300/85 mb-5">
|
||||
Still Wondering
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.08}>
|
||||
<h2
|
||||
className="font-display text-stone-100 text-[clamp(2rem,4.4vw,3.5rem)] leading-[1.04] tracking-[-0.02em]"
|
||||
style={{ textWrap: "balance" }}
|
||||
>
|
||||
Still have
|
||||
<br className="hidden sm:block" />{" "}
|
||||
<span className="italic text-amber-200/85">questions?</span>
|
||||
</h2>
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.18}>
|
||||
<div className="mx-auto mt-7 h-px w-16 bg-gradient-to-r from-emerald-500 to-amber-400" />
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.26}>
|
||||
<p className="mt-7 text-stone-400 text-lg leading-[1.65]">
|
||||
We’re happy to help — reach out anytime. Most
|
||||
messages get a reply within one business day.
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.34}>
|
||||
<div className="mt-10">
|
||||
<Link
|
||||
href="/tuxedo/contact"
|
||||
className="group inline-flex items-center gap-3 rounded-full bg-gradient-to-br from-emerald-700 to-emerald-600 px-9 py-4 text-sm font-bold text-white shadow-[0_8px_32px_rgba(16,185,129,0.3),inset_0_1px_0_rgba(255,255,255,0.2)] hover:-translate-y-0.5 hover:shadow-[0_12px_40px_rgba(16,185,129,0.4),inset_0_1px_0_rgba(255,255,255,0.2)] transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-950"
|
||||
>
|
||||
<span>Contact Us</span>
|
||||
<svg
|
||||
className="w-4 h-4 transition-transform group-hover:translate-x-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<StorefrontFooter brandName="Tuxedo Corn" brandSlug="tuxedo" brandAccent="green" />
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import FAQClientPage from "./FAQClientPage";
|
||||
import { serializeJsonForScript } from "@/lib/safe-json";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||
@@ -144,7 +143,7 @@ export default function TuxedoFAQLayout({ children }: { children: React.ReactNod
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: serializeJsonForScript(organizationJsonLd) }}
|
||||
/>
|
||||
<FAQClientPage />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+477
-394
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { gsap } from "gsap";
|
||||
import LayoutContainer from "@/components/layout/LayoutContainer";
|
||||
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
||||
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
||||
import LayoutContainer from "@/components/layout/LayoutContainer";
|
||||
import { TUXEDO_IMAGES } from "@/components/storefront/tuxedo-images";
|
||||
import type { PublicStop } from "@/actions/stops";
|
||||
|
||||
type Props = {
|
||||
@@ -16,22 +18,30 @@ type Props = {
|
||||
|
||||
export default function TuxedoStopsList({ stops, brandName, brandSlug }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [now] = useState(() => new Date());
|
||||
|
||||
// Subtle scroll-in stagger for the stops list. The Motion policy in
|
||||
// ScrollAnimations.tsx caps translation to 16px / 320ms, which matches
|
||||
// what we want here — the list reveals as a quiet beat, not a show.
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const ctx = gsap.context(() => {
|
||||
gsap.from(".stop-card", {
|
||||
y: 30,
|
||||
gsap.from(".stop-row", {
|
||||
y: 16,
|
||||
opacity: 0,
|
||||
duration: 0.5,
|
||||
stagger: 0.08,
|
||||
ease: "power3.out",
|
||||
duration: 0.32,
|
||||
stagger: 0.04,
|
||||
ease: "power1.out",
|
||||
scrollTrigger: {
|
||||
trigger: containerRef.current,
|
||||
start: "top 90%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
});
|
||||
}, containerRef);
|
||||
return () => ctx.revert();
|
||||
}, []);
|
||||
|
||||
const now = new Date();
|
||||
const upcomingStops = stops.filter((s) => new Date(s.date) >= now);
|
||||
const pastStops = stops.filter((s) => new Date(s.date) < now);
|
||||
|
||||
@@ -39,123 +49,315 @@ export default function TuxedoStopsList({ stops, brandName, brandSlug }: Props)
|
||||
<div ref={containerRef} className="min-h-screen bg-stone-50">
|
||||
<StorefrontHeader brandName={brandName} brandSlug={brandSlug} brandAccent="green" />
|
||||
|
||||
<main className="py-16 md:py-20">
|
||||
<LayoutContainer>
|
||||
<div className="max-w-4xl mx-auto mb-12 text-center">
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-emerald-600 mb-4">
|
||||
{brandName}
|
||||
</p>
|
||||
<h1 className="text-4xl md:text-5xl font-black tracking-tight text-stone-950 mb-4">
|
||||
Pickup Stops
|
||||
</h1>
|
||||
<p className="text-stone-500 text-lg">
|
||||
Fresh Olathe Sweet™ sweet corn delivered to a stop near you.
|
||||
</p>
|
||||
<div className="mt-6 h-1 w-12 bg-emerald-600 mx-auto rounded-full" />
|
||||
<main>
|
||||
{/* Hero — dark editorial opener with the field photograph as a
|
||||
soft half-bleed at the bottom, same register as the
|
||||
Contact page hero. */}
|
||||
<section className="relative bg-stone-950 overflow-hidden isolate">
|
||||
{/* Soft field image, bottom-anchored, partially visible
|
||||
behind the headline column. */}
|
||||
<div className="absolute inset-x-0 bottom-0 h-2/3 -z-10">
|
||||
<Image
|
||||
src={TUXEDO_IMAGES.heroField}
|
||||
alt=""
|
||||
fill
|
||||
sizes="100vw"
|
||||
quality={85}
|
||||
priority
|
||||
className="object-cover object-center filter-editorial opacity-40"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-stone-950 via-stone-950/70 to-stone-950" />
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 bottom-0 h-px bg-amber-300/30 z-10"
|
||||
/>
|
||||
|
||||
{upcomingStops.length === 0 ? (
|
||||
<div className="max-w-2xl mx-auto text-center py-16">
|
||||
<div className="w-20 h-20 rounded-2xl bg-stone-100 flex items-center justify-center mx-auto mb-6">
|
||||
<svg className="w-10 h-10 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-stone-800 mb-2">No Upcoming Stops Yet</h2>
|
||||
<p className="text-stone-500 mb-8 max-w-md mx-auto">
|
||||
The corn is still ripening in the field. New pickup stops are added every week — check back soon, or browse the full season schedule.
|
||||
<LayoutContainer>
|
||||
<div className="relative py-20 sm:py-28 lg:py-32 max-w-3xl">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-amber-300/85 mb-6">
|
||||
{brandName} · Summer 2026
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
|
||||
<Link
|
||||
href="/tuxedo"
|
||||
className="inline-flex items-center gap-2 rounded-2xl bg-stone-950 px-6 py-3 text-sm font-semibold text-white hover:bg-stone-800 active:bg-stone-900 transition-colors"
|
||||
>
|
||||
Back to Tuxedo Corn
|
||||
</Link>
|
||||
<h1
|
||||
className="font-display text-stone-50 text-[clamp(2.5rem,5.4vw,4.5rem)] leading-[1.02] tracking-[-0.02em]"
|
||||
style={{ textWrap: "balance" }}
|
||||
>
|
||||
Stops
|
||||
<br className="hidden sm:block" />{" "}
|
||||
<span className="italic text-amber-200/90">near you.</span>
|
||||
</h1>
|
||||
<div className="mt-8 h-px w-16 bg-gradient-to-r from-emerald-500 via-amber-400 to-amber-300/0" />
|
||||
<p className="mt-8 text-stone-300 text-lg sm:text-xl leading-[1.65]">
|
||||
Fresh Olathe Sweet™ sweet corn delivered to a stop
|
||||
near you. Preorder for pickup, or just show up — we’ll
|
||||
have coolers on the truck.
|
||||
</p>
|
||||
<div className="mt-10 flex flex-wrap items-center gap-x-8 gap-y-3 text-[11px] uppercase tracking-[0.28em]">
|
||||
<span className="text-amber-200/80">
|
||||
{upcomingStops.length} upcoming
|
||||
</span>
|
||||
<span className="text-stone-700">·</span>
|
||||
<span className="text-stone-400">Updated weekly</span>
|
||||
<span className="text-stone-700">·</span>
|
||||
<Link
|
||||
href="/api/tuxedo/schedule-pdf"
|
||||
download
|
||||
className="inline-flex items-center gap-2 rounded-2xl bg-white px-6 py-3 text-sm font-semibold text-stone-700 ring-1 ring-stone-200 hover:bg-stone-50 transition-colors"
|
||||
className="text-stone-400 hover:text-amber-200/80 underline underline-offset-4 transition-colors"
|
||||
>
|
||||
Download Full Schedule
|
||||
Download full schedule
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-4xl mx-auto space-y-3">
|
||||
{upcomingStops.map((stop) => (
|
||||
<Link
|
||||
key={stop.id}
|
||||
href={`/${brandSlug}/stops/${stop.slug}`}
|
||||
className="stop-card group block bg-white rounded-2xl p-5 shadow-sm ring-1 ring-stone-200/60 transition-all duration-300 hover:shadow-lg hover:ring-emerald-200"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 flex flex-col items-center justify-center text-white shadow-md shadow-emerald-500/20">
|
||||
<span className="text-[10px] font-bold uppercase opacity-80">
|
||||
{new Date(stop.date).toLocaleDateString("en-US", { month: "short" })}
|
||||
</span>
|
||||
<span className="text-xl font-black leading-none">
|
||||
{new Date(stop.date).getDate()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-bold text-stone-950 group-hover:text-emerald-700 transition-colors">
|
||||
{stop.city}, {stop.state}
|
||||
</h3>
|
||||
<p className="text-sm text-stone-500 truncate">{stop.location}</p>
|
||||
{stop.address && (
|
||||
<p className="text-xs text-stone-400 truncate">{stop.address}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold text-stone-700">{stop.time}</p>
|
||||
{stop.cutoff_time && (
|
||||
<p className="text-xs text-stone-400">Order by {new Date(stop.cutoff_time).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-emerald-50 flex items-center justify-center group-hover:bg-emerald-500 transition-colors">
|
||||
<svg className="w-5 h-5 text-emerald-600 group-hover:text-white transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{pastStops.length > 0 && (
|
||||
<div className="mt-8 pt-8 border-t border-stone-200">
|
||||
<h2 className="text-sm font-bold uppercase tracking-widest text-stone-400 mb-4">Past Stops</h2>
|
||||
<div className="space-y-2 opacity-60">
|
||||
{pastStops.slice(0, 5).map((stop) => (
|
||||
<div
|
||||
key={stop.id}
|
||||
className="flex items-center gap-4 p-4 rounded-xl bg-white/50 text-stone-500"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-stone-200 flex items-center justify-center text-xs font-bold text-stone-500">
|
||||
{new Date(stop.date).toLocaleDateString("en-US", { month: "short", day: "numeric" })}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{stop.city}, {stop.state}</p>
|
||||
<p className="text-xs">{stop.location}</p>
|
||||
</div>
|
||||
<span className="text-xs">Completed</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Stops list — hairline-ruled magazine sidebar style. Each
|
||||
row is a single horizontal hairline + date numerals + city
|
||||
+ location + time + chevron. No cards, no gradients. */}
|
||||
<section className="relative bg-stone-50 py-20 sm:py-24">
|
||||
<LayoutContainer>
|
||||
{upcomingStops.length === 0 ? (
|
||||
<EmptyStops />
|
||||
) : (
|
||||
<div className="mx-auto max-w-5xl">
|
||||
{/* Eyebrow + hairline-ruled list header */}
|
||||
<div className="flex items-baseline justify-between gap-4 mb-8">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-emerald-700">
|
||||
Upcoming
|
||||
</p>
|
||||
<p className="text-[10px] uppercase tracking-[0.3em] text-stone-400 tabular-nums">
|
||||
{upcomingStops.length} stops
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</LayoutContainer>
|
||||
|
||||
<ol className="border-t border-stone-900/15">
|
||||
{upcomingStops.map((stop) => (
|
||||
<StopRow key={stop.id} stop={stop} brandSlug={brandSlug} />
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{pastStops.length > 0 && (
|
||||
<div className="mt-20">
|
||||
<div className="flex items-baseline justify-between gap-4 mb-8">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.32em] text-stone-400">
|
||||
Past Stops
|
||||
</p>
|
||||
<p className="text-[10px] uppercase tracking-[0.3em] text-stone-400 tabular-nums">
|
||||
Last {Math.min(5, pastStops.length)}
|
||||
</p>
|
||||
</div>
|
||||
<ol className="border-t border-stone-900/10 opacity-70">
|
||||
{pastStops.slice(0, 5).map((stop) => (
|
||||
<StopRow
|
||||
key={stop.id}
|
||||
stop={stop}
|
||||
brandSlug={brandSlug}
|
||||
past
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<StorefrontFooter brandName={brandName} brandSlug={brandSlug} brandAccent="green" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── STOP ROW — hairline-ruled magazine-sidebar cell ────────────────
|
||||
// Date numerals on the left (large tabular day, small month above),
|
||||
// city/state + location + time in the middle, chevron on the right.
|
||||
// All hairline-divided rows; the list reads as a sidebar, not a feed.
|
||||
function StopRow({
|
||||
stop,
|
||||
brandSlug,
|
||||
past = false,
|
||||
}: {
|
||||
stop: PublicStop;
|
||||
brandSlug: string;
|
||||
past?: boolean;
|
||||
}) {
|
||||
const date = new Date(stop.date);
|
||||
const day = date.getDate();
|
||||
const month = date.toLocaleDateString("en-US", { month: "short" });
|
||||
const weekday = date.toLocaleDateString("en-US", { weekday: "short" });
|
||||
|
||||
const content = (
|
||||
<div className="grid grid-cols-12 gap-4 sm:gap-6 items-center py-5 sm:py-6 group">
|
||||
{/* Date column — big tabular day, small month/weekday above */}
|
||||
<div className="col-span-3 sm:col-span-2">
|
||||
<p
|
||||
className={`text-[10px] uppercase tracking-[0.28em] tabular-nums ${
|
||||
past ? "text-stone-400" : "text-emerald-700"
|
||||
}`}
|
||||
>
|
||||
{weekday} · {month}
|
||||
</p>
|
||||
<p
|
||||
className={`mt-1 font-display tabular-nums leading-none tracking-[-0.02em] ${
|
||||
past
|
||||
? "text-stone-400 text-3xl sm:text-4xl"
|
||||
: "text-stone-950 text-4xl sm:text-5xl"
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* City + location */}
|
||||
<div className="col-span-9 sm:col-span-6 min-w-0">
|
||||
<h3
|
||||
className={`font-display leading-[1.15] tracking-[-0.012em] ${
|
||||
past
|
||||
? "text-stone-500 text-base sm:text-lg"
|
||||
: "text-stone-950 text-lg sm:text-xl group-hover:text-emerald-800 transition-colors"
|
||||
}`}
|
||||
>
|
||||
{stop.city}, {stop.state}
|
||||
</h3>
|
||||
<p
|
||||
className={`mt-1 truncate ${past ? "text-stone-400 text-sm" : "text-stone-500 text-sm"}`}
|
||||
>
|
||||
{stop.location}
|
||||
</p>
|
||||
{stop.address && (
|
||||
<p
|
||||
className={`mt-0.5 truncate ${past ? "text-stone-400 text-xs" : "text-stone-400 text-xs"}`}
|
||||
>
|
||||
{stop.address}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Time + cutoff */}
|
||||
<div className="col-span-8 sm:col-span-3 text-right">
|
||||
<p
|
||||
className={`font-mono tabular-nums text-sm ${
|
||||
past ? "text-stone-400" : "text-stone-950"
|
||||
}`}
|
||||
>
|
||||
{stop.time}
|
||||
</p>
|
||||
{!past && stop.cutoff_time && (
|
||||
<p className="mt-1 text-[10px] uppercase tracking-[0.2em] text-stone-400">
|
||||
Order by{" "}
|
||||
{new Date(stop.cutoff_time).toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{past && (
|
||||
<p className="mt-1 text-[10px] uppercase tracking-[0.2em] text-stone-400">
|
||||
Completed
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chevron — only on upcoming rows */}
|
||||
<div className="hidden sm:flex col-span-1 justify-end">
|
||||
{!past && (
|
||||
<svg
|
||||
className="w-4 h-4 text-stone-300 group-hover:text-emerald-700 group-hover:translate-x-1 transition-all"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const inner = (
|
||||
<li className="stop-row border-b border-stone-900/15 last:border-b-0">
|
||||
{content}
|
||||
</li>
|
||||
);
|
||||
|
||||
if (past) {
|
||||
return inner;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/${brandSlug}/stops/${stop.slug}`}
|
||||
className="stop-row block border-b border-stone-900/15 last:border-b-0 hover:bg-stone-100/60 transition-colors"
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ── EMPTY STATE — editorial Fraunces italic copy ──────────────────
|
||||
// Same "no stops yet" message as before, but rendered as a quiet
|
||||
// editorial panel instead of an icon-in-rounded-box illustration.
|
||||
function EmptyStops() {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl text-center py-12">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-emerald-700 mb-5">
|
||||
Coming Soon
|
||||
</p>
|
||||
<h2
|
||||
className="font-display text-stone-950 text-[clamp(2rem,4.4vw,3.25rem)] leading-[1.05] tracking-[-0.02em]"
|
||||
style={{ textWrap: "balance" }}
|
||||
>
|
||||
The corn is still ripening
|
||||
<br className="hidden sm:block" />{" "}
|
||||
<span className="italic text-amber-700/90">in the field.</span>
|
||||
</h2>
|
||||
<div className="mx-auto mt-7 h-px w-12 bg-emerald-700/60" />
|
||||
<p className="mt-7 text-stone-600 text-lg leading-[1.65]">
|
||||
New pickup stops are added every week. Check back soon, or browse
|
||||
the full season schedule below.
|
||||
</p>
|
||||
<div className="mt-10 flex flex-col sm:flex-row items-center justify-center gap-3">
|
||||
<Link
|
||||
href="/tuxedo"
|
||||
className="group inline-flex items-center gap-3 rounded-full bg-gradient-to-br from-emerald-700 to-emerald-600 px-8 py-3.5 text-sm font-bold text-white shadow-[0_8px_32px_rgba(16,185,129,0.3),inset_0_1px_0_rgba(255,255,255,0.2)] hover:-translate-y-0.5 transition-all duration-200"
|
||||
>
|
||||
<span>Back to Tuxedo Corn</span>
|
||||
<svg
|
||||
className="w-4 h-4 transition-transform group-hover:translate-x-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
href="/api/tuxedo/schedule-pdf"
|
||||
download
|
||||
className="inline-flex items-center gap-2 rounded-full bg-white px-8 py-3.5 text-sm font-bold text-stone-900 ring-1 ring-stone-200 hover:ring-emerald-600 hover:text-emerald-700 transition-all"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
Download Schedule
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+257
-108
@@ -10,6 +10,7 @@ import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
||||
import LayoutContainer from "@/components/layout/LayoutContainer";
|
||||
import { getStorefrontStopById } from "@/actions/storefront";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { FadeOnScroll } from "@/components/ui/ScrollAnimations";
|
||||
|
||||
type Product = {
|
||||
id: string;
|
||||
@@ -31,30 +32,25 @@ type Stop = {
|
||||
time: string;
|
||||
location: string;
|
||||
brand_id: string;
|
||||
cutoff_time?: string | null;
|
||||
address?: string | null;
|
||||
slug?: string;
|
||||
};
|
||||
|
||||
export default function StopPage() {
|
||||
const BRAND_NAME = "Tuxedo Corn";
|
||||
const BRAND_SLUG = "tuxedo";
|
||||
|
||||
export default function TuxedoStopPage() {
|
||||
const params = useParams();
|
||||
const slug = params.id as string;
|
||||
|
||||
const [stop, setStop] = useState<{
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: string;
|
||||
time: string;
|
||||
location: string;
|
||||
brand_id: string;
|
||||
} | null>(null);
|
||||
const [stop, setStop] = useState<Stop | null>(null);
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [brandSlug, setBrandSlug] = useState("tuxedo");
|
||||
const [brandAccent, setBrandAccent] = useState<"green" | "orange" | "blue">("green");
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const result = await getStorefrontStopById(slug);
|
||||
if (!result) return;
|
||||
|
||||
setStop(result.stop as unknown as Stop);
|
||||
setProducts(result.products as unknown as Product[]);
|
||||
}
|
||||
@@ -64,38 +60,89 @@ export default function StopPage() {
|
||||
if (!stop) {
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
<StorefrontHeader brandName="Tuxedo Corn" brandSlug="tuxedo" brandAccent="green" />
|
||||
<main className="py-20">
|
||||
<StorefrontHeader
|
||||
brandName={BRAND_NAME}
|
||||
brandSlug={BRAND_SLUG}
|
||||
brandAccent="green"
|
||||
/>
|
||||
<main className="py-32">
|
||||
<LayoutContainer>
|
||||
<div className="max-w-5xl mx-auto text-center">
|
||||
<h1 className="text-5xl font-black tracking-tight text-stone-950">Stop Not Found</h1>
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-amber-700 mb-5">
|
||||
Off the Route
|
||||
</p>
|
||||
<h1
|
||||
className="font-display text-stone-950 text-[clamp(2rem,4.4vw,3.5rem)] leading-[1.04] tracking-[-0.02em]"
|
||||
style={{ textWrap: "balance" }}
|
||||
>
|
||||
That stop isn’t{" "}
|
||||
<span className="italic text-amber-700/90">on the calendar.</span>
|
||||
</h1>
|
||||
<div className="mx-auto mt-7 h-px w-12 bg-emerald-700/60" />
|
||||
<p className="mt-7 text-stone-600 leading-[1.65]">
|
||||
It may have passed, or the URL is off by a character.
|
||||
Browse the full list to find a stop near you.
|
||||
</p>
|
||||
<div className="mt-10">
|
||||
<Link
|
||||
href="/tuxedo/stops"
|
||||
className="group inline-flex items-center gap-3 rounded-full bg-stone-950 px-8 py-3.5 text-sm font-bold text-white hover:bg-stone-800 transition-all"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 transition-transform group-hover:-translate-x-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"
|
||||
/>
|
||||
</svg>
|
||||
<span>All Stops</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</main>
|
||||
<StorefrontFooter
|
||||
brandName={BRAND_NAME}
|
||||
brandSlug={BRAND_SLUG}
|
||||
brandAccent="green"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isBlue = brandAccent === "blue";
|
||||
const brandLabel = isBlue ? "Indian River Direct" : "Tuxedo Corn";
|
||||
const date = new Date(stop.date);
|
||||
const day = date.getDate();
|
||||
const weekday = date.toLocaleDateString("en-US", { weekday: "long" });
|
||||
const monthYear = date.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
<StopSetEffect stop={stop} />
|
||||
<StorefrontHeader
|
||||
brandName={brandLabel}
|
||||
brandSlug={brandSlug}
|
||||
brandAccent={brandAccent}
|
||||
brandName={BRAND_NAME}
|
||||
brandSlug={BRAND_SLUG}
|
||||
brandAccent="green"
|
||||
/>
|
||||
|
||||
<main className="py-16 md:py-20">
|
||||
<LayoutContainer>
|
||||
<div className="max-w-5xl mx-auto">
|
||||
|
||||
{/* Back navigation */}
|
||||
<div className="mb-10 flex items-center gap-2">
|
||||
<main>
|
||||
{/* Back nav — kept slim, no white card wrapper. */}
|
||||
<div className="bg-stone-50 border-b border-stone-200/60">
|
||||
<LayoutContainer>
|
||||
<div className="py-5 flex items-center gap-2 text-sm">
|
||||
<Link
|
||||
href={`/${brandSlug}#stops`}
|
||||
className="flex items-center gap-2 text-sm font-medium text-stone-500 hover:text-stone-800 transition-colors group"
|
||||
href={`/${BRAND_SLUG}#stops`}
|
||||
className="flex items-center gap-2 text-stone-500 hover:text-stone-950 transition-colors group"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4 transition-transform duration-200 group-hover:-translate-x-1"
|
||||
@@ -103,89 +150,164 @@ export default function StopPage() {
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"
|
||||
/>
|
||||
</svg>
|
||||
All Stops
|
||||
</Link>
|
||||
<span className="text-stone-300">/</span>
|
||||
<span className="text-sm text-stone-700 font-medium">{stop.city}, {stop.state}</span>
|
||||
<span className="text-stone-700 font-medium truncate">
|
||||
{stop.city}, {stop.state}
|
||||
</span>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</div>
|
||||
|
||||
{/* Stop header */}
|
||||
<div className="mb-12">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-widest text-emerald-600 mb-4">
|
||||
{brandLabel}
|
||||
</p>
|
||||
<h1 className="text-5xl md:text-7xl font-black tracking-tight text-stone-950 leading-[1.0]">
|
||||
{stop.city},<br className="hidden md:block" /> {stop.state}
|
||||
</h1>
|
||||
<p className="mt-5 max-w-xl text-lg text-stone-500 leading-relaxed">
|
||||
{isBlue
|
||||
? "Order fresh Florida citrus for pickup at this stop."
|
||||
: "Order fresh Olathe Sweet™ sweet corn for pickup at this stop."}
|
||||
</p>
|
||||
<div className="mt-6 h-px w-12 bg-emerald-600" />
|
||||
</div>
|
||||
{/* Hero — dark editorial opener. Eyebrow + huge Fraunces
|
||||
city/state headline with the date numerals set as part of
|
||||
the type composition, not as a separate stat block. */}
|
||||
<section className="relative bg-stone-950 overflow-hidden isolate">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 bottom-0 h-px bg-amber-300/30 z-10"
|
||||
/>
|
||||
|
||||
{/* Stop info */}
|
||||
<div className="rounded-3xl bg-white p-8 mb-14 shadow-sm ring-1 ring-stone-200/60 transition-all duration-300 hover:shadow-xl hover:ring-stone-300/60">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="flex items-start gap-4 py-4 px-5 rounded-2xl bg-stone-50">
|
||||
<div className={`flex-shrink-0 w-10 h-10 rounded-xl flex items-center justify-center ${isBlue ? "bg-blue-50" : "bg-emerald-50"}`}>
|
||||
<svg className={`h-5 w-5 ${isBlue ? "text-blue-500" : "text-emerald-600"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-1">Date</p>
|
||||
<p className="font-bold text-stone-950">{formatDate(stop.date)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4 py-4 px-5 rounded-2xl bg-stone-50">
|
||||
<div className={`flex-shrink-0 w-10 h-10 rounded-xl flex items-center justify-center ${isBlue ? "bg-blue-50" : "bg-emerald-50"}`}>
|
||||
<svg className={`h-5 w-5 ${isBlue ? "text-blue-500" : "text-emerald-600"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-1">Time</p>
|
||||
<p className="font-bold text-stone-950">{stop.time}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4 py-4 px-5 rounded-2xl bg-stone-50">
|
||||
<div className={`flex-shrink-0 w-10 h-10 rounded-xl flex items-center justify-center ${isBlue ? "bg-blue-50" : "bg-emerald-50"}`}>
|
||||
<svg className={`h-5 w-5 ${isBlue ? "text-blue-500" : "text-emerald-600"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-1">Location</p>
|
||||
<p className="font-bold text-stone-950 leading-tight">{stop.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
<LayoutContainer>
|
||||
<div className="relative py-20 sm:py-28 lg:py-32 grid grid-cols-1 lg:grid-cols-12 gap-10 lg:gap-16 items-end">
|
||||
{/* Left: copy + headline */}
|
||||
<div className="lg:col-span-8">
|
||||
<FadeOnScroll from="up">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-amber-300/85 mb-6">
|
||||
Pickup Stop · {monthYear}
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.08}>
|
||||
<h1
|
||||
className="font-display text-stone-50 text-[clamp(2.75rem,7vw,5.5rem)] leading-[0.98] tracking-[-0.025em]"
|
||||
style={{ textWrap: "balance" }}
|
||||
>
|
||||
{stop.city},
|
||||
<br className="hidden sm:block" />{" "}
|
||||
<span className="italic text-amber-200/90">
|
||||
{stop.state}.
|
||||
</span>
|
||||
</h1>
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.18}>
|
||||
<div className="mt-8 h-px w-16 bg-gradient-to-r from-emerald-500 via-amber-400 to-amber-300/0" />
|
||||
</FadeOnScroll>
|
||||
|
||||
<FadeOnScroll from="up" delay={0.26}>
|
||||
<p className="mt-8 text-stone-300 text-lg sm:text-xl leading-[1.65] max-w-xl">
|
||||
Order fresh Olathe Sweet™ sweet corn for pickup
|
||||
at this stop. We’ll have it on the truck the
|
||||
morning of — ready when you are.
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
|
||||
{/* Right: date as a big editorial stamp. */}
|
||||
<div className="lg:col-span-4 lg:text-right">
|
||||
<FadeOnScroll from="right">
|
||||
<p className="text-[10px] uppercase tracking-[0.32em] text-amber-300/70 mb-2 tabular-nums">
|
||||
{weekday}
|
||||
</p>
|
||||
<p className="font-display tabular-nums text-stone-50 text-[clamp(4rem,8vw,6.5rem)] leading-none tracking-[-0.03em]">
|
||||
{day}
|
||||
</p>
|
||||
<p className="mt-2 text-[11px] uppercase tracking-[0.28em] text-stone-400 tabular-nums">
|
||||
{date.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})}
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
|
||||
{/* Stop info — hairline-ruled four-column data grid. Date /
|
||||
Time / Location / Order by. Same magazine-sidebar register
|
||||
as the homepage's HarvestEditorial numbers strip. */}
|
||||
<section className="relative bg-stone-50 py-16 sm:py-20 border-b border-stone-200/60">
|
||||
<LayoutContainer>
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<ol className="grid grid-cols-2 lg:grid-cols-4 gap-y-10 lg:gap-y-0 lg:gap-x-10 border-t border-b border-stone-900/15">
|
||||
<StopInfoPoint label="Date" detail={formatDate(stop.date)} />
|
||||
<StopInfoPoint label="Time" detail={stop.time} mono />
|
||||
<StopInfoPoint label="Location" detail={stop.location} />
|
||||
<StopInfoPoint
|
||||
label="Order By"
|
||||
detail={
|
||||
stop.cutoff_time
|
||||
? new Date(stop.cutoff_time).toLocaleTimeString(
|
||||
"en-US",
|
||||
{ hour: "numeric", minute: "2-digit" }
|
||||
)
|
||||
: "Day-of"
|
||||
}
|
||||
mono
|
||||
/>
|
||||
</ol>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
|
||||
{/* Available Products — cream section with editorial framing,
|
||||
same register as the homepage ProductsSection header. */}
|
||||
<section className="relative bg-stone-50 py-24 sm:py-32">
|
||||
<LayoutContainer>
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<header className="mb-14 sm:mb-20 grid grid-cols-1 lg:grid-cols-12 gap-x-10 gap-y-6 items-end">
|
||||
<div className="lg:col-span-7">
|
||||
<FadeOnScroll from="up">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-emerald-700 mb-5">
|
||||
Farm-Direct · This Stop
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.08}>
|
||||
<h2
|
||||
className="font-display text-stone-950 text-[clamp(2rem,4.4vw,3.5rem)] leading-[1.04] tracking-[-0.02em]"
|
||||
style={{ textWrap: "balance" }}
|
||||
>
|
||||
Preorder
|
||||
<br className="hidden sm:block" />{" "}
|
||||
<span className="italic text-amber-700/90">
|
||||
for this stop.
|
||||
</span>
|
||||
</h2>
|
||||
</FadeOnScroll>
|
||||
<FadeOnScroll from="up" delay={0.16}>
|
||||
<div className="mt-7 h-px w-12 bg-emerald-700/60" />
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
<div className="lg:col-span-5">
|
||||
<FadeOnScroll from="up" delay={0.2}>
|
||||
<p className="text-stone-600 text-base leading-[1.7] lg:max-w-md lg:ml-auto">
|
||||
Add items below and we’ll have them ready at
|
||||
the stop. Preordering helps us bring the right
|
||||
amount of corn to each pickup.
|
||||
</p>
|
||||
</FadeOnScroll>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Available Products — editorial header */}
|
||||
<section>
|
||||
<div className="mb-10">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-widest text-emerald-600 mb-4">Farm-Direct</p>
|
||||
<h2 className="text-4xl md:text-5xl font-black tracking-tight text-stone-950 leading-tight">
|
||||
Available Products
|
||||
</h2>
|
||||
<div className="mt-5 h-px w-12 bg-emerald-600" />
|
||||
<p className="mt-5 text-base text-stone-500 leading-relaxed">
|
||||
Preorder for pickup — add items below and we will have them ready at the stop.
|
||||
</p>
|
||||
</div>
|
||||
{products.length === 0 ? (
|
||||
<div className="rounded-3xl bg-white p-12 text-center shadow-sm ring-1 ring-stone-200/60">
|
||||
<p className="text-stone-500">No products available for this stop.</p>
|
||||
<div className="border-t border-b border-stone-900/15 py-16 text-center">
|
||||
<p className="text-stone-500">
|
||||
No products available for this stop yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-8 md:grid-cols-3">
|
||||
<div className="grid gap-x-8 gap-y-12 md:grid-cols-2 lg:grid-cols-3">
|
||||
{products.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
@@ -195,26 +317,53 @@ export default function StopPage() {
|
||||
price={`$${product.price}`}
|
||||
type={product.type}
|
||||
imageUrl={product.image_url}
|
||||
brandSlug={brandSlug}
|
||||
brandName={brandLabel}
|
||||
brandSlug={BRAND_SLUG}
|
||||
brandName={BRAND_NAME}
|
||||
brandId={product.brand_id}
|
||||
brandAccent={brandAccent}
|
||||
brandAccent="green"
|
||||
is_taxable={product.is_taxable}
|
||||
pickup_type={product.pickup_type}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<StorefrontFooter
|
||||
brandName={brandLabel}
|
||||
brandSlug={brandSlug}
|
||||
brandAccent={brandAccent}
|
||||
brandName={BRAND_NAME}
|
||||
brandSlug={BRAND_SLUG}
|
||||
brandAccent="green"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// One column of the stop-info data strip — same hairline + label
|
||||
// treatment as the homepage's HarvestEditorial DataPoint.
|
||||
function StopInfoPoint({
|
||||
label,
|
||||
detail,
|
||||
mono = false,
|
||||
}: {
|
||||
label: string;
|
||||
detail: string;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<li className="relative pt-8 lg:pt-12">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.3em] text-emerald-700">
|
||||
{label}
|
||||
</p>
|
||||
<p
|
||||
className={`mt-4 text-stone-950 text-base leading-[1.55] ${
|
||||
mono ? "font-mono tabular-nums tracking-tight" : ""
|
||||
}`}
|
||||
>
|
||||
{detail}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import type { Metadata, Viewport } from "next";
|
||||
import WaterFieldClient from "@/components/water/WaterFieldClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Water Log · Tuxedo Ditch Co.",
|
||||
title: "Water Log · Tuxedo Corn",
|
||||
description:
|
||||
"Field log of headgate water readings — PIN-protected, mobile-only.",
|
||||
robots: {
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { m as motion } from "framer-motion";
|
||||
import LayoutContainer from "@/components/layout/LayoutContainer";
|
||||
|
||||
export default function CTASection() {
|
||||
return (
|
||||
<section className="py-20 bg-white relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-stone-100 to-transparent" />
|
||||
|
||||
<LayoutContainer>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 48 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-80px" }}
|
||||
transition={{ duration: 0.8, ease: [0.22, 0.61, 0.36, 1] }}
|
||||
className="relative text-center"
|
||||
>
|
||||
<h2 className="text-3xl md:text-4xl font-black tracking-tight text-stone-950">Ready to Order?</h2>
|
||||
<p className="mt-4 text-stone-500 text-lg">Experience the difference four decades of excellence makes.</p>
|
||||
|
||||
<div className="mt-10 flex flex-wrap justify-center gap-4">
|
||||
<Link
|
||||
href="/tuxedo#stops"
|
||||
className="group relative inline-flex items-center gap-3 rounded-full bg-stone-900 px-8 py-4 font-bold text-white overflow-hidden"
|
||||
>
|
||||
<span className="absolute inset-0 bg-gradient-to-r from-emerald-700 to-emerald-600 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<span className="relative flex items-center gap-2">
|
||||
Find a Stop
|
||||
<svg className="w-5 h-5 group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 8.25L21 12m0 0l-3.75 3.75M21 12H3" />
|
||||
</svg>
|
||||
</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/tuxedo#products"
|
||||
className="group relative inline-flex items-center gap-3 rounded-full bg-white px-8 py-4 font-bold text-stone-900 ring-2 ring-stone-200 hover:ring-emerald-600 hover:text-emerald-700 transition-all"
|
||||
>
|
||||
<span className="relative">
|
||||
Shop Products
|
||||
<svg className="inline w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 8.25L21 12m0 0l-3.75 3.75M21 12H3" />
|
||||
</svg>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { m as motion } from "framer-motion";
|
||||
import LayoutContainer from "@/components/layout/LayoutContainer";
|
||||
|
||||
interface ContactSectionProps {
|
||||
address: string;
|
||||
}
|
||||
|
||||
export default function ContactSection({ address }: ContactSectionProps) {
|
||||
return (
|
||||
<section className="py-24 md:py-32 bg-stone-950 text-white relative overflow-hidden">
|
||||
<div className="absolute inset-0">
|
||||
<div className="absolute top-0 left-1/3 w-96 h-96 bg-emerald-900/20 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-0 right-1/3 w-80 h-80 bg-amber-900/10 rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<LayoutContainer>
|
||||
<div className="relative">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 48 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-80px" }}
|
||||
transition={{ duration: 0.8, ease: [0.22, 0.61, 0.36, 1] }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.25em] text-emerald-400/60">Get in Touch</span>
|
||||
<h2 className="mt-4 text-4xl md:text-5xl font-black tracking-tight text-white leading-tight">
|
||||
Visit Our Farm
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-12 max-w-4xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 48 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-80px" }}
|
||||
transition={{ duration: 0.8, delay: 0.1, ease: [0.22, 0.61, 0.36, 1] }}
|
||||
className="text-center group"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-2xl bg-stone-800 flex items-center justify-center mx-auto mb-6 group-hover:bg-emerald-900 transition-colors">
|
||||
<svg className="w-7 h-7 text-stone-400 group-hover:text-emerald-400 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-[11px] font-bold uppercase tracking-widest text-stone-500 mb-3">Address</p>
|
||||
<p className="text-stone-300 leading-relaxed">{address}</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 48 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-80px" }}
|
||||
transition={{ duration: 0.8, delay: 0.2, ease: [0.22, 0.61, 0.36, 1] }}
|
||||
className="text-center group"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-2xl bg-stone-800 flex items-center justify-center mx-auto mb-6 group-hover:bg-emerald-900 transition-colors">
|
||||
<svg className="w-7 h-7 text-stone-400 group-hover:text-emerald-400 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 01-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-[11px] font-bold uppercase tracking-widest text-stone-500 mb-3">Phone</p>
|
||||
<p className="text-stone-300">Shipping: 970-323-5631</p>
|
||||
<p className="text-stone-300 mt-1">Office: 970-323-6874</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 48 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-80px" }}
|
||||
transition={{ duration: 0.8, delay: 0.3, ease: [0.22, 0.61, 0.36, 1] }}
|
||||
className="text-center group"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-2xl bg-stone-800 flex items-center justify-center mx-auto mb-6 group-hover:bg-emerald-900 transition-colors">
|
||||
<svg className="w-7 h-7 text-stone-400 group-hover:text-emerald-400 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-[11px] font-bold uppercase tracking-widest text-stone-500 mb-3">Hours</p>
|
||||
<p className="text-stone-300">M–F: 7AM–6PM</p>
|
||||
<p className="text-stone-300 mt-1">Sat & Sun: 8AM–2PM</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { m as motion } from "framer-motion";
|
||||
import LayoutContainer from "@/components/layout/LayoutContainer";
|
||||
|
||||
const FAMILY_MEMBERS = [
|
||||
{
|
||||
initials: "JH",
|
||||
name: "John Harold",
|
||||
role: "Founder",
|
||||
bio: "John Harold began selling Olathe's sweet corn directly from pickups in the 1970s. With unwavering dedication to community, family, and agriculture, he built Tuxedo Corn into a thriving family operation. Prior to farming, John established the first Group Home on the Western Slope and opened Colorow Care Center in Montrose County.",
|
||||
era: "1970s",
|
||||
},
|
||||
{
|
||||
initials: "DH",
|
||||
name: "David Harold",
|
||||
role: "Second Generation",
|
||||
bio: "David leads sustainability efforts across Harold farms, implementing drip irrigation and joining the Fair Food Program to ensure responsible labor practices. He continues the family's commitment to raising top-quality corn while protecting the land for future generations.",
|
||||
era: "Present",
|
||||
},
|
||||
{
|
||||
initials: "JW",
|
||||
name: "John William Harold",
|
||||
role: "Mexico Operations",
|
||||
bio: "John William has worked in Guaymas, Sonora, Mexico for over three decades growing sweet corn, onions, wheat, and vegetables. His international operations extend the Harold family farming tradition across borders.",
|
||||
era: "1990s–Present",
|
||||
},
|
||||
{
|
||||
initials: "JH",
|
||||
name: "Joseph Harold",
|
||||
role: "Third Generation",
|
||||
bio: "Joseph works at the corn shed during summer and handles photography and beverages, carrying the family tradition into the next generation while learning every aspect of the business.",
|
||||
era: "Present",
|
||||
},
|
||||
];
|
||||
|
||||
// Family member card with hover effects
|
||||
function FamilyCard({ member, index }: { member: typeof FAMILY_MEMBERS[0]; index: number }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 32 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-60px" }}
|
||||
transition={{ duration: 0.6, delay: index * 0.12, ease: [0.22, 0.61, 0.36, 1] }}
|
||||
whileHover={{ y: -6 }}
|
||||
className="group relative"
|
||||
>
|
||||
{/* Era badge */}
|
||||
<div className="absolute -top-3 -left-3 z-10">
|
||||
<span className="inline-block bg-stone-900 text-stone-200 text-[10px] font-bold uppercase tracking-widest px-3 py-1.5 rounded-full shadow-lg">
|
||||
{member.era}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative bg-gradient-to-b from-white to-stone-50 rounded-3xl p-8 shadow-lg shadow-black/5 ring-1 ring-stone-200/40 overflow-hidden h-full">
|
||||
{/* Decorative top line */}
|
||||
<motion.div
|
||||
initial={{ scaleX: 0 }}
|
||||
whileHover={{ scaleX: 1 }}
|
||||
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||
className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-emerald-600 via-emerald-400 to-amber-500 origin-left"
|
||||
/>
|
||||
|
||||
{/* Avatar */}
|
||||
<div className="mb-6 relative">
|
||||
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-stone-900 to-stone-800 flex items-center justify-center shadow-xl shadow-stone-900/30">
|
||||
<span className="text-2xl font-black text-white tracking-tight">{member.initials}</span>
|
||||
</div>
|
||||
{/* Decorative corner accent */}
|
||||
<div className="absolute -bottom-2 -right-2 w-8 h-8 bg-emerald-600/20 rounded-full blur-sm" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div>
|
||||
<h3 className="text-xl font-black text-stone-950 tracking-tight">{member.name}</h3>
|
||||
<p className="mt-2 text-[11px] font-bold uppercase tracking-widest text-emerald-700">{member.role}</p>
|
||||
<p className="mt-4 text-sm leading-relaxed text-stone-600">{member.bio}</p>
|
||||
</div>
|
||||
|
||||
{/* Bottom accent */}
|
||||
<div className="absolute bottom-0 left-8 right-8 h-px bg-gradient-to-r from-transparent via-stone-200 to-transparent" />
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FamilyTimelineSection() {
|
||||
return (
|
||||
<section className="py-24 md:py-32 bg-stone-100 relative">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,rgba(16,100,50,0.08)_0%,transparent_50%)]" />
|
||||
|
||||
<LayoutContainer>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 48 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-80px" }}
|
||||
transition={{ duration: 0.8, ease: [0.22, 0.61, 0.36, 1] }}
|
||||
className="text-center mb-20"
|
||||
>
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.25em] text-emerald-700">The People Behind the Corn</span>
|
||||
<h2 className="mt-4 text-4xl md:text-5xl font-black tracking-tight text-stone-950 leading-tight">
|
||||
The Harold Family
|
||||
</h2>
|
||||
<div className="mt-8 mx-auto h-px w-24 bg-gradient-to-r from-emerald-600 via-emerald-400 to-transparent" />
|
||||
</motion.div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-8 max-w-5xl mx-auto">
|
||||
{FAMILY_MEMBERS.map((member, index) => (
|
||||
<FamilyCard key={member.name} member={member} index={index} />
|
||||
))}
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { m as motion } from "framer-motion";
|
||||
import LayoutContainer from "@/components/layout/LayoutContainer";
|
||||
|
||||
export default function MissionSection() {
|
||||
return (
|
||||
<section className="py-24 md:py-32 bg-stone-50 relative overflow-hidden">
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-emerald-100/30 rounded-full blur-3xl -z-10" />
|
||||
|
||||
<LayoutContainer>
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 48 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-80px" }}
|
||||
transition={{ duration: 0.8, ease: [0.22, 0.61, 0.36, 1] }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.25em] text-emerald-700">Our Mission</span>
|
||||
<h2 className="mt-4 text-4xl md:text-5xl font-black tracking-tight text-stone-950 leading-tight">
|
||||
Raising the Best Corn<br />in the World
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-12">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 48 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-80px" }}
|
||||
transition={{ duration: 0.8, delay: 0.15, ease: [0.22, 0.61, 0.36, 1] }}
|
||||
className="prose prose-lg"
|
||||
>
|
||||
<p className="text-stone-600 leading-relaxed text-lg">
|
||||
Tuxedo Corn is the <strong className="text-stone-900">exclusive grower and shipper</strong> of Olathe Sweet Sweet Corn — the only corn that carries that prestigious name. When you order from us, you receive the real thing: grown by the only family that planted roots in Olathe, Colorado over forty years ago.
|
||||
</p>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 48 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-80px" }}
|
||||
transition={{ duration: 0.8, delay: 0.25, ease: [0.22, 0.61, 0.36, 1] }}
|
||||
className="prose prose-lg"
|
||||
>
|
||||
<p className="text-stone-600 leading-relaxed text-lg">
|
||||
Not all sweet corn is equal. Olathe Sweet was developed for our high-altitude mountain climate, where <strong className="text-stone-900">intense summer sun by day meets cool mountain air at night</strong>. That combination coaxes extraordinary sweetness and tenderness out of every single ear.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 48 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-80px" }}
|
||||
transition={{ duration: 0.8, delay: 0.35, ease: [0.22, 0.61, 0.36, 1] }}
|
||||
className="mt-16 bg-white rounded-3xl p-10 shadow-lg shadow-stone-900/5 ring-1 ring-stone-200/50"
|
||||
>
|
||||
<div className="flex items-start gap-6">
|
||||
<div className="w-14 h-14 rounded-2xl bg-emerald-100 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-7 h-7 text-emerald-700" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-stone-950 mb-3">Grown with Future Generations in Mind</h3>
|
||||
<p className="text-stone-600 leading-relaxed">
|
||||
We farm regeneratively — no-till and minimal-till methods protect soil structure, cover crops naturally cycle nutrients, and reduced chemical inputs keep our mountain water pure. Every ear is hand-picked at peak freshness, inspected by our dedicated crew. Not machine-harvested. Not sorted by strangers. <strong className="text-stone-900">Hand-picked.</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</LayoutContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -143,6 +143,7 @@ type FormState = {
|
||||
showTextAlerts: boolean;
|
||||
schedulePdfNotes: string;
|
||||
heroImageUrl: string;
|
||||
heroVideoUrl: string;
|
||||
brandPrimaryColor: string;
|
||||
brandSecondaryColor: string;
|
||||
brandBgColor: string;
|
||||
@@ -189,6 +190,7 @@ function buildInitialFormState(settings: BrandSettings | null, brandName: string
|
||||
showTextAlerts: settings?.show_text_alerts ?? false,
|
||||
schedulePdfNotes: settings?.schedule_pdf_notes ?? "",
|
||||
heroImageUrl: settings?.hero_image_url ?? "",
|
||||
heroVideoUrl: settings?.hero_video_url ?? "",
|
||||
brandPrimaryColor: settings?.brand_primary_color ?? "#16a34a",
|
||||
brandSecondaryColor: settings?.brand_secondary_color ?? "#f5f5f4",
|
||||
brandBgColor: settings?.brand_bg_color ?? "#fafaf9",
|
||||
@@ -258,6 +260,7 @@ function BrandSettingsFormBody({
|
||||
showTextAlerts: state.showTextAlerts,
|
||||
schedulePdfNotes: state.schedulePdfNotes || undefined,
|
||||
heroImageUrl: state.heroImageUrl || undefined,
|
||||
heroVideoUrl: state.heroVideoUrl || undefined,
|
||||
brandPrimaryColor: state.brandPrimaryColor || undefined,
|
||||
brandSecondaryColor: state.brandSecondaryColor || undefined,
|
||||
brandBgColor: state.brandBgColor || undefined,
|
||||
@@ -324,6 +327,7 @@ function BrandSettingsFormBody({
|
||||
<StorefrontSection
|
||||
heroTagline={state.heroTagline}
|
||||
heroImageUrl={state.heroImageUrl}
|
||||
heroVideoUrl={state.heroVideoUrl}
|
||||
aboutHeadline={state.aboutHeadline}
|
||||
aboutSubheadline={state.aboutSubheadline}
|
||||
customFooterText={state.customFooterText}
|
||||
@@ -673,6 +677,7 @@ function EmailInvoiceSection({
|
||||
function StorefrontSection({
|
||||
heroTagline,
|
||||
heroImageUrl,
|
||||
heroVideoUrl,
|
||||
aboutHeadline,
|
||||
aboutSubheadline,
|
||||
customFooterText,
|
||||
@@ -680,6 +685,7 @@ function StorefrontSection({
|
||||
}: {
|
||||
heroTagline: string;
|
||||
heroImageUrl: string;
|
||||
heroVideoUrl: string;
|
||||
aboutHeadline: string;
|
||||
aboutSubheadline: string;
|
||||
customFooterText: string;
|
||||
@@ -719,6 +725,18 @@ function StorefrontSection({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AdminInput
|
||||
label="Hero Video URL (optional)"
|
||||
helpText="mp4 or webm. Plays muted, looped, full-bleed behind the hero copy. If unset or fails to load, the hero falls back to the Hero Image URL above."
|
||||
>
|
||||
<AdminTextInput
|
||||
type="url"
|
||||
value={heroVideoUrl}
|
||||
onChange={(e) => setField("heroVideoUrl", e.target.value)}
|
||||
placeholder="https://cdn.example.com/hero-corn-fields.mp4"
|
||||
/>
|
||||
</AdminInput>
|
||||
|
||||
<AdminInput label="About Page Headline">
|
||||
<AdminTextInput
|
||||
value={aboutHeadline}
|
||||
|
||||
@@ -158,7 +158,7 @@ function TodaySummary({
|
||||
{/* Hero gauge */}
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<div className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#1a4d2e]/70">
|
||||
Tuxedo Ditch Co.
|
||||
Tuxedo Corn
|
||||
</div>
|
||||
<WaterGauge
|
||||
size={120}
|
||||
|
||||
@@ -200,14 +200,19 @@ export function FieldPinScreen({
|
||||
// `100dvh` excludes iOS Safari's collapsing chrome; the legacy
|
||||
// `100vh` fallback keeps Android/older browsers full-bleed too.
|
||||
<div className="flex min-h-[100dvh] flex-col bg-[#F2F2F7]">
|
||||
{/* ── Branded header ───────────────────────────────────────── */}
|
||||
{/* ── Branded header (Apple HIG liquid glass) ───────────────── */}
|
||||
<header
|
||||
className="px-5 pb-3 pt-[max(env(safe-area-inset-top),1rem)]"
|
||||
style={{
|
||||
// Fades the chrome into the form below so the brand strap
|
||||
// feels suspended in the air rather than locked to the
|
||||
// top — same trick Apple uses on the iOS lock screen.
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(255,255,255,0.92) 0%, rgba(242,242,247,0) 100%)",
|
||||
backdropFilter: "blur(10px)",
|
||||
WebkitBackdropFilter: "blur(10px)",
|
||||
"linear-gradient(180deg, rgba(255,255,255,0.92) 0%, rgba(255,255,255,0.62) 60%, rgba(242,242,247,0) 100%)",
|
||||
backdropFilter: "blur(24px) saturate(180%)",
|
||||
WebkitBackdropFilter: "blur(24px) saturate(180%)",
|
||||
boxShadow:
|
||||
"inset 0 -0.5px 0 0 rgba(0,0,0,0.04)",
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto flex max-w-md items-center justify-center gap-2">
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useReducer, useRef } from "react";
|
||||
import { useEffect, useReducer, useRef, useState } from "react";
|
||||
import { m as motion } from "framer-motion";
|
||||
import { gsap } from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
|
||||
// Register GSAP plugins
|
||||
if (typeof window !== "undefined") {
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
}
|
||||
@@ -16,14 +15,19 @@ type TuxedoVideoHeroProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
olatheSweetLogoUrl?: string | null;
|
||||
/** URL of a video (mp4/webm) to play muted & looped as the hero backdrop. */
|
||||
videoUrl?: string | null;
|
||||
/** Image shown as the video poster and as the static fallback if the
|
||||
* video fails to load, is blocked by autoplay policies, or the user
|
||||
* prefers reduced motion. Without this, the gradient stack stands on
|
||||
* its own. */
|
||||
posterUrl?: string | null;
|
||||
primaryButton?: string;
|
||||
secondaryButton?: string;
|
||||
onPrimaryClick?: () => void;
|
||||
onSecondaryClick?: () => void;
|
||||
};
|
||||
|
||||
const VIDEO_URL = "/videos/tuxedo-hero.mp4";
|
||||
|
||||
const HERO_STATS = [
|
||||
{ stat: "40+", label: "Years Growing" },
|
||||
{ stat: "3", label: "Generations" },
|
||||
@@ -47,14 +51,25 @@ function reducer(state: State, action: Action): State {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// CINEMATIC HERO - SCROLL-DRIVEN ANIMATIONS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* The big thesis panel for the storefront. Composes, in z-order:
|
||||
* - poster image (Next/Image, full-bleed, always present)
|
||||
* - optional muted looping video (renders only when its URL loads)
|
||||
* - gradient overlays (warm gold -> deep night)
|
||||
* - foreground editorial content (logo / eyebrow / title / copy / CTAs / stats)
|
||||
*
|
||||
* The hero is designed to feel complete as a STILL even without the video.
|
||||
* If `videoUrl` is missing, errors, or autoplay is blocked, the poster
|
||||
* image stays as the background and the page is not left with an empty
|
||||
* dark hole.
|
||||
*/
|
||||
export default function TuxedoVideoHero({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
olatheSweetLogoUrl,
|
||||
videoUrl,
|
||||
posterUrl,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
onPrimaryClick,
|
||||
@@ -64,22 +79,41 @@ export default function TuxedoVideoHero({
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
// ─── GSAP SCROLL ANIMATIONS ────────────────────────────────────────────────
|
||||
// Motion pass: the original used a 1.15 video scale, an 80px y-shift on the
|
||||
// hero content, a parallax -100/+80 movement on decorative orbs, and a 1s
|
||||
// 40px slide-up entrance with power3.out easing. All of those are strong
|
||||
// vestibular triggers. The motion here is now:
|
||||
// - tiny entrance slide (8px) on first paint, no scale, 320ms, linear-ish ease
|
||||
// - video stays static on scroll (no parallax)
|
||||
// - decorative orbs float on a 10s CSS keyframe (gentle, opt-in to reduced motion)
|
||||
// - scroll indicator just fades on scroll, no positional shift
|
||||
// `videoPlaying` reflects whether the <video> successfully produced frames.
|
||||
// It can flip false at any time: 404, codec error, autoplay blocked, or
|
||||
// prefers-reduced-motion. We tear the video element out of the DOM when
|
||||
// it's false so the poster image underneath becomes the visible backdrop.
|
||||
const [videoPlaying, setVideoPlaying] = useState(false);
|
||||
|
||||
// ─── Lifecycle: gate autoplay off if the user prefers reduced motion. ────
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const mq = window.matchMedia?.("(prefers-reduced-motion: reduce)");
|
||||
if (!mq || !mq.matches) return;
|
||||
// Pause + suppress the video entirely for reduced-motion users.
|
||||
const v = videoRef.current;
|
||||
if (v && !v.paused) v.pause();
|
||||
}, []);
|
||||
|
||||
// ─── APPLE-QUALITY MOTION PASS ──────────────────────────────────────────
|
||||
// Choreographed entrance — logo → eyebrow → rule → title-words →
|
||||
// description → CTAs → stats. Each item: opacity 0→1 + Y 16→0 +
|
||||
// blur(6px)→0 with power3.out (≈ cubic-bezier(0.16, 1, 0.3, 1), the
|
||||
// curve Apple/Linear use for sheet handoffs and Vision Pro scroll).
|
||||
//
|
||||
// Scroll-fade now holds through the FULL hero (was 40% top — too
|
||||
// aggressive, content disappeared before user finished reading).
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !sectionRef.current) return;
|
||||
|
||||
const prefersReduced =
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
// Scroll progress tracking (the bar at the top of the page).
|
||||
ScrollTrigger.create({
|
||||
trigger: sectionRef.current,
|
||||
start: "top top",
|
||||
@@ -90,50 +124,74 @@ export default function TuxedoVideoHero({
|
||||
},
|
||||
});
|
||||
|
||||
// Hero content — gentle fade on scroll, NO positional shift.
|
||||
// Hero content — fade through the FULL scroll length so the type
|
||||
// holds its presence as the user reads. scrub: 0.6 keeps it
|
||||
// physically tied to the scrollbar, not floating away.
|
||||
if (contentRef.current) {
|
||||
gsap.to(contentRef.current, {
|
||||
opacity: 0,
|
||||
ease: "power1.out",
|
||||
ease: "none",
|
||||
scrollTrigger: {
|
||||
trigger: sectionRef.current,
|
||||
start: "top top",
|
||||
end: "40% top",
|
||||
scrub: 1.2,
|
||||
end: "bottom top",
|
||||
scrub: 0.6,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Video — no scale, no parallax. Just a static background.
|
||||
if (prefersReduced) {
|
||||
// Snap everything to end state. No choreography.
|
||||
gsap.set(".hero-reveal, .hero-word", {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
filter: "none",
|
||||
});
|
||||
} else {
|
||||
// Block-level cascade — logo first, then everything else.
|
||||
const reveals = gsap.utils.toArray<HTMLElement>(".hero-reveal");
|
||||
reveals.forEach((el, i) => {
|
||||
gsap.fromTo(
|
||||
el,
|
||||
{ opacity: 0, y: 16, filter: "blur(6px)" },
|
||||
{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
filter: "blur(0px)",
|
||||
duration: 0.55,
|
||||
delay: 0.05 + i * 0.08,
|
||||
ease: "power3.out",
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Staggered entrance for content elements — small Y, no scale, light ease.
|
||||
const contentElements = gsap.utils.toArray<Element>(".hero-reveal");
|
||||
contentElements.forEach((el) => {
|
||||
gsap.fromTo(
|
||||
el,
|
||||
{ opacity: 0, y: 8 },
|
||||
{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
duration: 0.32,
|
||||
ease: "power1.out",
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
start: "top 95%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
// Word-level cascade for the title — runs after the eyebrow +
|
||||
// rule have settled so the words appear once the runway is set.
|
||||
const titleWords = gsap.utils.toArray<HTMLElement>(".hero-word");
|
||||
if (titleWords.length) {
|
||||
gsap.fromTo(
|
||||
titleWords,
|
||||
{ opacity: 0, y: 18, filter: "blur(8px)" },
|
||||
{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
filter: "blur(0px)",
|
||||
duration: 0.6,
|
||||
delay: 0.35,
|
||||
stagger: 0.05,
|
||||
ease: "power3.out",
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll indicator — just fades, no bounce, no positional shift.
|
||||
gsap.to(".scroll-indicator", {
|
||||
opacity: 0,
|
||||
scrollTrigger: {
|
||||
trigger: sectionRef.current,
|
||||
start: "10% top",
|
||||
end: "30% top",
|
||||
scrub: 1,
|
||||
scrub: 0.5,
|
||||
},
|
||||
});
|
||||
}, sectionRef);
|
||||
@@ -141,7 +199,6 @@ export default function TuxedoVideoHero({
|
||||
return () => ctx.revert();
|
||||
}, []);
|
||||
|
||||
// Smooth scroll to stops section
|
||||
const handlePrimaryClick = () => {
|
||||
const stopsSection = document.getElementById("stops");
|
||||
if (stopsSection) {
|
||||
@@ -157,13 +214,18 @@ export default function TuxedoVideoHero({
|
||||
|
||||
<section
|
||||
ref={sectionRef}
|
||||
className="relative min-h-screen flex items-center overflow-hidden"
|
||||
className="relative min-h-screen flex items-end overflow-hidden isolate bg-stone-950"
|
||||
>
|
||||
<HeroBackground videoRef={videoRef} />
|
||||
<HeroBackdrop
|
||||
videoUrl={videoUrl}
|
||||
posterUrl={posterUrl}
|
||||
videoRef={videoRef}
|
||||
onVideoStateChange={setVideoPlaying}
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="relative z-10 mx-auto w-full max-w-6xl px-6 pb-32 pt-32 flex flex-col justify-end"
|
||||
className="relative z-10 mx-auto w-full max-w-6xl px-6 pb-24 md:pb-32 pt-32"
|
||||
>
|
||||
<div className="max-w-3xl">
|
||||
<HeroContent
|
||||
@@ -180,7 +242,7 @@ export default function TuxedoVideoHero({
|
||||
</div>
|
||||
|
||||
<ScrollIndicator onClick={handlePrimaryClick} />
|
||||
<CornerDecorations />
|
||||
<CornerDecorations videoPlaying={videoPlaying} />
|
||||
</section>
|
||||
|
||||
<HeroAnimations />
|
||||
@@ -188,10 +250,10 @@ export default function TuxedoVideoHero({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scroll Progress Bar ───────────────────────────────────────────────────────
|
||||
// ── Scroll Progress Bar ───────────────────────────────────────────────
|
||||
function ScrollProgressBar({ scrollProgress }: { scrollProgress: number }) {
|
||||
return (
|
||||
<div className="fixed top-0 left-0 right-0 h-1 z-[1000] bg-black/20 scroll-progress-bar">
|
||||
<div className="fixed top-0 left-0 right-0 h-1 z-[1000] bg-black/20 scroll-progress-bar pointer-events-none">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-emerald-600 via-emerald-400 to-amber-400"
|
||||
style={{
|
||||
@@ -203,43 +265,111 @@ function ScrollProgressBar({ scrollProgress }: { scrollProgress: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Hero Background (video + overlays + orbs) ────────────────────────────────
|
||||
function HeroBackground({ videoRef }: { videoRef: React.RefObject<HTMLVideoElement | null> }) {
|
||||
// ── Backdrop: poster + optional video + overlays ─────────────────────────
|
||||
function HeroBackdrop({
|
||||
videoUrl,
|
||||
posterUrl,
|
||||
videoRef,
|
||||
onVideoStateChange,
|
||||
}: {
|
||||
videoUrl?: string | null;
|
||||
posterUrl?: string | null;
|
||||
videoRef: React.RefObject<HTMLVideoElement | null>;
|
||||
onVideoStateChange: (playing: boolean) => void;
|
||||
}) {
|
||||
// On video failure we set this true and let the poster image take over.
|
||||
// Suppresses DOM noise in the failure case (no broken <video> controls).
|
||||
return (
|
||||
<>
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 w-full h-[120%] object-cover"
|
||||
style={{ zIndex: 0 }}
|
||||
src={VIDEO_URL}
|
||||
/>
|
||||
{/* Poster as still fallback. Uses Next/Image so the LCP image is
|
||||
optimised by the framework; sits underneath everything with
|
||||
`object-cover: contain-ar-correct` style. Slow Ken Burns drift
|
||||
animates the poster when the video isn't covering it (initial
|
||||
2-3s of load, or if the video fails) — adds ambient motion to
|
||||
the first impression without competing with the video. */}
|
||||
{posterUrl ? (
|
||||
<Image
|
||||
src={posterUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
fill
|
||||
sizes="100vw"
|
||||
priority
|
||||
quality={80}
|
||||
style={{
|
||||
objectFit: "cover",
|
||||
objectPosition: "center",
|
||||
zIndex: 0,
|
||||
}}
|
||||
className="select-none pointer-events-none animate-tvh-kenburns filter-editorial"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Gradient overlays */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
zIndex: 1,
|
||||
background: "linear-gradient(135deg, rgba(255,215,0,0.12) 0%, rgba(255,193,7,0.06) 50%, rgba(255,235,0,0.04) 100%)",
|
||||
}}
|
||||
/>
|
||||
{/*
|
||||
Video: render unconditionally when a URL is provided, but mark the
|
||||
element as `data-failed` once any of the error events fire so we
|
||||
can hide it with CSS instead of pulling it from the DOM (keeps
|
||||
the network-friendly fetch behaviour). `autoPlay muted playsInline`
|
||||
are the three browser flags that together unblock iOS + Android
|
||||
autoplay; React renders them as proper boolean attributes.
|
||||
`preload="metadata"` keeps the first frame available without
|
||||
pulling the whole file on slow connections.
|
||||
*/}
|
||||
{videoUrl ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
disablePictureInPicture
|
||||
controlsList="nodownload nofullscreen noremoteplayback"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
className="absolute inset-0 w-full h-full object-cover transition-opacity duration-500 data-[failed=true]:opacity-0 data-[failed=true]:pointer-events-none"
|
||||
style={{ zIndex: 1 }}
|
||||
poster={posterUrl ?? undefined}
|
||||
src={videoUrl}
|
||||
onCanPlay={() => onVideoStateChange(true)}
|
||||
onError={() => onVideoStateChange(false)}
|
||||
onStalled={() => {
|
||||
// If we never fire `canPlay`, the element stays hidden via
|
||||
// the `data-failed` data-attribute on the next paint.
|
||||
onVideoStateChange(false);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Tint layer — adds warmth without darkening the underlying field.
|
||||
`mix-blend-multiply` keeps the golden hour of the photograph. */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
zIndex: 2,
|
||||
background: "radial-gradient(ellipse at center, rgba(255,200,0,0.08) 0%, rgba(0,0,0,0.4) 50%, rgba(0,0,0,0.9) 100%)",
|
||||
background:
|
||||
"linear-gradient(135deg, rgba(255,193,7,0.10) 0%, rgba(255,213,79,0.05) 50%, transparent 100%)",
|
||||
mixBlendMode: "multiply",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Vignette — keeps eyes on the centered type. */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
zIndex: 3,
|
||||
background: "linear-gradient(to top, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.2) 50%, rgba(0,0,0,0.05) 100%)",
|
||||
background:
|
||||
"radial-gradient(ellipse at 50% 60%, rgba(0,0,0,0) 0%, rgba(0,0,0,0.35) 60%, rgba(0,0,0,0.85) 100%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Base darken — helps white text legibility in either regime. */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
zIndex: 4,
|
||||
background:
|
||||
"linear-gradient(to top, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0.15) 40%, rgba(0,0,0,0.05) 100%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -248,40 +378,39 @@ function HeroBackground({ videoRef }: { videoRef: React.RefObject<HTMLVideoEleme
|
||||
);
|
||||
}
|
||||
|
||||
// ── Floating Orbs ─────────────────────────────────────────────────────────────
|
||||
// ── Floating Orbs ───────────────────────────────────────────────────────
|
||||
function FloatingOrbs() {
|
||||
// Slowed from the original 950ms (which felt like a strobe) to 7s/8s
|
||||
// so the gold/green ambient lights drift like sunset on the field.
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none z-[4]">
|
||||
{/* Golden orb top-left */}
|
||||
<div className="absolute inset-0 pointer-events-none z-[5]" aria-hidden="true">
|
||||
<div
|
||||
className="parallax-float absolute top-1/4 left-1/4 w-64 h-64 rounded-full opacity-20"
|
||||
className="absolute top-1/4 left-1/4 w-64 h-64 rounded-full opacity-20 animate-tvh-float-slow"
|
||||
style={{
|
||||
background: "radial-gradient(circle, rgba(255,215,0,0.4) 0%, transparent 70%)",
|
||||
background:
|
||||
"radial-gradient(circle, rgba(232,163,23,0.45) 0%, transparent 70%)",
|
||||
filter: "blur(8px)",
|
||||
animation: "tvh-float-slow 950ms ease-in-out infinite",
|
||||
}}
|
||||
/>
|
||||
{/* Emerald orb bottom-right */}
|
||||
<div
|
||||
className="parallax-float absolute bottom-1/3 right-1/4 w-48 h-48 rounded-full opacity-15"
|
||||
className="absolute bottom-1/3 right-1/4 w-48 h-48 rounded-full opacity-15 animate-tvh-float-delayed"
|
||||
style={{
|
||||
background: "radial-gradient(circle, rgba(16,185,129,0.4) 0%, transparent 70%)",
|
||||
background:
|
||||
"radial-gradient(circle, rgba(63,110,58,0.45) 0%, transparent 70%)",
|
||||
filter: "blur(8px)",
|
||||
animation: "tvh-float-delayed 950ms ease-in-out infinite",
|
||||
}}
|
||||
/>
|
||||
{/* Subtle grain overlay */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.03]"
|
||||
className="absolute inset-0 opacity-[0.085] mix-blend-overlay pointer-events-none"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E")`,
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E")`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Hero Content (logo, eyebrow, title, description, CTAs, stats) ────────────
|
||||
// ── Hero Content (logo, eyebrow, title, description, CTAs, stats) ──────
|
||||
type HeroContentProps = {
|
||||
logoSrc: string | null;
|
||||
eyebrow?: string;
|
||||
@@ -307,14 +436,24 @@ function HeroContent({
|
||||
<>
|
||||
<HeroLogo logoSrc={logoSrc} />
|
||||
|
||||
<div className="hero-reveal mb-6">
|
||||
<div className="hero-reveal mt-6 flex items-center gap-4">
|
||||
{eyebrow && (
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.3em] text-amber-400/80">
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.3em] text-amber-300/90">
|
||||
{eyebrow}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-3 h-px w-16 bg-gradient-to-r from-emerald-500 to-transparent" />
|
||||
{/* Altitude ribbon — runs alongside the existing eyebrow with a
|
||||
hairline divider, like a magazine masthead. Reinforces the
|
||||
geographic / altitude voice of the brand. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="hidden sm:block h-px flex-1 max-w-[64px] bg-amber-300/35"
|
||||
/>
|
||||
<p className="hidden sm:block text-[10px] font-bold uppercase tracking-[0.32em] text-amber-200/65">
|
||||
5,360 ft · Uncompahgre Valley
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3 h-px w-16 bg-gradient-to-r from-amber-300/80 to-transparent" />
|
||||
|
||||
<HeroTitle title={title} />
|
||||
<HeroDescription description={description} />
|
||||
@@ -333,12 +472,7 @@ function HeroContent({
|
||||
|
||||
function HeroLogo({ logoSrc }: { logoSrc: string | null }) {
|
||||
return (
|
||||
<motion.div
|
||||
className="hero-reveal mb-10 relative h-20 md:h-24 w-[320px] md:w-[400px]"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.32, delay: 0.05, ease: "easeOut" }}
|
||||
>
|
||||
<div className="hero-reveal mb-8 md:mb-10 relative h-20 md:h-24 w-[320px] md:w-[400px]">
|
||||
<div className="absolute -inset-8 rounded-full bg-emerald-500/10 blur-2xl" />
|
||||
{logoSrc ? (
|
||||
<Image
|
||||
@@ -351,25 +485,34 @@ function HeroLogo({ logoSrc }: { logoSrc: string | null }) {
|
||||
priority
|
||||
/>
|
||||
) : null}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroTitle({ title }: { title: string }) {
|
||||
// Subtle white/cool text shadow replaces the yellow tint (was reading
|
||||
// neon). Letter-spacing tightened on the largest sizes so the words
|
||||
// sit closer — feels like a printed farm-stand sign at highway scale.
|
||||
// Title is split into words so the Apple-quality motion pass can
|
||||
// choreograph a per-word blur-up — the signature hero move.
|
||||
return (
|
||||
<h1
|
||||
className="hero-reveal text-6xl md:text-7xl lg:text-8xl font-black tracking-tight text-white leading-[0.95] mb-6"
|
||||
className="text-6xl md:text-7xl lg:text-8xl font-black tracking-[-0.025em] text-white leading-[0.95] mb-6"
|
||||
style={{
|
||||
textShadow: "0 4px 30px rgba(0,0,0,0.3), 0 0 60px rgba(255,215,0,0.1)",
|
||||
textShadow:
|
||||
"0 4px 30px rgba(0,0,0,0.45), 0 1px 1px rgba(0,0,0,0.25)",
|
||||
}}
|
||||
aria-label={title}
|
||||
>
|
||||
{title.split(" ").map((word, idx) => (
|
||||
{title.split(" ").map((word, i) => (
|
||||
<span
|
||||
key={`${idx}-${word}`}
|
||||
className="inline-block mr-4"
|
||||
key={i}
|
||||
className="hero-word inline-block"
|
||||
style={{
|
||||
animationDelay: `${0.2 + idx * 0.1}s`,
|
||||
marginRight: "0.18em",
|
||||
willChange: "transform, opacity, filter",
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
{word}
|
||||
</span>
|
||||
@@ -381,7 +524,7 @@ function HeroTitle({ title }: { title: string }) {
|
||||
function HeroDescription({ description }: { description: string }) {
|
||||
return (
|
||||
<p
|
||||
className="hero-reveal text-xl md:text-2xl text-white/70 leading-relaxed max-w-xl mb-10"
|
||||
className="hero-reveal text-xl md:text-2xl text-white/75 leading-relaxed max-w-xl mb-10"
|
||||
style={{ fontWeight: 300 }}
|
||||
>
|
||||
{description}
|
||||
@@ -416,13 +559,17 @@ function PrimaryButton({ label, onClick }: { label: string; onClick: () => void
|
||||
return (
|
||||
<motion.button
|
||||
onClick={onClick}
|
||||
className="group relative rounded-full px-10 py-4 text-sm font-bold tracking-widest uppercase overflow-hidden"
|
||||
className="group relative rounded-full px-10 py-4 text-sm font-bold tracking-widest uppercase overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-950"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #059669 0%, #10b981 100%)",
|
||||
boxShadow: "0 8px 32px rgba(16, 185, 129, 0.4), inset 0 1px 0 rgba(255,255,255,0.2)",
|
||||
background: "linear-gradient(135deg, #15803d 0%, #16a34a 100%)",
|
||||
boxShadow:
|
||||
"0 8px 32px rgba(22, 163, 74, 0.35), inset 0 1px 0 rgba(255,255,255,0.2)",
|
||||
}}
|
||||
whileHover={{ y: -3 }}
|
||||
transition={{ type: "spring", stiffness: 320, damping: 22 }}
|
||||
aria-label={label}
|
||||
>
|
||||
<span className="relative z-10 flex items-center gap-3">
|
||||
<span className="relative z-10 flex items-center gap-3 text-white">
|
||||
<span>{label}</span>
|
||||
<svg
|
||||
className="w-4 h-4 transition-transform group-hover:translate-x-1"
|
||||
@@ -434,34 +581,22 @@ function PrimaryButton({ label, onClick }: { label: string; onClick: () => void
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
</svg>
|
||||
</span>
|
||||
<div
|
||||
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #10b981 0%, #059669 100%)",
|
||||
}}
|
||||
/>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
function SecondaryButton({
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
function SecondaryButton({ label, onClick }: { label?: string; onClick?: () => void }) {
|
||||
if (!label) return null;
|
||||
return (
|
||||
<button type="button"
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="group flex items-center gap-3 text-sm font-semibold"
|
||||
style={{ color: "rgba(255,255,255,0.8)" }}
|
||||
aria-label="Next">
|
||||
className="group flex items-center gap-3 text-sm font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-950 rounded-full"
|
||||
style={{ color: "rgba(255,255,255,0.85)" }}
|
||||
aria-label={label}
|
||||
>
|
||||
<span className="uppercase tracking-widest text-[11px]">{label}</span>
|
||||
<span
|
||||
className="w-10 h-10 rounded-full border flex items-center justify-center transition-all duration-300 group-hover:bg-white/10 group-hover:border-white/30"
|
||||
style={{ borderColor: "rgba(255,255,255,0.3)" }}
|
||||
>
|
||||
<span className="w-10 h-10 rounded-full border flex items-center justify-center transition-all duration-300 group-hover:bg-white/10 group-hover:border-white/40">
|
||||
<svg
|
||||
className="w-4 h-4 transition-transform group-hover:translate-x-0.5"
|
||||
fill="none"
|
||||
@@ -478,18 +613,18 @@ function SecondaryButton({
|
||||
|
||||
function HeroStats() {
|
||||
return (
|
||||
<div className="hero-reveal mt-16 flex flex-wrap gap-12">
|
||||
<div className="hero-reveal mt-12 md:mt-16 flex flex-wrap gap-x-10 gap-y-6 md:gap-x-14">
|
||||
{HERO_STATS.map((item) => (
|
||||
<div key={item.label} className="text-center">
|
||||
<div key={item.label}>
|
||||
<div
|
||||
className="text-3xl md:text-4xl font-black text-white"
|
||||
style={{ textShadow: "0 2px 20px rgba(0,0,0,0.3)" }}
|
||||
className="text-2xl md:text-3xl font-black text-white tabular-nums"
|
||||
style={{ textShadow: "0 2px 20px rgba(0,0,0,0.4)" }}
|
||||
>
|
||||
{item.stat}
|
||||
</div>
|
||||
<div
|
||||
className="text-[10px] uppercase tracking-[0.2em] mt-1"
|
||||
style={{ color: "rgba(255,255,255,0.5)" }}
|
||||
className="text-[10px] uppercase tracking-[0.25em] mt-1"
|
||||
style={{ color: "rgba(255,255,255,0.55)" }}
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
@@ -499,98 +634,112 @@ function HeroStats() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scroll Indicator ──────────────────────────────────────────────────────────
|
||||
// ── Scroll Indicator ─────────────────────────────────────────────────────
|
||||
function ScrollIndicator({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className="scroll-indicator absolute bottom-10 left-1/2 -translate-x-1/2 z-20 flex flex-col items-center gap-3"
|
||||
className="scroll-indicator absolute bottom-8 left-1/2 -translate-x-1/2 z-20 flex flex-col items-center gap-3"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
>
|
||||
<button type="button"
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex flex-col items-center gap-2 text-white/50 hover:text-white/80 transition-colors duration-200"
|
||||
className="flex flex-col items-center gap-2 text-white/55 hover:text-white/85 transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300 rounded-full"
|
||||
aria-label="Scroll to stops"
|
||||
>
|
||||
<span className="text-[10px] uppercase tracking-[0.25em] font-medium">Explore</span>
|
||||
<div
|
||||
className="w-6 h-10 rounded-full border border-white/30 flex items-start justify-center p-1.5"
|
||||
>
|
||||
<div
|
||||
className="w-1.5 h-3 rounded-full bg-white tvh-scroll-indicator"
|
||||
style={{ animationDuration: "950ms" }}
|
||||
/>
|
||||
<div className="w-6 h-10 rounded-full border border-white/30 flex items-start justify-center p-1.5">
|
||||
<div className="w-1.5 h-3 rounded-full bg-white animate-tvh-scroll-dot" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Corner Decorations ────────────────────────────────────────────────────────
|
||||
function CornerDecorations() {
|
||||
// ── Corner Decorations ───────────────────────────────────────────────────
|
||||
function CornerDecorations({ videoPlaying }: { videoPlaying: boolean }) {
|
||||
// The decorative SVG rings on the corners are interesting but distract
|
||||
// when the video is already playing. We tint them by the current
|
||||
// backdrop mode instead of always animating them.
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="parallax-float absolute top-8 right-8 opacity-30"
|
||||
style={{ animationDelay: "1s" }}
|
||||
className="absolute top-8 right-8 opacity-25 hidden md:block"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg className="w-16 h-16" viewBox="0 0 64 64" fill="none">
|
||||
<path
|
||||
d="M32 4C48 16, 56 32, 48 56C40 64, 24 64, 8 56C0 32, 16 16, 32 4"
|
||||
stroke="rgba(255,215,0,0.5)"
|
||||
stroke="rgba(232,163,23,0.55)"
|
||||
strokeWidth="1"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M32 12C44 22, 48 36, 42 52C36 58, 26 58, 18 52C12 36, 20 22, 32 12"
|
||||
stroke="rgba(255,215,0,0.3)"
|
||||
stroke="rgba(232,163,23,0.35)"
|
||||
strokeWidth="1"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
className="parallax-float absolute bottom-20 left-8 opacity-20"
|
||||
style={{ animationDelay: "1.5s" }}
|
||||
className="absolute bottom-20 left-8 opacity-20 hidden md:block"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg className="w-12 h-12" viewBox="0 0 48 48" fill="none">
|
||||
<circle cx="24" cy="24" r="20" stroke="rgba(16,185,129,0.4)" strokeWidth="1" />
|
||||
<circle cx="24" cy="24" r="14" stroke="rgba(16,185,129,0.3)" strokeWidth="1" />
|
||||
<circle cx="24" cy="24" r="8" stroke="rgba(16,185,129,0.2)" strokeWidth="1" />
|
||||
<circle cx="24" cy="24" r="20" stroke="rgba(63,110,58,0.55)" strokeWidth="1" />
|
||||
<circle cx="24" cy="24" r="14" stroke="rgba(63,110,58,0.4)" strokeWidth="1" />
|
||||
<circle cx="24" cy="24" r="8" stroke="rgba(63,110,58,0.25)" strokeWidth="1" />
|
||||
</svg>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Hero Animations (global keyframes + reduced-motion overrides) ─────────────
|
||||
// ── Hero Animations (global keyframes + reduced-motion overrides) ───────
|
||||
function HeroAnimations() {
|
||||
return (
|
||||
<style dangerouslySetInnerHTML={{ __html: `
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@keyframes tvh-float-slow {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
50% { transform: translate(6px, -8px); }
|
||||
50% { transform: translate(8px, -10px); }
|
||||
}
|
||||
|
||||
@keyframes tvh-float-delayed {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
50% { transform: translate(-8px, 6px); }
|
||||
50% { transform: translate(-10px, 8px); }
|
||||
}
|
||||
|
||||
@keyframes tvh-scroll-indicator {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(3px); }
|
||||
@keyframes tvh-scroll-dot {
|
||||
0%, 100% { transform: translateY(0); opacity: 1; }
|
||||
50% { transform: translateY(8px); opacity: 0.4; }
|
||||
}
|
||||
|
||||
.tvh-scroll-indicator {
|
||||
animation: tvh-scroll-indicator 2.4s cubic-bezier(0.16, 1, 0.3, 1) infinite;
|
||||
/* Slow Ken Burns on the hero poster. Animates while the video is
|
||||
still loading (first ~2s) or if the video fails entirely.
|
||||
Scaled subtly + tiny pan so it reads as ambient, not motion. */
|
||||
@keyframes tvh-kenburns {
|
||||
0% { transform: scale(1.02) translate(0, 0); }
|
||||
50% { transform: scale(1.07) translate(-0.6%, -0.4%); }
|
||||
100% { transform: scale(1.10) translate(-1.2%, -0.8%); }
|
||||
}
|
||||
|
||||
.parallax-float {
|
||||
.animate-tvh-kenburns {
|
||||
animation: tvh-kenburns 26s ease-in-out infinite alternate;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.hero-reveal {
|
||||
will-change: opacity, transform;
|
||||
.animate-tvh-float-slow {
|
||||
animation: tvh-float-slow 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-tvh-float-delayed {
|
||||
animation: tvh-float-delayed 8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-tvh-scroll-dot {
|
||||
animation: tvh-scroll-dot 2.4s cubic-bezier(0.16, 1, 0.3, 1) infinite;
|
||||
}
|
||||
|
||||
button, a {
|
||||
@@ -598,7 +747,11 @@ function HeroAnimations() {
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.parallax-float, .animate-bounce, .hero-reveal {
|
||||
.animate-tvh-float-slow,
|
||||
.animate-tvh-float-delayed,
|
||||
.animate-tvh-scroll-dot,
|
||||
.animate-tvh-kenburns,
|
||||
.hero-reveal {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
@@ -606,7 +759,13 @@ function HeroAnimations() {
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
/* Pause any looping media so the still poster stays put. */
|
||||
video {
|
||||
animation-play-state: paused !important;
|
||||
}
|
||||
}
|
||||
`}} />
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Editorial imagery for the Tuxedo storefront, sourced from the original
|
||||
* tuxedocorn.com WordPress library and re-hosted on our MinIO bucket so
|
||||
* the brand controls its own media. These are stable, brand-specific
|
||||
* assets — they don't need to be admin-configurable yet. (If we ever
|
||||
* add more brands, lift these into `brand_settings` columns.)
|
||||
*
|
||||
* Lifted out of `src/app/tuxedo/page.tsx` so the about page, FAQ page,
|
||||
* and any future Tuxedo subpage can share the same library without
|
||||
* duplicating the URL list.
|
||||
*/
|
||||
export const TUXEDO_IMAGES = {
|
||||
/** Wide panoramic corn row with irrigation canal + Colorado sky — the
|
||||
* hero poster on the homepage. */
|
||||
heroField:
|
||||
"https://s3.crispygoat.com/videos/wp-import/images/tuxedo-field-irrigation.jpg",
|
||||
/** Pre-storm purple sky over deep-green tasseled rows receding to
|
||||
* mountains — the editorial pull-quote band on the homepage + about. */
|
||||
stormField:
|
||||
"https://s3.crispygoat.com/videos/wp-import/images/tuxedo-field-storm.jpg",
|
||||
/** White-haired founder inspecting tassels — editorial-graded version
|
||||
* of the original WP shot. Golden-hour lighting, shallow DOF. */
|
||||
founderPortrait:
|
||||
"https://s3.crispygoat.com/videos/wp-import/images/tuxedo-founder-john-editorial.jpg",
|
||||
/** Single yellow ear with silk and dark green bokeh — the product
|
||||
* moment in the editorial "Why" replacement. */
|
||||
earMacro:
|
||||
"https://s3.crispygoat.com/videos/wp-import/images/tuxedo-ear-macro.jpg",
|
||||
/** Packing-shed packshot — dozens of husked cobs piled on the table
|
||||
* the morning of pick. Documentary, not styled. */
|
||||
piledCobs:
|
||||
"https://s3.crispygoat.com/videos/wp-import/images/corn-field-zane-original.jpg",
|
||||
} as const;
|
||||
|
||||
export type TuxedoImageKey = keyof typeof TUXEDO_IMAGES;
|
||||
@@ -392,8 +392,15 @@ const TRANS_ES: Translations = {
|
||||
|
||||
function getLangCookie(): string {
|
||||
if (typeof document === "undefined") return "en";
|
||||
const match = document.cookie.match(/time_tracking_lang=(\w+)/);
|
||||
return match ? match[1] : "en";
|
||||
// `time_tracking_lang` is the explicit override. Fall back to
|
||||
// `wl_lang` so workers who switched to Spanish on the Water Log
|
||||
// mobile app inherit the same language when they tap the Time tab
|
||||
// — without this, the Time tab resets to English every time.
|
||||
const tt = document.cookie.match(/time_tracking_lang=(\w+)/);
|
||||
if (tt) return tt[1];
|
||||
const wl = document.cookie.match(/wl_lang=(\w+)/);
|
||||
if (wl) return wl[1];
|
||||
return "en";
|
||||
}
|
||||
|
||||
function setLangCookie(lang: string) {
|
||||
@@ -410,8 +417,8 @@ function langToBcp(): "en-US" | "es-ES" {
|
||||
// Cheap global so `formatTime` doesn't have to thread `lang` around.
|
||||
// Resolved at call time from the lang cookie that's always set.
|
||||
if (typeof document === "undefined") return "en-US";
|
||||
const m = document.cookie.match(/time_tracking_lang=(\w+)/);
|
||||
return m?.[1] === "es" ? "es-ES" : "en-US";
|
||||
const m = getLangCookie();
|
||||
return m === "es" ? "es-ES" : "en-US";
|
||||
}
|
||||
|
||||
function formatHours(minutes: number): string {
|
||||
@@ -538,7 +545,12 @@ function emptyManualDraft(): State["manualEntryDraft"] {
|
||||
|
||||
const initialState: State = {
|
||||
lang: (getLangCookie() as "en" | "es") ?? "en",
|
||||
screen: "pin",
|
||||
// Start in `"loading"` so the init effect can resolve the session
|
||||
// cookie + open clock-in before the UI renders. Previously this
|
||||
// was `"pin"`, which caused a ~one-tick flash of the PIN screen on
|
||||
// every tab switch — workers with a valid session saw the PIN UI
|
||||
// for a moment before it was swapped out for Hub/Working.
|
||||
screen: "loading",
|
||||
tasks: [],
|
||||
openEntry: null,
|
||||
payPeriod: null,
|
||||
|
||||
@@ -19,6 +19,23 @@ function prefersReducedMotion(): boolean {
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches === true;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// APPLE MOTION TOKENS — used by the AppleReveal / AppleWordReveal / AppleSticky
|
||||
// primitives below. Designed to feel like iOS sheet handoffs and Vision
|
||||
// Pro scrolltelling: snappy departure, soft landing.
|
||||
//
|
||||
// Easing: power3.out ≈ cubic-bezier(0.16, 1, 0.3, 1)
|
||||
// Distance: 18px max (Apple scroll fades never translate far)
|
||||
// Duration: 0.55s primary, 0.4s secondary — measured, never rushed
|
||||
// Stagger: 0.06s between siblings — visible cadence without lag
|
||||
// Blur: 6px → 0px (subtle depth-of-field unfocus on reveal)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export const APPLE_EASE = "power3.out";
|
||||
export const APPLE_DURATION = 0.55;
|
||||
export const APPLE_DURATION_FAST = 0.4;
|
||||
export const APPLE_DISTANCE = 18;
|
||||
export const APPLE_STAGGER = 0.06;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SCROLL-REVEAL WRAPPER - Layers content as user scrolls
|
||||
// Motion-reduced: max 12px translation (was 60px), 320ms (was 1s),
|
||||
@@ -321,4 +338,289 @@ export function FadeOnScroll({
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AppleReveal — premium scroll-reveal for editorial sections.
|
||||
//
|
||||
// Reveal choreography: opacity 0→1 + Y 18→0 + filter blur(6px)→0.
|
||||
// Uses power3.out (≈ Apple/Linear's cubic-bezier(0.16, 1, 0.3, 1)) with
|
||||
// 0.55s primary duration. Triggers at "top 88%" so it fires just before
|
||||
// the element is fully in view — feels anticipatory, never late.
|
||||
//
|
||||
// One-shot: toggleActions "play none none none" so reverse scroll does
|
||||
// not re-fire. Reduced-motion safe (CSS transition only, ~280ms).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
interface AppleRevealProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
duration?: number;
|
||||
distance?: number;
|
||||
blur?: number;
|
||||
/** When true, fires on mount rather than on scroll. Useful for hero content. */
|
||||
immediate?: boolean;
|
||||
}
|
||||
|
||||
export function AppleReveal({
|
||||
children,
|
||||
className = "",
|
||||
delay = 0,
|
||||
duration = APPLE_DURATION,
|
||||
distance = APPLE_DISTANCE,
|
||||
blur = 6,
|
||||
immediate = false,
|
||||
}: AppleRevealProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !ref.current) return;
|
||||
|
||||
if (prefersReducedMotion()) {
|
||||
// Just snap to the end state. No animation, no filter.
|
||||
ref.current.style.opacity = "1";
|
||||
ref.current.style.transform = "none";
|
||||
ref.current.style.filter = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
gsap.fromTo(
|
||||
ref.current,
|
||||
{
|
||||
opacity: 0,
|
||||
y: distance,
|
||||
filter: `blur(${blur}px)`,
|
||||
willChange: "transform, opacity, filter",
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
filter: "blur(0px)",
|
||||
duration,
|
||||
delay,
|
||||
ease: APPLE_EASE,
|
||||
...(immediate
|
||||
? {}
|
||||
: {
|
||||
scrollTrigger: {
|
||||
trigger: ref.current,
|
||||
start: "top 88%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
}, ref);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, [delay, duration, distance, blur, immediate]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AppleStaggerReveal — staggered siblings (eyebrow → rule → h2 → lede).
|
||||
//
|
||||
// Wrap any block of children with `data-apple-stagger` on each child you
|
||||
// want to reveal in sequence. The wrapper coordinates a 0.06s stagger
|
||||
// between siblings. Each sibling uses the AppleReveal choreography.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
interface AppleStaggerProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
/** Initial delay before the first sibling fires. */
|
||||
startDelay?: number;
|
||||
/** Per-sibling stagger in seconds. */
|
||||
stagger?: number;
|
||||
}
|
||||
|
||||
export function AppleStagger({
|
||||
children,
|
||||
className = "",
|
||||
startDelay = 0,
|
||||
stagger = APPLE_STAGGER,
|
||||
}: AppleStaggerProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !ref.current) return;
|
||||
|
||||
if (prefersReducedMotion()) {
|
||||
const items = ref.current.querySelectorAll<HTMLElement>("[data-apple-stagger]");
|
||||
items.forEach((el) => {
|
||||
el.style.opacity = "1";
|
||||
el.style.transform = "none";
|
||||
el.style.filter = "none";
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
const items = gsap.utils.toArray<HTMLElement>("[data-apple-stagger]");
|
||||
if (!items.length) return;
|
||||
|
||||
gsap.fromTo(
|
||||
items,
|
||||
{ opacity: 0, y: APPLE_DISTANCE, filter: "blur(6px)" },
|
||||
{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
filter: "blur(0px)",
|
||||
duration: APPLE_DURATION,
|
||||
delay: startDelay,
|
||||
stagger,
|
||||
ease: APPLE_EASE,
|
||||
scrollTrigger: {
|
||||
trigger: ref.current,
|
||||
start: "top 88%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
}
|
||||
);
|
||||
}, ref);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, [startDelay, stagger]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AppleStickySection — sticky scrolltelling.
|
||||
//
|
||||
// Puts its first child into `position: sticky` while the rest of the
|
||||
// content scrolls past it. Perfect for the "PullQuoteBand" /
|
||||
// "HarvestEditorial" hero moments where one big element should stay on
|
||||
// screen while supporting copy reveals underneath.
|
||||
//
|
||||
// Usage:
|
||||
// <AppleStickySection>
|
||||
// <YourBigImage />
|
||||
// <YourCopyReveals />
|
||||
// </AppleStickySection>
|
||||
//
|
||||
// The container takes the height of all its children; the sticky child
|
||||
// stays pinned to the top of the viewport while subsequent siblings
|
||||
// scroll past it.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
interface AppleStickySectionProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
/** Top offset for the sticky child (e.g. "0" or "10vh"). */
|
||||
top?: string;
|
||||
}
|
||||
|
||||
export function AppleStickySection({
|
||||
children,
|
||||
className = "",
|
||||
top = "0",
|
||||
}: AppleStickySectionProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
|
||||
// Mark the first child sticky. Done in JS so we don't fight SSR
|
||||
// (sticky inside flex / grid can have surprises on first paint).
|
||||
const first = ref.current.firstElementChild as HTMLElement | null;
|
||||
if (first) {
|
||||
first.style.position = "sticky";
|
||||
first.style.top = top;
|
||||
// Hardware-accelerated; smoother during scroll.
|
||||
first.style.willChange = "transform";
|
||||
}
|
||||
|
||||
// Add a scroll-linked parallax to the sticky child — 12% drift
|
||||
// over its full scroll length. Subtle depth cue.
|
||||
if (!prefersReducedMotion() && first) {
|
||||
const ctx = gsap.context(() => {
|
||||
gsap.fromTo(
|
||||
first,
|
||||
{ yPercent: -3 },
|
||||
{
|
||||
yPercent: 3,
|
||||
ease: "none",
|
||||
scrollTrigger: {
|
||||
trigger: ref.current,
|
||||
start: "top top",
|
||||
end: "bottom bottom",
|
||||
scrub: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
}, ref);
|
||||
return () => ctx.revert();
|
||||
}
|
||||
}, [top]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AppleCountUp — refined counter animation.
|
||||
//
|
||||
// Uses power3.out (Apple expo-out) over 1.6s — slower than the previous
|
||||
// 2s linear, but the eased curve makes the *perceived* duration feel
|
||||
// shorter (lands on the target faster, with a soft tail).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
interface AppleCountUpProps {
|
||||
end: number;
|
||||
duration?: number;
|
||||
className?: string;
|
||||
format?: (n: number) => string;
|
||||
}
|
||||
|
||||
export function AppleCountUp({
|
||||
end,
|
||||
duration = 1.6,
|
||||
className = "",
|
||||
format = (n) => Math.floor(n).toLocaleString(),
|
||||
}: AppleCountUpProps) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
|
||||
if (prefersReducedMotion()) {
|
||||
ref.current.textContent = format(end);
|
||||
return;
|
||||
}
|
||||
|
||||
const obj = { v: 0 };
|
||||
const ctx = gsap.context(() => {
|
||||
gsap.to(obj, {
|
||||
v: end,
|
||||
duration,
|
||||
ease: APPLE_EASE,
|
||||
scrollTrigger: {
|
||||
trigger: ref.current,
|
||||
start: "top 88%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
onUpdate: () => {
|
||||
if (ref.current) ref.current.textContent = format(obj.v);
|
||||
},
|
||||
});
|
||||
}, ref);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, [end, duration, format]);
|
||||
|
||||
return (
|
||||
<span ref={ref} className={className}>
|
||||
0
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,14 +63,22 @@ export function MobileWaterHeadgateList({
|
||||
paddingTop: "env(safe-area-inset-top)",
|
||||
}}
|
||||
>
|
||||
{/* ── Top nav ──────────────────────────────────────────────── */}
|
||||
{/* ── Top nav (Apple HIG liquid glass) ─────────────────────── */}
|
||||
<header
|
||||
className="sticky top-0 z-30 flex h-11 items-center justify-between border-b border-[rgba(60,60,67,0.12)] bg-[rgba(255,255,255,0.85)] px-2"
|
||||
className="sticky top-0 z-30 flex h-11 items-center justify-between px-2"
|
||||
style={{
|
||||
paddingTop: "env(safe-area-inset-top)",
|
||||
height: "calc(44px + env(safe-area-inset-top))",
|
||||
backdropFilter: "blur(20px) saturate(180%)",
|
||||
WebkitBackdropFilter: "blur(20px) saturate(180%)",
|
||||
// Gradient + saturate(180%) reads as "liquid glass" — the
|
||||
// gold standard iOS Settings / Notes / Mail chrome uses
|
||||
// the same recipe. Single shadow with two insets handles
|
||||
// the top highlight + bottom hairline in one rule.
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(255,255,255,0.88) 0%, rgba(255,255,255,0.62) 100%)",
|
||||
backdropFilter: "blur(24px) saturate(180%)",
|
||||
WebkitBackdropFilter: "blur(24px) saturate(180%)",
|
||||
boxShadow:
|
||||
"inset 0 0.5px 0 0 rgba(255,255,255,1), inset 0 -0.5px 0 0 rgba(0,0,0,0.06)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
@@ -26,12 +26,12 @@
|
||||
*/
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type FormEvent,
|
||||
} from "react";
|
||||
import { SegmentedControl } from "./SegmentedControl";
|
||||
import { ThresholdMeter } from "./ThresholdMeter";
|
||||
import type { FieldHeadgate, MobileUnit } from "./types";
|
||||
import { isIntegerUnit, MOBILE_UNITS } from "./types";
|
||||
import { ChevronLeft, Clock, Gauge, Logout, Pencil, Pin, Spinner } from "./icons";
|
||||
@@ -101,19 +101,10 @@ export function MobileWaterLogForm({
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// ── Threshold hint derived from the headgate. ────────────────────
|
||||
const thresholdHint = useMemo(() => {
|
||||
const h = headgate.high_threshold;
|
||||
const l = headgate.low_threshold;
|
||||
const u = formatUnit(unit, lang);
|
||||
if (h == null && l == null) return null;
|
||||
if (h != null && l != null) {
|
||||
return t.log.normalRange(l, h, u);
|
||||
}
|
||||
if (h != null) return t.log.alertAbove(h, u);
|
||||
// TS narrowing: reaching this line means `h == null && l != null`.
|
||||
return t.log.alertBelow(l as number, u);
|
||||
}, [headgate, unit, lang, t]);
|
||||
// ── Threshold hint is now rendered by <ThresholdMeter /> below —
|
||||
// the meter derives its own zone state from the live value +
|
||||
// headgate thresholds, so the form no longer needs a
|
||||
// useMemo for the static caption. ─────────────────────
|
||||
|
||||
const submitted = (e?: FormEvent) => {
|
||||
e?.preventDefault();
|
||||
@@ -165,13 +156,21 @@ export function MobileWaterLogForm({
|
||||
paddingTop: "env(safe-area-inset-top)",
|
||||
}}
|
||||
>
|
||||
{/* ── Top nav ────────────────────────────────────────────── */}
|
||||
{/* ── Top nav (Apple HIG liquid glass) ─────────────────────── */}
|
||||
<header
|
||||
className="sticky top-0 z-30 flex items-center gap-2 border-b border-[rgba(60,60,67,0.12)] bg-[rgba(255,255,255,0.85)] px-2 py-2"
|
||||
className="sticky top-0 z-30 flex items-center gap-2 px-2 py-2"
|
||||
style={{
|
||||
paddingTop: "calc(env(safe-area-inset-top) + 4px)",
|
||||
backdropFilter: "blur(20px) saturate(180%)",
|
||||
WebkitBackdropFilter: "blur(20px) saturate(180%)",
|
||||
// Mirror the headgate-list chrome so the worker feels the
|
||||
// same shell as they navigate. Gradient + saturate(180%)
|
||||
// gives the modern iOS look; insets deliver the highlight
|
||||
// and hairline in one rule.
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(255,255,255,0.88) 0%, rgba(255,255,255,0.62) 100%)",
|
||||
backdropFilter: "blur(24px) saturate(180%)",
|
||||
WebkitBackdropFilter: "blur(24px) saturate(180%)",
|
||||
boxShadow:
|
||||
"inset 0 0.5px 0 0 rgba(255,255,255,1), inset 0 -0.5px 0 0 rgba(0,0,0,0.06)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
@@ -290,11 +289,16 @@ export function MobileWaterLogForm({
|
||||
{unitLabel}
|
||||
</span>
|
||||
</div>
|
||||
{thresholdHint && (
|
||||
<p className="mt-3 border-t border-[rgba(60,60,67,0.10)] pt-3 text-[13px] text-[rgba(60,60,67,0.6)]">
|
||||
{thresholdHint}
|
||||
</p>
|
||||
)}
|
||||
{/* Live threshold meter — the screen's signature. Shows
|
||||
* the entered value's position against the headgate's
|
||||
* configured low/high thresholds, with a color-coded
|
||||
* thumb and zone label. ──────────────────────────── */}
|
||||
<ThresholdMeter
|
||||
value={measurement}
|
||||
unit={unit}
|
||||
lowThreshold={headgate.low_threshold}
|
||||
highThreshold={headgate.high_threshold}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Unit segmented control — labels use the localized
|
||||
@@ -374,13 +378,22 @@ export function MobileWaterLogForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Sticky submit bar ───────────────────────────────── */}
|
||||
{/* ── Sticky submit bar (Apple HIG liquid glass) ──────────── */}
|
||||
{/* Gradient runs the OPPOSITE direction from the top nav —
|
||||
brighter where it meets the form (top edge), slightly
|
||||
more transparent where it flows into the safe area.
|
||||
The hairline lives on the top edge of the bar so it
|
||||
reads as a divider, not a frame. */}
|
||||
<div
|
||||
className="sticky bottom-0 z-20 border-t border-[rgba(60,60,67,0.10)] bg-[rgba(255,255,255,0.92)] px-4 py-3"
|
||||
className="sticky bottom-0 z-20 px-4 py-3"
|
||||
style={{
|
||||
paddingBottom: "max(env(safe-area-inset-bottom), 0.75rem)",
|
||||
backdropFilter: "blur(20px) saturate(180%)",
|
||||
WebkitBackdropFilter: "blur(20px) saturate(180%)",
|
||||
background:
|
||||
"linear-gradient(0deg, rgba(255,255,255,0.65) 0%, rgba(255,255,255,0.88) 100%)",
|
||||
backdropFilter: "blur(24px) saturate(180%)",
|
||||
WebkitBackdropFilter: "blur(24px) saturate(180%)",
|
||||
boxShadow:
|
||||
"inset 0 0.5px 0 0 rgba(0,0,0,0.06), inset 0 -0.5px 0 0 rgba(255,255,255,1)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -36,7 +36,14 @@
|
||||
* - `verifyTimeTrackingPin` → unifies PIN with Time tab (Cycle 4)
|
||||
* - `logoutTimeTracking` → clears the time-tracking cookie on logout
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
verifyWaterPin,
|
||||
submitWaterEntry,
|
||||
@@ -353,11 +360,18 @@ export function MobileWaterApp() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── TabBar (Cycle 4) ───────────────────────────────────────────
|
||||
// Sticky two-tab top nav for the Tuxedo worker experience.
|
||||
// Stays visible across Headgates → Log → Success AND on the Time
|
||||
// tab. Extracted as a presentational component (no server actions,
|
||||
// no effects) so it's pure and easy to iterate on visually.
|
||||
// ── TabBar (Cycle 4 → Apple HIG pass) ──────────────────────────
|
||||
// Sticky top nav for the Tuxedo worker experience. Stays visible
|
||||
// across Headgates → Log → Success AND on the Time tab.
|
||||
//
|
||||
// Two HIG principles in play:
|
||||
// 1. Use UISegmentedControl for the mode switch (not two pills).
|
||||
// The control is a true iOS segmented control: gray track,
|
||||
// sliding white pill, spring timing. The same widget appears
|
||||
// as the unit selector inside the log form, just bigger.
|
||||
// 2. Use a "liquid glass" chrome on the bar itself: gradient
|
||||
// background, strong saturate(180%), inner top highlight, and
|
||||
// a hairline at the bottom edge — not a flat colored band.
|
||||
function TabBar({
|
||||
activeTab,
|
||||
onChange,
|
||||
@@ -372,41 +386,35 @@ function TabBar({
|
||||
const t = useLang().t;
|
||||
return (
|
||||
<div
|
||||
className="sticky top-0 z-30 flex items-center justify-between border-b border-[rgba(60,60,67,0.12)] bg-white/85 px-3 py-2 backdrop-blur"
|
||||
role="tablist"
|
||||
aria-label="Worker navigation"
|
||||
className="sticky top-0 z-30 flex items-center justify-between px-3"
|
||||
style={{
|
||||
paddingTop: "env(safe-area-inset-top)",
|
||||
height: "calc(44px + env(safe-area-inset-top))",
|
||||
// "Liquid glass": top catches more light than the bottom,
|
||||
// so the gradient runs brighter at the top and slightly
|
||||
// more transparent below. Saturation boost makes any
|
||||
// background read with a hint of brand warmth as the
|
||||
// worker scrolls headgates past the bar.
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(255,255,255,0.88) 0%, rgba(255,255,255,0.62) 100%)",
|
||||
backdropFilter: "blur(24px) saturate(180%)",
|
||||
WebkitBackdropFilter: "blur(24px) saturate(180%)",
|
||||
// Inner highlight + hairline (one box-shadow with two
|
||||
// insets reads cheaper than separate pseudo-elements).
|
||||
boxShadow:
|
||||
"inset 0 0.5px 0 0 rgba(255,255,255,1), inset 0 -0.5px 0 0 rgba(0,0,0,0.06)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === "headgates"}
|
||||
onClick={() => onChange("headgates")}
|
||||
className={
|
||||
"rounded-full px-3 py-1 text-[13px] font-semibold transition " +
|
||||
(activeTab === "headgates"
|
||||
? "bg-[#1a4d2e] text-white shadow-sm"
|
||||
: "text-[rgba(60,60,67,0.7)] hover:bg-[rgba(60,60,67,0.08)]")
|
||||
}
|
||||
>
|
||||
{t.tab.headgates}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === "time"}
|
||||
onClick={() => onChange("time")}
|
||||
className={
|
||||
"rounded-full px-3 py-1 text-[13px] font-semibold transition " +
|
||||
(activeTab === "time"
|
||||
? "bg-[#1a4d2e] text-white shadow-sm"
|
||||
: "text-[rgba(60,60,67,0.7)] hover:bg-[rgba(60,60,67,0.08)]")
|
||||
}
|
||||
>
|
||||
{t.tab.time}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<MiniSegmented
|
||||
ariaLabel="Worker navigation"
|
||||
value={activeTab}
|
||||
onChange={onChange}
|
||||
options={[
|
||||
{ value: "headgates", label: t.tab.headgates },
|
||||
{ value: "time", label: t.tab.time },
|
||||
]}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5 pr-0.5">
|
||||
{irrigatorName && (
|
||||
<span className="hidden text-[12px] font-medium text-[rgba(60,60,67,0.55)] sm:inline">
|
||||
{irrigatorName}
|
||||
@@ -415,8 +423,8 @@ function TabBar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
className="rounded-full px-2.5 py-1 text-[12px] font-semibold text-[rgba(60,60,67,0.7)] hover:bg-[rgba(60,60,67,0.08)]"
|
||||
aria-label="Log out"
|
||||
aria-label={t.tab.logout}
|
||||
className="rounded-full px-2.5 py-1 text-[13px] font-semibold text-[#14532d] active:bg-[rgba(120,120,128,0.16)]"
|
||||
>
|
||||
{t.tab.logout}
|
||||
</button>
|
||||
@@ -425,4 +433,110 @@ function TabBar({
|
||||
);
|
||||
}
|
||||
|
||||
// ── MiniSegmented (nav-bar-sized UISegmentedControl) ──────────────
|
||||
// Sliding-pill variant of SegmentedControl tuned for the 32pt nav
|
||||
// bar context. Reuses the same measurement + spring-timing approach
|
||||
// as the larger control in `SegmentedControl.tsx` so the two feel
|
||||
// like one widget at different sizes.
|
||||
function MiniSegmented<V extends string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
}: {
|
||||
options: ReadonlyArray<{ value: V; label: ReactNode }>;
|
||||
value: V;
|
||||
onChange: (next: V) => void;
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Sliding indicator rect. The indicator uses
|
||||
// `transform: translateX(...)` with Apple's spring curve so the
|
||||
// transition reads as "physical," not "fade."
|
||||
const [rect, setRect] = useState<{ x: number; w: number } | null>(null);
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const root = containerRef.current;
|
||||
if (!root) return;
|
||||
const btn = root.querySelector<HTMLButtonElement>(
|
||||
`[data-seg-key="${value}"]`,
|
||||
);
|
||||
if (!btn) return;
|
||||
const rootRect = root.getBoundingClientRect();
|
||||
const btnRect = btn.getBoundingClientRect();
|
||||
// `x` is measured from the container's content edge (after the
|
||||
// 2px padding) so the indicator rides just inside the track.
|
||||
setRect({ x: btnRect.left - rootRect.left - 2, w: btnRect.width });
|
||||
}, [value]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
measure();
|
||||
}, [measure]);
|
||||
|
||||
// Re-measure whenever the option set changes (e.g. labels
|
||||
// translate via the Lang context and the active segment width
|
||||
// shifts) so the indicator stays in place.
|
||||
useLayoutEffect(() => {
|
||||
const root = containerRef.current;
|
||||
if (!root) return;
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(root);
|
||||
return () => ro.disconnect();
|
||||
}, [measure]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
role="tablist"
|
||||
aria-label={ariaLabel}
|
||||
className="relative inline-flex h-8 items-stretch rounded-[9px] p-[2px]"
|
||||
style={{ background: "rgba(120,120,128,0.20)" }}
|
||||
>
|
||||
{rect && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute rounded-[7px] bg-white"
|
||||
style={{
|
||||
top: 2,
|
||||
bottom: 2,
|
||||
left: 2,
|
||||
width: rect.w,
|
||||
transform: `translateX(${rect.x}px)`,
|
||||
boxShadow:
|
||||
"0 3px 8px rgba(0,0,0,0.06), 0 3px 1px rgba(0,0,0,0.04), inset 0 -0.5px 0 0 rgba(0,0,0,0.04), inset 0 0.5px 0 0 rgba(255,255,255,1)",
|
||||
transition:
|
||||
"transform 320ms cubic-bezier(0.32, 0.72, 0, 1), width 320ms cubic-bezier(0.32, 0.72, 0, 1)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{options.map((opt) => {
|
||||
const selected = opt.value === value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selected}
|
||||
data-seg-key={opt.value}
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={[
|
||||
"relative z-10 select-none px-3 text-[13px] font-semibold",
|
||||
"flex items-center justify-center",
|
||||
"min-w-[78px] active:opacity-80",
|
||||
selected ? "text-[#1d1d1f]" : "text-[rgba(60,60,67,0.65)]",
|
||||
].join(" ")}
|
||||
style={{
|
||||
height: 28,
|
||||
transition:
|
||||
"color 220ms cubic-bezier(0.32, 0.72, 0, 1)",
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{opt.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileWaterApp;
|
||||
@@ -73,14 +73,17 @@ export function MobileWaterSuccessScreen({
|
||||
paddingBottom: "env(safe-area-inset-bottom)",
|
||||
}}
|
||||
>
|
||||
{/* Brand strip — same as PIN screen for visual continuity. */}
|
||||
{/* Brand strip — same liquid-glass recipe as the PIN screen so
|
||||
the success state feels like a sibling, not a stranger. */}
|
||||
<header
|
||||
className="px-5 pb-3 pt-[max(env(safe-area-inset-top),1rem)]"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(255,255,255,0.92) 0%, rgba(242,242,247,0) 100%)",
|
||||
backdropFilter: "blur(10px)",
|
||||
WebkitBackdropFilter: "blur(10px)",
|
||||
"linear-gradient(180deg, rgba(255,255,255,0.92) 0%, rgba(255,255,255,0.62) 60%, rgba(242,242,247,0) 100%)",
|
||||
backdropFilter: "blur(24px) saturate(180%)",
|
||||
WebkitBackdropFilter: "blur(24px) saturate(180%)",
|
||||
boxShadow:
|
||||
"inset 0 -0.5px 0 0 rgba(0,0,0,0.04)",
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto flex max-w-md items-center justify-center gap-2">
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ThresholdMeter — a live "blip" indicator under the 44pt
|
||||
* measurement input that ties the form to its real domain (a
|
||||
* worker reading a real gauge) instead of being a decorative
|
||||
* gauge trophy.
|
||||
*
|
||||
* This is the screen's signature element. It mirrors Apple's
|
||||
* Volume Limit / Screen Time sliders: a neutral track with
|
||||
* colored zones (red → green → red) and a white thumb with a
|
||||
* colored ring that slides horizontally as the worker types.
|
||||
*
|
||||
* Visual layering, back to front:
|
||||
* 1. Neutral track (iOS systemGray, 6px tall, fully rounded).
|
||||
* 2. Optional colored zones (only when thresholds are set):
|
||||
* below-low · between low-high · above-high.
|
||||
* 3. Thumb (18px white circle with a 3px colored ring) that
|
||||
* uses `transform: translateX(...)` with Apple's standard
|
||||
* spring curve, so the motion reads as physical.
|
||||
* 4. Two-line status caption: left = color-coded zone label,
|
||||
* right = numeric scale (monospaced).
|
||||
*
|
||||
* Auto-grows the visual scale to fit the entered value when no
|
||||
* thresholds are configured, so the bar still has a usable
|
||||
* range even on first-run headgates without admin-configured
|
||||
* limits.
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { formatMeasurement, formatUnit } from "./format";
|
||||
import { isIntegerUnit } from "./types";
|
||||
import { useLang } from "@/lib/water-log/lang-context";
|
||||
|
||||
type Zone = "none" | "low" | "normal" | "high";
|
||||
|
||||
type Props = {
|
||||
/** Raw input value (string from the input). Empty → bar shows no thumb. */
|
||||
value: string;
|
||||
/** Selected unit. Captions + scale thumb reposition on changes. */
|
||||
unit: string;
|
||||
/** Headgate's configured low threshold, or null. */
|
||||
lowThreshold: number | null;
|
||||
/** Headgate's configured high threshold, or null. */
|
||||
highThreshold: number | null;
|
||||
};
|
||||
|
||||
// Apple system colors — pulled from HIG. Reusing the same palette
|
||||
// as the success button (systemGreen) and the error toast
|
||||
// (systemRed) so the meter's color language is already familiar
|
||||
// to anyone who's used the rest of the app.
|
||||
const COLOR_TRACK = "rgba(120,120,128,0.16)";
|
||||
const COLOR_NORMAL = "#34C759";
|
||||
const COLOR_ALERT = "#FF3B30";
|
||||
|
||||
export function ThresholdMeter({
|
||||
value,
|
||||
unit,
|
||||
lowThreshold,
|
||||
highThreshold,
|
||||
}: Props) {
|
||||
const { t, lang } = useLang();
|
||||
|
||||
const numeric = parseFloat(value);
|
||||
const hasValue =
|
||||
value.trim().length > 0 && Number.isFinite(numeric) && numeric >= 0;
|
||||
const integerUnit = isIntegerUnit(unit);
|
||||
const unitLabel = formatUnit(unit, lang);
|
||||
const hasThresholds = lowThreshold != null || highThreshold != null;
|
||||
|
||||
// ── Visual scale (max value the track represents) ────────
|
||||
// Grow to whichever is largest: 1.5× the high threshold (so
|
||||
// there's always a "tail" of alert zone past the alert line),
|
||||
// 2× the low threshold (so the in-range area starts before
|
||||
// the low edge — symmetric on either side), or 1.3× the
|
||||
// entered value (so the thumb isn't pinned at the right edge
|
||||
// for the typical first entry).
|
||||
const maxScale = useMemo(() => {
|
||||
const seed = hasValue ? numeric : 0;
|
||||
if (highThreshold != null && lowThreshold != null) {
|
||||
return Math.max(highThreshold * 1.5, lowThreshold * 0.6, seed * 1.2, 10);
|
||||
}
|
||||
if (highThreshold != null) {
|
||||
return Math.max(highThreshold * 1.5, seed * 1.2, 10);
|
||||
}
|
||||
if (lowThreshold != null) {
|
||||
return Math.max(lowThreshold * 1.8, seed * 1.3, 10);
|
||||
}
|
||||
return Math.max(seed * 1.3, 10);
|
||||
}, [hasValue, numeric, lowThreshold, highThreshold]);
|
||||
|
||||
// ── Marker position & zone fills ───────────────────────
|
||||
const markerPct = hasValue
|
||||
? Math.min(1, Math.max(0, numeric / maxScale))
|
||||
: 0;
|
||||
|
||||
const lowPct =
|
||||
lowThreshold != null
|
||||
? Math.min(1, Math.max(0, lowThreshold / maxScale))
|
||||
: 0;
|
||||
const highPct =
|
||||
highThreshold != null
|
||||
? Math.min(1, Math.max(0, highThreshold / maxScale))
|
||||
: 1;
|
||||
|
||||
// ── Zone classification ─────────────────────────────────
|
||||
const zone: Zone = !hasValue
|
||||
? "none"
|
||||
: lowThreshold != null && numeric < lowThreshold
|
||||
? "low"
|
||||
: highThreshold != null && numeric > highThreshold
|
||||
? "high"
|
||||
: "normal";
|
||||
|
||||
// ── Captions ────────────────────────────────────────────
|
||||
const displayValue = hasValue
|
||||
? formatMeasurement(numeric, integerUnit)
|
||||
: null;
|
||||
|
||||
let leftLabel: string;
|
||||
let leftColor: string;
|
||||
let rightLabel: string | null;
|
||||
|
||||
if (!hasValue) {
|
||||
if (hasThresholds && lowThreshold != null && highThreshold != null) {
|
||||
leftLabel = t.log.meterNormalRange(
|
||||
formatMeasurement(lowThreshold, integerUnit),
|
||||
formatMeasurement(highThreshold, integerUnit),
|
||||
unitLabel,
|
||||
);
|
||||
} else if (highThreshold != null) {
|
||||
leftLabel = t.log.alertAbove(
|
||||
formatMeasurement(highThreshold, integerUnit),
|
||||
unitLabel,
|
||||
);
|
||||
} else if (lowThreshold != null) {
|
||||
leftLabel = t.log.alertBelow(
|
||||
formatMeasurement(lowThreshold, integerUnit),
|
||||
unitLabel,
|
||||
);
|
||||
} else {
|
||||
leftLabel = t.log.zoneTypeToLog;
|
||||
}
|
||||
leftColor = "rgba(60,60,67,0.5)";
|
||||
rightLabel = null;
|
||||
} else if (zone === "low") {
|
||||
leftLabel = t.log.zoneBelowNormal;
|
||||
leftColor = COLOR_ALERT;
|
||||
rightLabel = t.log.alertBelow(
|
||||
formatMeasurement(lowThreshold as number, integerUnit),
|
||||
unitLabel,
|
||||
);
|
||||
} else if (zone === "high") {
|
||||
leftLabel = t.log.zoneAboveAlert;
|
||||
leftColor = COLOR_ALERT;
|
||||
rightLabel = t.log.alertAbove(
|
||||
formatMeasurement(highThreshold as number, integerUnit),
|
||||
unitLabel,
|
||||
);
|
||||
} else if (lowThreshold != null && highThreshold != null) {
|
||||
leftLabel = t.log.zoneInRange;
|
||||
leftColor = COLOR_NORMAL;
|
||||
rightLabel = t.log.meterNormalRange(
|
||||
formatMeasurement(lowThreshold, integerUnit),
|
||||
formatMeasurement(highThreshold, integerUnit),
|
||||
unitLabel,
|
||||
);
|
||||
} else if (highThreshold != null) {
|
||||
leftLabel = t.log.zoneInRange;
|
||||
leftColor = COLOR_NORMAL;
|
||||
rightLabel = t.log.alertAbove(
|
||||
formatMeasurement(highThreshold, integerUnit),
|
||||
unitLabel,
|
||||
);
|
||||
} else if (lowThreshold != null) {
|
||||
leftLabel = t.log.zoneAboveMin;
|
||||
leftColor = COLOR_NORMAL;
|
||||
rightLabel = t.log.alertBelow(
|
||||
formatMeasurement(lowThreshold, integerUnit),
|
||||
unitLabel,
|
||||
);
|
||||
} else {
|
||||
leftLabel = t.log.zoneReading;
|
||||
leftColor = "rgba(60,60,67,0.85)";
|
||||
rightLabel = null;
|
||||
}
|
||||
|
||||
// Append "· {value} {unit}" to the left label only when the
|
||||
// zone label is meaningful on its own. That keeps the empty /
|
||||
// no-threshold states readable (no trailing "· undefined CFS").
|
||||
const formattedReading = hasValue
|
||||
? `${displayValue} ${unitLabel}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="mt-4 border-t border-[rgba(60,60,67,0.08)] pt-3">
|
||||
{/* ── Track + zones + thumb ──────────────────────────── */}
|
||||
<div className="relative mx-1.5 h-[6px]">
|
||||
{/* 1. Neutral track */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 rounded-full"
|
||||
style={{ background: COLOR_TRACK }}
|
||||
/>
|
||||
{/* 2a. Below-low zone */}
|
||||
{hasThresholds && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute top-0 bottom-0 rounded-full"
|
||||
style={{
|
||||
left: 0,
|
||||
width: `${lowPct * 100}%`,
|
||||
background: COLOR_ALERT,
|
||||
opacity: 0.85,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* 2b. Normal zone */}
|
||||
{hasThresholds && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute top-0 bottom-0 rounded-full"
|
||||
style={{
|
||||
left: `${lowPct * 100}%`,
|
||||
width: `${Math.max(0, (highPct - lowPct) * 100)}%`,
|
||||
background: COLOR_NORMAL,
|
||||
opacity: 0.85,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* 2c. Above-high zone */}
|
||||
{hasThresholds && highThreshold != null && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute top-0 bottom-0 rounded-full"
|
||||
style={{
|
||||
left: `${highPct * 100}%`,
|
||||
width: `${Math.max(0, (1 - highPct) * 100)}%`,
|
||||
background: COLOR_ALERT,
|
||||
opacity: 0.85,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* 3. Thumb */}
|
||||
{hasValue && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute"
|
||||
style={{
|
||||
top: "50%",
|
||||
left: `${markerPct * 100}%`,
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 9999,
|
||||
background: "white",
|
||||
border:
|
||||
zone === "low" || zone === "high"
|
||||
? `3px solid ${COLOR_ALERT}`
|
||||
: zone === "normal"
|
||||
? `3px solid ${COLOR_NORMAL}`
|
||||
: "3px solid rgba(60,60,67,0.6)",
|
||||
boxShadow:
|
||||
"0 2px 5px rgba(0,0,0,0.12), 0 0 0 0.5px rgba(0,0,0,0.06)",
|
||||
transition:
|
||||
"left 240ms cubic-bezier(0.32, 0.72, 0, 1), border-color 200ms ease-out",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* ── Caption (zone label + scale range) ─────────────── */}
|
||||
<div className="mt-2.5 flex items-baseline justify-between gap-3">
|
||||
<span
|
||||
className="truncate text-[12px] font-semibold tabular-nums"
|
||||
style={{
|
||||
color: leftColor,
|
||||
transition: "color 220ms ease-out",
|
||||
}}
|
||||
>
|
||||
{leftLabel}
|
||||
{formattedReading && (
|
||||
<>
|
||||
<span className="mx-1.5 font-normal opacity-50">·</span>
|
||||
<span className="tabular-nums">{formattedReading}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{rightLabel && zone === "normal" && (
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-[rgba(60,60,67,0.5)]">
|
||||
{rightLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThresholdMeter;
|
||||
@@ -102,9 +102,29 @@ export type Labels = {
|
||||
submitError: string;
|
||||
/** Defensive guard — shouldn't ever happen. */
|
||||
noHeadgateSelected: string;
|
||||
normalRange: (low: number, high: number, unit: string) => string;
|
||||
alertAbove: (high: number, unit: string) => string;
|
||||
alertBelow: (low: number, unit: string) => string;
|
||||
/** Accepts pre-formatted strings so callers can render the same
|
||||
* shape (integer-cleaned, locale-aware) the meter uses. */
|
||||
normalRange: (low: string, high: string, unit: string) => string;
|
||||
alertAbove: (value: string, unit: string) => string;
|
||||
alertBelow: (value: string, unit: string) => string;
|
||||
// ThresholdMeter zone labels. Names only — the value + unit are
|
||||
// rendered beside each label so we don't have to re-localize
|
||||
// the variables. Strings live here so translators see them in
|
||||
// context with the rest of `log.*` instead of under a separate
|
||||
// namespace that nobody updates.
|
||||
zoneBelowNormal: string;
|
||||
zoneAboveAlert: string;
|
||||
zoneInRange: string;
|
||||
/** Only a `low` threshold configured; value is below it. */
|
||||
zoneBelowAlert: string;
|
||||
/** Only a `low` threshold configured; value is above it. */
|
||||
zoneAboveMin: string;
|
||||
/** Value entered, but no thresholds configured. */
|
||||
zoneReading: string;
|
||||
/** Empty value, no thresholds configured. */
|
||||
zoneTypeToLog: string;
|
||||
/** Compact "Normal 2.5 – 8 CFS" form for the meter's right side. */
|
||||
meterNormalRange: (low: string, high: string, unit: string) => string;
|
||||
};
|
||||
/** `/water` success screen after submit. */
|
||||
success: {
|
||||
@@ -208,8 +228,18 @@ export const LABELS: Record<Lang, Labels> = {
|
||||
noHeadgateSelected: "Pick a headgate first.",
|
||||
normalRange: (low, high, unit) =>
|
||||
`Normal range: ${low} – ${high} ${unit}`,
|
||||
alertAbove: (high, unit) => `Alert above ${high} ${unit}`,
|
||||
alertBelow: (low, unit) => `Alert below ${low} ${unit}`,
|
||||
alertAbove: (value, unit) => `Alert above ${value} ${unit}`,
|
||||
alertBelow: (value, unit) => `Alert below ${value} ${unit}`,
|
||||
// ThresholdMeter zone labels — see type def above.
|
||||
zoneBelowNormal: "Below normal",
|
||||
zoneAboveAlert: "Above alert",
|
||||
zoneInRange: "In range",
|
||||
zoneBelowAlert: "Below alert",
|
||||
zoneAboveMin: "Above minimum",
|
||||
zoneReading: "Reading",
|
||||
zoneTypeToLog: "Type a reading above",
|
||||
meterNormalRange: (low, high, unit) =>
|
||||
`Normal ${low} – ${high} ${unit}`,
|
||||
},
|
||||
success: {
|
||||
title: "Reading logged",
|
||||
@@ -305,8 +335,18 @@ export const LABELS: Record<Lang, Labels> = {
|
||||
noHeadgateSelected: "Elige una compuerta primero.",
|
||||
normalRange: (low, high, unit) =>
|
||||
`Rango normal: ${low} – ${high} ${unit}`,
|
||||
alertAbove: (high, unit) => `Alerta arriba de ${high} ${unit}`,
|
||||
alertBelow: (low, unit) => `Alerta abajo de ${low} ${unit}`,
|
||||
alertAbove: (value, unit) => `Alerta arriba de ${value} ${unit}`,
|
||||
alertBelow: (value, unit) => `Alerta abajo de ${value} ${unit}`,
|
||||
// ThresholdMeter zone labels — Spanish.
|
||||
zoneBelowNormal: "Bajo lo normal",
|
||||
zoneAboveAlert: "Sobre alerta",
|
||||
zoneInRange: "En rango",
|
||||
zoneBelowAlert: "Bajo alerta",
|
||||
zoneAboveMin: "Sobre el mínimo",
|
||||
zoneReading: "Lectura",
|
||||
zoneTypeToLog: "Ingresa una lectura arriba",
|
||||
meterNormalRange: (low, high, unit) =>
|
||||
`Normal ${low} – ${high} ${unit}`,
|
||||
},
|
||||
success: {
|
||||
title: "Lectura registrada",
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Regression tests for `src/actions/time-tracking/route-summary.ts` →
|
||||
* `getFleetRouteSummary`.
|
||||
*
|
||||
* Why this exists: the original implementation referenced
|
||||
* `fw.display_name` and `fw.pin` in its SQL, but the unified
|
||||
* `field_workers` table (cycle 10) uses `name` and `pin_hash`. The
|
||||
* wrong-column reference crashed the `/admin/time-tracking` overview
|
||||
* page on render with `column "fw.display_name" does not exist`. No
|
||||
* test caught it because nothing exercised this code path.
|
||||
*
|
||||
* These tests mock the bare minimum (admin user + pg pool) and assert:
|
||||
* 1. The SQL passed to `pool.query` references the correct columns
|
||||
* (`fw.name`, `fw.pin_hash`) and NOT the legacy names.
|
||||
* 2. The row mapper projects `r.name` / `r.pin_hash` (not the
|
||||
* legacy keys).
|
||||
* 3. Empty result set yields `[]` without crashing.
|
||||
* 4. Boundary: an unknown date still produces a valid SQL query
|
||||
* and a typed return shape.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const { mockGetAdminUser, mockQuery } = vi.hoisted(() => {
|
||||
return {
|
||||
mockGetAdminUser: vi.fn(),
|
||||
mockQuery: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
vi.mock("@/lib/admin-permissions", () => ({
|
||||
getAdminUser: mockGetAdminUser,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
getPool: () => ({ query: mockQuery }),
|
||||
}));
|
||||
|
||||
// Stub the withBrand call inside `getRouteTimeSummary` so importing the
|
||||
// module doesn't try to resolve drizzle at test time.
|
||||
vi.mock("@/db/client", () => ({
|
||||
withBrand: vi.fn(async (_brandId: string, fn: (db: unknown) => unknown) =>
|
||||
fn({ select: () => ({ from: () => ({ where: () => ({ limit: () => [] }) }) }) }),
|
||||
),
|
||||
}));
|
||||
|
||||
// Stub the cross-module call into the admin time-tracking settings.
|
||||
vi.mock("@/actions/time-tracking", () => ({
|
||||
getTimeTrackingSettings: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
// Imported AFTER all mocks are wired.
|
||||
import { getFleetRouteSummary } from "@/actions/time-tracking/route-summary";
|
||||
|
||||
const BRAND_ID = "11111111-2222-3333-4444-555555555555";
|
||||
const TODAY = "2026-07-06";
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetAdminUser.mockReset();
|
||||
mockQuery.mockReset();
|
||||
mockGetAdminUser.mockResolvedValue({ id: "admin-1", role: "platform_admin" });
|
||||
});
|
||||
|
||||
describe("getFleetRouteSummary — SQL column contract", () => {
|
||||
it("references fw.name and fw.pin_hash (NOT the legacy fw.display_name / fw.pin)", async () => {
|
||||
mockQuery.mockResolvedValueOnce({ rows: [] });
|
||||
|
||||
await getFleetRouteSummary(BRAND_ID, TODAY);
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledTimes(1);
|
||||
const [sql] = mockQuery.mock.calls[0];
|
||||
|
||||
expect(sql).toContain("fw.name");
|
||||
expect(sql).toContain("fw.pin_hash");
|
||||
// The legacy columns must NOT appear anywhere in the query —
|
||||
// catching them in the SELECT, JOIN, or ORDER BY is the whole
|
||||
// point of this test.
|
||||
expect(sql).not.toMatch(/fw\.display_name/);
|
||||
expect(sql).not.toMatch(/\bfw\.pin\b/);
|
||||
});
|
||||
|
||||
it("queries with the brand id and ISO date bounds (regression: off-by-one date math)", async () => {
|
||||
mockQuery.mockResolvedValueOnce({ rows: [] });
|
||||
|
||||
await getFleetRouteSummary(BRAND_ID, TODAY);
|
||||
|
||||
const [, params] = mockQuery.mock.calls[0];
|
||||
expect(params).toHaveLength(3);
|
||||
expect(params[0]).toBe(BRAND_ID);
|
||||
// Lower bound is the requested date at UTC midnight; upper bound
|
||||
// is the NEXT day at UTC midnight (24-hour window, half-open).
|
||||
expect(params[1]).toBe(`${TODAY}T00:00:00.000Z`);
|
||||
const upper = new Date(params[2] as string);
|
||||
const lower = new Date(params[1] as string);
|
||||
expect(upper.getTime() - lower.getTime()).toBe(24 * 60 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFleetRouteSummary — row projection", () => {
|
||||
it("maps the row's `name` field onto workerName and falls back when name is null", async () => {
|
||||
mockQuery.mockResolvedValueOnce({
|
||||
rows: [
|
||||
{
|
||||
id: "worker-1",
|
||||
name: "Tyler",
|
||||
pin_hash: "scrypt$abc123$xyz",
|
||||
active: true,
|
||||
water_entries: "3",
|
||||
water_gallons: "120",
|
||||
worked_minutes: "240",
|
||||
is_clocked_in: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const rows = await getFleetRouteSummary(BRAND_ID, TODAY);
|
||||
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
workerId: "worker-1",
|
||||
workerName: "Tyler",
|
||||
active: true,
|
||||
water: { entryCount: 3, totalGallons: 120 },
|
||||
time: { workedMinutesToday: 240, isClockedIn: true },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to a 6-char pin_hash fingerprint when name is null (not the legacy pin column)", async () => {
|
||||
mockQuery.mockResolvedValueOnce({
|
||||
rows: [
|
||||
{
|
||||
id: "worker-2",
|
||||
name: null,
|
||||
pin_hash: "scrypt$ABCDEF$rest",
|
||||
active: false,
|
||||
water_entries: "0",
|
||||
water_gallons: null,
|
||||
worked_minutes: "0",
|
||||
is_clocked_in: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const rows = await getFleetRouteSummary(BRAND_ID, TODAY);
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].workerName).toBe("scrypt");
|
||||
expect(rows[0].active).toBe(false);
|
||||
expect(rows[0].water.totalGallons).toBe(0);
|
||||
expect(rows[0].time.workedMinutesToday).toBe(0);
|
||||
expect(rows[0].time.isClockedIn).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFleetRouteSummary — boundaries + failure modes", () => {
|
||||
it("returns [] when there are no active workers in the brand", async () => {
|
||||
mockQuery.mockResolvedValueOnce({ rows: [] });
|
||||
|
||||
const rows = await getFleetRouteSummary(BRAND_ID, TODAY);
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] when the caller is unauthenticated (admin gate)", async () => {
|
||||
mockGetAdminUser.mockResolvedValueOnce(null);
|
||||
|
||||
const rows = await getFleetRouteSummary(BRAND_ID, TODAY);
|
||||
expect(rows).toEqual([]);
|
||||
// Critical: we must not have hit the pool if auth failed.
|
||||
expect(mockQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defaults to today's UTC date when no date argument is supplied", async () => {
|
||||
mockQuery.mockResolvedValueOnce({ rows: [] });
|
||||
|
||||
await getFleetRouteSummary(BRAND_ID);
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledTimes(1);
|
||||
const [, params] = mockQuery.mock.calls[0];
|
||||
// The bounds must form a one-day window; lower < upper, and
|
||||
// lower must be UTC midnight on today's date.
|
||||
const lower = new Date(params[1] as string);
|
||||
const upper = new Date(params[2] as string);
|
||||
expect(lower.getUTCHours()).toBe(0);
|
||||
expect(upper.getTime() - lower.getTime()).toBe(24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("coerces null numeric aggregates to 0 (water_gallons / worked_minutes can be null)", async () => {
|
||||
mockQuery.mockResolvedValueOnce({
|
||||
rows: [
|
||||
{
|
||||
id: "worker-3",
|
||||
name: "Pat",
|
||||
pin_hash: "scrypt$XXX",
|
||||
active: true,
|
||||
water_entries: "0",
|
||||
water_gallons: null,
|
||||
worked_minutes: null,
|
||||
is_clocked_in: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const rows = await getFleetRouteSummary(BRAND_ID, TODAY);
|
||||
|
||||
expect(rows[0].water.totalGallons).toBe(0);
|
||||
expect(rows[0].water.entryCount).toBe(0);
|
||||
expect(rows[0].time.workedMinutesToday).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -252,21 +252,21 @@ describe("Mobile label helpers (interpolation)", () => {
|
||||
});
|
||||
|
||||
it("log.normalRange interpolates the bounds + unit", () => {
|
||||
expect(LABELS.en.log.normalRange(2, 8, "CFS")).toBe(
|
||||
expect(LABELS.en.log.normalRange("2", "8", "CFS")).toBe(
|
||||
"Normal range: 2 – 8 CFS",
|
||||
);
|
||||
expect(LABELS.es.log.normalRange(2, 8, "CFS")).toBe(
|
||||
expect(LABELS.es.log.normalRange("2", "8", "CFS")).toBe(
|
||||
"Rango normal: 2 – 8 CFS",
|
||||
);
|
||||
});
|
||||
|
||||
it("log.alertAbove / alertBelow interpolate single bound + unit", () => {
|
||||
expect(LABELS.en.log.alertAbove(10, "CFS")).toBe("Alert above 10 CFS");
|
||||
expect(LABELS.es.log.alertAbove(10, "CFS")).toBe(
|
||||
expect(LABELS.en.log.alertAbove("10", "CFS")).toBe("Alert above 10 CFS");
|
||||
expect(LABELS.es.log.alertAbove("10", "CFS")).toBe(
|
||||
"Alerta arriba de 10 CFS",
|
||||
);
|
||||
expect(LABELS.en.log.alertBelow(1, "CFS")).toBe("Alert below 1 CFS");
|
||||
expect(LABELS.es.log.alertBelow(1, "CFS")).toBe("Alerta abajo de 1 CFS");
|
||||
expect(LABELS.en.log.alertBelow("1", "CFS")).toBe("Alert below 1 CFS");
|
||||
expect(LABELS.es.log.alertBelow("1", "CFS")).toBe("Alerta abajo de 1 CFS");
|
||||
});
|
||||
|
||||
it("success.loggedBy interpolates the irrigator name", () => {
|
||||
|
||||
Reference in New Issue
Block a user