diff --git a/db/migrations/0099_brand_hero_video.sql b/db/migrations/0099_brand_hero_video.sql new file mode 100644 index 0000000..f291a9e --- /dev/null +++ b/db/migrations/0099_brand_hero_video.sql @@ -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; diff --git a/src/actions/brand-settings.ts b/src/actions/brand-settings.ts index 34a099a..9f3b1cf 100644 --- a/src/actions/brand-settings.ts +++ b/src/actions/brand-settings.ts @@ -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( + `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)}` }; diff --git a/src/app/tuxedo/page.tsx b/src/app/tuxedo/page.tsx index bdf368d..3a673d1 100644 --- a/src/app/tuxedo/page.tsx +++ b/src/app/tuxedo/page.tsx @@ -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: ( @@ -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: ( @@ -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: ( @@ -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: ( @@ -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: ( @@ -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: ( @@ -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: ( @@ -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: ( @@ -347,61 +328,50 @@ function FeatureCard({ feature, index }: { feature: Feature; index: number }) { const ref = useRef(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 ( - {/* Animated top accent line */} + {/* Top accent rule — hairline line that fills on hover. */}
- +
- {/* Icon with glow on hover */} -
+ {/* Icon plate */} +
{feature.icon}
{/* Label */} -

+

{feature.label}

{/* Headline */} -

+

{feature.headline}

{/* Story */} -

+

{feature.story}

- - {/* Corner decoration */} -
); } @@ -411,10 +381,13 @@ function WhyTuxedoCorn() { const headerInView = useInView(headerRef, { once: true, margin: "-80px" }); return ( -
- {/* Atmospheric glow */} -
-
+
+ {/* Single soft golden-hour glow — replaces the prior emerald/amber + two-tone gradient which made the section read as kaleidoscope. */} + {/* Asymmetric masonry grid with staggered offsets */}
- {/* Row 1: tall, normal, tall, normal — with vertical offset stagger */}
@@ -468,7 +440,6 @@ function WhyTuxedoCorn() {
- {/* Row 2: normal, tall, normal, tall — offset in opposite direction */}
@@ -483,16 +454,22 @@ function WhyTuxedoCorn() {
- {/* Footer */} - - Olathe Sweet™  ·  Olathe, Colorado - + Family-Grown + · + Olathe, Colorado + · + 5,360 ft altitude + · + Est. 1982 +
); @@ -701,93 +678,91 @@ function ProductsSection({ // ── Story section ─────────────────────────────────────────────────── function StorySection() { return ( -
- {/* Parallax decorative elements */} -
- -
- - -
- -
- - {/* Large background text for parallax depth */} -
- - SINCE - -
+
+ {/* Single soft golden-hour glow behind the headline. */} +
@@ -188,10 +213,10 @@ export default function TuxedoVideoHero({ ); } -// ── Scroll Progress Bar ─────────────────────────────────────────────────────── +// ── Scroll Progress Bar ─────────────────────────────────────────────── function ScrollProgressBar({ scrollProgress }: { scrollProgress: number }) { return ( -
+
}) { +// ── Backdrop: poster + optional video + overlays ───────────────────────── +function HeroBackdrop({ + videoUrl, + posterUrl, + videoRef, + onVideoStateChange, +}: { + videoUrl?: string | null; + posterUrl?: string | null; + videoRef: React.RefObject; + 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