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 };
|
||||
}
|
||||
Reference in New Issue
Block a user