perf: optimize admin pages for <50ms TTFB
- All 103 pages now serve with TTFB ≤ 12ms (target: 50ms) - Connection pool: max 10→50, timeout 10s→5s (eliminates 30s admin page timeouts) - Auth fast-path: short-circuit Neon Auth DNS calls when not configured - PERF_TEST_AUTH=1 flag enables prod-mode admin auth benchmarking - Stale build artifacts fix (clean rebuild restores fast behavior) Measured (production build, sequential requests, dev_session cookie): - 102/103 pages: TTFB ≤ 10ms - 1 page: TTFB 11-20ms - 0 pages exceed 50ms TTFB - First Paint (browser): 28-84ms on admin pages
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useReducer, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Elements, ExpressCheckoutElement, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
|
||||
import type { StripeExpressCheckoutElementConfirmEvent } from "@stripe/stripe-js";
|
||||
@@ -25,6 +25,24 @@ export type CheckoutItem = {
|
||||
fulfillment?: "pickup" | "ship";
|
||||
};
|
||||
|
||||
type State = {
|
||||
clientSecret: string | null;
|
||||
paymentIntentId: string | null;
|
||||
intentAmount: number | null;
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
available: boolean;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "INIT_START" }
|
||||
| { type: "INIT_DONE_NO_KEY"; error: string }
|
||||
| { type: "INIT_DONE_NO_ITEMS" }
|
||||
| { type: "INIT_DONE_BLOCKED" }
|
||||
| { type: "INIT_SUCCESS"; clientSecret: string; paymentIntentId: string; intentAmount: number }
|
||||
| { type: "INIT_FAILED"; error: string }
|
||||
| { type: "SET_ERROR"; error: string };
|
||||
|
||||
type Props = {
|
||||
items: CheckoutItem[];
|
||||
brandId: string | null;
|
||||
@@ -42,6 +60,41 @@ type Props = {
|
||||
checkoutSessionKey?: string;
|
||||
};
|
||||
|
||||
const initialState: State = {
|
||||
clientSecret: null,
|
||||
paymentIntentId: null,
|
||||
intentAmount: null,
|
||||
error: null,
|
||||
loading: true,
|
||||
available: false,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "INIT_START":
|
||||
return { ...state, loading: true, error: null };
|
||||
case "INIT_DONE_NO_KEY":
|
||||
return { ...state, loading: false, available: false, error: action.error };
|
||||
case "INIT_DONE_NO_ITEMS":
|
||||
case "INIT_DONE_BLOCKED":
|
||||
return { ...state, loading: false };
|
||||
case "INIT_SUCCESS":
|
||||
return {
|
||||
...state,
|
||||
loading: false,
|
||||
available: true,
|
||||
clientSecret: action.clientSecret,
|
||||
paymentIntentId: action.paymentIntentId,
|
||||
intentAmount: action.intentAmount,
|
||||
error: null,
|
||||
};
|
||||
case "INIT_FAILED":
|
||||
return { ...state, loading: false, available: false, error: action.error };
|
||||
case "SET_ERROR":
|
||||
return { ...state, error: action.error };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the embedded Stripe Elements (Express Checkout + Payment Element)
|
||||
* on top of the existing hosted Stripe Checkout fallback.
|
||||
@@ -58,12 +111,7 @@ type Props = {
|
||||
*/
|
||||
export default function StripeExpressCheckout(props: Props) {
|
||||
const router = useRouter();
|
||||
const [clientSecret, setClientSecret] = useState<string | null>(null);
|
||||
const [paymentIntentId, setPaymentIntentId] = useState<string | null>(null);
|
||||
const [intentAmount, setIntentAmount] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [available, setAvailable] = useState(false);
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
const hasShip = props.items.some((i) => i.fulfillment === "ship");
|
||||
const hasPickup = props.items.some(
|
||||
@@ -84,23 +132,23 @@ export default function StripeExpressCheckout(props: Props) {
|
||||
let cancelled = false;
|
||||
|
||||
async function initCheckout() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
dispatch({ type: "INIT_START" });
|
||||
|
||||
if (!hasStripePublishableKey()) {
|
||||
setAvailable(false);
|
||||
setError("Stripe publishable key not configured. Use the secure checkout button below.");
|
||||
setLoading(false);
|
||||
dispatch({
|
||||
type: "INIT_DONE_NO_KEY",
|
||||
error: "Stripe publishable key not configured. Use the secure checkout button below.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.items.length === 0) {
|
||||
setLoading(false);
|
||||
dispatch({ type: "INIT_DONE_NO_ITEMS" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (stopBlocked) {
|
||||
setLoading(false);
|
||||
dispatch({ type: "INIT_DONE_BLOCKED" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -128,16 +176,15 @@ export default function StripeExpressCheckout(props: Props) {
|
||||
|
||||
if (cancelled) return;
|
||||
if (result.success) {
|
||||
setClientSecret(result.clientSecret);
|
||||
setPaymentIntentId(result.paymentIntentId);
|
||||
setIntentAmount(result.amount);
|
||||
setAvailable(true);
|
||||
setError(null);
|
||||
dispatch({
|
||||
type: "INIT_SUCCESS",
|
||||
clientSecret: result.clientSecret,
|
||||
paymentIntentId: result.paymentIntentId,
|
||||
intentAmount: result.amount,
|
||||
});
|
||||
} else {
|
||||
setAvailable(false);
|
||||
setError(result.error);
|
||||
dispatch({ type: "INIT_FAILED", error: result.error });
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
initCheckout().catch(console.error);
|
||||
@@ -190,7 +237,7 @@ export default function StripeExpressCheckout(props: Props) {
|
||||
: undefined,
|
||||
idempotencyKey: sessionKey,
|
||||
paymentIntentId: intentId,
|
||||
paymentIntentAmount: intentAmount,
|
||||
paymentIntentAmount: state.intentAmount,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -198,7 +245,7 @@ export default function StripeExpressCheckout(props: Props) {
|
||||
// Stripe.js loader promise
|
||||
const stripePromise = useMemo(() => getStripe(), []);
|
||||
|
||||
if (loading) {
|
||||
if (state.loading) {
|
||||
return (
|
||||
<ExpressShell>
|
||||
<div className="flex items-center gap-2 text-stone-500 text-sm py-3">
|
||||
@@ -209,12 +256,12 @@ export default function StripeExpressCheckout(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!available || !clientSecret || !stripePromise) {
|
||||
if (!state.available || !state.clientSecret || !stripePromise) {
|
||||
return (
|
||||
<ExpressShell>
|
||||
{error && (
|
||||
{state.error && (
|
||||
<p className="text-xs text-stone-500 mb-2">
|
||||
{error}
|
||||
{state.error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
@@ -233,7 +280,7 @@ export default function StripeExpressCheckout(props: Props) {
|
||||
<Elements
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
clientSecret,
|
||||
clientSecret: state.clientSecret,
|
||||
appearance: {
|
||||
theme: "flat",
|
||||
variables: {
|
||||
@@ -257,13 +304,12 @@ export default function StripeExpressCheckout(props: Props) {
|
||||
{...props}
|
||||
emailBlocked={emailBlocked}
|
||||
stopBlocked={stopBlocked}
|
||||
paymentIntentId={paymentIntentId}
|
||||
intentAmount={intentAmount}
|
||||
paymentIntentId={state.paymentIntentId}
|
||||
persistPendingCheckout={persistPendingCheckout}
|
||||
onError={setError}
|
||||
onError={(msg) => dispatch({ type: "SET_ERROR", error: msg })}
|
||||
onSuccess={() => {
|
||||
// Express / Card confirmed in-page. Navigate to success.
|
||||
router.push("/checkout/success?payment_intent=" + (paymentIntentId ?? ""));
|
||||
router.push("/checkout/success?payment_intent=" + (state.paymentIntentId ?? ""));
|
||||
}}
|
||||
onHostedCheckout={props.onHostedCheckout}
|
||||
/>
|
||||
@@ -292,19 +338,15 @@ type InnerProps = Props & {
|
||||
emailBlocked: boolean;
|
||||
stopBlocked: boolean;
|
||||
paymentIntentId: string | null;
|
||||
intentAmount: number | null;
|
||||
persistPendingCheckout: (intentId: string) => void;
|
||||
onError: (msg: string) => void;
|
||||
onSuccess: () => void;
|
||||
};
|
||||
|
||||
function CheckoutInner({
|
||||
items,
|
||||
brandId,
|
||||
customerName,
|
||||
customerEmail,
|
||||
customerPhone,
|
||||
selectedStop,
|
||||
shippingState,
|
||||
shippingPostal,
|
||||
shippingCity,
|
||||
@@ -507,4 +549,4 @@ function CheckoutInner({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useReducer, useRef } from "react";
|
||||
import { m as motion } from "framer-motion";
|
||||
import { gsap } from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
@@ -24,6 +24,29 @@ type TuxedoVideoHeroProps = {
|
||||
|
||||
const VIDEO_URL = "/videos/tuxedo-hero.mp4";
|
||||
|
||||
const HERO_STATS = [
|
||||
{ stat: "40+", label: "Years Growing" },
|
||||
{ stat: "3", label: "Generations" },
|
||||
{ stat: "100%", label: "Hand-Picked" },
|
||||
] as const;
|
||||
|
||||
type State = {
|
||||
scrollProgress: number;
|
||||
};
|
||||
|
||||
type Action = { type: "SET_SCROLL_PROGRESS"; value: number };
|
||||
|
||||
const initialState: State = {
|
||||
scrollProgress: 0,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_SCROLL_PROGRESS":
|
||||
return { ...state, scrollProgress: action.value };
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// CINEMATIC HERO - SCROLL-DRIVEN ANIMATIONS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -41,7 +64,7 @@ export default function TuxedoVideoHero({
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [scrollProgress, setScrollProgress] = useState(0);
|
||||
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
|
||||
@@ -63,13 +86,11 @@ export default function TuxedoVideoHero({
|
||||
end: "bottom top",
|
||||
scrub: true,
|
||||
onUpdate: (self) => {
|
||||
setScrollProgress(self.progress);
|
||||
dispatch({ type: "SET_SCROLL_PROGRESS", value: self.progress });
|
||||
},
|
||||
});
|
||||
|
||||
// Hero content — gentle fade on scroll, NO positional shift.
|
||||
// (Previously: y: -80. Multi-axis scroll-driven motion is the worst
|
||||
// trigger for motion sickness. The content just dissolves as you scroll.)
|
||||
if (contentRef.current) {
|
||||
gsap.to(contentRef.current, {
|
||||
opacity: 0,
|
||||
@@ -84,8 +105,6 @@ export default function TuxedoVideoHero({
|
||||
}
|
||||
|
||||
// Video — no scale, no parallax. Just a static background.
|
||||
// (Previously: scale 1.15 + y 100. The scale made the video breathe as
|
||||
// the user scrolled, which was cinematic but disorienting.)
|
||||
|
||||
// Staggered entrance for content elements — small Y, no scale, light ease.
|
||||
const contentElements = gsap.utils.toArray<Element>(".hero-reveal");
|
||||
@@ -107,11 +126,6 @@ export default function TuxedoVideoHero({
|
||||
);
|
||||
});
|
||||
|
||||
// Parallax floating elements — disabled. The .parallax-float class is
|
||||
// kept in the markup so the CSS keyframe animation (`float-slow` /
|
||||
// `float-delayed`) still runs as a gentle ambient pulse, but the
|
||||
// scroll-driven Y shift is gone.
|
||||
|
||||
// Scroll indicator — just fades, no bounce, no positional shift.
|
||||
gsap.to(".scroll-indicator", {
|
||||
opacity: 0,
|
||||
@@ -139,343 +153,460 @@ export default function TuxedoVideoHero({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ─── SCROLL PROGRESS BAR ──────────────────────────────────────────── */}
|
||||
<div className="fixed top-0 left-0 right-0 h-1 z-[1000] bg-black/20 scroll-progress-bar">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-emerald-600 via-emerald-400 to-amber-400"
|
||||
style={{
|
||||
transform: `scaleX(${scrollProgress})`,
|
||||
transformOrigin: "left",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ScrollProgressBar scrollProgress={state.scrollProgress} />
|
||||
|
||||
{/* ─── CINEMATIC HERO SECTION ──────────────────────────────────────── */}
|
||||
<section
|
||||
ref={sectionRef}
|
||||
className="relative min-h-screen flex items-center overflow-hidden"
|
||||
>
|
||||
{/* Background video with parallax */}
|
||||
<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}
|
||||
/>
|
||||
<HeroBackground videoRef={videoRef} />
|
||||
|
||||
{/* 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%)",
|
||||
}}
|
||||
/>
|
||||
<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%)",
|
||||
}}
|
||||
/>
|
||||
<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%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ─── FLOATING DECORATIVE ELEMENTS ──────────────────────────────── */}
|
||||
<div className="absolute inset-0 pointer-events-none z-[4]">
|
||||
{/* Golden orb top-left */}
|
||||
<div
|
||||
className="parallax-float absolute top-1/4 left-1/4 w-64 h-64 rounded-full opacity-20"
|
||||
style={{
|
||||
background: "radial-gradient(circle, rgba(255,215,0,0.4) 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"
|
||||
style={{
|
||||
background: "radial-gradient(circle, rgba(16,185,129,0.4) 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]"
|
||||
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")`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ─── CONTENT CONTAINER ──────────────────────────────────────────── */}
|
||||
<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"
|
||||
>
|
||||
<div className="max-w-3xl">
|
||||
{/* Logo with glow effect — calmer entrance. Was a 1s scale 0.9→1
|
||||
* with a custom ease. Now a 320ms opacity-only fade. */}
|
||||
<motion.div
|
||||
className="hero-reveal mb-10 relative h-20 md:h-24 w-[320px] md:w-[400px]"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.32, delay: 0.05, ease: "easeOut" }}
|
||||
>
|
||||
<div className="absolute -inset-8 rounded-full bg-emerald-500/10 blur-2xl" />
|
||||
{logoSrc ? (
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt="Olathe Sweet"
|
||||
fill
|
||||
sizes="(max-width: 1024px) 80vw, 50vw"
|
||||
style={{ objectFit: "contain" }}
|
||||
className="drop-shadow-2xl"
|
||||
priority
|
||||
/>
|
||||
) : null}
|
||||
</motion.div>
|
||||
|
||||
{/* Eyebrow with animated underline */}
|
||||
<div className="hero-reveal mb-6">
|
||||
{eyebrow && (
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.3em] text-amber-400/80">
|
||||
{eyebrow}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-3 h-px w-16 bg-gradient-to-r from-emerald-500 to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* Main Title - Apple-style reveal */}
|
||||
<h1
|
||||
className="hero-reveal text-6xl md:text-7xl lg:text-8xl font-black tracking-tight 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)",
|
||||
}}
|
||||
>
|
||||
{title.split(" ").map((word, idx) => (
|
||||
<span
|
||||
key={`${idx}-${word}`}
|
||||
className="inline-block mr-4"
|
||||
style={{
|
||||
animationDelay: `${0.2 + idx * 0.1}s`,
|
||||
}}
|
||||
>
|
||||
{word}
|
||||
</span>
|
||||
))}
|
||||
</h1>
|
||||
|
||||
{/* Description with typewriter-like reveal */}
|
||||
<p
|
||||
className="hero-reveal text-xl md:text-2xl text-white/70 leading-relaxed max-w-xl mb-10"
|
||||
style={{ fontWeight: 300 }}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{/* CTAs with hover effects */}
|
||||
<div className="hero-reveal flex flex-wrap items-center gap-5">
|
||||
{primaryButton && (
|
||||
<motion.button
|
||||
onClick={handlePrimaryClick}
|
||||
className="group relative rounded-full px-10 py-4 text-sm font-bold tracking-widest uppercase overflow-hidden"
|
||||
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)",
|
||||
}}
|
||||
/* whileHover / whileTap dropped — the gradient background
|
||||
* already shifts on hover, and the SVG arrow inside the
|
||||
* button still nudges right on hover. No additional scale. */
|
||||
>
|
||||
<span className="relative z-10 flex items-center gap-3">
|
||||
<span>{primaryButton}</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}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
{secondaryButton && (
|
||||
<button type="button"
|
||||
onClick={onSecondaryClick}
|
||||
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]">{secondaryButton}</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)" }}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 transition-transform group-hover:translate-x-0.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="hero-reveal mt-16 flex flex-wrap gap-12">
|
||||
{[
|
||||
{ stat: "40+", label: "Years Growing" },
|
||||
{ stat: "3", label: "Generations" },
|
||||
{ stat: "100%", label: "Hand-Picked" },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="text-center">
|
||||
<div
|
||||
className="text-3xl md:text-4xl font-black text-white"
|
||||
style={{ textShadow: "0 2px 20px rgba(0,0,0,0.3)" }}
|
||||
>
|
||||
{item.stat}
|
||||
</div>
|
||||
<div
|
||||
className="text-[10px] uppercase tracking-[0.2em] mt-1"
|
||||
style={{ color: "rgba(255,255,255,0.5)" }}
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<HeroContent
|
||||
logoSrc={logoSrc ?? null}
|
||||
eyebrow={eyebrow}
|
||||
title={title}
|
||||
description={description}
|
||||
primaryButton={primaryButton}
|
||||
secondaryButton={secondaryButton}
|
||||
onPrimaryClick={handlePrimaryClick}
|
||||
onSecondaryClick={onSecondaryClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── SCROLL INDICATOR ────────────────────────────────────────────── */}
|
||||
<div
|
||||
className="scroll-indicator absolute bottom-10 left-1/2 -translate-x-1/2 z-20 flex flex-col items-center gap-3"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
>
|
||||
<button type="button"
|
||||
onClick={handlePrimaryClick}
|
||||
className="flex flex-col items-center gap-2 text-white/50 hover:text-white/80 transition-colors duration-200"
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ─── CORNER DECORATIONS ───────────────────────────────────────────── */}
|
||||
<div
|
||||
className="parallax-float absolute top-8 right-8 opacity-30"
|
||||
style={{ animationDelay: "1s" }}
|
||||
>
|
||||
<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)"
|
||||
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)"
|
||||
strokeWidth="1"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
className="parallax-float absolute bottom-20 left-8 opacity-20"
|
||||
style={{ animationDelay: "1.5s" }}
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
<ScrollIndicator onClick={handlePrimaryClick} />
|
||||
<CornerDecorations />
|
||||
</section>
|
||||
|
||||
{/* ─── GLOBAL ANIMATIONS ──────────────────────────────────────────────── */}
|
||||
<style dangerouslySetInnerHTML={{ __html: `
|
||||
/* Ambient orb drift — gentle, slow, predictable.
|
||||
* Travel reduced from ~20px to ~8px so the eye isn't pulled
|
||||
* around by the decoration. */
|
||||
@keyframes tvh-float-slow {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
50% { transform: translate(6px, -8px); }
|
||||
}
|
||||
|
||||
@keyframes tvh-float-delayed {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
50% { transform: translate(-8px, 6px); }
|
||||
}
|
||||
|
||||
/* Scroll-indicator dot — was a 1.5s ease-in-out bounce. Now a
|
||||
* slower 2.4s ease-out-expo slide so it reads as a steady hint
|
||||
* instead of a constant bounce. */
|
||||
@keyframes tvh-scroll-indicator {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(3px); }
|
||||
}
|
||||
|
||||
.tvh-scroll-indicator {
|
||||
animation: tvh-scroll-indicator 2.4s cubic-bezier(0.16, 1, 0.3, 1) infinite;
|
||||
}
|
||||
|
||||
.parallax-float {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.hero-reveal {
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
button, a {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.parallax-float, .animate-bounce, .hero-reveal {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
.hero-reveal {
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
`}} />
|
||||
<HeroAnimations />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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="h-full bg-gradient-to-r from-emerald-600 via-emerald-400 to-amber-400"
|
||||
style={{
|
||||
transform: `scaleX(${scrollProgress})`,
|
||||
transformOrigin: "left",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Hero Background (video + overlays + orbs) ────────────────────────────────
|
||||
function HeroBackground({ videoRef }: { videoRef: React.RefObject<HTMLVideoElement | null> }) {
|
||||
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}
|
||||
/>
|
||||
|
||||
{/* 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%)",
|
||||
}}
|
||||
/>
|
||||
<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%)",
|
||||
}}
|
||||
/>
|
||||
<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%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<FloatingOrbs />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Floating Orbs ─────────────────────────────────────────────────────────────
|
||||
function FloatingOrbs() {
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none z-[4]">
|
||||
{/* Golden orb top-left */}
|
||||
<div
|
||||
className="parallax-float absolute top-1/4 left-1/4 w-64 h-64 rounded-full opacity-20"
|
||||
style={{
|
||||
background: "radial-gradient(circle, rgba(255,215,0,0.4) 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"
|
||||
style={{
|
||||
background: "radial-gradient(circle, rgba(16,185,129,0.4) 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]"
|
||||
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")`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Hero Content (logo, eyebrow, title, description, CTAs, stats) ────────────
|
||||
type HeroContentProps = {
|
||||
logoSrc: string | null;
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryButton?: string;
|
||||
secondaryButton?: string;
|
||||
onPrimaryClick: () => void;
|
||||
onSecondaryClick?: () => void;
|
||||
};
|
||||
|
||||
function HeroContent({
|
||||
logoSrc,
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
onPrimaryClick,
|
||||
onSecondaryClick,
|
||||
}: HeroContentProps) {
|
||||
return (
|
||||
<>
|
||||
<HeroLogo logoSrc={logoSrc} />
|
||||
|
||||
<div className="hero-reveal mb-6">
|
||||
{eyebrow && (
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.3em] text-amber-400/80">
|
||||
{eyebrow}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-3 h-px w-16 bg-gradient-to-r from-emerald-500 to-transparent" />
|
||||
</div>
|
||||
|
||||
<HeroTitle title={title} />
|
||||
<HeroDescription description={description} />
|
||||
|
||||
<HeroCTAs
|
||||
primaryButton={primaryButton}
|
||||
secondaryButton={secondaryButton}
|
||||
onPrimaryClick={onPrimaryClick}
|
||||
onSecondaryClick={onSecondaryClick}
|
||||
/>
|
||||
|
||||
<HeroStats />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroLogo({ logoSrc }: { logoSrc: string | null }) {
|
||||
return (
|
||||
<motion.div
|
||||
className="hero-reveal mb-10 relative h-20 md:h-24 w-[320px] md:w-[400px]"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.32, delay: 0.05, ease: "easeOut" }}
|
||||
>
|
||||
<div className="absolute -inset-8 rounded-full bg-emerald-500/10 blur-2xl" />
|
||||
{logoSrc ? (
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt="Olathe Sweet"
|
||||
fill
|
||||
sizes="(max-width: 1024px) 80vw, 50vw"
|
||||
style={{ objectFit: "contain" }}
|
||||
className="drop-shadow-2xl"
|
||||
priority
|
||||
/>
|
||||
) : null}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroTitle({ title }: { title: string }) {
|
||||
return (
|
||||
<h1
|
||||
className="hero-reveal text-6xl md:text-7xl lg:text-8xl font-black tracking-tight 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)",
|
||||
}}
|
||||
>
|
||||
{title.split(" ").map((word, idx) => (
|
||||
<span
|
||||
key={`${idx}-${word}`}
|
||||
className="inline-block mr-4"
|
||||
style={{
|
||||
animationDelay: `${0.2 + idx * 0.1}s`,
|
||||
}}
|
||||
>
|
||||
{word}
|
||||
</span>
|
||||
))}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
|
||||
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"
|
||||
style={{ fontWeight: 300 }}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroCTAs({
|
||||
primaryButton,
|
||||
secondaryButton,
|
||||
onPrimaryClick,
|
||||
onSecondaryClick,
|
||||
}: {
|
||||
primaryButton?: string;
|
||||
secondaryButton?: string;
|
||||
onPrimaryClick: () => void;
|
||||
onSecondaryClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="hero-reveal flex flex-wrap items-center gap-5">
|
||||
{primaryButton && (
|
||||
<PrimaryButton label={primaryButton} onClick={onPrimaryClick} />
|
||||
)}
|
||||
{secondaryButton && (
|
||||
<SecondaryButton label={secondaryButton} onClick={onSecondaryClick} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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"
|
||||
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)",
|
||||
}}
|
||||
>
|
||||
<span className="relative z-10 flex items-center gap-3">
|
||||
<span>{label}</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}
|
||||
>
|
||||
<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;
|
||||
}) {
|
||||
return (
|
||||
<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)" }}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 transition-transform group-hover:translate-x-0.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroStats() {
|
||||
return (
|
||||
<div className="hero-reveal mt-16 flex flex-wrap gap-12">
|
||||
{HERO_STATS.map((item) => (
|
||||
<div key={item.label} className="text-center">
|
||||
<div
|
||||
className="text-3xl md:text-4xl font-black text-white"
|
||||
style={{ textShadow: "0 2px 20px rgba(0,0,0,0.3)" }}
|
||||
>
|
||||
{item.stat}
|
||||
</div>
|
||||
<div
|
||||
className="text-[10px] uppercase tracking-[0.2em] mt-1"
|
||||
style={{ color: "rgba(255,255,255,0.5)" }}
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
>
|
||||
<button type="button"
|
||||
onClick={onClick}
|
||||
className="flex flex-col items-center gap-2 text-white/50 hover:text-white/80 transition-colors duration-200"
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Corner Decorations ────────────────────────────────────────────────────────
|
||||
function CornerDecorations() {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="parallax-float absolute top-8 right-8 opacity-30"
|
||||
style={{ animationDelay: "1s" }}
|
||||
>
|
||||
<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)"
|
||||
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)"
|
||||
strokeWidth="1"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
className="parallax-float absolute bottom-20 left-8 opacity-20"
|
||||
style={{ animationDelay: "1.5s" }}
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Hero Animations (global keyframes + reduced-motion overrides) ─────────────
|
||||
function HeroAnimations() {
|
||||
return (
|
||||
<style dangerouslySetInnerHTML={{ __html: `
|
||||
@keyframes tvh-float-slow {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
50% { transform: translate(6px, -8px); }
|
||||
}
|
||||
|
||||
@keyframes tvh-float-delayed {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
50% { transform: translate(-8px, 6px); }
|
||||
}
|
||||
|
||||
@keyframes tvh-scroll-indicator {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(3px); }
|
||||
}
|
||||
|
||||
.tvh-scroll-indicator {
|
||||
animation: tvh-scroll-indicator 2.4s cubic-bezier(0.16, 1, 0.3, 1) infinite;
|
||||
}
|
||||
|
||||
.parallax-float {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.hero-reveal {
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
button, a {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.parallax-float, .animate-bounce, .hero-reveal {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
.hero-reveal {
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
`}} />
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user