From 9f3dc9b68e72933d69debfda6b61d2022f1bc419 Mon Sep 17 00:00:00 2001 From: Nora Date: Thu, 25 Jun 2026 17:04:53 -0600 Subject: [PATCH] refactor(storefront): replace supabase shim in storefront pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy lib/supabase.ts shim returns { data: null } for every query, which silently broke the tuxedo and indian-river-direct storefronts: they displayed 'No stops on the calendar just yet' regardless of the real data in Postgres. Added src/actions/storefront.ts as a new "use server" module with typed actions that hit the shared pg pool directly: - getStorefrontData(slug) — brand + stops + products in one call - getStorefrontStopBySlug(slug) — single stop + its products - getStorefrontWholesaleSettings(slug) — public invoice/business info Updated to use these actions instead of the shim: - src/app/tuxedo/page.tsx - src/app/tuxedo/stops/[slug]/page.tsx - src/app/tuxedo/contact/ContactClientPage.tsx - src/app/indian-river-direct/page.tsx - src/app/indian-river-direct/stops/[slug]/page.tsx The shim file itself is still imported by other pages and API routes that will be migrated in follow-up steps. --- src/actions/storefront.ts | 194 ++++++++++++++++++ src/app/indian-river-direct/page.tsx | 16 +- .../indian-river-direct/stops/[slug]/page.tsx | 22 +- src/app/tuxedo/contact/ContactClientPage.tsx | 11 +- src/app/tuxedo/page.tsx | 16 +- src/app/tuxedo/stops/[slug]/page.tsx | 22 +- 6 files changed, 219 insertions(+), 62 deletions(-) create mode 100644 src/actions/storefront.ts diff --git a/src/actions/storefront.ts b/src/actions/storefront.ts new file mode 100644 index 0000000..8671d63 --- /dev/null +++ b/src/actions/storefront.ts @@ -0,0 +1,194 @@ +"use server"; + +/** + * Public storefront data accessors. + * + * These replace the legacy `lib/supabase.ts` query-builder shim that the + * tuxedo / indian-river-direct storefront pages used to call client-side. + * The shim was a no-op that returned `{ data: null }`, leaving storefronts + * with empty stops / products and no real brand record. + * + * Each function hits Postgres directly via the shared `pg` pool from + * `@/lib/db` and is safe to call from public (unauthenticated) pages + * because the SQL filters by `is_public = true` / `active = true` and + * never returns anything the storefront shouldn't expose. + */ + +import { pool } from "@/lib/db"; + +export type StorefrontBrand = { + id: string; + name: string; + slug: string; +}; + +export type StorefrontStop = { + id: string; + brand_id: string; + name: string | null; + city: string; + state: string; + zip: string | null; + address: string | null; + location: string | null; + date: string; + time: string | null; + cutoff_date: string | null; + status: string; + notes: string | null; + is_public: boolean; +}; + +export type StorefrontProduct = { + id: string; + brand_id: string; + name: string; + description: string | null; + price: number; + type: string | null; + image_url: string | null; + is_taxable: boolean | null; + pickup_type: string | null; + active: boolean; +}; + +export type StorefrontData = { + brand: StorefrontBrand | null; + stops: StorefrontStop[]; + products: StorefrontProduct[]; +}; + +/** + * Look up a brand by its public slug. Returns `null` if no brand matches + * (which means the storefront slug is wrong, or the brand was renamed). + */ +export async function getStorefrontBrandBySlug( + slug: string, +): Promise { + const { rows } = await pool.query( + `SELECT id, name, slug + FROM brands + WHERE slug = $1 + LIMIT 1`, + [slug], + ); + return rows[0] ?? null; +} + +/** + * Public stops for a brand storefront. Filters out draft / private stops + * and any stop whose date has already passed. + */ +export async function getStorefrontStops(brandId: string): Promise { + const { rows } = await pool.query( + `SELECT id, brand_id, name, city, state, zip, address, location, + date::text AS date, time, cutoff_date::text AS cutoff_date, + status, notes, is_public + FROM stops + WHERE brand_id = $1 + AND is_public = true + AND status = 'active' + AND date >= CURRENT_DATE + ORDER BY date ASC, time ASC NULLS LAST`, + [brandId], + ); + return rows; +} + +/** + * Active products visible on the storefront. Same shape as the admin + * product list but without the brand-internal columns (cost, supplier, …). + */ +export async function getStorefrontProducts( + brandId: string, +): Promise { + const { rows } = await pool.query( + `SELECT id, brand_id, name, description, price::float8 AS price, + type, image_url, is_taxable, pickup_type, active + FROM products + WHERE brand_id = $1 + AND active = true + AND deleted_at IS NULL + ORDER BY name ASC`, + [brandId], + ); + return rows; +} + +/** + * Single server action that the storefront Client Components call from + * their initial `useEffect`. Replaces the three separate `supabase.from(...)` + * calls that the legacy `lib/supabase.ts` shim used to make — the shim + * returned `{ data: null }` for everything, which is why storefronts + * were showing empty stops / products. + * + * Returns `{ brand: null, stops: [], products: [] }` if the slug doesn't + * match any brand — pages fall back to default copy in that case. + */ +export async function getStorefrontData(slug: string): Promise { + const brand = await getStorefrontBrandBySlug(slug); + if (!brand) return { brand: null, stops: [], products: [] }; + const [stops, products] = await Promise.all([ + getStorefrontStops(brand.id), + getStorefrontProducts(brand.id), + ]); + return { brand, stops, products }; +} + +export type StorefrontWholesaleSettings = { + invoice_business_name: string | null; + invoice_business_address: string | null; + invoice_business_phone: string | null; + invoice_business_email: string | null; + invoice_business_website: string | null; +}; + +/** + * Public wholesale-settings record for a brand — just the public-facing + * invoice/business info used by the storefront contact page. Returns + * `null` if the brand or its wholesale settings row doesn't exist. + */ +export async function getStorefrontWholesaleSettings( + slug: string, +): Promise { + const brand = await getStorefrontBrandBySlug(slug); + if (!brand) return null; + const { rows } = await pool.query( + `SELECT invoice_business_name, invoice_business_address, + invoice_business_phone, invoice_business_email, invoice_business_website + FROM wholesale_settings + WHERE brand_id = $1 + LIMIT 1`, + [brand.id], + ); + return rows[0] ?? null; +} + +/** + * Look up a single public stop by its URL slug, plus the brand's + * active products. Used by `/[brand]/stops/[slug]` pages. + * + * Returns `null` if the stop is not public, not active, or its date has + * already passed. Returns the empty product array if the brand has no + * active products at the moment. + */ +export async function getStorefrontStopBySlug(slug: string): Promise<{ + stop: StorefrontStop; + products: StorefrontProduct[]; +} | null> { + const { rows: stopRows } = await pool.query( + `SELECT id, brand_id, name, city, state, zip, address, location, + date::text AS date, time, cutoff_date::text AS cutoff_date, + status, notes, is_public + FROM stops + WHERE slug = $1 + AND is_public = true + AND status = 'active' + LIMIT 1`, + [slug], + ); + const stop = stopRows[0]; + if (!stop) return null; + const products = await getStorefrontProducts(stop.brand_id); + return { stop, products }; +} \ No newline at end of file diff --git a/src/app/indian-river-direct/page.tsx b/src/app/indian-river-direct/page.tsx index 564d7e6..4600de9 100644 --- a/src/app/indian-river-direct/page.tsx +++ b/src/app/indian-river-direct/page.tsx @@ -8,8 +8,8 @@ import StorefrontHeader from "@/components/storefront/StorefrontHeader"; import StorefrontFooter from "@/components/storefront/StorefrontFooter"; import LayoutContainer from "@/components/layout/LayoutContainer"; import WhyIndianRiverDirect from "@/components/storefront/WhyIndianRiverDirect"; -import { supabase } from "@/lib/supabase"; import { getBrandSettingsPublic } from "@/actions/brand-settings"; +import { getStorefrontData } from "@/actions/storefront"; type Brand = { id: string; name: string; slug: string }; type Stop = { id: string; city: string; state: string; date: string; time: string; location: string; slug: string; brand_id: string }; @@ -85,12 +85,12 @@ export default function IndianRiverDirectPage() { useEffect(() => { async function load() { const slug = "indian-river-direct"; - const [brandResult, settingsResult] = await Promise.all([ - supabase.from("brands").select("*").eq("slug", slug).single(), + const [storefront, settingsResult] = await Promise.all([ + getStorefrontData(slug), getBrandSettingsPublic(slug), ]); - const brandData = brandResult.data as Brand | null; + const brandData = storefront.brand as Brand | null; setBrand(brandData); if (settingsResult.success && settingsResult.settings) { @@ -110,12 +110,8 @@ export default function IndianRiverDirectPage() { } catch { /* not logged in */ } if (brandData?.id) { - const [{ data: stopsData }, { data: productsData }] = await Promise.all([ - supabase.from("stops").select("*").eq("brand_id", brandData.id).eq("active", true) as unknown as { data: Stop[] | null }, - supabase.from("products").select("*").eq("brand_id", brandData.id).eq("active", true) as unknown as { data: Product[] | null }, - ]); - setStops(stopsData ?? []); - setProducts(productsData ?? []); + setStops(storefront.stops as unknown as Stop[]); + setProducts(storefront.products as unknown as Product[]); } } load(); diff --git a/src/app/indian-river-direct/stops/[slug]/page.tsx b/src/app/indian-river-direct/stops/[slug]/page.tsx index 0580a32..5468918 100644 --- a/src/app/indian-river-direct/stops/[slug]/page.tsx +++ b/src/app/indian-river-direct/stops/[slug]/page.tsx @@ -9,7 +9,7 @@ import StopSetEffect from "@/components/storefront/StopSetEffect"; import StorefrontHeader from "@/components/storefront/StorefrontHeader"; import StorefrontFooter from "@/components/storefront/StorefrontFooter"; import LayoutContainer from "@/components/layout/LayoutContainer"; -import { supabase } from "@/lib/supabase"; +import { getStorefrontStopBySlug } from "@/actions/storefront"; import { formatDate } from "@/lib/format-date"; type Product = { @@ -53,26 +53,14 @@ export default function StopPage() { useEffect(() => { async function load() { - const { data: stopData } = await supabase - .from("stops") - .select("*") - .eq("slug", slug) - .eq("active", true) - .single() as unknown as { data: Stop | null }; + const result = await getStorefrontStopBySlug(slug); + if (!result) return; - if (!stopData) return; - - setStop(stopData); + setStop(result.stop as unknown as Stop); const isIndian = slug.includes("indian"); setBrandSlug(isIndian ? "indian-river-direct" : "tuxedo"); setBrandAccent(isIndian ? "blue" : "green"); - - const { data: productsData } = await supabase - .from("products") - .select("*") - .eq("brand_id", stopData.brand_id) - .eq("active", true) as unknown as { data: Product[] | null }; - setProducts(productsData ?? []); + setProducts(result.products as unknown as Product[]); } load(); }, [slug]); diff --git a/src/app/tuxedo/contact/ContactClientPage.tsx b/src/app/tuxedo/contact/ContactClientPage.tsx index 5933528..041ef3f 100644 --- a/src/app/tuxedo/contact/ContactClientPage.tsx +++ b/src/app/tuxedo/contact/ContactClientPage.tsx @@ -5,7 +5,7 @@ import Link from "next/link"; import StorefrontHeader from "@/components/storefront/StorefrontHeader"; import StorefrontFooter from "@/components/storefront/StorefrontFooter"; import LayoutContainer from "@/components/layout/LayoutContainer"; -import { supabase } from "@/lib/supabase"; +import { getStorefrontWholesaleSettings } from "@/actions/storefront"; type BrandSettings = { invoice_business_name: string | null; @@ -29,13 +29,8 @@ export default function TuxedoContactPage() { const [isSubmitting, setIsSubmitting] = useState(false); useEffect(() => { - const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; - supabase - .from("wholesale_settings") - .select("invoice_business_name, invoice_business_address, invoice_business_phone, invoice_business_email, invoice_business_website") - .eq("brand_id", TUXEDO_BRAND_ID) - .single() - .then(({ data }) => setBrandSettings((data as unknown as BrandSettings) ?? null)); + getStorefrontWholesaleSettings("tuxedo") + .then((data) => setBrandSettings(data as BrandSettings | null)); }, []); function handleSubmit(e: React.FormEvent) { diff --git a/src/app/tuxedo/page.tsx b/src/app/tuxedo/page.tsx index cb977c7..65918d3 100644 --- a/src/app/tuxedo/page.tsx +++ b/src/app/tuxedo/page.tsx @@ -12,8 +12,8 @@ import StorefrontHeader from "@/components/storefront/StorefrontHeader"; import StorefrontFooter from "@/components/storefront/StorefrontFooter"; import BrandStylesProvider from "@/components/storefront/BrandStylesProvider"; import { ScrollReveal, ParallaxLayer, FadeOnScroll } from "@/components/ui/ScrollAnimations"; -import { supabase } from "@/lib/supabase"; import { getBrandSettingsPublic } from "@/actions/brand-settings"; +import { getStorefrontData } from "@/actions/storefront"; import { gsap } from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; @@ -448,12 +448,12 @@ export default function TuxedoPage() { async function load() { const slug = "tuxedo"; - const [brandResult, settingsResult] = await Promise.all([ - supabase.from("brands").select("*").eq("slug", slug).single(), + const [storefront, settingsResult] = await Promise.all([ + getStorefrontData(slug), getBrandSettingsPublic(slug), ]); - const brandData = brandResult.data as Brand | null; + const brandData = storefront.brand as Brand | null; setBrand(brandData); if (settingsResult.success && settingsResult.settings) { @@ -484,12 +484,8 @@ export default function TuxedoPage() { } if (brandData?.id) { - const [{ data: stopsData }, { data: productsData }] = await Promise.all([ - supabase.from("stops").select("*").eq("brand_id", brandData.id).eq("active", true) as unknown as { data: Stop[] | null }, - supabase.from("products").select("*").eq("brand_id", brandData.id).eq("active", true) as unknown as { data: Product[] | null }, - ]); - setStops(stopsData ?? []); - setProducts(productsData ?? []); + setStops(storefront.stops as unknown as Stop[]); + setProducts(storefront.products as unknown as Product[]); } } load(); diff --git a/src/app/tuxedo/stops/[slug]/page.tsx b/src/app/tuxedo/stops/[slug]/page.tsx index d3fbdb9..0c331bf 100644 --- a/src/app/tuxedo/stops/[slug]/page.tsx +++ b/src/app/tuxedo/stops/[slug]/page.tsx @@ -8,7 +8,7 @@ import StopSetEffect from "@/components/storefront/StopSetEffect"; import StorefrontHeader from "@/components/storefront/StorefrontHeader"; import StorefrontFooter from "@/components/storefront/StorefrontFooter"; import LayoutContainer from "@/components/layout/LayoutContainer"; -import { supabase } from "@/lib/supabase"; +import { getStorefrontStopBySlug } from "@/actions/storefront"; import { formatDate } from "@/lib/format-date"; type Product = { @@ -52,26 +52,14 @@ export default function StopPage() { useEffect(() => { async function load() { - const { data: stopData } = await supabase - .from("stops") - .select("*") - .eq("slug", slug) - .eq("active", true) - .single() as unknown as { data: Stop | null }; + const result = await getStorefrontStopBySlug(slug); + if (!result) return; - if (!stopData) return; - - setStop(stopData); + setStop(result.stop as unknown as Stop); const isIndian = slug.includes("indian"); setBrandSlug(isIndian ? "indian-river-direct" : "tuxedo"); setBrandAccent(isIndian ? "blue" : "green"); - - const { data: productsData } = await supabase - .from("products") - .select("*") - .eq("brand_id", stopData.brand_id) - .eq("active", true) as unknown as { data: Product[] | null }; - setProducts(productsData ?? []); + setProducts(result.products as unknown as Product[]); } load(); }, [slug]);