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 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]);
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
Reference in New Issue
Block a user