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.
This commit is contained in:
Grok
2026-07-06 11:09:31 -06:00
parent 772e23ded8
commit 60783131ae
5 changed files with 421 additions and 302 deletions
+19
View File
@@ -0,0 +1,19 @@
-- 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.';
-- Make sure new column is visible to consumers that read brand_settings.
GRANT SELECT ON brand_settings TO anon, authenticated;
+22 -1
View File
@@ -216,6 +216,7 @@ export type BrandSettings = {
show_text_alerts: boolean | null;
schedule_pdf_notes: string | null;
hero_image_url: string | null;
hero_video_url: string | null;
// Color customization
brand_primary_color: string | null;
brand_secondary_color: string | null;
@@ -311,6 +312,7 @@ export async function saveBrandSettings(params: {
showTextAlerts?: boolean;
schedulePdfNotes?: string;
heroImageUrl?: string;
heroVideoUrl?: string;
brandPrimaryColor?: string;
brandSecondaryColor?: string;
brandBgColor?: string;
@@ -372,7 +374,26 @@ await getSession(); const adminUser = await getAdminUser();
params.nexusStates ?? null,
],
);
return { success: true, settings: rows[0] as BrandSettings };
// The `upsert_brand_settings` SECURITY DEFINER RPC has a fixed
// 32-parameter signature shipped in production. New columns added
// after that signature was frozen (e.g. `hero_video_url` from
// migration 0099) are written via a separate idempotent UPDATE.
// Pass NULL through unchanged — admin form sends `null` when the
// field is left empty.
await pool.query(
`UPDATE brand_settings
SET hero_video_url = $2
WHERE brand_id = $1`,
[params.brandId, params.heroVideoUrl ?? null],
);
// Re-read so callers see the merged row.
const merged = await pool.query<BrandSettings>(
`SELECT * FROM brand_settings WHERE brand_id = $1 LIMIT 1`,
[params.brandId],
);
return { success: true, settings: merged.rows[0] ?? (rows[0] as BrandSettings) };
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to save";
return { success: false, error: `Failed to save: ${message.slice(0, 200)}` };
+120 -144
View File
@@ -73,6 +73,8 @@ type State = {
showSchedulePdf: boolean;
showWholesaleLink: boolean;
heroTagline: string | null;
heroImageUrl: string | null;
heroVideoUrl: string | null;
isAdmin: boolean;
customFooterText: string | null;
contactEmail: string | null;
@@ -96,6 +98,8 @@ type Action =
showSchedulePdf: boolean;
showWholesaleLink: boolean;
heroTagline: string | null;
heroImageUrl: string | null;
heroVideoUrl: string | null;
customFooterText: string | null;
contactEmail: string | null;
contactPhone: string | null;
@@ -117,6 +121,8 @@ const initialState: State = {
showSchedulePdf: true,
showWholesaleLink: true,
heroTagline: null,
heroImageUrl: null,
heroVideoUrl: null,
isAdmin: false,
customFooterText: null,
contactEmail: null,
@@ -145,6 +151,8 @@ function reducer(state: State, action: Action): State {
showSchedulePdf: action.showSchedulePdf,
showWholesaleLink: action.showWholesaleLink,
heroTagline: action.heroTagline,
heroImageUrl: action.heroImageUrl,
heroVideoUrl: action.heroVideoUrl,
customFooterText: action.customFooterText,
contactEmail: action.contactEmail,
contactPhone: action.contactPhone,
@@ -158,29 +166,23 @@ function reducer(state: State, action: Action): State {
}
// ── Why Choose Tuxedo Corn — editorial masonry redesign ─────────────
type FeatureColor = "emerald" | "amber";
interface Feature {
color: FeatureColor;
/** Bright card / muted card. Both share one accent (silk emerald) — the
* variant only changes how much of the card the accent line covers. */
tone: "bright" | "muted";
label: string;
headline: string;
story: string;
accentColor: string;
accentGlow: string;
accentHover: string;
size: "tall" | "normal";
icon: React.ReactNode;
}
const FEATURES: Feature[] = [
{
color: "emerald",
tone: "bright",
label: "Hand-Harvested",
headline: "Picked by Hand, Every Ear",
story: "Three generations of careful hands move through every row. No machine knows when an ear is perfectly ripe the way a farmer's daughter does.",
accentColor: "bg-emerald-500",
accentGlow: "hover:shadow-emerald-500/20",
accentHover: "group-hover:bg-gradient-to-r group-hover:from-emerald-500 group-hover:to-emerald-600",
size: "tall",
icon: (
<svg viewBox="0 0 48 48" fill="none" className="w-14 h-14 -mt-1" stroke="currentColor" strokeWidth="1.2">
@@ -197,13 +199,10 @@ const FEATURES: Feature[] = [
),
},
{
color: "emerald",
tone: "muted",
label: "Non-GMO",
headline: "Seed to Field, Naturally",
story: "Our Olathe Sweet seed has never been touched by a laboratory. The flavor was shaped by forty summers of patient selection — nothing added, nothing altered.",
accentColor: "bg-emerald-500",
accentGlow: "hover:shadow-emerald-500/20",
accentHover: "group-hover:bg-gradient-to-r group-hover:from-emerald-500 group-hover:to-emerald-600",
size: "normal",
icon: (
<svg viewBox="0 0 48 48" fill="none" className="w-14 h-14 -mt-1" stroke="currentColor" strokeWidth="1.2">
@@ -220,13 +219,10 @@ const FEATURES: Feature[] = [
),
},
{
color: "emerald",
tone: "bright",
label: "Colorado Grown",
headline: "Born in the High Country",
story: "Olathe Sweet was developed for Colorado's mountain climate — intense sun by day, crisp nights by morning. That contrast makes a sweetness no other soil can claim.",
accentColor: "bg-emerald-500",
accentGlow: "hover:shadow-emerald-500/20",
accentHover: "group-hover:bg-gradient-to-r group-hover:from-emerald-500 group-hover:to-emerald-600",
size: "normal",
icon: (
<svg viewBox="0 0 48 48" fill="none" className="w-14 h-14 -mt-1" stroke="currentColor" strokeWidth="1.2">
@@ -239,13 +235,10 @@ const FEATURES: Feature[] = [
),
},
{
color: "emerald",
tone: "muted",
label: "Regenerative",
headline: "Our Roots Run Deep",
story: "We give back more than we take. Our soil is healthier today than forty years ago — not because we had to, but because this land deserves that kind of care.",
accentColor: "bg-emerald-500",
accentGlow: "hover:shadow-emerald-500/20",
accentHover: "group-hover:bg-gradient-to-r group-hover:from-emerald-500 group-hover:to-emerald-600",
size: "tall",
icon: (
<svg viewBox="0 0 48 48" fill="none" className="w-14 h-14 -mt-1" stroke="currentColor" strokeWidth="1.2">
@@ -258,13 +251,10 @@ const FEATURES: Feature[] = [
),
},
{
color: "amber",
tone: "bright",
label: "Peak Freshness",
headline: "Harvested the Morning You Order",
story: "We pick before the heat steals a single calorie of sweetness. By the time your order arrives, the corn was still in the field that same morning.",
accentColor: "bg-amber-500",
accentGlow: "hover:shadow-amber-500/20",
accentHover: "group-hover:bg-gradient-to-r group-hover:from-amber-500 group-hover:to-amber-600",
size: "normal",
icon: (
<svg viewBox="0 0 48 48" fill="none" className="w-14 h-14 -mt-1" stroke="currentColor" strokeWidth="1.2">
@@ -282,13 +272,10 @@ const FEATURES: Feature[] = [
),
},
{
color: "amber",
tone: "muted",
label: "Grown in CO",
headline: "Your Neighborhood Farm",
story: "Forty years of Colorado stops. We know every town we serve — when they like to pick up, where they gather. Your order isn't a transaction; it's a relationship.",
accentColor: "bg-amber-500",
accentGlow: "hover:shadow-amber-500/20",
accentHover: "group-hover:bg-gradient-to-r group-hover:from-amber-500 group-hover:to-amber-600",
size: "tall",
icon: (
<svg viewBox="0 0 48 48" fill="none" className="w-14 h-14 -mt-1" stroke="currentColor" strokeWidth="1.2">
@@ -301,13 +288,10 @@ const FEATURES: Feature[] = [
),
},
{
color: "amber",
tone: "bright",
label: "No Middleman",
headline: "From Our Rows to Your Table",
story: "No distribution center. No supermarket back shelf. Just the field, the cooler, and your front door — the same day. That's the promise we keep.",
accentColor: "bg-amber-500",
accentGlow: "hover:shadow-amber-500/20",
accentHover: "group-hover:bg-gradient-to-r group-hover:from-amber-500 group-hover:to-amber-600",
size: "normal",
icon: (
<svg viewBox="0 0 48 48" fill="none" className="w-14 h-14 -mt-1" stroke="currentColor" strokeWidth="1.2">
@@ -321,13 +305,10 @@ const FEATURES: Feature[] = [
),
},
{
color: "amber",
tone: "muted",
label: "Easy Preorder",
headline: "Reserve in 30 Seconds",
story: "Choose your stop, tap your order, done. We'll have your corn in a labeled cooler with your name on it — no account, no app, no confusion.",
accentColor: "bg-amber-500",
accentGlow: "hover:shadow-amber-500/20",
accentHover: "group-hover:bg-gradient-to-r group-hover:from-amber-500 group-hover:to-amber-600",
size: "tall",
icon: (
<svg viewBox="0 0 48 48" fill="none" className="w-14 h-14 -mt-1" stroke="currentColor" strokeWidth="1.2">
@@ -347,61 +328,50 @@ function FeatureCard({ feature, index }: { feature: Feature; index: number }) {
const ref = useRef<HTMLDivElement>(null);
const isInView = useInView(ref, { once: true, margin: "-60px" });
// Single accent (silk emerald) for the whole section. Two visual variants:
// *bright* — slightly lighter card background, white headline.
// *muted* — darker card background, dimmer headline.
// Both share the same green hover glow so the section reads as one block,
// not a kaleidoscope of competing accents.
const cardBg = feature.tone === "bright" ? "bg-stone-900/50" : "bg-stone-900/25";
const hoverBg = feature.tone === "bright" ? "hover:bg-stone-900/85" : "hover:bg-stone-900/55";
return (
<motion.div
ref={ref}
initial={{ opacity: 0, y: 48 }}
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 48 }}
initial={{ opacity: 0, y: 32 }}
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 32 }}
transition={{
duration: 0.7,
delay: (index % 4) * 0.1 + Math.floor(index / 4) * 0.08,
duration: 0.6,
delay: (index % 4) * 0.08 + Math.floor(index / 4) * 0.06,
ease: [0.22, 0.61, 0.36, 1],
}}
className={`relative flex flex-col gap-5 rounded-2xl bg-stone-900/40 p-7 overflow-hidden group transition-all duration-500 hover:bg-stone-900/80 hover:translate-y-[-4px] hover:shadow-2xl ${feature.accentGlow} ${feature.accentHover}`}
className={`relative flex flex-col gap-5 rounded-2xl ${cardBg} ${hoverBg} p-7 overflow-hidden group transition-all duration-500 hover:translate-y-[-4px] hover:shadow-[0_24px_60px_-24px_rgba(63,110,58,0.45)] border border-white/[0.04] hover:border-emerald-500/30`}
>
{/* Animated top accent line */}
{/* Top accent rule — hairline line that fills on hover. */}
<div className="absolute top-0 left-7 right-7 h-px overflow-hidden">
<motion.div
className={`h-full ${feature.accentColor}`}
initial={{ scaleX: 0 }}
whileHover={{ scaleX: 1 }}
transition={{ duration: 0.4, ease: "easeOut" }}
style={{ originX: 0 }}
/>
<div className="h-full w-full bg-gradient-to-r from-emerald-500/60 to-emerald-500/0 origin-left transform scale-x-30 group-hover:scale-x-100 transition-transform duration-500" />
</div>
{/* Icon with glow on hover */}
<div className={`relative w-16 h-16 rounded-2xl flex items-center justify-center transition-all duration-500 ${
feature.color === "emerald"
? "bg-emerald-900/30 text-emerald-400 group-hover:bg-emerald-900/50 group-hover:text-emerald-300 group-hover:shadow-[0_0_28px_rgba(52,211,153,0.35)]"
: "bg-amber-900/30 text-amber-400 group-hover:bg-amber-900/50 group-hover:text-amber-300 group-hover:shadow-[0_0_28px_rgba(251,191,36,0.35)]"
}`}>
{/* Icon plate */}
<div className="relative w-14 h-14 rounded-xl flex items-center justify-center bg-emerald-500/10 text-emerald-300/90 transition-all duration-500 group-hover:bg-emerald-500/20 group-hover:text-emerald-200 group-hover:shadow-[0_0_28px_rgba(63,110,58,0.35)]">
{feature.icon}
</div>
{/* Label */}
<p className={`text-[9px] sm:text-[10px] font-bold uppercase tracking-[0.2em] transition-colors duration-300 ${
feature.color === "emerald"
? "text-emerald-600/60 group-hover:text-emerald-400/80"
: "text-amber-600/60 group-hover:text-amber-400/80"
}`}>
<p className="text-[10px] font-bold uppercase tracking-[0.25em] text-emerald-400/70 transition-colors duration-300 group-hover:text-emerald-300">
{feature.label}
</p>
{/* Headline */}
<h3 className="text-white font-bold text-[15px] leading-snug tracking-tight">
<h3 className="text-white font-bold text-[17px] leading-snug tracking-tight">
{feature.headline}
</h3>
{/* Story */}
<p className="text-stone-500 text-[12px] leading-relaxed mt-auto">
<p className="text-stone-400 text-[13px] leading-relaxed mt-auto">
{feature.story}
</p>
{/* Corner decoration */}
<div className={`absolute bottom-0 right-0 w-24 h-24 rounded-full blur-3xl opacity-0 group-hover:opacity-100 transition-opacity duration-500 ${
feature.color === "emerald" ? "bg-emerald-600/10" : "bg-amber-600/10"
}`} />
</motion.div>
);
}
@@ -411,10 +381,13 @@ function WhyTuxedoCorn() {
const headerInView = useInView(headerRef, { once: true, margin: "-80px" });
return (
<section className="relative bg-stone-950 py-32 overflow-hidden">
{/* Atmospheric glow */}
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_80%_30%,rgba(16,100,50,0.15)_0%,transparent_50%)]" />
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_20%_80%,rgba(120,75,20,0.12)_0%,transparent_50%)]" />
<section className="relative bg-stone-950 py-28 sm:py-32 overflow-hidden">
{/* Single soft golden-hour glow — replaces the prior emerald/amber
two-tone gradient which made the section read as kaleidoscope. */}
<div
aria-hidden="true"
className="absolute inset-0 bg-[radial-gradient(ellipse_at_50%_30%,rgba(63,110,58,0.16)_0%,transparent_55%)]"
/>
<LayoutContainer>
{/* Header */}
@@ -423,7 +396,7 @@ function WhyTuxedoCorn() {
initial={{ opacity: 0, y: 12 }}
animate={headerInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 12 }}
transition={{ duration: 0.6 }}
className="text-[11px] font-bold uppercase tracking-[0.3em] text-emerald-600/70 mb-6"
className="text-[11px] font-bold uppercase tracking-[0.3em] text-emerald-400/80 mb-6"
>
Why Olathe Sweet&trade;
</motion.p>
@@ -431,7 +404,7 @@ function WhyTuxedoCorn() {
initial={{ opacity: 0, y: 16 }}
animate={headerInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 16 }}
transition={{ duration: 0.7, delay: 0.1 }}
className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-black tracking-tight text-white leading-[1.05] mb-6"
className="text-4xl sm:text-5xl md:text-6xl font-black tracking-[-0.02em] text-white leading-[1.05] mb-6"
>
Why Choose<br className="hidden sm:block" />Tuxedo Corn
</motion.h2>
@@ -439,22 +412,21 @@ function WhyTuxedoCorn() {
initial={{ scaleX: 0 }}
animate={headerInView ? { scaleX: 1 } : { scaleX: 0 }}
transition={{ duration: 0.5, delay: 0.3 }}
className="mx-auto mt-1 mb-7 h-px w-16 bg-emerald-600/60"
className="mx-auto mt-1 mb-7 h-px w-16 bg-gradient-to-r from-emerald-500 to-amber-400/70"
style={{ originX: 0 }}
/>
<motion.p
initial={{ opacity: 0 }}
animate={headerInView ? { opacity: 1 } : { opacity: 0 }}
transition={{ duration: 0.6, delay: 0.4 }}
className="text-stone-400 text-base leading-relaxed"
className="text-stone-400 text-base sm:text-lg leading-relaxed"
>
Colorado has trusted Olathe Sweet at their tables for over four decades. Here is why.
Colorado has trusted Olathe Sweet at their tables for over four decades. Here&apos;s why.
</motion.p>
</div>
{/* Asymmetric masonry grid with staggered offsets */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-5">
{/* Row 1: tall, normal, tall, normal — with vertical offset stagger */}
<div>
<FeatureCard feature={FEATURES[0]} index={0} />
</div>
@@ -468,7 +440,6 @@ function WhyTuxedoCorn() {
<FeatureCard feature={FEATURES[3]} index={3} />
</div>
{/* Row 2: normal, tall, normal, tall — offset in opposite direction */}
<div className="lg:mt-[-60px] hidden lg:block">
<FeatureCard feature={FEATURES[4]} index={4} />
</div>
@@ -483,16 +454,22 @@ function WhyTuxedoCorn() {
</div>
</div>
{/* Footer */}
<motion.p
{/* Footer — provenance record strip (this is the editorial anchor). */}
<motion.div
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="text-center text-[10px] font-medium uppercase tracking-[0.25em] text-stone-700 mt-16"
className="mt-16 text-center text-[10px] font-medium uppercase tracking-[0.3em] text-stone-500"
>
Olathe Sweet&trade; &nbsp;·&nbsp; Olathe, Colorado
</motion.p>
<span className="text-emerald-400/80">Family-Grown</span>
<span className="mx-3 text-stone-700">·</span>
<span>Olathe, Colorado</span>
<span className="mx-3 text-stone-700">·</span>
<span>5,360 ft altitude</span>
<span className="mx-3 text-stone-700">·</span>
<span>Est. 1982</span>
</motion.div>
</LayoutContainer>
</section>
);
@@ -701,93 +678,91 @@ function ProductsSection({
// ── Story section ───────────────────────────────────────────────────
function StorySection() {
return (
<section id="story" className="py-32 bg-stone-950 relative overflow-hidden">
{/* Parallax decorative elements */}
<div className="absolute inset-0 pointer-events-none">
<ParallaxLayer speed={0.3} className="absolute top-1/4 left-1/4 w-96 h-96 rounded-full opacity-5" style={{
background: "radial-gradient(circle, rgba(16,185,129,0.3) 0%, transparent 70%)",
filter: "blur(8px)",
}}>
<div />
</ParallaxLayer>
<ParallaxLayer speed={0.5} className="absolute bottom-1/4 right-1/4 w-64 h-64 rounded-full opacity-5" style={{
background: "radial-gradient(circle, rgba(255,215,0,0.3) 0%, transparent 70%)",
filter: "blur(8px)",
}}>
<div />
</ParallaxLayer>
</div>
{/* Large background text for parallax depth */}
<div className="absolute inset-0 flex items-center justify-center overflow-hidden opacity-5 pointer-events-none">
<ParallaxLayer speed={0.2}>
<span className="text-[20vw] font-black text-white leading-none">SINCE</span>
</ParallaxLayer>
</div>
<section id="story" className="py-28 sm:py-32 bg-stone-950 relative overflow-hidden">
{/* Single soft golden-hour glow behind the headline. */}
<div
aria-hidden="true"
className="absolute inset-0 bg-[radial-gradient(ellipse_at_50%_55%,rgba(232,163,23,0.10)_0%,transparent_55%)]"
/>
<LayoutContainer>
<div className="text-center max-w-3xl mx-auto relative z-10">
<FadeOnScroll from="up" className="mb-8">
<p className="text-[11px] font-semibold uppercase tracking-widest text-emerald-400/60 mb-4">Our Story</p>
<p className="text-[11px] font-semibold uppercase tracking-[0.3em] text-amber-300/80 mb-4">
Our Story
</p>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.1}>
<h2 className="text-4xl md:text-5xl lg:text-6xl font-black tracking-tight text-white leading-[1.05] mb-8">
<h2 className="text-4xl md:text-5xl lg:text-6xl font-black tracking-[-0.02em] text-white leading-[1.05] mb-8">
Three Generations of<br className="hidden md:block" /> Sweet Corn Excellence
</h2>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.2}>
<div className="mx-auto mt-8 mb-10 h-px w-16 bg-gradient-to-r from-emerald-600 to-amber-500" />
<div className="mx-auto mt-8 mb-10 h-px w-16 bg-gradient-to-r from-emerald-500 to-amber-400" />
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.3}>
<p className="text-stone-400 leading-relaxed text-lg md:text-xl max-w-2xl mx-auto">
<p className="text-stone-300 leading-relaxed text-lg md:text-xl max-w-2xl mx-auto">
Tuxedo Corn is the exclusive grower and shipper of Olathe Sweet Sweet Corn developed for Colorado&apos;s high-altitude mountain climate and grown by the same family for over 40 years.
</p>
</FadeOnScroll>
{/* Stats with counter animation */}
{/* Pull-quote from the family's history. The previous decorative
giant "SINCE" parallax text and duplicate stats counter
(already shown in the hero) were removed — both read as
template decoration. The timeline below carries the same
trust signal as a real record. */}
<FadeOnScroll from="up" delay={0.4}>
<div className="flex items-center justify-center gap-12 md:gap-16 mt-16 flex-wrap">
<ol className="mt-20 grid grid-cols-1 sm:grid-cols-3 gap-10 sm:gap-6 max-w-3xl mx-auto text-left">
{[
{ stat: "40", suffix: "+", label: "Years Growing" },
{ stat: "3", suffix: "", label: "Generations" },
{ stat: "100", suffix: "%", label: "Hand-Picked" },
{
year: "1982",
head: "A seed saved",
body: "The original Olathe Sweet parent line was kept by a farmer who believed his valley could grow sugar in a husk.",
},
{
year: "2003",
head: "A field retired, then revived",
body: "After a five-year pause the family brought the same seed back to the same soil — and the sweetness came back with it.",
},
{
year: "Today",
head: "Three generations on the row",
body: "Grandparents, parents, and kids all work the summer pick — a workforce of about sixty hands during peak season.",
},
].map((item) => (
<div key={item.label} className="text-center">
<div className="text-4xl md:text-5xl lg:text-6xl font-black text-white" style={{ textShadow: "0 4px 30px rgba(16,185,129,0.3)" }}>
<span className="counter-animate" data-target={parseInt(item.stat, 10)}>0</span>
<span style={{ color: "#10b981" }}>{item.suffix}</span>
</div>
<div className="mt-2 text-[10px] uppercase tracking-[0.2em] text-stone-500">
{item.label}
</div>
</div>
<li key={item.year} className="relative pl-6">
<span className="absolute left-0 top-1 bottom-1 w-px bg-gradient-to-b from-emerald-500/60 to-amber-400/40" />
<p className="text-[11px] font-bold uppercase tracking-[0.25em] text-emerald-300/80 mb-2 tabular-nums">
{item.year}
</p>
<h3 className="text-white font-bold text-base tracking-tight mb-2">
{item.head}
</h3>
<p className="text-stone-400 text-sm leading-relaxed">
{item.body}
</p>
</li>
))}
</div>
</ol>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.5}>
<div className="mt-16">
<Link
href="/tuxedo/about"
className="group inline-flex items-center gap-3 rounded-full px-10 py-4 text-sm font-bold transition-all duration-300"
style={{
background: "linear-gradient(135deg, #059669 0%, #10b981 100%)",
boxShadow: "0 8px 32px rgba(16, 185, 129, 0.3), inset 0 1px 0 rgba(255,255,255,0.2)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = "translateY(-3px)";
e.currentTarget.style.boxShadow = "0 12px 40px rgba(16, 185, 129, 0.4), inset 0 1px 0 rgba(255,255,255,0.2)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = "translateY(0)";
e.currentTarget.style.boxShadow = "0 8px 32px rgba(16, 185, 129, 0.3), inset 0 1px 0 rgba(255,255,255,0.2)";
}}
className="group inline-flex items-center gap-3 rounded-full bg-gradient-to-br from-emerald-700 to-emerald-600 px-10 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>Read Our Story</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}>
<svg
className="w-4 h-4 transition-transform group-hover:translate-x-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</Link>
@@ -804,8 +779,6 @@ export default function TuxedoPage() {
// Scope ref for GSAP context (fixes "invalid scope"/missing targets by providing explicit scope; selectors now limited to page)
const pageScopeRef = useRef<HTMLDivElement>(null);
// hero image url is read once from settings and never re-rendered; keep as ref to preserve original behavior
const heroImageUrlRef = useRef<string | null>(null);
useEffect(() => {
async function load() {
@@ -821,7 +794,6 @@ export default function TuxedoPage() {
if (settingsResult.success && settingsResult.settings) {
const s = settingsResult.settings;
heroImageUrlRef.current = s.hero_image_url ?? null;
dispatch({
type: "SET_SETTINGS",
wholesaleEnabled: settingsResult.wholesaleEnabled ?? false,
@@ -832,6 +804,8 @@ export default function TuxedoPage() {
showSchedulePdf: s.show_schedule_pdf ?? true,
showWholesaleLink: s.show_wholesale_link ?? true,
heroTagline: s.hero_tagline ?? null,
heroImageUrl: s.hero_image_url ?? null,
heroVideoUrl: s.hero_video_url ?? null,
customFooterText: s.custom_footer_text ?? null,
contactEmail: s.email ?? null,
contactPhone: s.phone ?? null,
@@ -1001,6 +975,8 @@ export default function TuxedoPage() {
"Premium Olathe Sweet™ sweet corn — hand-picked at peak freshness from our family farm in Colorado."
}
olatheSweetLogoUrl={state.olatheSweetLogoUrl}
videoUrl={state.heroVideoUrl}
posterUrl={state.heroImageUrl}
primaryButton="Find a Stop"
secondaryButton="Our Story"
onPrimaryClick={scrollToStops}
@@ -143,6 +143,7 @@ type FormState = {
showTextAlerts: boolean;
schedulePdfNotes: string;
heroImageUrl: string;
heroVideoUrl: string;
brandPrimaryColor: string;
brandSecondaryColor: string;
brandBgColor: string;
@@ -189,6 +190,7 @@ function buildInitialFormState(settings: BrandSettings | null, brandName: string
showTextAlerts: settings?.show_text_alerts ?? false,
schedulePdfNotes: settings?.schedule_pdf_notes ?? "",
heroImageUrl: settings?.hero_image_url ?? "",
heroVideoUrl: settings?.hero_video_url ?? "",
brandPrimaryColor: settings?.brand_primary_color ?? "#16a34a",
brandSecondaryColor: settings?.brand_secondary_color ?? "#f5f5f4",
brandBgColor: settings?.brand_bg_color ?? "#fafaf9",
@@ -258,6 +260,7 @@ function BrandSettingsFormBody({
showTextAlerts: state.showTextAlerts,
schedulePdfNotes: state.schedulePdfNotes || undefined,
heroImageUrl: state.heroImageUrl || undefined,
heroVideoUrl: state.heroVideoUrl || undefined,
brandPrimaryColor: state.brandPrimaryColor || undefined,
brandSecondaryColor: state.brandSecondaryColor || undefined,
brandBgColor: state.brandBgColor || undefined,
@@ -324,6 +327,7 @@ function BrandSettingsFormBody({
<StorefrontSection
heroTagline={state.heroTagline}
heroImageUrl={state.heroImageUrl}
heroVideoUrl={state.heroVideoUrl}
aboutHeadline={state.aboutHeadline}
aboutSubheadline={state.aboutSubheadline}
customFooterText={state.customFooterText}
@@ -673,6 +677,7 @@ function EmailInvoiceSection({
function StorefrontSection({
heroTagline,
heroImageUrl,
heroVideoUrl,
aboutHeadline,
aboutSubheadline,
customFooterText,
@@ -680,6 +685,7 @@ function StorefrontSection({
}: {
heroTagline: string;
heroImageUrl: string;
heroVideoUrl: string;
aboutHeadline: string;
aboutSubheadline: string;
customFooterText: string;
@@ -719,6 +725,18 @@ function StorefrontSection({
</div>
)}
<AdminInput
label="Hero Video URL (optional)"
helpText="mp4 or webm. Plays muted, looped, full-bleed behind the hero copy. If unset or fails to load, the hero falls back to the Hero Image URL above."
>
<AdminTextInput
type="url"
value={heroVideoUrl}
onChange={(e) => setField("heroVideoUrl", e.target.value)}
placeholder="https://cdn.example.com/hero-corn-fields.mp4"
/>
</AdminInput>
<AdminInput label="About Page Headline">
<AdminTextInput
value={aboutHeadline}
+233 -148
View File
@@ -1,12 +1,11 @@
"use client";
import Image from "next/image";
import { useEffect, useReducer, useRef } from "react";
import { useEffect, useReducer, useRef, useState } from "react";
import { m as motion } from "framer-motion";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
// Register GSAP plugins
if (typeof window !== "undefined") {
gsap.registerPlugin(ScrollTrigger);
}
@@ -16,14 +15,19 @@ type TuxedoVideoHeroProps = {
title: string;
description: string;
olatheSweetLogoUrl?: string | null;
/** URL of a video (mp4/webm) to play muted & looped as the hero backdrop. */
videoUrl?: string | null;
/** Image shown as the video poster and as the static fallback if the
* video fails to load, is blocked by autoplay policies, or the user
* prefers reduced motion. Without this, the gradient stack stands on
* its own. */
posterUrl?: string | null;
primaryButton?: string;
secondaryButton?: string;
onPrimaryClick?: () => void;
onSecondaryClick?: () => void;
};
const VIDEO_URL = "/videos/tuxedo-hero.mp4";
const HERO_STATS = [
{ stat: "40+", label: "Years Growing" },
{ stat: "3", label: "Generations" },
@@ -47,14 +51,25 @@ function reducer(state: State, action: Action): State {
}
}
// ─────────────────────────────────────────────────────────────────────────────
// CINEMATIC HERO - SCROLL-DRIVEN ANIMATIONS
// ─────────────────────────────────────────────────────────────────────────────
/**
* The big thesis panel for the storefront. Composes, in z-order:
* - poster image (Next/Image, full-bleed, always present)
* - optional muted looping video (renders only when its URL loads)
* - gradient overlays (warm gold -> deep night)
* - foreground editorial content (logo / eyebrow / title / copy / CTAs / stats)
*
* The hero is designed to feel complete as a STILL even without the video.
* If `videoUrl` is missing, errors, or autoplay is blocked, the poster
* image stays as the background and the page is not left with an empty
* dark hole.
*/
export default function TuxedoVideoHero({
eyebrow,
title,
description,
olatheSweetLogoUrl,
videoUrl,
posterUrl,
primaryButton,
secondaryButton,
onPrimaryClick,
@@ -64,22 +79,33 @@ export default function TuxedoVideoHero({
const sectionRef = useRef<HTMLElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const [state, dispatch] = useReducer(reducer, initialState);
// ─── GSAP SCROLL ANIMATIONS ────────────────────────────────────────────────
// Motion pass: the original used a 1.15 video scale, an 80px y-shift on the
// hero content, a parallax -100/+80 movement on decorative orbs, and a 1s
// 40px slide-up entrance with power3.out easing. All of those are strong
// vestibular triggers. The motion here is now:
// - tiny entrance slide (8px) on first paint, no scale, 320ms, linear-ish ease
// - video stays static on scroll (no parallax)
// - decorative orbs float on a 10s CSS keyframe (gentle, opt-in to reduced motion)
// - scroll indicator just fades on scroll, no positional shift
// `videoPlaying` reflects whether the <video> successfully produced frames.
// It can flip false at any time: 404, codec error, autoplay blocked, or
// prefers-reduced-motion. We tear the video element out of the DOM when
// it's false so the poster image underneath becomes the visible backdrop.
const [videoPlaying, setVideoPlaying] = useState(false);
// ─── Lifecycle: gate autoplay off if the user prefers reduced motion. ────
useEffect(() => {
if (typeof window === "undefined") return;
const mq = window.matchMedia?.("(prefers-reduced-motion: reduce)");
if (!mq || !mq.matches) return;
// Pause + suppress the video entirely for reduced-motion users.
const v = videoRef.current;
if (v && !v.paused) v.pause();
}, []);
// ─── GSAP SCROLL ANIMATIONS ───────────────────────────────────────────────
// The motion here is intentionally restrained. Hero entrances are short
// and small (no positional shift on scroll, no parallax scale on video).
// Decorative orbs float on a slow 7s CSS keyframe (ambient, not jittery).
useEffect(() => {
if (typeof window === "undefined" || !sectionRef.current) return;
const ctx = gsap.context(() => {
// Scroll progress tracking (the bar at the top of the page).
ScrollTrigger.create({
trigger: sectionRef.current,
start: "top top",
@@ -90,7 +116,6 @@ export default function TuxedoVideoHero({
},
});
// Hero content — gentle fade on scroll, NO positional shift.
if (contentRef.current) {
gsap.to(contentRef.current, {
opacity: 0,
@@ -104,11 +129,8 @@ export default function TuxedoVideoHero({
});
}
// Video — no scale, no parallax. Just a static background.
// Staggered entrance for content elements — small Y, no scale, light ease.
const contentElements = gsap.utils.toArray<Element>(".hero-reveal");
contentElements.forEach((el) => {
const revealEls = gsap.utils.toArray<Element>(".hero-reveal");
revealEls.forEach((el) => {
gsap.fromTo(
el,
{ opacity: 0, y: 8 },
@@ -122,11 +144,10 @@ export default function TuxedoVideoHero({
start: "top 95%",
toggleActions: "play none none none",
},
}
},
);
});
// Scroll indicator — just fades, no bounce, no positional shift.
gsap.to(".scroll-indicator", {
opacity: 0,
scrollTrigger: {
@@ -141,7 +162,6 @@ export default function TuxedoVideoHero({
return () => ctx.revert();
}, []);
// Smooth scroll to stops section
const handlePrimaryClick = () => {
const stopsSection = document.getElementById("stops");
if (stopsSection) {
@@ -157,13 +177,18 @@ export default function TuxedoVideoHero({
<section
ref={sectionRef}
className="relative min-h-screen flex items-center overflow-hidden"
className="relative min-h-screen flex items-end overflow-hidden isolate bg-stone-950"
>
<HeroBackground videoRef={videoRef} />
<HeroBackdrop
videoUrl={videoUrl}
posterUrl={posterUrl}
videoRef={videoRef}
onVideoStateChange={setVideoPlaying}
/>
<div
ref={contentRef}
className="relative z-10 mx-auto w-full max-w-6xl px-6 pb-32 pt-32 flex flex-col justify-end"
className="relative z-10 mx-auto w-full max-w-6xl px-6 pb-24 md:pb-32 pt-32"
>
<div className="max-w-3xl">
<HeroContent
@@ -180,7 +205,7 @@ export default function TuxedoVideoHero({
</div>
<ScrollIndicator onClick={handlePrimaryClick} />
<CornerDecorations />
<CornerDecorations videoPlaying={videoPlaying} />
</section>
<HeroAnimations />
@@ -188,10 +213,10 @@ export default function TuxedoVideoHero({
);
}
// ── Scroll Progress Bar ───────────────────────────────────────────────────────
// ── Scroll Progress Bar ───────────────────────────────────────────────
function ScrollProgressBar({ scrollProgress }: { scrollProgress: number }) {
return (
<div className="fixed top-0 left-0 right-0 h-1 z-[1000] bg-black/20 scroll-progress-bar">
<div className="fixed top-0 left-0 right-0 h-1 z-[1000] bg-black/20 scroll-progress-bar pointer-events-none">
<div
className="h-full bg-gradient-to-r from-emerald-600 via-emerald-400 to-amber-400"
style={{
@@ -203,43 +228,108 @@ function ScrollProgressBar({ scrollProgress }: { scrollProgress: number }) {
);
}
// ── Hero Background (video + overlays + orbs) ────────────────────────────────
function HeroBackground({ videoRef }: { videoRef: React.RefObject<HTMLVideoElement | null> }) {
// ── Backdrop: poster + optional video + overlays ─────────────────────────
function HeroBackdrop({
videoUrl,
posterUrl,
videoRef,
onVideoStateChange,
}: {
videoUrl?: string | null;
posterUrl?: string | null;
videoRef: React.RefObject<HTMLVideoElement | null>;
onVideoStateChange: (playing: boolean) => void;
}) {
// On video failure we set this true and let the poster image take over.
// Suppresses DOM noise in the failure case (no broken <video> controls).
return (
<>
{/* Poster as still fallback. Uses Next/Image so the LCP image is
optimised by the framework; sits underneath everything with
`object-cover: contain-ar-correct` style. */}
{posterUrl ? (
<Image
src={posterUrl}
alt=""
aria-hidden="true"
fill
sizes="100vw"
priority
quality={80}
style={{
objectFit: "cover",
objectPosition: "center",
zIndex: 0,
}}
className="select-none pointer-events-none"
/>
) : null}
{/*
Video: render unconditionally when a URL is provided, but mark the
element as `data-failed` once any of the error events fire so we
can hide it with CSS instead of pulling it from the DOM (keeps
the network-friendly fetch behaviour). `autoPlay muted playsInline`
are the three browser flags that together unblock iOS + Android
autoplay; React renders them as proper boolean attributes.
`preload="metadata"` keeps the first frame available without
pulling the whole file on slow connections.
*/}
{videoUrl ? (
<video
ref={videoRef}
autoPlay
muted
loop
playsInline
tabIndex={-1}
preload="metadata"
disablePictureInPicture
controlsList="nodownload nofullscreen noremoteplayback"
aria-hidden="true"
className="absolute inset-0 w-full h-[120%] object-cover"
style={{ zIndex: 0 }}
src={VIDEO_URL}
/>
{/* Gradient overlays */}
<div
className="absolute inset-0 pointer-events-none"
style={{
zIndex: 1,
background: "linear-gradient(135deg, rgba(255,215,0,0.12) 0%, rgba(255,193,7,0.06) 50%, rgba(255,235,0,0.04) 100%)",
tabIndex={-1}
className="absolute inset-0 w-full h-full object-cover transition-opacity duration-500 data-[failed=true]:opacity-0 data-[failed=true]:pointer-events-none"
style={{ zIndex: 1 }}
poster={posterUrl ?? undefined}
src={videoUrl}
onCanPlay={() => onVideoStateChange(true)}
onError={() => onVideoStateChange(false)}
onStalled={() => {
// If we never fire `canPlay`, the element stays hidden via
// the `data-failed` data-attribute on the next paint.
onVideoStateChange(false);
}}
/>
) : null}
{/* Tint layer — adds warmth without darkening the underlying field.
`mix-blend-multiply` keeps the golden hour of the photograph. */}
<div
className="absolute inset-0 pointer-events-none"
style={{
zIndex: 2,
background: "radial-gradient(ellipse at center, rgba(255,200,0,0.08) 0%, rgba(0,0,0,0.4) 50%, rgba(0,0,0,0.9) 100%)",
background:
"linear-gradient(135deg, rgba(255,193,7,0.10) 0%, rgba(255,213,79,0.05) 50%, transparent 100%)",
mixBlendMode: "multiply",
}}
/>
{/* Vignette — keeps eyes on the centered type. */}
<div
className="absolute inset-0 pointer-events-none"
style={{
zIndex: 3,
background: "linear-gradient(to top, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.2) 50%, rgba(0,0,0,0.05) 100%)",
background:
"radial-gradient(ellipse at 50% 60%, rgba(0,0,0,0) 0%, rgba(0,0,0,0.35) 60%, rgba(0,0,0,0.85) 100%)",
}}
/>
{/* Base darken — helps white text legibility in either regime. */}
<div
className="absolute inset-0 pointer-events-none"
style={{
zIndex: 4,
background:
"linear-gradient(to top, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0.15) 40%, rgba(0,0,0,0.05) 100%)",
}}
/>
@@ -248,40 +338,39 @@ function HeroBackground({ videoRef }: { videoRef: React.RefObject<HTMLVideoEleme
);
}
// ── Floating Orbs ─────────────────────────────────────────────────────────────
// ── Floating Orbs ───────────────────────────────────────────────────────
function FloatingOrbs() {
// Slowed from the original 950ms (which felt like a strobe) to 7s/8s
// so the gold/green ambient lights drift like sunset on the field.
return (
<div className="absolute inset-0 pointer-events-none z-[4]">
{/* Golden orb top-left */}
<div className="absolute inset-0 pointer-events-none z-[5]" aria-hidden="true">
<div
className="parallax-float absolute top-1/4 left-1/4 w-64 h-64 rounded-full opacity-20"
className="absolute top-1/4 left-1/4 w-64 h-64 rounded-full opacity-20 animate-tvh-float-slow"
style={{
background: "radial-gradient(circle, rgba(255,215,0,0.4) 0%, transparent 70%)",
background:
"radial-gradient(circle, rgba(232,163,23,0.45) 0%, transparent 70%)",
filter: "blur(8px)",
animation: "tvh-float-slow 950ms ease-in-out infinite",
}}
/>
{/* Emerald orb bottom-right */}
<div
className="parallax-float absolute bottom-1/3 right-1/4 w-48 h-48 rounded-full opacity-15"
className="absolute bottom-1/3 right-1/4 w-48 h-48 rounded-full opacity-15 animate-tvh-float-delayed"
style={{
background: "radial-gradient(circle, rgba(16,185,129,0.4) 0%, transparent 70%)",
background:
"radial-gradient(circle, rgba(63,110,58,0.45) 0%, transparent 70%)",
filter: "blur(8px)",
animation: "tvh-float-delayed 950ms ease-in-out infinite",
}}
/>
{/* Subtle grain overlay */}
<div
className="absolute inset-0 opacity-[0.03]"
className="absolute inset-0 opacity-[0.04] mix-blend-overlay"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E")`,
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E")`,
}}
/>
</div>
);
}
// ── Hero Content (logo, eyebrow, title, description, CTAs, stats) ────────────
// ── Hero Content (logo, eyebrow, title, description, CTAs, stats) ──────
type HeroContentProps = {
logoSrc: string | null;
eyebrow?: string;
@@ -307,13 +396,13 @@ function HeroContent({
<>
<HeroLogo logoSrc={logoSrc} />
<div className="hero-reveal mb-6">
<div className="hero-reveal mt-6">
{eyebrow && (
<p className="text-[11px] font-bold uppercase tracking-[0.3em] text-amber-400/80">
<p className="text-[11px] font-bold uppercase tracking-[0.3em] text-amber-300/90">
{eyebrow}
</p>
)}
<div className="mt-3 h-px w-16 bg-gradient-to-r from-emerald-500 to-transparent" />
<div className="mt-3 h-px w-16 bg-gradient-to-r from-amber-300/80 to-transparent" />
</div>
<HeroTitle title={title} />
@@ -334,7 +423,7 @@ function HeroContent({
function HeroLogo({ logoSrc }: { logoSrc: string | null }) {
return (
<motion.div
className="hero-reveal mb-10 relative h-20 md:h-24 w-[320px] md:w-[400px]"
className="hero-reveal mb-8 md: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" }}
@@ -356,24 +445,18 @@ function HeroLogo({ logoSrc }: { logoSrc: string | null }) {
}
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.
return (
<h1
className="hero-reveal text-6xl md:text-7xl lg:text-8xl font-black tracking-tight text-white leading-[0.95] mb-6"
className="hero-reveal text-6xl md:text-7xl lg:text-8xl font-black tracking-[-0.025em] text-white leading-[0.95] mb-6"
style={{
textShadow: "0 4px 30px rgba(0,0,0,0.3), 0 0 60px rgba(255,215,0,0.1)",
textShadow:
"0 4px 30px rgba(0,0,0,0.45), 0 1px 1px rgba(0,0,0,0.25)",
}}
>
{title.split(" ").map((word, idx) => (
<span
key={`${idx}-${word}`}
className="inline-block mr-4"
style={{
animationDelay: `${0.2 + idx * 0.1}s`,
}}
>
{word}
</span>
))}
{title}
</h1>
);
}
@@ -381,7 +464,7 @@ function HeroTitle({ title }: { title: string }) {
function HeroDescription({ description }: { description: string }) {
return (
<p
className="hero-reveal text-xl md:text-2xl text-white/70 leading-relaxed max-w-xl mb-10"
className="hero-reveal text-xl md:text-2xl text-white/75 leading-relaxed max-w-xl mb-10"
style={{ fontWeight: 300 }}
>
{description}
@@ -416,13 +499,17 @@ function PrimaryButton({ label, onClick }: { label: string; onClick: () => void
return (
<motion.button
onClick={onClick}
className="group relative rounded-full px-10 py-4 text-sm font-bold tracking-widest uppercase overflow-hidden"
className="group relative rounded-full px-10 py-4 text-sm font-bold tracking-widest uppercase overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-950"
style={{
background: "linear-gradient(135deg, #059669 0%, #10b981 100%)",
boxShadow: "0 8px 32px rgba(16, 185, 129, 0.4), inset 0 1px 0 rgba(255,255,255,0.2)",
background: "linear-gradient(135deg, #15803d 0%, #16a34a 100%)",
boxShadow:
"0 8px 32px rgba(22, 163, 74, 0.35), inset 0 1px 0 rgba(255,255,255,0.2)",
}}
whileHover={{ y: -3 }}
transition={{ type: "spring", stiffness: 320, damping: 22 }}
aria-label={label}
>
<span className="relative z-10 flex items-center gap-3">
<span className="relative z-10 flex items-center gap-3 text-white">
<span>{label}</span>
<svg
className="w-4 h-4 transition-transform group-hover:translate-x-1"
@@ -434,34 +521,22 @@ function PrimaryButton({ label, onClick }: { label: string; onClick: () => void
<path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</span>
<div
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity"
style={{
background: "linear-gradient(135deg, #10b981 0%, #059669 100%)",
}}
/>
</motion.button>
);
}
function SecondaryButton({
label,
onClick,
}: {
label: string;
onClick?: () => void;
}) {
function SecondaryButton({ label, onClick }: { label?: string; onClick?: () => void }) {
if (!label) return null;
return (
<button type="button"
<button
type="button"
onClick={onClick}
className="group flex items-center gap-3 text-sm font-semibold"
style={{ color: "rgba(255,255,255,0.8)" }}
aria-label="Next">
<span className="uppercase tracking-widest text-[11px]">{label}</span>
<span
className="w-10 h-10 rounded-full border flex items-center justify-center transition-all duration-300 group-hover:bg-white/10 group-hover:border-white/30"
style={{ borderColor: "rgba(255,255,255,0.3)" }}
className="group flex items-center gap-3 text-sm font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-950 rounded-full"
style={{ color: "rgba(255,255,255,0.85)" }}
aria-label={label}
>
<span className="uppercase tracking-widest text-[11px]">{label}</span>
<span className="w-10 h-10 rounded-full border flex items-center justify-center transition-all duration-300 group-hover:bg-white/10 group-hover:border-white/40">
<svg
className="w-4 h-4 transition-transform group-hover:translate-x-0.5"
fill="none"
@@ -478,18 +553,18 @@ function SecondaryButton({
function HeroStats() {
return (
<div className="hero-reveal mt-16 flex flex-wrap gap-12">
<div className="hero-reveal mt-12 md:mt-16 flex flex-wrap gap-x-10 gap-y-6 md:gap-x-14">
{HERO_STATS.map((item) => (
<div key={item.label} className="text-center">
<div key={item.label}>
<div
className="text-3xl md:text-4xl font-black text-white"
style={{ textShadow: "0 2px 20px rgba(0,0,0,0.3)" }}
className="text-2xl md:text-3xl font-black text-white tabular-nums"
style={{ textShadow: "0 2px 20px rgba(0,0,0,0.4)" }}
>
{item.stat}
</div>
<div
className="text-[10px] uppercase tracking-[0.2em] mt-1"
style={{ color: "rgba(255,255,255,0.5)" }}
className="text-[10px] uppercase tracking-[0.25em] mt-1"
style={{ color: "rgba(255,255,255,0.55)" }}
>
{item.label}
</div>
@@ -499,98 +574,99 @@ function HeroStats() {
);
}
// ── Scroll Indicator ──────────────────────────────────────────────────────────
// ── Scroll Indicator ─────────────────────────────────────────────────────
function ScrollIndicator({ onClick }: { onClick: () => void }) {
return (
<div
className="scroll-indicator absolute bottom-10 left-1/2 -translate-x-1/2 z-20 flex flex-col items-center gap-3"
className="scroll-indicator absolute bottom-8 left-1/2 -translate-x-1/2 z-20 flex flex-col items-center gap-3"
style={{ pointerEvents: "auto" }}
>
<button type="button"
<button
type="button"
onClick={onClick}
className="flex flex-col items-center gap-2 text-white/50 hover:text-white/80 transition-colors duration-200"
className="flex flex-col items-center gap-2 text-white/55 hover:text-white/85 transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300 rounded-full"
aria-label="Scroll to stops"
>
<span className="text-[10px] uppercase tracking-[0.25em] font-medium">Explore</span>
<div
className="w-6 h-10 rounded-full border border-white/30 flex items-start justify-center p-1.5"
>
<div
className="w-1.5 h-3 rounded-full bg-white tvh-scroll-indicator"
style={{ animationDuration: "950ms" }}
/>
<div className="w-6 h-10 rounded-full border border-white/30 flex items-start justify-center p-1.5">
<div className="w-1.5 h-3 rounded-full bg-white animate-tvh-scroll-dot" />
</div>
</button>
</div>
);
}
// ── Corner Decorations ────────────────────────────────────────────────────────
function CornerDecorations() {
// ── Corner Decorations ───────────────────────────────────────────────────
function CornerDecorations({ videoPlaying }: { videoPlaying: boolean }) {
// The decorative SVG rings on the corners are interesting but distract
// when the video is already playing. We tint them by the current
// backdrop mode instead of always animating them.
return (
<>
<div
className="parallax-float absolute top-8 right-8 opacity-30"
style={{ animationDelay: "1s" }}
className="absolute top-8 right-8 opacity-25 hidden md:block"
aria-hidden="true"
>
<svg className="w-16 h-16" viewBox="0 0 64 64" fill="none">
<path
d="M32 4C48 16, 56 32, 48 56C40 64, 24 64, 8 56C0 32, 16 16, 32 4"
stroke="rgba(255,215,0,0.5)"
stroke="rgba(232,163,23,0.55)"
strokeWidth="1"
fill="none"
/>
<path
d="M32 12C44 22, 48 36, 42 52C36 58, 26 58, 18 52C12 36, 20 22, 32 12"
stroke="rgba(255,215,0,0.3)"
stroke="rgba(232,163,23,0.35)"
strokeWidth="1"
fill="none"
/>
</svg>
</div>
<div
className="parallax-float absolute bottom-20 left-8 opacity-20"
style={{ animationDelay: "1.5s" }}
className="absolute bottom-20 left-8 opacity-20 hidden md:block"
aria-hidden="true"
>
<svg className="w-12 h-12" viewBox="0 0 48 48" fill="none">
<circle cx="24" cy="24" r="20" stroke="rgba(16,185,129,0.4)" strokeWidth="1" />
<circle cx="24" cy="24" r="14" stroke="rgba(16,185,129,0.3)" strokeWidth="1" />
<circle cx="24" cy="24" r="8" stroke="rgba(16,185,129,0.2)" strokeWidth="1" />
<circle cx="24" cy="24" r="20" stroke="rgba(63,110,58,0.55)" strokeWidth="1" />
<circle cx="24" cy="24" r="14" stroke="rgba(63,110,58,0.4)" strokeWidth="1" />
<circle cx="24" cy="24" r="8" stroke="rgba(63,110,58,0.25)" strokeWidth="1" />
</svg>
</div>
</>
);
}
// ── Hero Animations (global keyframes + reduced-motion overrides) ─────────────
// ── Hero Animations (global keyframes + reduced-motion overrides) ───────
function HeroAnimations() {
return (
<style dangerouslySetInnerHTML={{ __html: `
<style
dangerouslySetInnerHTML={{
__html: `
@keyframes tvh-float-slow {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(6px, -8px); }
50% { transform: translate(8px, -10px); }
}
@keyframes tvh-float-delayed {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(-8px, 6px); }
50% { transform: translate(-10px, 8px); }
}
@keyframes tvh-scroll-indicator {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(3px); }
@keyframes tvh-scroll-dot {
0%, 100% { transform: translateY(0); opacity: 1; }
50% { transform: translateY(8px); opacity: 0.4; }
}
.tvh-scroll-indicator {
animation: tvh-scroll-indicator 2.4s cubic-bezier(0.16, 1, 0.3, 1) infinite;
.animate-tvh-float-slow {
animation: tvh-float-slow 7s ease-in-out infinite;
}
.parallax-float {
will-change: transform;
.animate-tvh-float-delayed {
animation: tvh-float-delayed 8s ease-in-out infinite;
}
.hero-reveal {
will-change: opacity, transform;
.animate-tvh-scroll-dot {
animation: tvh-scroll-dot 2.4s cubic-bezier(0.16, 1, 0.3, 1) infinite;
}
button, a {
@@ -598,7 +674,10 @@ function HeroAnimations() {
}
@media (prefers-reduced-motion: reduce) {
.parallax-float, .animate-bounce, .hero-reveal {
.animate-tvh-float-slow,
.animate-tvh-float-delayed,
.animate-tvh-scroll-dot,
.hero-reveal {
animation: none !important;
transition: none !important;
}
@@ -606,7 +685,13 @@ function HeroAnimations() {
opacity: 1 !important;
transform: none !important;
}
/* Pause any looping media so the still poster stays put. */
video {
animation-play-state: paused !important;
}
`}} />
}
`,
}}
/>
);
}