refactor(storefront): replace supabase shim in storefront pages

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.
This commit is contained in:
Nora
2026-06-25 17:04:53 -06:00
parent 6452363989
commit 9f3dc9b68e
6 changed files with 219 additions and 62 deletions
+194
View File
@@ -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<StorefrontBrand | null> {
const { rows } = await pool.query<StorefrontBrand>(
`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<StorefrontStop[]> {
const { rows } = await pool.query<StorefrontStop>(
`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<StorefrontProduct[]> {
const { rows } = await pool.query<StorefrontProduct>(
`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<StorefrontData> {
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<StorefrontWholesaleSettings | null> {
const brand = await getStorefrontBrandBySlug(slug);
if (!brand) return null;
const { rows } = await pool.query<StorefrontWholesaleSettings>(
`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<StorefrontStop>(
`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 };
}
+6 -10
View File
@@ -8,8 +8,8 @@ import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter"; import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import LayoutContainer from "@/components/layout/LayoutContainer"; import LayoutContainer from "@/components/layout/LayoutContainer";
import WhyIndianRiverDirect from "@/components/storefront/WhyIndianRiverDirect"; import WhyIndianRiverDirect from "@/components/storefront/WhyIndianRiverDirect";
import { supabase } from "@/lib/supabase";
import { getBrandSettingsPublic } from "@/actions/brand-settings"; import { getBrandSettingsPublic } from "@/actions/brand-settings";
import { getStorefrontData } from "@/actions/storefront";
type Brand = { id: string; name: string; slug: string }; 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 }; 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(() => { useEffect(() => {
async function load() { async function load() {
const slug = "indian-river-direct"; const slug = "indian-river-direct";
const [brandResult, settingsResult] = await Promise.all([ const [storefront, settingsResult] = await Promise.all([
supabase.from("brands").select("*").eq("slug", slug).single(), getStorefrontData(slug),
getBrandSettingsPublic(slug), getBrandSettingsPublic(slug),
]); ]);
const brandData = brandResult.data as Brand | null; const brandData = storefront.brand as Brand | null;
setBrand(brandData); setBrand(brandData);
if (settingsResult.success && settingsResult.settings) { if (settingsResult.success && settingsResult.settings) {
@@ -110,12 +110,8 @@ export default function IndianRiverDirectPage() {
} catch { /* not logged in */ } } catch { /* not logged in */ }
if (brandData?.id) { if (brandData?.id) {
const [{ data: stopsData }, { data: productsData }] = await Promise.all([ setStops(storefront.stops as unknown as Stop[]);
supabase.from("stops").select("*").eq("brand_id", brandData.id).eq("active", true) as unknown as { data: Stop[] | null }, setProducts(storefront.products as unknown as Product[]);
supabase.from("products").select("*").eq("brand_id", brandData.id).eq("active", true) as unknown as { data: Product[] | null },
]);
setStops(stopsData ?? []);
setProducts(productsData ?? []);
} }
} }
load(); load();
@@ -9,7 +9,7 @@ import StopSetEffect from "@/components/storefront/StopSetEffect";
import StorefrontHeader from "@/components/storefront/StorefrontHeader"; import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter"; import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import LayoutContainer from "@/components/layout/LayoutContainer"; import LayoutContainer from "@/components/layout/LayoutContainer";
import { supabase } from "@/lib/supabase"; import { getStorefrontStopBySlug } from "@/actions/storefront";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
type Product = { type Product = {
@@ -53,26 +53,14 @@ export default function StopPage() {
useEffect(() => { useEffect(() => {
async function load() { async function load() {
const { data: stopData } = await supabase const result = await getStorefrontStopBySlug(slug);
.from("stops") if (!result) return;
.select("*")
.eq("slug", slug)
.eq("active", true)
.single() as unknown as { data: Stop | null };
if (!stopData) return; setStop(result.stop as unknown as Stop);
setStop(stopData);
const isIndian = slug.includes("indian"); const isIndian = slug.includes("indian");
setBrandSlug(isIndian ? "indian-river-direct" : "tuxedo"); setBrandSlug(isIndian ? "indian-river-direct" : "tuxedo");
setBrandAccent(isIndian ? "blue" : "green"); setBrandAccent(isIndian ? "blue" : "green");
setProducts(result.products as unknown as Product[]);
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 ?? []);
} }
load(); load();
}, [slug]); }, [slug]);
+3 -8
View File
@@ -5,7 +5,7 @@ import Link from "next/link";
import StorefrontHeader from "@/components/storefront/StorefrontHeader"; import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter"; import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import LayoutContainer from "@/components/layout/LayoutContainer"; import LayoutContainer from "@/components/layout/LayoutContainer";
import { supabase } from "@/lib/supabase"; import { getStorefrontWholesaleSettings } from "@/actions/storefront";
type BrandSettings = { type BrandSettings = {
invoice_business_name: string | null; invoice_business_name: string | null;
@@ -29,13 +29,8 @@ export default function TuxedoContactPage() {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => { useEffect(() => {
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; getStorefrontWholesaleSettings("tuxedo")
supabase .then((data) => setBrandSettings(data as BrandSettings | null));
.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));
}, []); }, []);
function handleSubmit(e: React.FormEvent) { function handleSubmit(e: React.FormEvent) {
+6 -10
View File
@@ -12,8 +12,8 @@ import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter"; import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import BrandStylesProvider from "@/components/storefront/BrandStylesProvider"; import BrandStylesProvider from "@/components/storefront/BrandStylesProvider";
import { ScrollReveal, ParallaxLayer, FadeOnScroll } from "@/components/ui/ScrollAnimations"; import { ScrollReveal, ParallaxLayer, FadeOnScroll } from "@/components/ui/ScrollAnimations";
import { supabase } from "@/lib/supabase";
import { getBrandSettingsPublic } from "@/actions/brand-settings"; import { getBrandSettingsPublic } from "@/actions/brand-settings";
import { getStorefrontData } from "@/actions/storefront";
import { gsap } from "gsap"; import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger"; import { ScrollTrigger } from "gsap/ScrollTrigger";
@@ -448,12 +448,12 @@ export default function TuxedoPage() {
async function load() { async function load() {
const slug = "tuxedo"; const slug = "tuxedo";
const [brandResult, settingsResult] = await Promise.all([ const [storefront, settingsResult] = await Promise.all([
supabase.from("brands").select("*").eq("slug", slug).single(), getStorefrontData(slug),
getBrandSettingsPublic(slug), getBrandSettingsPublic(slug),
]); ]);
const brandData = brandResult.data as Brand | null; const brandData = storefront.brand as Brand | null;
setBrand(brandData); setBrand(brandData);
if (settingsResult.success && settingsResult.settings) { if (settingsResult.success && settingsResult.settings) {
@@ -484,12 +484,8 @@ export default function TuxedoPage() {
} }
if (brandData?.id) { if (brandData?.id) {
const [{ data: stopsData }, { data: productsData }] = await Promise.all([ setStops(storefront.stops as unknown as Stop[]);
supabase.from("stops").select("*").eq("brand_id", brandData.id).eq("active", true) as unknown as { data: Stop[] | null }, setProducts(storefront.products as unknown as Product[]);
supabase.from("products").select("*").eq("brand_id", brandData.id).eq("active", true) as unknown as { data: Product[] | null },
]);
setStops(stopsData ?? []);
setProducts(productsData ?? []);
} }
} }
load(); load();
+5 -17
View File
@@ -8,7 +8,7 @@ import StopSetEffect from "@/components/storefront/StopSetEffect";
import StorefrontHeader from "@/components/storefront/StorefrontHeader"; import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter"; import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import LayoutContainer from "@/components/layout/LayoutContainer"; import LayoutContainer from "@/components/layout/LayoutContainer";
import { supabase } from "@/lib/supabase"; import { getStorefrontStopBySlug } from "@/actions/storefront";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
type Product = { type Product = {
@@ -52,26 +52,14 @@ export default function StopPage() {
useEffect(() => { useEffect(() => {
async function load() { async function load() {
const { data: stopData } = await supabase const result = await getStorefrontStopBySlug(slug);
.from("stops") if (!result) return;
.select("*")
.eq("slug", slug)
.eq("active", true)
.single() as unknown as { data: Stop | null };
if (!stopData) return; setStop(result.stop as unknown as Stop);
setStop(stopData);
const isIndian = slug.includes("indian"); const isIndian = slug.includes("indian");
setBrandSlug(isIndian ? "indian-river-direct" : "tuxedo"); setBrandSlug(isIndian ? "indian-river-direct" : "tuxedo");
setBrandAccent(isIndian ? "blue" : "green"); setBrandAccent(isIndian ? "blue" : "green");
setProducts(result.products as unknown as Product[]);
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 ?? []);
} }
load(); load();
}, [slug]); }, [slug]);