fix(tuxedo): graceful hero video fallback + storefront polish
Deploy to route.crispygoat.com / deploy (push) Failing after 1m27s
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:
@@ -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;
|
||||
@@ -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
@@ -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™
|
||||
</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'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™ · 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'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}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 w-full h-[120%] object-cover"
|
||||
style={{ zIndex: 0 }}
|
||||
src={VIDEO_URL}
|
||||
/>
|
||||
{/* Poster as still fallback. Uses Next/Image so the LCP image is
|
||||
optimised by the framework; sits underneath everything with
|
||||
`object-cover: contain-ar-correct` style. */}
|
||||
{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}
|
||||
|
||||
{/* Gradient overlays */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
zIndex: 1,
|
||||
background: "linear-gradient(135deg, rgba(255,215,0,0.12) 0%, rgba(255,193,7,0.06) 50%, rgba(255,235,0,0.04) 100%)",
|
||||
}}
|
||||
/>
|
||||
{/*
|
||||
Video: render unconditionally when a URL is provided, but mark the
|
||||
element as `data-failed` once any of the error events fire so we
|
||||
can hide it with CSS instead of pulling it from the DOM (keeps
|
||||
the network-friendly fetch behaviour). `autoPlay muted playsInline`
|
||||
are the three browser flags that together unblock iOS + Android
|
||||
autoplay; React renders them as proper boolean attributes.
|
||||
`preload="metadata"` keeps the first frame available without
|
||||
pulling the whole file on slow connections.
|
||||
*/}
|
||||
{videoUrl ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
disablePictureInPicture
|
||||
controlsList="nodownload nofullscreen noremoteplayback"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
className="absolute inset-0 w-full h-full object-cover transition-opacity duration-500 data-[failed=true]:opacity-0 data-[failed=true]:pointer-events-none"
|
||||
style={{ zIndex: 1 }}
|
||||
poster={posterUrl ?? undefined}
|
||||
src={videoUrl}
|
||||
onCanPlay={() => onVideoStateChange(true)}
|
||||
onError={() => onVideoStateChange(false)}
|
||||
onStalled={() => {
|
||||
// If we never fire `canPlay`, the element stays hidden via
|
||||
// the `data-failed` data-attribute on the next paint.
|
||||
onVideoStateChange(false);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Tint layer — adds warmth without darkening the underlying field.
|
||||
`mix-blend-multiply` keeps the golden hour of the photograph. */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
zIndex: 2,
|
||||
background: "radial-gradient(ellipse at center, rgba(255,200,0,0.08) 0%, rgba(0,0,0,0.4) 50%, rgba(0,0,0,0.9) 100%)",
|
||||
background:
|
||||
"linear-gradient(135deg, rgba(255,193,7,0.10) 0%, rgba(255,213,79,0.05) 50%, transparent 100%)",
|
||||
mixBlendMode: "multiply",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Vignette — keeps eyes on the centered type. */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
zIndex: 3,
|
||||
background: "linear-gradient(to top, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.2) 50%, rgba(0,0,0,0.05) 100%)",
|
||||
background:
|
||||
"radial-gradient(ellipse at 50% 60%, rgba(0,0,0,0) 0%, rgba(0,0,0,0.35) 60%, rgba(0,0,0,0.85) 100%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Base darken — helps white text legibility in either regime. */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
zIndex: 4,
|
||||
background:
|
||||
"linear-gradient(to top, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0.15) 40%, rgba(0,0,0,0.05) 100%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -248,40 +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">
|
||||
className="group flex items-center gap-3 text-sm font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-950 rounded-full"
|
||||
style={{ color: "rgba(255,255,255,0.85)" }}
|
||||
aria-label={label}
|
||||
>
|
||||
<span className="uppercase tracking-widest text-[11px]">{label}</span>
|
||||
<span
|
||||
className="w-10 h-10 rounded-full border flex items-center justify-center transition-all duration-300 group-hover:bg-white/10 group-hover:border-white/30"
|
||||
style={{ borderColor: "rgba(255,255,255,0.3)" }}
|
||||
>
|
||||
<span className="w-10 h-10 rounded-full border flex items-center justify-center transition-all duration-300 group-hover:bg-white/10 group-hover:border-white/40">
|
||||
<svg
|
||||
className="w-4 h-4 transition-transform group-hover:translate-x-0.5"
|
||||
fill="none"
|
||||
@@ -478,18 +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;
|
||||
}
|
||||
}
|
||||
`}} />
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user