14 Commits

Author SHA1 Message Date
Tyler 19377ea6cf feat(tuxedo): Apple-quality motion choreography
Deploy to route.crispygoat.com / deploy (push) Successful in 4m42s
Adds four new motion primitives in src/components/ui/ScrollAnimations.tsx
and wires them into the hero, PullQuoteBand, TheCorn, FounderStrip,
HarvestEditorial. Existing FadeOnScroll/ScrollReveal/ParallaxLayer are
left untouched so other pages (and the rest of Tuxedo) keep their
current vestibular-safe motion.

New primitives (all reduced-motion safe):
  AppleReveal       opacity 0→1 + Y 18→0 + blur(6px)→0
                    power3.out (≈ cubic-bezier(0.16, 1, 0.3, 1))
                    0.55s primary, 0.4s secondary, fires at 'top 88%'
                    one-shot toggleActions, supports immediate mount
  AppleStagger      wraps a set of [data-apple-stagger] children, fires
                    them with 0.06s stagger; ideal for editorial cascade
                    (eyebrow → h2 → rule → lede → supporting copy)
  AppleStickySection pins the first child to the top while siblings
                    scroll past; adds 6% scroll-linked yPercent drift for
                    depth. PullQuoteBand now uses this for the pre-storm
                    field photograph.
  AppleCountUp      refined count-up: power3.out, 1.6s. Replaces the
                    HarvestEditorial 2s linear-feeling counters.

Hero upgrades:
  - Title split into <span class='hero-word'> so each word cascades
    in with its own blur-up; logo → eyebrow → rule → lede → CTAs →
    stats get the block-level Apple reveal with 0.08s stagger.
  - Scroll-fade holds through the FULL hero scroll length
    (start top top, end bottom top, scrub 0.6) instead of fading by
    40% which made the type disappear before users finished reading.
  - Scroll-indicator fade uses scrub 0.5 (was 1.0) so it's physically
    tied to the scrollbar.
  - Logo uses plain div (was framer-motion); the GSAP pass animates
    all hero-reveal elements with a single consistent curve.

Editorial sections unified:
  TheCorn, FounderStrip, HarvestEditorial headers now use AppleStagger
  for their eyebrow → headline → rule → lede cascades. The harvest
  data strip uses AppleCountUp.

Also brings 'Upcoming Stops' into the magazine type system:
  font-display + clamp() H2, tracking-[0.32em] eyebrow, leading-[1.65]
  body — matches TheCorn/HarvestEditorial instead of the raw
  text-3xl/font-black treatment.
2026-07-07 09:36:35 -06:00
Nora c71354d2ae tuxedo: align storefront subpages with Field Almanac editorial voice
Deploy to route.crispygoat.com / deploy (push) Successful in 4m25s
Reskin /tuxedo/contact, /tuxedo/faq, /tuxedo/stops, and
/tuxedo/stops/[id] so they match the homepage's editorial
magazine language (Fraunces display serif, stone-50/stone-950
alternation, amber rims, hairline rules, tabular numerals).

- About page rewritten as inlined editorial sections sharing
  one WP_IMAGES library with the homepage; the four lazy-loaded
  Tuxedo about components are removed (Indian River has its own
  variants).
- Contact page now uses a dark hero with heroField half-bleed,
  a hairline-ruled info strip (address/phone/email), a two-column
  form section with bottom-border editorial inputs, and a closing
  Fraunces italic quote. /tuxedo/contact was 404ing in prod because
  page.tsx was missing — created and removed the duplicated render
  from layout.tsx.
- FAQ page uses a cream hero with Fraunces headline, an editorial
  bottom-border search, hairline-ruled accordion rows (no card
  wrappers), and a dark closing CTA with Contact pill.
- Stops list uses a dark hero with heroField bleed and a cream
  hairline-ruled sidebar list: tabular date numerals on the left,
  city/location middle, mono time + cutoff right, chevron for
  upcoming rows.
- Stop detail uses a dark hero pairing the city/state headline
  with a giant tabular day stamp, a cream hairline-ruled 4-col
  info strip (Date/Time/Location/Order by), and the editorial
  products header. Removed the dual-brand isBlue branching now
  that Indian River has its own page.
- Extracted TUXEDO_IMAGES to src/components/storefront/tuxedo-images.ts
  so the homepage, about, contact, and stops list share one
  brand-specific asset library.
- /tuxedo/faq/layout.tsx no longer double-renders the page
  component (was rendering <FAQClientPage /> alongside children).

Verified: tsc --noEmit clean, eslint clean (only pre-existing
homepage warnings), npm run build produces all Tuxedo routes
including the now-existing /tuxedo/contact.
2026-07-06 14:40:18 -06:00
Nora da09cfbd1a feat(tuxedo): editorial photo color grade across hero/pull-quote/founder/packshot
Deploy to route.crispygoat.com / deploy (push) Successful in 4m15s
Pulls the saturated-yellow WP-import JPGs down a half-step in the brightness hierarchy so the white type stays dominant and the photos read as editorial, not product photography.

- globals.css: new filter utilities (.filter-editorial, .filter-editorial-shell with cool/amber split-tone, .filter-editorial-strong, .filter-editorial-vignette) + reduced-motion reset
- TuxedoVideoHero: grade the Ken Burns backdrop image
- tuxedo/page.tsx: apply grades to PullQuoteBand, TheCorn ear macro, FounderStrip (also swap to the retouched founder image from b90f0fa), and HarvestEditorial packshot; bump founder quality 88 -> 92
2026-07-06 14:23:22 -06:00
Nora 68851cfede feat(tuxedo): magazine editorial — altitude ribbon, harvest spread, data strip
Deploy to route.crispygoat.com / deploy (push) Successful in 4m29s
Adds a 'Cereal-magazine-did-a-feature' editorial moment to the Tuxedo
homepage instead of a templated farm-stand layout. Three pieces:

1. Hero (TuxedoVideoHero)
   - Adds '5,360 FT · UNCOMPAHGRE VALLEY' masthead chip next to the
     existing eyebrow, hairline-divided like a magazine cover line.
     Reinforces the geographic / altitude voice of the brand.
   - Adds slow Ken Burns drift on the poster image (26s alternate,
     scale 1.02 → 1.10 + tiny translate). Plays under the video while
     loading; if the video fails, the poster now breathes instead of
     sitting static. Honors prefers-reduced-motion.
   - Bumps grain overlay opacity 0.04 → 0.085 for editorial texture.

2. New HarvestEditorial section (page.tsx), placed after FounderStrip
   and before StorySection per user direction.
   - Full-bleed packing-shed packshot (corn-field-zane-original.jpg,
     WP-import, 2500×1406) with subtle harvest-drift Ken Burns and a
     hairline-bordered provenance plate ('HARVEST MORNING · AUGUST ·
     OLATHE, CO') + photo credit.
   - Magazine 'BY THE NUMBERS' data strip — four cells with large
     Fraunces tabular numerals (40+, 3, 5,360, ~60), amber uppercase
     labels, and editorial details. Hairline amber rules top + bottom.
     Count-up animation reuses the existing .counter-animate GSAP
     setup so no new infra. No icons, no boxes, no color highlights —
     reads as a magazine sidebar.
   - Italic editorial caption in Fraunces with attribution: 'You can't
     replicate the diurnal swing in a greenhouse…' — John Harold,
     second generation.

3. globals.css: adds the @keyframes harvest-drift + reduced-motion
   guard for the new packshot drift (26s alternate, scale 1.04 → 1.10).

Verified on prod 2026-07-06:
  - No failed network requests
  - Hero altitude ribbon text present
  - HarvestEditorial section renders at expected scroll position
  - Packshot: complete=true, natural 1280×720, rendered 1232×587
  - All 4 data points present with correct labels & details
  - Counter values animate 0 → target when section enters viewport
  - Mobile (390px) renders cleanly with correct wrap
2026-07-06 13:55:48 -06:00
Nora b90f0fae52 fix(tuxedo): copy public/ to standalone, mark storm/founder priority
Deploy to route.crispygoat.com / deploy (push) Successful in 4m17s
Three issues caused the 'images don't work, video doesn't play' report on
https://water.tuxedocorn.com/tuxedo:

1. (ROOT CAUSE) Next.js standalone build was missing .next/standalone/public/.
   The deploy workflow copied .next/static to .next/standalone/.next/static
   but never mirrored public/. Without that, the image optimizer returns
   400 'url parameter is not allowed' on every local-relative URL — so the
   brand logo (and favicon, etc.) went blank. Symptom: HTTP 400 on
   /_next/image?url=%2Fbrand-logos%2F64294306-...%2Flogo.png.

   Fixed by:
   - cp -r public/. .next/standalone/public/ on the running server (live)
   - updating .gitea/workflows/deploy.yml to mirror public/ on every deploy
   - adding scripts/regression-brand-logos.mjs so this can't regress silently

2. Storm-field and founder-portrait images rendered as 'loading=lazy' and
   appeared blank above the fold. Marked priority so they preload.

3. Pre-existing build error: Next.js 16.2 RSC bundler treats
   'export type { FieldSession }' re-exports through server-action modules
   as runtime exports ('Export FieldSession doesn't exist in target
   module'). Removed the re-exports in water-log/auth.ts and field.ts;
   field.ts now imports FieldSession directly from @/lib/water-log/session-server.

Verified live (post-fix) on 2026-07-06:
  - regression-brand-logos.mjs: HTTP 200 image/png, header+footer logos
    render with naturalWidth=160
  - diag-home-only.mjs: all 6 images complete=true, natural sizes match
    source (storm 1440x1080, founder 604x340, ear 692x476, etc.)
  - verify-hero-video.mjs: readyState=4, paused=false, currentTime
    advancing 2.86s -> 6.87s over 4s (=30fps; the prior 'video doesn't
    play' was likely user perception — the poster+video look near-
    identical, and 'net::ERR_ABORTED' on the MP4 is normal Chrome
    behavior for parallel byte-range requests)
2026-07-06 13:33:54 -06:00
Nora 5a72705e71 fix(ops): switch pm2 to standalone server + fix wholesale_settings query
Deploy to route.crispygoat.com / deploy (push) Successful in 4m14s
Three problems were silently breaking the prod Tuxedo redesign and
the offline DB error swallowed all visibility into them:

1. `next start` against `output: "standalone"` is unsupported. Next.js
   prints "next start does not work with output: standalone" and
   silently disables the image optimizer — every `/_next/image?url=...`
   returned "url parameter is not allowed". pm2 was previously
   started as `pm2 start npm -- start -- -p 3100`. Switched to
   `node /home/tyler/route-commerce/scripts/start-standalone.cjs`.

2. The standalone server reads `.next/static/` relative to itself, so
   deploy must `cp -r .next/static .next/standalone/.next/static` on
   every sync. Without this, every `_next/static/chunks/*.js` returns
   404 and the page hydration dies. Added to the deploy workflow.

3. bash's `set -a; . ./.env` truncates DATABASE_URL at the `&` in
   `&channel_binding=require`, so the standalone server boots with
   empty DB env. `getBrandSettingsPublic` then caught the empty-pool
   error and returned `{success:false, error:'Failed to fetch brand settings'}`,
   so `state.heroImageUrl` stayed null and the hero poster never
   rendered. The new `scripts/start-standalone.cjs` parses .env in
   Node (which handles `&` correctly) and exec's the server with the
   loaded env.

4. `getBrandSettingsPublic` queried `ws.wholesale_enabled`, which
   no longer exists in `wholesale_settings`. The schema migration
   renamed it to `online_payment_enabled`. Switched the column
   reference and added a console.error log so future drift surfaces
   instead of being swallowed by the empty catch.

Plus `next.config.ts` had `hostname: "s3.crispygoat.com"` added
under `images.remotePatterns` so the optimizer accepts MinIO URLs.
2026-07-06 12:50:43 -06:00
Nora 129c9d253c feat(tuxedo): polish home with WP imagery
Deploy to route.crispygoat.com / deploy (push) Successful in 4m22s
Swap the templated 8-tile Why-grid for three image-driven editorial
moments using photos pulled from the tuxedocorn.com WordPress library:

  - PullQuoteBand: full-bleed pre-storm field with single Fraunces quote
  - TheCorn: single paragraph + ear macro, replaces feature grid
  - FounderStrip: John Harold portrait + family narrative

Hero poster switched from the tight cob macro to the wide irrigation
row with Colorado sky and cumulus clouds. The video underneath stays.

Editorial copy tightened. Display type moved to Fraunces for headlines
and timeline markers to give the page a real voice instead of
default sans throughout. Tone: "you can taste the altitude."
2026-07-06 12:03:36 -06:00
Grok f8434685a8 fix(migration): drop GRANT anon line (Neon has no anon role)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m7s
2026-07-06 11:19:33 -06:00
Grok 60783131ae fix(tuxedo): graceful hero video fallback + storefront polish
Deploy to route.crispygoat.com / deploy (push) Failing after 1m27s
Hero video at /videos/tuxedo-hero.mp4 was hardcoded and 404ing, leaving
the page with no background.

* Add brand_settings.hero_video_url (migration 0099) so each brand
  can point its hero at any reachable mp4/webm without code changes.
  Saves via a separate UPDATE so the existing 32-param
  upsert_brand_settings RPC signature stays untouched.
* TuxedoVideoHero now accepts videoUrl + posterUrl. Poster renders
  underneath the <video> element via next/image so the hero is
  complete even without a video. <video> flips a data-failed attribute
  on error/stall so the poster takes over without DOM churn.
  Ambient float animations slowed 950ms → 7s/8s.
* Why section: dropped emerald/amber kaleidoscope, unified accent.
  Footer is now a provenance strip (Family-Grown · 5,360 ft · Est 1982).
* Story section: removed giant decorative 'SINCE' parallax text and
  the duplicate stats counter (already in hero). Replaced with a
  1982/2003/Today timeline.
2026-07-06 11:09:31 -06:00
Tyler 772e23ded8 fix(pwa): bump field-shell cache to v2 — evicts stale UI for installed PWAs
Deploy to route.crispygoat.com / deploy (push) Successful in 4m7s
The Apple HIG polish pass (liquid-glass chrome, iOS SegmentedControl,
ThresholdMeter) was already on origin/main and live, but anyone with
the /water PWA installed on their home screen kept seeing the OLD UI
because the SW cache name never bumped.

Old SW had field-shell-v1 baked into its bytes for every prior deploy.
The activate handler only deletes caches that aren't SHELL_CACHE or
DATA_CACHE and start with 'field-'. Same name = no eviction = stale
HTML + chunks forever.

Bumping SHELL_CACHE to 'field-shell-v2' makes the SW bytes change,
which triggers the normal SW upgrade flow:
  install → skipWaiting → activate → claim clients
The activate handler then evicts field-shell-v1 (it's now a foreign
field-* cache). On next navigation the new SHELL_CACHE is repopulated
with the fresh /water HTML + static assets, and the user gets the new
UI.
2026-07-06 10:39:52 -06:00
Tyler 5dc9a4604f Apple HIG pass: liquid-glass chrome + iOS SegmentedControl + ThresholdMeter
Deploy to route.crispygoat.com / deploy (push) Successful in 4m22s
Water log mobile field experience (PIN → Headgates → Log → Success):

* Brand rename: "Tuxedo Ditch Co." → "Tuxedo Corn" everywhere it
  was a stale reference (page metadata + admin Today tab caption).
  Constants already say Tuxedo Corn; this just aligns the stragglers.

* Tab bar (Headgates | Time): pill pair → real UISegmentedControl.
  MiniSegmented lives next to TabBar in MobileWaterApp.tsx and uses
  the same ResizeObserver-measure + spring-timing pattern as the
  existing SegmentedControl — feels like one widget at two sizes
  (this one at 32pt, the unit selector at 44pt).

* Liquid-glass chrome (recipe applied 5×, mirrored on the bottom
  submit bar): linear-gradient background + blur(24px) +
  saturate(180%) + inset top highlight + inset bottom hairline.
  TabBar, HeadgateList top nav, LogForm top nav, LogForm sticky
  submit bar, FieldPinScreen + SuccessScreen brand strips.

* ThresholdMeter (new component, mobile/ThresholdMeter.tsx): the
  screen's signature. 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. Track + optional colored zones (red·green·red)
  + thumb that slides on Apple's spring curve + color-coded
  zone label. Mirrors Apple's Volume Limit / Screen Time sliders.

* i18n: 9 new zone labels per language (zoneBelowNormal,
  zoneAboveAlert, zoneInRange, zoneBelowAlert, zoneAboveMin,
  zoneReading, zoneTypeToLog, meterNormalRange + ES twins).
  Legacy normalRange/alertAbove/alertBelow signatures widened
  to accept pre-formatted strings so integer units render clean.

* Updated water-log-i18n unit test for the wider signatures.
  All 27 tests pass; tsc + eslint clean.
2026-07-06 10:32:36 -06:00
Nora 5188960bd2 fix(time-tracking): keep workers signed in for 7 days
Deploy to route.crispygoat.com / deploy (push) Successful in 3m42s
Field workers were forced to re-enter their PIN every 12 hours. That's
noticeable friction on devices that stay signed in for a week or more.
Bumped COOKIE_MAX_AGE to 7 days (604800s) so a worker who clocks out
on Friday doesn't see the PIN screen Monday morning.

The cookie is httpOnly + secure (in prod) + sameSite=lax, so the
extended lifetime doesn't materially change the threat surface. The
expires_at inside the cookie value is recomputed from the same
constant, so the in-cookie expiry and the browser-side Max-Age agree.

Field workers still log out explicitly via the Hub 'Sign out' tile
(or the server-side /api time-tracking/notify handler) when they
want to end the session sooner. Forced re-auth only happens when the
cookie actually expires or the worker logs out.
2026-07-06 10:14:00 -06:00
Nora 6f1aa37a26 fix(time-tracking): inherit lang from water-log and stop PIN flash
Deploy to route.crispygoat.com / deploy (push) Successful in 4m18s
Two UX bugs in the worker Time Tracking mobile flow:

1) Spanish parity between Water Log and Time Tracking. The Water Log
   mobile app (/water) uses the wl_lang cookie for its language; the
   Time Tracking field client used its own time_tracking_lang cookie.
   A worker who set Spanish on /water then tapped the Time tab saw
   English. getLangCookie() now falls back to wl_lang when
   time_tracking_lang is unset, so the Time tab inherits the worker's
   Water Log preference. Standalone /tuxedo/time-clock is unaffected
   (no wl_lang cookie there).

2) PIN screen flash on tab change. initialState started at screen
   'pin', so the init effect that resolves the session cookie + open
   clock-in caused a one-tick flash of the PIN screen even when the
   worker was already authenticated. initialState now starts at
   'loading' (a spinner) and the init effect dispatches the correct
   screen (hub / working / pin) once the session check resolves.

Public contract unchanged: exported types, function signatures, and
the cookie name on writes are all preserved. The change is additive
on read (extra cookie fallback) and a one-key swap on initial state.

Verified locally: tsc clean, lint unchanged (354 problems = baseline),
vitest 226 pass / 29 fail (same pre-existing auth failures, none
related to this change).
2026-07-06 10:05:55 -06:00
Nora c7c83890e4 fix(time-tracking): use correct field_workers columns in fleet summary
Deploy to route.crispygoat.com / deploy (push) Has been cancelled
The /admin/time-tracking overview page crashed on render with
`column "fw.display_name" does not exist`. Cycle 10 unified
`time_tracking_workers` + `water_irrigators` into `field_workers`,
which uses `name` and `pin_hash` (not the legacy `display_name` /
`pin` from `admin_users`). This fix aligns the SQL, row type, and
result mapper with the actual schema.

End-to-end verified against water.tuxedocorn.com with worker PIN 2229:
PIN auth, task picker, clock-in, and clock-out all succeed with no
console errors and no failed HTTP requests.

Includes a regression test that fails on the buggy SQL (4/8 fail) and
passes after the fix (8/8 pass). Covers the column contract, row
projection, empty-result + unauthenticated boundaries, and null
aggregate coercion.
2026-07-06 09:56:13 -06:00
41 changed files with 4265 additions and 1614 deletions
+35 -4
View File
@@ -204,9 +204,40 @@ jobs:
# Install deps and restart on server # Install deps and restart on server
echo "Installing deps and restarting PM2..." echo "Installing deps and restarting PM2..."
# IMPORTANT: --update-env so PM2 re-reads .env. Without it PM2 keeps # IMPORTANT: With `output: "standalone"` in next.config.ts we MUST
# the original env vars cached at first start and silently ignores # run `node .next/standalone/server.js` (not `next start`) — `next start`
# newly added ones (like SMARTSHEET_*). # against a standalone build is unsupported and silently disables the
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; }" # 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" echo "Deployed successfully"
+16
View File
@@ -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.';
+6
View File
@@ -34,6 +34,12 @@ const nextConfig: NextConfig = {
protocol: "https", protocol: "https",
hostname: "picsum.photos", 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"], formats: ["image/avif", "image/webp"],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
+6 -1
View File
@@ -12,7 +12,12 @@
// Cache names are deliberately prefixed with `field-` so they never // Cache names are deliberately prefixed with `field-` so they never
// collide with the main `rc-shell-v*` / `rc-data-v*` caches. // 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 DATA_CACHE = "field-data-v1";
const OFFLINE_URL = "/offline.html"; const OFFLINE_URL = "/offline.html";
+82
View File
@@ -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.');
}
+62
View File
@@ -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));
}
+26 -3
View File
@@ -216,6 +216,7 @@ export type BrandSettings = {
show_text_alerts: boolean | null; show_text_alerts: boolean | null;
schedule_pdf_notes: string | null; schedule_pdf_notes: string | null;
hero_image_url: string | null; hero_image_url: string | null;
hero_video_url: string | null;
// Color customization // Color customization
brand_primary_color: string | null; brand_primary_color: string | null;
brand_secondary_color: string | null; brand_secondary_color: string | null;
@@ -261,7 +262,7 @@ export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBran
await getSession(); try { await getSession(); try {
const { rows } = await pool.query<BrandSettings & { wholesale_enabled?: boolean | null }>( 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 FROM brands b
JOIN brand_settings bs ON bs.brand_id = b.id JOIN brand_settings bs ON bs.brand_id = b.id
LEFT JOIN wholesale_settings ws ON ws.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]; const data = rows[0];
if (!data) { if (!data) {
console.warn(`[getBrandSettingsPublic] No row for slug=${brandSlug}`);
return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined }; return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
} }
return { return {
@@ -278,7 +280,8 @@ await getSession(); try {
settings: data, settings: data,
wholesaleEnabled: data.wholesale_enabled, 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 }; return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
} }
} }
@@ -311,6 +314,7 @@ export async function saveBrandSettings(params: {
showTextAlerts?: boolean; showTextAlerts?: boolean;
schedulePdfNotes?: string; schedulePdfNotes?: string;
heroImageUrl?: string; heroImageUrl?: string;
heroVideoUrl?: string;
brandPrimaryColor?: string; brandPrimaryColor?: string;
brandSecondaryColor?: string; brandSecondaryColor?: string;
brandBgColor?: string; brandBgColor?: string;
@@ -372,7 +376,26 @@ await getSession(); const adminUser = await getAdminUser();
params.nexusStates ?? null, 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) { } catch (err) {
const message = err instanceof Error ? err.message : "Failed to save"; const message = err instanceof Error ? err.message : "Failed to save";
return { success: false, error: `Failed to save: ${message.slice(0, 200)}` }; return { success: false, error: `Failed to save: ${message.slice(0, 200)}` };
+6 -1
View File
@@ -46,7 +46,12 @@ export type TimeTrackingSession = {
// ── Cookie helpers ───────────────────────────────────────────────────────── // ── Cookie helpers ─────────────────────────────────────────────────────────
const SESSION_COOKIE = "time_tracking_session"; 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 { function sessionCookieValue(session: TimeTrackingSession): string {
return [ return [
+13 -5
View File
@@ -207,10 +207,14 @@ export async function getFleetRouteSummary(
end.setUTCDate(end.getUTCDate() + 1); end.setUTCDate(end.getUTCDate() + 1);
const pool = getPool(); 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<{ const { rows } = await pool.query<{
id: string; id: string;
display_name: string | null; name: string | null;
pin: string; pin_hash: string;
active: boolean; active: boolean;
water_entries: string; water_entries: string;
water_gallons: string | null; water_gallons: string | null;
@@ -219,8 +223,8 @@ export async function getFleetRouteSummary(
}>( }>(
`SELECT `SELECT
fw.id, fw.id,
fw.display_name, fw.name,
fw.pin, fw.pin_hash,
fw.active, fw.active,
COALESCE(w.cnt, 0)::text AS water_entries, COALESCE(w.cnt, 0)::text AS water_entries,
COALESCE(w.gal, 0)::text AS water_gallons, COALESCE(w.gal, 0)::text AS water_gallons,
@@ -252,9 +256,13 @@ export async function getFleetRouteSummary(
[brandId, start.toISOString(), end.toISOString()], [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) => ({ return rows.map((r) => ({
workerId: r.id, 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, active: r.active,
water: { water: {
entryCount: Number(r.water_entries ?? 0), entryCount: Number(r.water_entries ?? 0),
+9 -4
View File
@@ -40,11 +40,16 @@ import {
// ── Result types ──────────────────────────────────────────────────────── // ── Result types ────────────────────────────────────────────────────────
// `FieldSession` is now defined in `@/lib/water-log/session.ts` so the // `FieldSession` is now defined in `@/lib/water-log/session-server.ts`
// `FieldSession | { ok: false, error }` shape is shared with // so the `FieldSession | { ok: false, error }` shape is shared with
// `lib/water-log/pin-login.ts` (Phase 2) and the rest of the module // `lib/water-log/pin-login.ts` (Phase 2) and the rest of the module
// surface. Re-export for back-compat with existing call sites. // surface. Consumers should import as:
export type { FieldSession }; // `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 = export type SiteAdminPermission =
| { ok: true; adminUser: NonNullable<Awaited<ReturnType<typeof getAdminUser>>> } | { ok: true; adminUser: NonNullable<Awaited<ReturnType<typeof getAdminUser>>> }
+6 -2
View File
@@ -32,10 +32,10 @@ import { verifyPin, validatePin } from "@/lib/water-log-pin";
import { logAlert } from "@/lib/water-log-audit"; import { logAlert } from "@/lib/water-log-audit";
import { import {
requireFieldSession, requireFieldSession,
type FieldSession,
} from "@/actions/water-log/auth"; } from "@/actions/water-log/auth";
import { import {
WL_SESSION_COOKIE, WL_SESSION_COOKIE,
type FieldSession,
clearSessionCookie, clearSessionCookie,
readSessionCookie, readSessionCookie,
setSessionCookie, setSessionCookie,
@@ -45,7 +45,11 @@ import { triggerSyncForEntry } from "@/actions/water-log/smartsheet";
// Re-export so existing call sites that import from // Re-export so existing call sites that import from
// `@/actions/water-log/field` keep working. // `@/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 // Field sessions last 8h — a working day in the field. Long enough
// that a single sign-in covers a morning shift + afternoon shift. // that a single sign-in covers a morning shift + afternoon shift.
+104
View File
@@ -1383,3 +1383,107 @@ select:-webkit-autofill:focus {
-webkit-backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
border-top: 1px solid rgba(0, 0, 0, 0.05); 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;
}
}
+550 -99
View File
@@ -1,15 +1,12 @@
"use client"; "use client";
import { Suspense, lazy, useState } from "react";
import Image from "next/image"; 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 StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter"; import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import { TUXEDO_IMAGES as WP_IMAGES } from "@/components/storefront/tuxedo-images";
const MissionSection = lazy(() => import("@/components/about/MissionSection")); import { FadeOnScroll } from "@/components/ui/ScrollAnimations";
const FamilyTimelineSection = lazy(() => import("@/components/about/FamilyTimelineSection"));
const ContactSection = lazy(() => import("@/components/about/ContactSection"));
const CTASection = lazy(() => import("@/components/about/CTASection"));
interface AboutClientProps { interface AboutClientProps {
logoUrl: string | null; logoUrl: string | null;
@@ -28,7 +25,6 @@ interface AboutClientProps {
export default function AboutClient({ export default function AboutClient({
logoUrl, logoUrl,
logoUrlDark, logoUrlDark,
olatheSweetLogoUrlDark,
aboutHeadline, aboutHeadline,
aboutSubheadline, aboutSubheadline,
showWholesaleLink, showWholesaleLink,
@@ -51,101 +47,25 @@ export default function AboutClient({
/> />
<main> <main>
{/* Hero Section */} <HeroSection
<section className="relative bg-stone-950 overflow-hidden"> headline={aboutHeadline ?? "Three generations of sweet corn excellence."}
{/* Decorative elements */} subheadline={
<div className="absolute inset-0 opacity-20"> aboutSubheadline ??
<div className="absolute top-0 left-1/4 w-96 h-96 bg-emerald-900/20 rounded-full blur-3xl" /> "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."
<div className="absolute bottom-0 right-1/4 w-80 h-80 bg-amber-900/10 rounded-full blur-3xl" /> }
</div> />
{/* Pattern overlay */} <TheCornSection />
<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)' }} />
<div className="relative mx-auto max-w-6xl px-6 py-24 md:py-36"> <PullQuoteBand />
<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>
<motion.h1 <FounderStrip />
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>
<motion.p <FamilyTimeline />
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>
<motion.div <VisitTheFarm address={address} />
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>
{/* Logo */} <CTASection />
<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>
</main> </main>
<StorefrontFooter <StorefrontFooter
@@ -163,6 +83,537 @@ export default function AboutClient({
); );
} }
function LoadingFallback({ bg }: { bg: string }) { // ── HERO — dark editorial opener ────────────────────────────────────
return <div className={`py-24 md:py-32 ${bg}`} />; // 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&rsquo;t
airbrush the field because we don&rsquo;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" }}
>
&ldquo;The field decides the sweetness.<br className="hidden sm:block" />{" "}
<span className="text-amber-200/95">We just pick it on time.&rdquo;</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&rsquo;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: "1990sPresent",
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&rsquo;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={["MonFri · 7A6P", "SatSun · 8A2P"]}
/>
</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]">
&ldquo;We don&rsquo;t ship corn that&rsquo;s more than a day from
the field. The valley won&rsquo;t let us.&rdquo;
</p>
<p className="mt-5 text-center text-[10px] uppercase tracking-[0.32em] text-stone-500">
&mdash; 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>
);
}
+527 -167
View File
@@ -1,11 +1,13 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import LayoutContainer from "@/components/layout/LayoutContainer";
import StorefrontHeader from "@/components/storefront/StorefrontHeader"; import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter"; import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import LayoutContainer from "@/components/layout/LayoutContainer"; import { TUXEDO_IMAGES } from "@/components/storefront/tuxedo-images";
import { supabase } from "@/lib/supabase"; import { FadeOnScroll } from "@/components/ui/ScrollAnimations";
type BrandSettings = { type BrandSettings = {
invoice_business_name: string | null; invoice_business_name: string | null;
@@ -22,6 +24,11 @@ type FormState = {
message: string; 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() { export default function TuxedoContactPage() {
const [brandSettings, setBrandSettings] = useState<BrandSettings | null>(null); const [brandSettings, setBrandSettings] = useState<BrandSettings | null>(null);
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
@@ -30,12 +37,16 @@ export default function TuxedoContactPage() {
useEffect(() => { useEffect(() => {
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
supabase import("@/lib/supabase").then(({ supabase }) => {
.from("wholesale_settings") supabase
.select("invoice_business_name, invoice_business_address, invoice_business_phone, invoice_business_email, invoice_business_website") .from("wholesale_settings")
.eq("brand_id", TUXEDO_BRAND_ID) .select(
.single() "invoice_business_name, invoice_business_address, invoice_business_phone, invoice_business_email, invoice_business_website"
.then(({ data }) => setBrandSettings((data as unknown as BrandSettings) ?? null)); )
.eq("brand_id", TUXEDO_BRAND_ID)
.single()
.then(({ data }) => setBrandSettings((data as unknown as BrandSettings) ?? null));
});
}, []); }, []);
function handleSubmit(e: React.FormEvent) { function handleSubmit(e: React.FormEvent) {
@@ -47,182 +58,531 @@ export default function TuxedoContactPage() {
}, 600); }, 600);
} }
const address = brandSettings?.invoice_business_address ?? FALLBACK_ADDRESS;
const email = brandSettings?.invoice_business_email ?? FALLBACK_EMAIL;
return ( return (
<div className="min-h-screen bg-stone-50"> <div className="min-h-screen bg-stone-50">
<StorefrontHeader brandName="Tuxedo Corn" brandSlug="tuxedo" brandAccent="green" /> <StorefrontHeader brandName="Tuxedo Corn" brandSlug="tuxedo" brandAccent="green" />
<main className="py-16 md:py-20"> <main>
<LayoutContainer> <ContactHero />
<div className="max-w-5xl mx-auto">
{/* Page header */} {/* Contact info strip — hairline-ruled four-column data grid,
<div className="text-center mb-16"> same magazine-sidebar register as the About page's
<p className="text-[11px] font-semibold uppercase tracking-widest text-emerald-600 mb-4">Get in Touch</p> "Visit the Farm" section. */}
<h1 className="text-5xl md:text-6xl font-black tracking-tight text-stone-950 leading-tight"> <ContactInfoStrip
Contact Us address={address}
</h1> email={email}
<p className="mt-5 text-lg text-stone-500 max-w-xl mx-auto leading-relaxed"> shippingPhone={SHIPPING_PHONE}
Questions about corn, stops, or wholesale accounts we are happy to help. officePhone={OFFICE_PHONE}
</p> />
</div>
{/* Contact info grid */} {/* Form section — cream stone-50, two-column layout. Left
<div className="grid gap-6 md:grid-cols-3 mb-16"> column carries the editorial framing (eyebrow + Fraunces
<div className="rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-200/60 text-center"> headline + paragraph + small hours caption). Right column
<div className="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-50 mb-5"> holds the form itself, no wrapper white card. */}
<svg className="h-5 w-5 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}> <section className="relative bg-stone-50 py-24 sm:py-32 border-t border-stone-200/60">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" /> <LayoutContainer>
<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" /> <div className="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-16">
</svg> {/* Left: editorial framing */}
</div> <div className="lg:col-span-5">
<h3 className="text-base font-bold text-stone-950 mb-3">Farm Address</h3> <FadeOnScroll from="up">
<p className="text-sm text-stone-500 leading-relaxed"> <p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-emerald-700 mb-5">
{brandSettings?.invoice_business_address ?? "59751 David Road, Olathe, CO 81425"} Send a Message
</p> </p>
</div> </FadeOnScroll>
<div className="rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-200/60 text-center"> <FadeOnScroll from="up" delay={0.08}>
<div className="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-50 mb-5"> <h2
<svg className="h-5 w-5 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}> className="font-display text-stone-950 text-[clamp(2rem,4.2vw,3.25rem)] leading-[1.05] tracking-[-0.02em]"
<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" /> style={{ textWrap: "balance" }}
</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"
> >
Send another message Tell us what you&rsquo;re after,
</button> <br className="hidden sm:block" />{" "}
</div> <span className="italic text-amber-700/90">we&rsquo;ll write back.</span>
) : ( </h2>
<form onSubmit={handleSubmit} className="space-y-6"> </FadeOnScroll>
<div className="grid gap-6 md:grid-cols-2"> <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&rsquo;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> <div>
<label htmlFor="fld-1-your-name" className="block text-sm font-semibold text-stone-700 mb-2">Your Name</label> <dt className="text-[10px] font-bold uppercase tracking-[0.3em] text-emerald-700">
<input id="fld-1-your-name" aria-label="Jane Smith" Weekdays
required </dt>
value={form.name} <dd className="mt-2 font-mono tabular-nums text-stone-950">
onChange={(e) => setForm({ ...form, name: e.target.value })} 7A 6P
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" </dd>
placeholder="Jane Smith"
/>
</div> </div>
<div> <div>
<label htmlFor="fld-2-email" className="block text-sm font-semibold text-stone-700 mb-2">Email</label> <dt className="text-[10px] font-bold uppercase tracking-[0.3em] text-emerald-700">
<input id="fld-2-email" aria-label="Jane@example.com" Weekends
required </dt>
type="email" <dd className="mt-2 font-mono tabular-nums text-stone-950">
value={form.email} 8A 2P
onChange={(e) => setForm({ ...form, email: e.target.value })} </dd>
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"
/>
</div> </div>
</div> </dl>
<div> </FadeOnScroll>
<label htmlFor="fld-3-topic" className="block text-sm font-semibold text-stone-700 mb-2">Topic</label> </div>
<select id="fld-3-topic" aria-label="Select"
value={form.topic} {/* Right: form */}
onChange={(e) => setForm({ ...form, topic: e.target.value })} <div className="lg:col-span-7">
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" <FadeOnScroll from="right">
> {submitted ? (
<option value="general">General Question</option> <FormSuccess
<option value="orders">Orders & Pickup</option> onReset={() => {
<option value="wholesale">Wholesale Inquiry</option> setSubmitted(false);
<option value="media">Media & Partnership</option> setForm({ name: "", email: "", topic: "general", message: "" });
</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?"
/> />
</div> ) : (
<button <ContactForm
type="submit" form={form}
disabled={isSubmitting} setForm={setForm}
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" onSubmit={handleSubmit}
> isSubmitting={isSubmitting}
{isSubmitting ? "Sending..." : "Send Message"} />
</button> )}
</form> </FadeOnScroll>
)} </div>
</div> </div>
</LayoutContainer>
</section>
{/* Business hours — elevated */} <ContactClosingQuote />
<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>
</main> </main>
<StorefrontFooter brandName="Tuxedo Corn" brandSlug="tuxedo" brandAccent="green" /> <StorefrontFooter brandName="Tuxedo Corn" brandSlug="tuxedo" brandAccent="green" />
</div> </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 12 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&rsquo;ll be in touch within one to two business days. If it&rsquo;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" }}
>
&ldquo;Pick up the phone. Write us a note. Either way, you&rsquo;re
reaching a person who knows the field.&rdquo;
</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 -2
View File
@@ -1,5 +1,4 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import ContactClientPage from "./ContactClientPage";
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com"; 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 }) { export default function TuxedoContactLayout({ children }: { children: React.ReactNode }) {
return <ContactClientPage />; return <>{children}</>;
} }
+5
View File
@@ -0,0 +1,5 @@
import ContactClientPage from "./ContactClientPage";
export default function TuxedoContactPage() {
return <ContactClientPage />;
}
+270 -104
View File
@@ -1,9 +1,11 @@
"use client"; "use client";
import { useState, useMemo } from "react"; import { useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import LayoutContainer from "@/components/layout/LayoutContainer";
import StorefrontHeader from "@/components/storefront/StorefrontHeader"; import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter"; import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import { FadeOnScroll } from "@/components/ui/ScrollAnimations";
type FAQItem = { type FAQItem = {
question: string; question: string;
@@ -16,15 +18,18 @@ const FAQ_CATEGORIES = [
items: [ items: [
{ {
question: "How do I place a preorder for corn?", 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?", 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?", 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: [ items: [
{ {
question: "Do you ship corn?", 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?", 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?", 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 37 business days.",
}, },
], ],
}, },
@@ -50,11 +58,13 @@ const FAQ_CATEGORIES = [
items: [ items: [
{ {
question: "How do I set up a wholesale account?", 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 12 business days. Wholesale accounts are available to retailers, restaurants, and farm stands.",
}, },
{ {
question: "Is there a minimum order for wholesale?", 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: [ items: [
{ {
question: "What makes Olathe Sweet different?", 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?", 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?", 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 35 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 [open, setOpen] = useState<string | null>(null);
const filtered = useMemo(() => { const filtered = useMemo(() => {
@@ -90,47 +109,74 @@ function FAQAccordion({ items, searchQuery }: { items: FAQItem[]; searchQuery: s
); );
}, [items, searchQuery]); }, [items, searchQuery]);
if (filtered.length === 0) {
return (
<div className="py-10 text-center">
<p className="text-stone-500 text-sm">
No results for &ldquo;{searchQuery}&rdquo;
</p>
<p className="mt-1 text-stone-400 text-sm">
Try a different term or browse the categories below.
</p>
</div>
);
}
return ( return (
<div className="space-y-2"> <ul className="divide-y divide-stone-900/15 border-t border-b border-stone-900/15">
{filtered.length === 0 ? ( {filtered.map((item) => {
<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"> const isOpen = open === item.question;
<p className="text-stone-500 text-sm">No results for &quot;{searchQuery}&quot;</p> return (
<p className="mt-1 text-stone-400 text-sm">Try a different term or browse all categories below.</p> <li key={item.question}>
</div> <button
) : ( type="button"
<> onClick={() => setOpen(isOpen ? null : item.question)}
{filtered.map((item) => { className="group flex w-full items-start justify-between gap-6 py-6 sm:py-7 text-left"
const isOpen = open === item.question; aria-expanded={isOpen}
return ( >
<div <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">
key={item.question} {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" </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" <svg
onClick={() => setOpen(isOpen ? null : item.question)} className="w-4 h-4"
className="flex w-full items-center justify-between px-4 sm:px-6 py-4 sm:py-5 text-left group" fill="none"
aria-expanded={isOpen} 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"> <path
{item.question} strokeLinecap="round"
</span> strokeLinejoin="round"
<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"}`}> d="M12 4.5v15m7.5-7.5h-15"
<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>
</svg> </span>
</span> </button>
</button> <div
<div className={`overflow-hidden transition-all duration-300 ease-out ${isOpen ? "max-h-48 opacity-100" : "max-h-0 opacity-0"}`}> className={`grid transition-all duration-300 ease-out ${
<div className="px-4 sm:px-6 pb-5 sm:pb-6 pt-1 border-t border-stone-100"> isOpen
<p className="text-sm text-stone-500 leading-relaxed">{item.answer}</p> ? "grid-rows-[1fr] opacity-100 pb-6 sm:pb-7"
</div> : "grid-rows-[0fr] opacity-0"
</div> }`}
>
<div className="overflow-hidden">
<p className="text-stone-600 leading-[1.7] max-w-3xl pr-10">
{item.answer}
</p>
</div> </div>
); </div>
})} </li>
</> );
)} })}
</div> </ul>
); );
} }
@@ -141,63 +187,183 @@ export default function TuxedoFAQPage() {
<div className="min-h-screen bg-stone-50"> <div className="min-h-screen bg-stone-50">
<StorefrontHeader brandName="Tuxedo Corn" brandSlug="tuxedo" brandAccent="green" /> <StorefrontHeader brandName="Tuxedo Corn" brandSlug="tuxedo" brandAccent="green" />
<main className="py-12 sm:py-16 md:py-20 px-4 sm:px-6"> <main>
<div className="max-w-3xl mx-auto"> {/* Hero — cream stone-50 with editorial framing. */}
{/* Page header */} <section className="relative bg-stone-50 pt-20 pb-12 sm:pt-24 sm:pb-16 border-b border-stone-200/60">
<div className="text-center mb-10 sm:mb-14"> <LayoutContainer>
<p className="text-[10px] sm:text-[11px] font-semibold uppercase tracking-widest text-emerald-600 mb-3 sm:mb-4">Questions</p> <div className="mx-auto max-w-3xl text-center">
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-black tracking-tight text-stone-950 leading-tight">FAQ</h1> <FadeOnScroll from="up">
<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> <p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-emerald-700 mb-5">
</div> Help · Frequently Asked
</p>
{/* Search */} </FadeOnScroll>
<div className="mb-8 sm:mb-12 relative"> <FadeOnScroll from="up" delay={0.08}>
<div className="absolute inset-y-0 left-4 sm:left-5 flex items-center pointer-events-none"> <h1
<svg className="h-4 w-4 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> className="font-display text-stone-950 text-[clamp(2.5rem,5.2vw,4.25rem)] leading-[1.02] tracking-[-0.02em]"
<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" /> style={{ textWrap: "balance" }}
</svg> >
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> </div>
<input aria-label="Search Questions..." </LayoutContainer>
type="text" </section>
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>
{/* FAQ categories */} {/* Search — editorial bottom-border-only input. */}
<div className="space-y-10 sm:space-y-14"> <section className="relative bg-stone-50 py-12 sm:py-16">
{FAQ_CATEGORIES.map((cat) => ( <LayoutContainer>
<div key={cat.category}> <div className="mx-auto max-w-3xl">
<h2 className="text-[10px] sm:text-xs font-bold uppercase tracking-[0.2em] text-stone-400 mb-4 sm:mb-5">{cat.category}</h2> <FadeOnScroll from="up">
<FAQAccordion items={cat.items} searchQuery={searchQuery} /> <div className="relative">
</div> <label htmlFor="faq-search" className="sr-only">
))} Search questions
</div> </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 */} {/* Categories — each rendered as a category with hairline-ruled
<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"> accordion list. No card wrappers; each item is a single row. */}
<p className="text-xl sm:text-2xl font-black text-white tracking-tight">Still have questions?</p> <section className="relative bg-stone-50 pb-24 sm:pb-32">
<p className="mt-2 text-stone-400 text-sm">We are happy to help reach out anytime.</p> <LayoutContainer>
<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"> <div className="mx-auto max-w-3xl space-y-16 sm:space-y-20">
Contact Us {FAQ_CATEGORIES.map((cat) => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <div key={cat.category}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /> <FadeOnScroll from="up">
</svg> <p className="text-[10px] font-bold uppercase tracking-[0.32em] text-emerald-700 mb-6">
</Link> {cat.category}
</div> </p>
</div> </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&rsquo;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> </main>
<StorefrontFooter brandName="Tuxedo Corn" brandSlug="tuxedo" brandAccent="green" /> <StorefrontFooter brandName="Tuxedo Corn" brandSlug="tuxedo" brandAccent="green" />
+1 -2
View File
@@ -1,5 +1,4 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import FAQClientPage from "./FAQClientPage";
import { serializeJsonForScript } from "@/lib/safe-json"; import { serializeJsonForScript } from "@/lib/safe-json";
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com"; 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" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: serializeJsonForScript(organizationJsonLd) }} dangerouslySetInnerHTML={{ __html: serializeJsonForScript(organizationJsonLd) }}
/> />
<FAQClientPage /> {children}
</> </>
); );
} }
+477 -394
View File
File diff suppressed because it is too large Load Diff
+312 -110
View File
@@ -1,11 +1,13 @@
"use client"; "use client";
import { useEffect, useRef } from "react"; import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { gsap } from "gsap"; import { gsap } from "gsap";
import LayoutContainer from "@/components/layout/LayoutContainer";
import StorefrontHeader from "@/components/storefront/StorefrontHeader"; import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter"; 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"; import type { PublicStop } from "@/actions/stops";
type Props = { type Props = {
@@ -16,22 +18,30 @@ type Props = {
export default function TuxedoStopsList({ stops, brandName, brandSlug }: Props) { export default function TuxedoStopsList({ stops, brandName, brandSlug }: Props) {
const containerRef = useRef<HTMLDivElement>(null); 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(() => { useEffect(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
const ctx = gsap.context(() => { const ctx = gsap.context(() => {
gsap.from(".stop-card", { gsap.from(".stop-row", {
y: 30, y: 16,
opacity: 0, opacity: 0,
duration: 0.5, duration: 0.32,
stagger: 0.08, stagger: 0.04,
ease: "power3.out", ease: "power1.out",
scrollTrigger: {
trigger: containerRef.current,
start: "top 90%",
toggleActions: "play none none none",
},
}); });
}, containerRef); }, containerRef);
return () => ctx.revert(); return () => ctx.revert();
}, []); }, []);
const now = new Date();
const upcomingStops = stops.filter((s) => new Date(s.date) >= now); const upcomingStops = stops.filter((s) => new Date(s.date) >= now);
const pastStops = 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"> <div ref={containerRef} className="min-h-screen bg-stone-50">
<StorefrontHeader brandName={brandName} brandSlug={brandSlug} brandAccent="green" /> <StorefrontHeader brandName={brandName} brandSlug={brandSlug} brandAccent="green" />
<main className="py-16 md:py-20"> <main>
<LayoutContainer> {/* Hero — dark editorial opener with the field photograph as a
<div className="max-w-4xl mx-auto mb-12 text-center"> soft half-bleed at the bottom, same register as the
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-emerald-600 mb-4"> Contact page hero. */}
{brandName} <section className="relative bg-stone-950 overflow-hidden isolate">
</p> {/* Soft field image, bottom-anchored, partially visible
<h1 className="text-4xl md:text-5xl font-black tracking-tight text-stone-950 mb-4"> behind the headline column. */}
Pickup Stops <div className="absolute inset-x-0 bottom-0 h-2/3 -z-10">
</h1> <Image
<p className="text-stone-500 text-lg"> src={TUXEDO_IMAGES.heroField}
Fresh Olathe Sweet&trade; sweet corn delivered to a stop near you. alt=""
</p> fill
<div className="mt-6 h-1 w-12 bg-emerald-600 mx-auto rounded-full" /> 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>
<div
aria-hidden="true"
className="absolute inset-x-0 bottom-0 h-px bg-amber-300/30 z-10"
/>
{upcomingStops.length === 0 ? ( <LayoutContainer>
<div className="max-w-2xl mx-auto text-center py-16"> <div className="relative py-20 sm:py-28 lg:py-32 max-w-3xl">
<div className="w-20 h-20 rounded-2xl bg-stone-100 flex items-center justify-center mx-auto mb-6"> <p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-amber-300/85 mb-6">
<svg className="w-10 h-10 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> {brandName} · Summer 2026
<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.
</p> </p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-3"> <h1
<Link className="font-display text-stone-50 text-[clamp(2.5rem,5.4vw,4.5rem)] leading-[1.02] tracking-[-0.02em]"
href="/tuxedo" style={{ textWrap: "balance" }}
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" >
> Stops
Back to Tuxedo Corn <br className="hidden sm:block" />{" "}
</Link> <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&trade; sweet corn delivered to a stop
near you. Preorder for pickup, or just show up we&rsquo;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 <Link
href="/api/tuxedo/schedule-pdf" href="/api/tuxedo/schedule-pdf"
download 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> </Link>
</div> </div>
</div> </div>
) : ( </LayoutContainer>
<div className="max-w-4xl mx-auto space-y-3"> </section>
{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>
<div className="flex-1 min-w-0"> {/* Stops list — hairline-ruled magazine sidebar style. Each
<h3 className="text-lg font-bold text-stone-950 group-hover:text-emerald-700 transition-colors"> row is a single horizontal hairline + date numerals + city
{stop.city}, {stop.state} + location + time + chevron. No cards, no gradients. */}
</h3> <section className="relative bg-stone-50 py-20 sm:py-24">
<p className="text-sm text-stone-500 truncate">{stop.location}</p> <LayoutContainer>
{stop.address && ( {upcomingStops.length === 0 ? (
<p className="text-xs text-stone-400 truncate">{stop.address}</p> <EmptyStops />
)} ) : (
</div> <div className="mx-auto max-w-5xl">
{/* Eyebrow + hairline-ruled list header */}
<div className="flex items-center gap-4"> <div className="flex items-baseline justify-between gap-4 mb-8">
<div className="text-right"> <p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-emerald-700">
<p className="text-sm font-semibold text-stone-700">{stop.time}</p> Upcoming
{stop.cutoff_time && ( </p>
<p className="text-xs text-stone-400">Order by {new Date(stop.cutoff_time).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}</p> <p className="text-[10px] uppercase tracking-[0.3em] text-stone-400 tabular-nums">
)} {upcomingStops.length} stops
</div> </p>
<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>
</div> </div>
)}
</div> <ol className="border-t border-stone-900/15">
)} {upcomingStops.map((stop) => (
</LayoutContainer> <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> </main>
<StorefrontFooter brandName={brandName} brandSlug={brandSlug} brandAccent="green" /> <StorefrontFooter brandName={brandName} brandSlug={brandSlug} brandAccent="green" />
</div> </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
View File
@@ -10,6 +10,7 @@ import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import LayoutContainer from "@/components/layout/LayoutContainer"; import LayoutContainer from "@/components/layout/LayoutContainer";
import { getStorefrontStopById } from "@/actions/storefront"; import { getStorefrontStopById } from "@/actions/storefront";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
import { FadeOnScroll } from "@/components/ui/ScrollAnimations";
type Product = { type Product = {
id: string; id: string;
@@ -31,30 +32,25 @@ type Stop = {
time: string; time: string;
location: string; location: string;
brand_id: 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 params = useParams();
const slug = params.id as string; const slug = params.id as string;
const [stop, setStop] = useState<{ const [stop, setStop] = useState<Stop | null>(null);
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
brand_id: string;
} | null>(null);
const [products, setProducts] = useState<Product[]>([]); const [products, setProducts] = useState<Product[]>([]);
const [brandSlug, setBrandSlug] = useState("tuxedo");
const [brandAccent, setBrandAccent] = useState<"green" | "orange" | "blue">("green");
useEffect(() => { useEffect(() => {
async function load() { async function load() {
const result = await getStorefrontStopById(slug); const result = await getStorefrontStopById(slug);
if (!result) return; if (!result) return;
setStop(result.stop as unknown as Stop); setStop(result.stop as unknown as Stop);
setProducts(result.products as unknown as Product[]); setProducts(result.products as unknown as Product[]);
} }
@@ -64,38 +60,89 @@ export default function StopPage() {
if (!stop) { if (!stop) {
return ( return (
<div className="min-h-screen bg-stone-50"> <div className="min-h-screen bg-stone-50">
<StorefrontHeader brandName="Tuxedo Corn" brandSlug="tuxedo" brandAccent="green" /> <StorefrontHeader
<main className="py-20"> brandName={BRAND_NAME}
brandSlug={BRAND_SLUG}
brandAccent="green"
/>
<main className="py-32">
<LayoutContainer> <LayoutContainer>
<div className="max-w-5xl mx-auto text-center"> <div className="mx-auto max-w-3xl text-center">
<h1 className="text-5xl font-black tracking-tight text-stone-950">Stop Not Found</h1> <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&rsquo;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> </div>
</LayoutContainer> </LayoutContainer>
</main> </main>
<StorefrontFooter
brandName={BRAND_NAME}
brandSlug={BRAND_SLUG}
brandAccent="green"
/>
</div> </div>
); );
} }
const isBlue = brandAccent === "blue"; const date = new Date(stop.date);
const brandLabel = isBlue ? "Indian River Direct" : "Tuxedo Corn"; 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 ( return (
<div className="min-h-screen bg-stone-50"> <div className="min-h-screen bg-stone-50">
<StopSetEffect stop={stop} />
<StorefrontHeader <StorefrontHeader
brandName={brandLabel} brandName={BRAND_NAME}
brandSlug={brandSlug} brandSlug={BRAND_SLUG}
brandAccent={brandAccent} brandAccent="green"
/> />
<main className="py-16 md:py-20"> <main>
<LayoutContainer> {/* Back nav — kept slim, no white card wrapper. */}
<div className="max-w-5xl mx-auto"> <div className="bg-stone-50 border-b border-stone-200/60">
<LayoutContainer>
{/* Back navigation */} <div className="py-5 flex items-center gap-2 text-sm">
<div className="mb-10 flex items-center gap-2">
<Link <Link
href={`/${brandSlug}#stops`} href={`/${BRAND_SLUG}#stops`}
className="flex items-center gap-2 text-sm font-medium text-stone-500 hover:text-stone-800 transition-colors group" className="flex items-center gap-2 text-stone-500 hover:text-stone-950 transition-colors group"
> >
<svg <svg
className="h-4 w-4 transition-transform duration-200 group-hover:-translate-x-1" 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" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
strokeWidth={2} 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> </svg>
All Stops All Stops
</Link> </Link>
<span className="text-stone-300">/</span> <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> </div>
</LayoutContainer>
</div>
{/* Stop header */} {/* Hero — dark editorial opener. Eyebrow + huge Fraunces
<div className="mb-12"> city/state headline with the date numerals set as part of
<p className="text-[11px] font-semibold uppercase tracking-widest text-emerald-600 mb-4"> the type composition, not as a separate stat block. */}
{brandLabel} <section className="relative bg-stone-950 overflow-hidden isolate">
</p> <div
<h1 className="text-5xl md:text-7xl font-black tracking-tight text-stone-950 leading-[1.0]"> aria-hidden="true"
{stop.city},<br className="hidden md:block" /> {stop.state} className="absolute inset-x-0 bottom-0 h-px bg-amber-300/30 z-10"
</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&trade; sweet corn for pickup at this stop."}
</p>
<div className="mt-6 h-px w-12 bg-emerald-600" />
</div>
{/* Stop info */} <LayoutContainer>
<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="relative py-20 sm:py-28 lg:py-32 grid grid-cols-1 lg:grid-cols-12 gap-10 lg:gap-16 items-end">
<div className="grid gap-4 md:grid-cols-3"> {/* Left: copy + headline */}
<div className="flex items-start gap-4 py-4 px-5 rounded-2xl bg-stone-50"> <div className="lg:col-span-8">
<div className={`flex-shrink-0 w-10 h-10 rounded-xl flex items-center justify-center ${isBlue ? "bg-blue-50" : "bg-emerald-50"}`}> <FadeOnScroll from="up">
<svg className={`h-5 w-5 ${isBlue ? "text-blue-500" : "text-emerald-600"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-amber-300/85 mb-6">
<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" /> Pickup Stop · {monthYear}
</svg> </p>
</div> </FadeOnScroll>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-1">Date</p> <FadeOnScroll from="up" delay={0.08}>
<p className="font-bold text-stone-950">{formatDate(stop.date)}</p> <h1
</div> className="font-display text-stone-50 text-[clamp(2.75rem,7vw,5.5rem)] leading-[0.98] tracking-[-0.025em]"
</div> style={{ textWrap: "balance" }}
<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"}`}> {stop.city},
<svg className={`h-5 w-5 ${isBlue ? "text-blue-500" : "text-emerald-600"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <br className="hidden sm:block" />{" "}
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /> <span className="italic text-amber-200/90">
</svg> {stop.state}.
</div> </span>
<div> </h1>
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-1">Time</p> </FadeOnScroll>
<p className="font-bold text-stone-950">{stop.time}</p>
</div> <FadeOnScroll from="up" delay={0.18}>
</div> <div className="mt-8 h-px w-16 bg-gradient-to-r from-emerald-500 via-amber-400 to-amber-300/0" />
<div className="flex items-start gap-4 py-4 px-5 rounded-2xl bg-stone-50"> </FadeOnScroll>
<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}> <FadeOnScroll from="up" delay={0.26}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" /> <p className="mt-8 text-stone-300 text-lg sm:text-xl leading-[1.65] max-w-xl">
<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" /> Order fresh Olathe Sweet&trade; sweet corn for pickup
</svg> at this stop. We&rsquo;ll have it on the truck the
</div> morning of ready when you are.
<div> </p>
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-1">Location</p> </FadeOnScroll>
<p className="font-bold text-stone-950 leading-tight">{stop.location}</p> </div>
</div>
</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>
</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&rsquo;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 ? ( {products.length === 0 ? (
<div className="rounded-3xl bg-white p-12 text-center shadow-sm ring-1 ring-stone-200/60"> <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.</p> <p className="text-stone-500">
No products available for this stop yet.
</p>
</div> </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) => ( {products.map((product) => (
<ProductCard <ProductCard
key={product.id} key={product.id}
@@ -195,26 +317,53 @@ export default function StopPage() {
price={`$${product.price}`} price={`$${product.price}`}
type={product.type} type={product.type}
imageUrl={product.image_url} imageUrl={product.image_url}
brandSlug={brandSlug} brandSlug={BRAND_SLUG}
brandName={brandLabel} brandName={BRAND_NAME}
brandId={product.brand_id} brandId={product.brand_id}
brandAccent={brandAccent} brandAccent="green"
is_taxable={product.is_taxable} is_taxable={product.is_taxable}
pickup_type={product.pickup_type} pickup_type={product.pickup_type}
/> />
))} ))}
</div> </div>
)} )}
</section> </div>
</div> </LayoutContainer>
</LayoutContainer> </section>
</main> </main>
<StorefrontFooter <StorefrontFooter
brandName={brandLabel} brandName={BRAND_NAME}
brandSlug={brandSlug} brandSlug={BRAND_SLUG}
brandAccent={brandAccent} brandAccent="green"
/> />
</div> </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>
);
} }
+1 -1
View File
@@ -17,7 +17,7 @@ import type { Metadata, Viewport } from "next";
import WaterFieldClient from "@/components/water/WaterFieldClient"; import WaterFieldClient from "@/components/water/WaterFieldClient";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Water Log · Tuxedo Ditch Co.", title: "Water Log · Tuxedo Corn",
description: description:
"Field log of headgate water readings — PIN-protected, mobile-only.", "Field log of headgate water readings — PIN-protected, mobile-only.",
robots: { robots: {
-52
View File
@@ -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>
);
}
-89
View File
@@ -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">MF: 7AM6PM</p>
<p className="text-stone-300 mt-1">Sat & Sun: 8AM2PM</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: "1990sPresent",
},
{
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>
);
}
-76
View File
@@ -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; showTextAlerts: boolean;
schedulePdfNotes: string; schedulePdfNotes: string;
heroImageUrl: string; heroImageUrl: string;
heroVideoUrl: string;
brandPrimaryColor: string; brandPrimaryColor: string;
brandSecondaryColor: string; brandSecondaryColor: string;
brandBgColor: string; brandBgColor: string;
@@ -189,6 +190,7 @@ function buildInitialFormState(settings: BrandSettings | null, brandName: string
showTextAlerts: settings?.show_text_alerts ?? false, showTextAlerts: settings?.show_text_alerts ?? false,
schedulePdfNotes: settings?.schedule_pdf_notes ?? "", schedulePdfNotes: settings?.schedule_pdf_notes ?? "",
heroImageUrl: settings?.hero_image_url ?? "", heroImageUrl: settings?.hero_image_url ?? "",
heroVideoUrl: settings?.hero_video_url ?? "",
brandPrimaryColor: settings?.brand_primary_color ?? "#16a34a", brandPrimaryColor: settings?.brand_primary_color ?? "#16a34a",
brandSecondaryColor: settings?.brand_secondary_color ?? "#f5f5f4", brandSecondaryColor: settings?.brand_secondary_color ?? "#f5f5f4",
brandBgColor: settings?.brand_bg_color ?? "#fafaf9", brandBgColor: settings?.brand_bg_color ?? "#fafaf9",
@@ -258,6 +260,7 @@ function BrandSettingsFormBody({
showTextAlerts: state.showTextAlerts, showTextAlerts: state.showTextAlerts,
schedulePdfNotes: state.schedulePdfNotes || undefined, schedulePdfNotes: state.schedulePdfNotes || undefined,
heroImageUrl: state.heroImageUrl || undefined, heroImageUrl: state.heroImageUrl || undefined,
heroVideoUrl: state.heroVideoUrl || undefined,
brandPrimaryColor: state.brandPrimaryColor || undefined, brandPrimaryColor: state.brandPrimaryColor || undefined,
brandSecondaryColor: state.brandSecondaryColor || undefined, brandSecondaryColor: state.brandSecondaryColor || undefined,
brandBgColor: state.brandBgColor || undefined, brandBgColor: state.brandBgColor || undefined,
@@ -324,6 +327,7 @@ function BrandSettingsFormBody({
<StorefrontSection <StorefrontSection
heroTagline={state.heroTagline} heroTagline={state.heroTagline}
heroImageUrl={state.heroImageUrl} heroImageUrl={state.heroImageUrl}
heroVideoUrl={state.heroVideoUrl}
aboutHeadline={state.aboutHeadline} aboutHeadline={state.aboutHeadline}
aboutSubheadline={state.aboutSubheadline} aboutSubheadline={state.aboutSubheadline}
customFooterText={state.customFooterText} customFooterText={state.customFooterText}
@@ -673,6 +677,7 @@ function EmailInvoiceSection({
function StorefrontSection({ function StorefrontSection({
heroTagline, heroTagline,
heroImageUrl, heroImageUrl,
heroVideoUrl,
aboutHeadline, aboutHeadline,
aboutSubheadline, aboutSubheadline,
customFooterText, customFooterText,
@@ -680,6 +685,7 @@ function StorefrontSection({
}: { }: {
heroTagline: string; heroTagline: string;
heroImageUrl: string; heroImageUrl: string;
heroVideoUrl: string;
aboutHeadline: string; aboutHeadline: string;
aboutSubheadline: string; aboutSubheadline: string;
customFooterText: string; customFooterText: string;
@@ -719,6 +725,18 @@ function StorefrontSection({
</div> </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"> <AdminInput label="About Page Headline">
<AdminTextInput <AdminTextInput
value={aboutHeadline} value={aboutHeadline}
@@ -158,7 +158,7 @@ function TodaySummary({
{/* Hero gauge */} {/* Hero gauge */}
<div className="flex flex-col items-center justify-center gap-2"> <div className="flex flex-col items-center justify-center gap-2">
<div className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#1a4d2e]/70"> <div className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#1a4d2e]/70">
Tuxedo Ditch Co. Tuxedo Corn
</div> </div>
<WaterGauge <WaterGauge
size={120} size={120}
+9 -4
View File
@@ -200,14 +200,19 @@ export function FieldPinScreen({
// `100dvh` excludes iOS Safari's collapsing chrome; the legacy // `100dvh` excludes iOS Safari's collapsing chrome; the legacy
// `100vh` fallback keeps Android/older browsers full-bleed too. // `100vh` fallback keeps Android/older browsers full-bleed too.
<div className="flex min-h-[100dvh] flex-col bg-[#F2F2F7]"> <div className="flex min-h-[100dvh] flex-col bg-[#F2F2F7]">
{/* ── Branded header ───────────────────────────────────────── */} {/* ── Branded header (Apple HIG liquid glass) ───────────────── */}
<header <header
className="px-5 pb-3 pt-[max(env(safe-area-inset-top),1rem)]" className="px-5 pb-3 pt-[max(env(safe-area-inset-top),1rem)]"
style={{ 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: background:
"linear-gradient(180deg, rgba(255,255,255,0.92) 0%, rgba(242,242,247,0) 100%)", "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(10px)", backdropFilter: "blur(24px) saturate(180%)",
WebkitBackdropFilter: "blur(10px)", 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"> <div className="mx-auto flex max-w-md items-center justify-center gap-2">
+333 -174
View File
@@ -1,12 +1,11 @@
"use client"; "use client";
import Image from "next/image"; 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 { m as motion } from "framer-motion";
import { gsap } from "gsap"; import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger"; import { ScrollTrigger } from "gsap/ScrollTrigger";
// Register GSAP plugins
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
gsap.registerPlugin(ScrollTrigger); gsap.registerPlugin(ScrollTrigger);
} }
@@ -16,14 +15,19 @@ type TuxedoVideoHeroProps = {
title: string; title: string;
description: string; description: string;
olatheSweetLogoUrl?: string | null; 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; primaryButton?: string;
secondaryButton?: string; secondaryButton?: string;
onPrimaryClick?: () => void; onPrimaryClick?: () => void;
onSecondaryClick?: () => void; onSecondaryClick?: () => void;
}; };
const VIDEO_URL = "/videos/tuxedo-hero.mp4";
const HERO_STATS = [ const HERO_STATS = [
{ stat: "40+", label: "Years Growing" }, { stat: "40+", label: "Years Growing" },
{ stat: "3", label: "Generations" }, { 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({ export default function TuxedoVideoHero({
eyebrow, eyebrow,
title, title,
description, description,
olatheSweetLogoUrl, olatheSweetLogoUrl,
videoUrl,
posterUrl,
primaryButton, primaryButton,
secondaryButton, secondaryButton,
onPrimaryClick, onPrimaryClick,
@@ -64,22 +79,41 @@ export default function TuxedoVideoHero({
const sectionRef = useRef<HTMLElement>(null); const sectionRef = useRef<HTMLElement>(null);
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
// ─── GSAP SCROLL ANIMATIONS ──────────────────────────────────────────────── // `videoPlaying` reflects whether the <video> successfully produced frames.
// Motion pass: the original used a 1.15 video scale, an 80px y-shift on the // It can flip false at any time: 404, codec error, autoplay blocked, or
// hero content, a parallax -100/+80 movement on decorative orbs, and a 1s // prefers-reduced-motion. We tear the video element out of the DOM when
// 40px slide-up entrance with power3.out easing. All of those are strong // it's false so the poster image underneath becomes the visible backdrop.
// vestibular triggers. The motion here is now: const [videoPlaying, setVideoPlaying] = useState(false);
// - tiny entrance slide (8px) on first paint, no scale, 320ms, linear-ish ease
// - video stays static on scroll (no parallax) // ─── Lifecycle: gate autoplay off if the user prefers reduced motion. ────
// - decorative orbs float on a 10s CSS keyframe (gentle, opt-in to reduced motion) useEffect(() => {
// - scroll indicator just fades on scroll, no positional shift 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(() => { useEffect(() => {
if (typeof window === "undefined" || !sectionRef.current) return; if (typeof window === "undefined" || !sectionRef.current) return;
const prefersReduced =
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
const ctx = gsap.context(() => { const ctx = gsap.context(() => {
// Scroll progress tracking (the bar at the top of the page).
ScrollTrigger.create({ ScrollTrigger.create({
trigger: sectionRef.current, trigger: sectionRef.current,
start: "top top", 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) { if (contentRef.current) {
gsap.to(contentRef.current, { gsap.to(contentRef.current, {
opacity: 0, opacity: 0,
ease: "power1.out", ease: "none",
scrollTrigger: { scrollTrigger: {
trigger: sectionRef.current, trigger: sectionRef.current,
start: "top top", start: "top top",
end: "40% top", end: "bottom top",
scrub: 1.2, 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. // Word-level cascade for the title — runs after the eyebrow +
const contentElements = gsap.utils.toArray<Element>(".hero-reveal"); // rule have settled so the words appear once the runway is set.
contentElements.forEach((el) => { const titleWords = gsap.utils.toArray<HTMLElement>(".hero-word");
gsap.fromTo( if (titleWords.length) {
el, gsap.fromTo(
{ opacity: 0, y: 8 }, titleWords,
{ { opacity: 0, y: 18, filter: "blur(8px)" },
opacity: 1, {
y: 0, opacity: 1,
duration: 0.32, y: 0,
ease: "power1.out", filter: "blur(0px)",
scrollTrigger: { duration: 0.6,
trigger: el, delay: 0.35,
start: "top 95%", stagger: 0.05,
toggleActions: "play none none none", ease: "power3.out",
}, }
} );
); }
}); }
// Scroll indicator — just fades, no bounce, no positional shift.
gsap.to(".scroll-indicator", { gsap.to(".scroll-indicator", {
opacity: 0, opacity: 0,
scrollTrigger: { scrollTrigger: {
trigger: sectionRef.current, trigger: sectionRef.current,
start: "10% top", start: "10% top",
end: "30% top", end: "30% top",
scrub: 1, scrub: 0.5,
}, },
}); });
}, sectionRef); }, sectionRef);
@@ -141,7 +199,6 @@ export default function TuxedoVideoHero({
return () => ctx.revert(); return () => ctx.revert();
}, []); }, []);
// Smooth scroll to stops section
const handlePrimaryClick = () => { const handlePrimaryClick = () => {
const stopsSection = document.getElementById("stops"); const stopsSection = document.getElementById("stops");
if (stopsSection) { if (stopsSection) {
@@ -157,13 +214,18 @@ export default function TuxedoVideoHero({
<section <section
ref={sectionRef} 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 <div
ref={contentRef} 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"> <div className="max-w-3xl">
<HeroContent <HeroContent
@@ -180,7 +242,7 @@ export default function TuxedoVideoHero({
</div> </div>
<ScrollIndicator onClick={handlePrimaryClick} /> <ScrollIndicator onClick={handlePrimaryClick} />
<CornerDecorations /> <CornerDecorations videoPlaying={videoPlaying} />
</section> </section>
<HeroAnimations /> <HeroAnimations />
@@ -188,10 +250,10 @@ export default function TuxedoVideoHero({
); );
} }
// ── Scroll Progress Bar ─────────────────────────────────────────────────────── // ── Scroll Progress Bar ───────────────────────────────────────────────
function ScrollProgressBar({ scrollProgress }: { scrollProgress: number }) { function ScrollProgressBar({ scrollProgress }: { scrollProgress: number }) {
return ( 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 <div
className="h-full bg-gradient-to-r from-emerald-600 via-emerald-400 to-amber-400" className="h-full bg-gradient-to-r from-emerald-600 via-emerald-400 to-amber-400"
style={{ style={{
@@ -203,43 +265,111 @@ function ScrollProgressBar({ scrollProgress }: { scrollProgress: number }) {
); );
} }
// ── Hero Background (video + overlays + orbs) ──────────────────────────────── // ── Backdrop: poster + optional video + overlays ─────────────────────────
function HeroBackground({ videoRef }: { videoRef: React.RefObject<HTMLVideoElement | null> }) { 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 ( return (
<> <>
<video {/* Poster as still fallback. Uses Next/Image so the LCP image is
ref={videoRef} optimised by the framework; sits underneath everything with
autoPlay `object-cover: contain-ar-correct` style. Slow Ken Burns drift
muted animates the poster when the video isn't covering it (initial
loop 2-3s of load, or if the video fails) — adds ambient motion to
playsInline the first impression without competing with the video. */}
tabIndex={-1} {posterUrl ? (
aria-hidden="true" <Image
className="absolute inset-0 w-full h-[120%] object-cover" src={posterUrl}
style={{ zIndex: 0 }} alt=""
src={VIDEO_URL} 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 Video: render unconditionally when a URL is provided, but mark the
className="absolute inset-0 pointer-events-none" element as `data-failed` once any of the error events fire so we
style={{ can hide it with CSS instead of pulling it from the DOM (keeps
zIndex: 1, the network-friendly fetch behaviour). `autoPlay muted playsInline`
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%)", 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 <div
className="absolute inset-0 pointer-events-none" className="absolute inset-0 pointer-events-none"
style={{ style={{
zIndex: 2, 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 <div
className="absolute inset-0 pointer-events-none" className="absolute inset-0 pointer-events-none"
style={{ style={{
zIndex: 3, 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() { 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 ( return (
<div className="absolute inset-0 pointer-events-none z-[4]"> <div className="absolute inset-0 pointer-events-none z-[5]" aria-hidden="true">
{/* Golden orb top-left */}
<div <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={{ 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)", filter: "blur(8px)",
animation: "tvh-float-slow 950ms ease-in-out infinite",
}} }}
/> />
{/* Emerald orb bottom-right */}
<div <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={{ 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)", filter: "blur(8px)",
animation: "tvh-float-delayed 950ms ease-in-out infinite",
}} }}
/> />
{/* Subtle grain overlay */}
<div <div
className="absolute inset-0 opacity-[0.03]" className="absolute inset-0 opacity-[0.085] mix-blend-overlay pointer-events-none"
style={{ 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> </div>
); );
} }
// ── Hero Content (logo, eyebrow, title, description, CTAs, stats) ──────────── // ── Hero Content (logo, eyebrow, title, description, CTAs, stats) ──────
type HeroContentProps = { type HeroContentProps = {
logoSrc: string | null; logoSrc: string | null;
eyebrow?: string; eyebrow?: string;
@@ -307,14 +436,24 @@ function HeroContent({
<> <>
<HeroLogo logoSrc={logoSrc} /> <HeroLogo logoSrc={logoSrc} />
<div className="hero-reveal mb-6"> <div className="hero-reveal mt-6 flex items-center gap-4">
{eyebrow && ( {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} {eyebrow}
</p> </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>
<div className="mt-3 h-px w-16 bg-gradient-to-r from-amber-300/80 to-transparent" />
<HeroTitle title={title} /> <HeroTitle title={title} />
<HeroDescription description={description} /> <HeroDescription description={description} />
@@ -333,12 +472,7 @@ function HeroContent({
function HeroLogo({ logoSrc }: { logoSrc: string | null }) { function HeroLogo({ logoSrc }: { logoSrc: string | null }) {
return ( return (
<motion.div <div className="hero-reveal mb-8 md:mb-10 relative h-20 md:h-24 w-[320px] md:w-[400px]">
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="absolute -inset-8 rounded-full bg-emerald-500/10 blur-2xl" /> <div className="absolute -inset-8 rounded-full bg-emerald-500/10 blur-2xl" />
{logoSrc ? ( {logoSrc ? (
<Image <Image
@@ -351,25 +485,34 @@ function HeroLogo({ logoSrc }: { logoSrc: string | null }) {
priority priority
/> />
) : null} ) : null}
</motion.div> </div>
); );
} }
function HeroTitle({ title }: { title: string }) { 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 ( return (
<h1 <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={{ 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 <span
key={`${idx}-${word}`} key={i}
className="inline-block mr-4" className="hero-word inline-block"
style={{ style={{
animationDelay: `${0.2 + idx * 0.1}s`, marginRight: "0.18em",
willChange: "transform, opacity, filter",
}} }}
aria-hidden
> >
{word} {word}
</span> </span>
@@ -381,7 +524,7 @@ function HeroTitle({ title }: { title: string }) {
function HeroDescription({ description }: { description: string }) { function HeroDescription({ description }: { description: string }) {
return ( return (
<p <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 }} style={{ fontWeight: 300 }}
> >
{description} {description}
@@ -416,13 +559,17 @@ function PrimaryButton({ label, onClick }: { label: string; onClick: () => void
return ( return (
<motion.button <motion.button
onClick={onClick} 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={{ style={{
background: "linear-gradient(135deg, #059669 0%, #10b981 100%)", background: "linear-gradient(135deg, #15803d 0%, #16a34a 100%)",
boxShadow: "0 8px 32px rgba(16, 185, 129, 0.4), inset 0 1px 0 rgba(255,255,255,0.2)", 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> <span>{label}</span>
<svg <svg
className="w-4 h-4 transition-transform group-hover:translate-x-1" 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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg> </svg>
</span> </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> </motion.button>
); );
} }
function SecondaryButton({ function SecondaryButton({ label, onClick }: { label?: string; onClick?: () => void }) {
label, if (!label) return null;
onClick,
}: {
label: string;
onClick?: () => void;
}) {
return ( return (
<button type="button" <button
type="button"
onClick={onClick} onClick={onClick}
className="group flex items-center gap-3 text-sm font-semibold" 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.8)" }} style={{ color: "rgba(255,255,255,0.85)" }}
aria-label="Next"> aria-label={label}
>
<span className="uppercase tracking-widest text-[11px]">{label}</span> <span className="uppercase tracking-widest text-[11px]">{label}</span>
<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/40">
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)" }}
>
<svg <svg
className="w-4 h-4 transition-transform group-hover:translate-x-0.5" className="w-4 h-4 transition-transform group-hover:translate-x-0.5"
fill="none" fill="none"
@@ -478,18 +613,18 @@ function SecondaryButton({
function HeroStats() { function HeroStats() {
return ( 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) => ( {HERO_STATS.map((item) => (
<div key={item.label} className="text-center"> <div key={item.label}>
<div <div
className="text-3xl md:text-4xl font-black text-white" className="text-2xl md:text-3xl font-black text-white tabular-nums"
style={{ textShadow: "0 2px 20px rgba(0,0,0,0.3)" }} style={{ textShadow: "0 2px 20px rgba(0,0,0,0.4)" }}
> >
{item.stat} {item.stat}
</div> </div>
<div <div
className="text-[10px] uppercase tracking-[0.2em] mt-1" className="text-[10px] uppercase tracking-[0.25em] mt-1"
style={{ color: "rgba(255,255,255,0.5)" }} style={{ color: "rgba(255,255,255,0.55)" }}
> >
{item.label} {item.label}
</div> </div>
@@ -499,98 +634,112 @@ function HeroStats() {
); );
} }
// ── Scroll Indicator ────────────────────────────────────────────────────────── // ── Scroll Indicator ─────────────────────────────────────────────────────
function ScrollIndicator({ onClick }: { onClick: () => void }) { function ScrollIndicator({ onClick }: { onClick: () => void }) {
return ( return (
<div <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" }} style={{ pointerEvents: "auto" }}
> >
<button type="button" <button
type="button"
onClick={onClick} 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" aria-label="Scroll to stops"
> >
<span className="text-[10px] uppercase tracking-[0.25em] font-medium">Explore</span> <span className="text-[10px] uppercase tracking-[0.25em] font-medium">Explore</span>
<div <div className="w-6 h-10 rounded-full border border-white/30 flex items-start justify-center p-1.5">
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
className="w-1.5 h-3 rounded-full bg-white tvh-scroll-indicator"
style={{ animationDuration: "950ms" }}
/>
</div> </div>
</button> </button>
</div> </div>
); );
} }
// ── Corner Decorations ──────────────────────────────────────────────────────── // ── Corner Decorations ───────────────────────────────────────────────────
function CornerDecorations() { 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 ( return (
<> <>
<div <div
className="parallax-float absolute top-8 right-8 opacity-30" className="absolute top-8 right-8 opacity-25 hidden md:block"
style={{ animationDelay: "1s" }} aria-hidden="true"
> >
<svg className="w-16 h-16" viewBox="0 0 64 64" fill="none"> <svg className="w-16 h-16" viewBox="0 0 64 64" fill="none">
<path <path
d="M32 4C48 16, 56 32, 48 56C40 64, 24 64, 8 56C0 32, 16 16, 32 4" 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" strokeWidth="1"
fill="none" fill="none"
/> />
<path <path
d="M32 12C44 22, 48 36, 42 52C36 58, 26 58, 18 52C12 36, 20 22, 32 12" 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" strokeWidth="1"
fill="none" fill="none"
/> />
</svg> </svg>
</div> </div>
<div <div
className="parallax-float absolute bottom-20 left-8 opacity-20" className="absolute bottom-20 left-8 opacity-20 hidden md:block"
style={{ animationDelay: "1.5s" }} aria-hidden="true"
> >
<svg className="w-12 h-12" viewBox="0 0 48 48" fill="none"> <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="20" stroke="rgba(63,110,58,0.55)" strokeWidth="1" />
<circle cx="24" cy="24" r="14" stroke="rgba(16,185,129,0.3)" 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(16,185,129,0.2)" strokeWidth="1" /> <circle cx="24" cy="24" r="8" stroke="rgba(63,110,58,0.25)" strokeWidth="1" />
</svg> </svg>
</div> </div>
</> </>
); );
} }
// ── Hero Animations (global keyframes + reduced-motion overrides) ───────────── // ── Hero Animations (global keyframes + reduced-motion overrides) ───────
function HeroAnimations() { function HeroAnimations() {
return ( return (
<style dangerouslySetInnerHTML={{ __html: ` <style
dangerouslySetInnerHTML={{
__html: `
@keyframes tvh-float-slow { @keyframes tvh-float-slow {
0%, 100% { transform: translate(0, 0); } 0%, 100% { transform: translate(0, 0); }
50% { transform: translate(6px, -8px); } 50% { transform: translate(8px, -10px); }
} }
@keyframes tvh-float-delayed { @keyframes tvh-float-delayed {
0%, 100% { transform: translate(0, 0); } 0%, 100% { transform: translate(0, 0); }
50% { transform: translate(-8px, 6px); } 50% { transform: translate(-10px, 8px); }
} }
@keyframes tvh-scroll-indicator { @keyframes tvh-scroll-dot {
0%, 100% { transform: translateY(0); } 0%, 100% { transform: translateY(0); opacity: 1; }
50% { transform: translateY(3px); } 50% { transform: translateY(8px); opacity: 0.4; }
} }
.tvh-scroll-indicator { /* Slow Ken Burns on the hero poster. Animates while the video is
animation: tvh-scroll-indicator 2.4s cubic-bezier(0.16, 1, 0.3, 1) infinite; 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%); }
} }
.animate-tvh-kenburns {
.parallax-float { animation: tvh-kenburns 26s ease-in-out infinite alternate;
will-change: transform; will-change: transform;
} }
.hero-reveal { .animate-tvh-float-slow {
will-change: opacity, transform; 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 { button, a {
@@ -598,7 +747,11 @@ function HeroAnimations() {
} }
@media (prefers-reduced-motion: reduce) { @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; animation: none !important;
transition: none !important; transition: none !important;
} }
@@ -606,7 +759,13 @@ function HeroAnimations() {
opacity: 1 !important; opacity: 1 !important;
transform: none !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 { function getLangCookie(): string {
if (typeof document === "undefined") return "en"; if (typeof document === "undefined") return "en";
const match = document.cookie.match(/time_tracking_lang=(\w+)/); // `time_tracking_lang` is the explicit override. Fall back to
return match ? match[1] : "en"; // `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) { 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. // Cheap global so `formatTime` doesn't have to thread `lang` around.
// Resolved at call time from the lang cookie that's always set. // Resolved at call time from the lang cookie that's always set.
if (typeof document === "undefined") return "en-US"; if (typeof document === "undefined") return "en-US";
const m = document.cookie.match(/time_tracking_lang=(\w+)/); const m = getLangCookie();
return m?.[1] === "es" ? "es-ES" : "en-US"; return m === "es" ? "es-ES" : "en-US";
} }
function formatHours(minutes: number): string { function formatHours(minutes: number): string {
@@ -538,7 +545,12 @@ function emptyManualDraft(): State["manualEntryDraft"] {
const initialState: State = { const initialState: State = {
lang: (getLangCookie() as "en" | "es") ?? "en", 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: [], tasks: [],
openEntry: null, openEntry: null,
payPeriod: null, payPeriod: null,
+303 -1
View File
@@ -19,6 +19,23 @@ function prefersReducedMotion(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches === true; 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 // SCROLL-REVEAL WRAPPER - Layers content as user scrolls
// Motion-reduced: max 12px translation (was 60px), 320ms (was 1s), // Motion-reduced: max 12px translation (was 60px), 320ms (was 1s),
@@ -321,4 +338,289 @@ export function FadeOnScroll({
{children} {children}
</div> </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>
);
}
+12 -4
View File
@@ -63,14 +63,22 @@ export function MobileWaterHeadgateList({
paddingTop: "env(safe-area-inset-top)", paddingTop: "env(safe-area-inset-top)",
}} }}
> >
{/* ── Top nav ──────────────────────────────────────────────── */} {/* ── Top nav (Apple HIG liquid glass) ─────────────────────── */}
<header <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={{ style={{
paddingTop: "env(safe-area-inset-top)", paddingTop: "env(safe-area-inset-top)",
height: "calc(44px + env(safe-area-inset-top))", height: "calc(44px + env(safe-area-inset-top))",
backdropFilter: "blur(20px) saturate(180%)", // Gradient + saturate(180%) reads as "liquid glass" — the
WebkitBackdropFilter: "blur(20px) saturate(180%)", // 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"> <div className="flex items-center gap-1">
+40 -27
View File
@@ -26,12 +26,12 @@
*/ */
import { import {
useEffect, useEffect,
useMemo,
useRef, useRef,
useState, useState,
type FormEvent, type FormEvent,
} from "react"; } from "react";
import { SegmentedControl } from "./SegmentedControl"; import { SegmentedControl } from "./SegmentedControl";
import { ThresholdMeter } from "./ThresholdMeter";
import type { FieldHeadgate, MobileUnit } from "./types"; import type { FieldHeadgate, MobileUnit } from "./types";
import { isIntegerUnit, MOBILE_UNITS } from "./types"; import { isIntegerUnit, MOBILE_UNITS } from "./types";
import { ChevronLeft, Clock, Gauge, Logout, Pencil, Pin, Spinner } from "./icons"; import { ChevronLeft, Clock, Gauge, Logout, Pencil, Pin, Spinner } from "./icons";
@@ -101,19 +101,10 @@ export function MobileWaterLogForm({
return () => window.clearInterval(id); return () => window.clearInterval(id);
}, []); }, []);
// ── Threshold hint derived from the headgate. ──────────────────── // ── Threshold hint is now rendered by <ThresholdMeter /> below —
const thresholdHint = useMemo(() => { // the meter derives its own zone state from the live value +
const h = headgate.high_threshold; // headgate thresholds, so the form no longer needs a
const l = headgate.low_threshold; // useMemo for the static caption. ─────────────────────
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]);
const submitted = (e?: FormEvent) => { const submitted = (e?: FormEvent) => {
e?.preventDefault(); e?.preventDefault();
@@ -165,13 +156,21 @@ export function MobileWaterLogForm({
paddingTop: "env(safe-area-inset-top)", paddingTop: "env(safe-area-inset-top)",
}} }}
> >
{/* ── Top nav ────────────────────────────────────────────── */} {/* ── Top nav (Apple HIG liquid glass) ─────────────────────── */}
<header <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={{ style={{
paddingTop: "calc(env(safe-area-inset-top) + 4px)", paddingTop: "calc(env(safe-area-inset-top) + 4px)",
backdropFilter: "blur(20px) saturate(180%)", // Mirror the headgate-list chrome so the worker feels the
WebkitBackdropFilter: "blur(20px) saturate(180%)", // 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 <button
@@ -290,11 +289,16 @@ export function MobileWaterLogForm({
{unitLabel} {unitLabel}
</span> </span>
</div> </div>
{thresholdHint && ( {/* Live threshold meter — the screen's signature. Shows
<p className="mt-3 border-t border-[rgba(60,60,67,0.10)] pt-3 text-[13px] text-[rgba(60,60,67,0.6)]"> * the entered value's position against the headgate's
{thresholdHint} * configured low/high thresholds, with a color-coded
</p> * thumb and zone label. ──────────────────────────── */}
)} <ThresholdMeter
value={measurement}
unit={unit}
lowThreshold={headgate.low_threshold}
highThreshold={headgate.high_threshold}
/>
</div> </div>
{/* Unit segmented control — labels use the localized {/* Unit segmented control — labels use the localized
@@ -374,13 +378,22 @@ export function MobileWaterLogForm({
)} )}
</div> </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 <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={{ style={{
paddingBottom: "max(env(safe-area-inset-bottom), 0.75rem)", paddingBottom: "max(env(safe-area-inset-bottom), 0.75rem)",
backdropFilter: "blur(20px) saturate(180%)", background:
WebkitBackdropFilter: "blur(20px) saturate(180%)", "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 <button
+156 -42
View File
@@ -36,7 +36,14 @@
* - `verifyTimeTrackingPin` → unifies PIN with Time tab (Cycle 4) * - `verifyTimeTrackingPin` → unifies PIN with Time tab (Cycle 4)
* - `logoutTimeTracking` → clears the time-tracking cookie on logout * - `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 { import {
verifyWaterPin, verifyWaterPin,
submitWaterEntry, submitWaterEntry,
@@ -353,11 +360,18 @@ export function MobileWaterApp() {
); );
} }
// ── TabBar (Cycle 4) ─────────────────────────────────────────── // ── TabBar (Cycle 4 → Apple HIG pass) ──────────────────────────
// Sticky two-tab top nav for the Tuxedo worker experience. // Sticky top nav for the Tuxedo worker experience. Stays visible
// Stays visible across Headgates → Log → Success AND on the Time // across Headgates → Log → Success AND on the Time tab.
// tab. Extracted as a presentational component (no server actions, //
// no effects) so it's pure and easy to iterate on visually. // 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({ function TabBar({
activeTab, activeTab,
onChange, onChange,
@@ -372,41 +386,35 @@ function TabBar({
const t = useLang().t; const t = useLang().t;
return ( return (
<div <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" className="sticky top-0 z-30 flex items-center justify-between px-3"
role="tablist" style={{
aria-label="Worker navigation" 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"> <MiniSegmented
<button ariaLabel="Worker navigation"
type="button" value={activeTab}
role="tab" onChange={onChange}
aria-selected={activeTab === "headgates"} options={[
onClick={() => onChange("headgates")} { value: "headgates", label: t.tab.headgates },
className={ { value: "time", label: t.tab.time },
"rounded-full px-3 py-1 text-[13px] font-semibold transition " + ]}
(activeTab === "headgates" />
? "bg-[#1a4d2e] text-white shadow-sm" <div className="flex items-center gap-1.5 pr-0.5">
: "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">
{irrigatorName && ( {irrigatorName && (
<span className="hidden text-[12px] font-medium text-[rgba(60,60,67,0.55)] sm:inline"> <span className="hidden text-[12px] font-medium text-[rgba(60,60,67,0.55)] sm:inline">
{irrigatorName} {irrigatorName}
@@ -415,8 +423,8 @@ function TabBar({
<button <button
type="button" type="button"
onClick={onLogout} 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={t.tab.logout}
aria-label="Log out" 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} {t.tab.logout}
</button> </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; export default MobileWaterApp;
@@ -73,14 +73,17 @@ export function MobileWaterSuccessScreen({
paddingBottom: "env(safe-area-inset-bottom)", 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 <header
className="px-5 pb-3 pt-[max(env(safe-area-inset-top),1rem)]" className="px-5 pb-3 pt-[max(env(safe-area-inset-top),1rem)]"
style={{ style={{
background: background:
"linear-gradient(180deg, rgba(255,255,255,0.92) 0%, rgba(242,242,247,0) 100%)", "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(10px)", backdropFilter: "blur(24px) saturate(180%)",
WebkitBackdropFilter: "blur(10px)", 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"> <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;
+47 -7
View File
@@ -102,9 +102,29 @@ export type Labels = {
submitError: string; submitError: string;
/** Defensive guard — shouldn't ever happen. */ /** Defensive guard — shouldn't ever happen. */
noHeadgateSelected: string; noHeadgateSelected: string;
normalRange: (low: number, high: number, unit: string) => string; /** Accepts pre-formatted strings so callers can render the same
alertAbove: (high: number, unit: string) => string; * shape (integer-cleaned, locale-aware) the meter uses. */
alertBelow: (low: number, unit: string) => string; 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. */ /** `/water` success screen after submit. */
success: { success: {
@@ -208,8 +228,18 @@ export const LABELS: Record<Lang, Labels> = {
noHeadgateSelected: "Pick a headgate first.", noHeadgateSelected: "Pick a headgate first.",
normalRange: (low, high, unit) => normalRange: (low, high, unit) =>
`Normal range: ${low} ${high} ${unit}`, `Normal range: ${low} ${high} ${unit}`,
alertAbove: (high, unit) => `Alert above ${high} ${unit}`, alertAbove: (value, unit) => `Alert above ${value} ${unit}`,
alertBelow: (low, unit) => `Alert below ${low} ${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: { success: {
title: "Reading logged", title: "Reading logged",
@@ -305,8 +335,18 @@ export const LABELS: Record<Lang, Labels> = {
noHeadgateSelected: "Elige una compuerta primero.", noHeadgateSelected: "Elige una compuerta primero.",
normalRange: (low, high, unit) => normalRange: (low, high, unit) =>
`Rango normal: ${low} ${high} ${unit}`, `Rango normal: ${low} ${high} ${unit}`,
alertAbove: (high, unit) => `Alerta arriba de ${high} ${unit}`, alertAbove: (value, unit) => `Alerta arriba de ${value} ${unit}`,
alertBelow: (low, unit) => `Alerta abajo de ${low} ${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: { success: {
title: "Lectura registrada", 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);
});
});
+6 -6
View File
@@ -252,21 +252,21 @@ describe("Mobile label helpers (interpolation)", () => {
}); });
it("log.normalRange interpolates the bounds + unit", () => { 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", "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", "Rango normal: 2 8 CFS",
); );
}); });
it("log.alertAbove / alertBelow interpolate single bound + unit", () => { it("log.alertAbove / alertBelow interpolate single bound + unit", () => {
expect(LABELS.en.log.alertAbove(10, "CFS")).toBe("Alert above 10 CFS"); expect(LABELS.en.log.alertAbove("10", "CFS")).toBe("Alert above 10 CFS");
expect(LABELS.es.log.alertAbove(10, "CFS")).toBe( expect(LABELS.es.log.alertAbove("10", "CFS")).toBe(
"Alerta arriba de 10 CFS", "Alerta arriba de 10 CFS",
); );
expect(LABELS.en.log.alertBelow(1, "CFS")).toBe("Alert below 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"); expect(LABELS.es.log.alertBelow("1", "CFS")).toBe("Alerta abajo de 1 CFS");
}); });
it("success.loggedBy interpolates the irrigator name", () => { it("success.loggedBy interpolates the irrigator name", () => {