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:
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+6
-10
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user