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 };
}