fix(storefront): align store actions with actual DB schema
Reviewer caught three runtime breakages that the prior commit would
have introduced (the legacy supabase shim masked them by returning
null):
1. **getStorefrontStopBySlug queried 'WHERE slug = $1' on stops, but
stops has no slug column** — every storefront stop-detail page
would have 500'd. The 'stops table has a slug' assumption is
pre-existing throughout the codebase (PublicStop type, sitemap,
StopCard prop), but the column has never existed. Fix: switch the
route param to the stop's UUID (id). Rename folder [slug] → [id]
for honesty. Update StopCard prop and the two stops-list links to
pass stop.id instead of stop.slug. The RPC get_public_stops_for_brand
already returns id, so this is what was actually being rendered.
2. **getStorefrontWholesaleSettings selected four non-existent
columns** (invoice_business_address/phone/email/website) on
wholesale_settings — only invoice_business_name is defined. The
contact pages had hardcoded fallbacks, but the broken query was
still throwing 500 on every render. Fix: only return
invoice_business_name, and drop the dead optional-chain references
in the contact pages (the values were always going to be the
hardcoded fallback anyway).
3. **getAIPreferences/saveAIPreferences referenced a non-existent
brand_ai_settings table** — no migration defines it, and no file
in the codebase imports either function. Deleted the whole
preferences.ts module (dead code that would have 500'd the moment
anyone wired it up).
4. **getStorefrontProducts returned price as cents** (no division),
so storefronts rendered '$$3500' for a $35 product. Fix: divide
by 100 in SQL; type already declares price: number, callers
already format as $${price}.
Verified by:
- npx tsc --noEmit: zero new errors (only pre-existing Stripe API
version and preconnect mock issues)
- npx vitest run: 174/175 (same baseline; pre-existing getAdminUser
mock failure unchanged)
- Live dev server: /tuxedo, /indian-river-direct, /tuxedo/stops/[id],
/wholesale/portal all return 200; the new action no longer throws
'column slug does not exist'.
This commit is contained in:
@@ -1,108 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
export type AIAuthConfig = {
|
||||
provider: string;
|
||||
api_key: string;
|
||||
organization_id: string;
|
||||
base_url: string;
|
||||
model: string;
|
||||
max_tokens: number;
|
||||
};
|
||||
|
||||
type AIRow = {
|
||||
api_key: string | null;
|
||||
organization_id: string | null;
|
||||
base_url: string | null;
|
||||
model: string | null;
|
||||
max_tokens: number | null;
|
||||
};
|
||||
|
||||
export async function getAIPreferences(brandId: string): Promise<{
|
||||
api_key?: string;
|
||||
organization_id?: string;
|
||||
base_url?: string;
|
||||
model?: string;
|
||||
max_tokens?: number;
|
||||
} | null> {
|
||||
const { rows } = await pool.query<AIRow>(
|
||||
`SELECT api_key, organization_id, base_url, model, max_tokens
|
||||
FROM brand_ai_settings
|
||||
WHERE brand_id = $1
|
||||
LIMIT 1`,
|
||||
[brandId],
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
return {
|
||||
api_key: row.api_key ?? undefined,
|
||||
organization_id: row.organization_id ?? undefined,
|
||||
base_url: row.base_url ?? undefined,
|
||||
model: row.model ?? undefined,
|
||||
max_tokens: row.max_tokens ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveAIPreferences(
|
||||
brandId: string,
|
||||
config: AIAuthConfig
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
await pool.query(
|
||||
`INSERT INTO brand_ai_settings
|
||||
(brand_id, provider, api_key, organization_id, base_url, model, max_tokens, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||
ON CONFLICT (brand_id) DO UPDATE
|
||||
SET provider = EXCLUDED.provider,
|
||||
api_key = EXCLUDED.api_key,
|
||||
organization_id = EXCLUDED.organization_id,
|
||||
base_url = EXCLUDED.base_url,
|
||||
model = EXCLUDED.model,
|
||||
max_tokens = EXCLUDED.max_tokens,
|
||||
updated_at = NOW()`,
|
||||
[
|
||||
brandId,
|
||||
config.provider,
|
||||
config.api_key || null,
|
||||
config.organization_id || null,
|
||||
config.base_url || null,
|
||||
config.model || "gpt-4o-mini",
|
||||
config.max_tokens || 4000,
|
||||
],
|
||||
);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Failed to save AI preferences",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function testAIConnection(config: AIAuthConfig): Promise<{ ok: boolean; message: string }> {
|
||||
if (!config.api_key?.trim()) {
|
||||
return { ok: false, message: "API key is required" };
|
||||
}
|
||||
|
||||
try {
|
||||
const baseUrl = config.base_url?.trim() || "https://api.openai.com/v1";
|
||||
const url = `${baseUrl.replace(/\/$/, "")}/models`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.api_key}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { ok: true, message: "Connection successful! Your API key is valid." };
|
||||
} else if (response.status === 401) {
|
||||
return { ok: false, message: "Invalid API key. Please check and try again." };
|
||||
} else {
|
||||
return { ok: false, message: `Error: ${response.status} ${response.statusText}` };
|
||||
}
|
||||
} catch {
|
||||
return { ok: false, message: "Could not connect. Check your network and API key." };
|
||||
}
|
||||
}
|
||||
+22
-15
@@ -98,12 +98,17 @@ export async function getStorefrontStops(brandId: string): Promise<StorefrontSto
|
||||
/**
|
||||
* Active products visible on the storefront. Same shape as the admin
|
||||
* product list but without the brand-internal columns (cost, supplier, …).
|
||||
*
|
||||
* Note: products.price is stored in cents (NUMERIC). We return it as a
|
||||
* float in **dollars** so callers can format directly as `$${price}`
|
||||
* without doing the cents-to-dollars math.
|
||||
*/
|
||||
export async function getStorefrontProducts(
|
||||
brandId: string,
|
||||
): Promise<StorefrontProduct[]> {
|
||||
const { rows } = await pool.query<StorefrontProduct>(
|
||||
`SELECT id, brand_id, name, description, price::float8 AS price,
|
||||
`SELECT id, brand_id, name, description,
|
||||
price::float8 / 100 AS price,
|
||||
type, image_url, is_taxable, pickup_type, active
|
||||
FROM products
|
||||
WHERE brand_id = $1
|
||||
@@ -137,16 +142,16 @@ export async function getStorefrontData(slug: string): Promise<StorefrontData> {
|
||||
|
||||
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.
|
||||
* Public wholesale-settings record for a brand — just the invoice
|
||||
* business name used by the storefront contact page. Returns `null`
|
||||
* if the brand or its wholesale settings row doesn't exist.
|
||||
*
|
||||
* NOTE: only `invoice_business_name` is exposed; other invoice-contact
|
||||
* fields don't exist on `wholesale_settings` yet and shouldn't be
|
||||
* surfaced here. If you need them, add a migration first.
|
||||
*/
|
||||
export async function getStorefrontWholesaleSettings(
|
||||
slug: string,
|
||||
@@ -154,8 +159,7 @@ export async function getStorefrontWholesaleSettings(
|
||||
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
|
||||
`SELECT invoice_business_name
|
||||
FROM wholesale_settings
|
||||
WHERE brand_id = $1
|
||||
LIMIT 1`,
|
||||
@@ -206,14 +210,17 @@ export async function getStorefrontBrandName(
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a single public stop by its URL slug, plus the brand's
|
||||
* active products. Used by `/[brand]/stops/[slug]` pages.
|
||||
* Look up a single public stop by its id, plus the brand's active
|
||||
* products. Used by `/[brand]/stops/[id]` pages.
|
||||
*
|
||||
* NOTE: The URL parameter is named `[id]` (not `[slug]`) because the
|
||||
* `stops` table has no slug column. URLs use the stop's UUID.
|
||||
*
|
||||
* 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<{
|
||||
export async function getStorefrontStopById(id: string): Promise<{
|
||||
stop: StorefrontStop;
|
||||
products: StorefrontProduct[];
|
||||
} | null> {
|
||||
@@ -222,11 +229,11 @@ export async function getStorefrontStopBySlug(slug: string): Promise<{
|
||||
date::text AS date, time, cutoff_date::text AS cutoff_date,
|
||||
status, notes, is_public
|
||||
FROM stops
|
||||
WHERE slug = $1
|
||||
WHERE id = $1
|
||||
AND is_public = true
|
||||
AND status = 'active'
|
||||
LIMIT 1`,
|
||||
[slug],
|
||||
[id],
|
||||
);
|
||||
const stop = stopRows[0];
|
||||
if (!stop) return null;
|
||||
|
||||
@@ -10,10 +10,6 @@ import { getStorefrontWholesaleSettings } from "@/actions/storefront";
|
||||
|
||||
type BrandSettings = {
|
||||
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;
|
||||
};
|
||||
|
||||
type FormState = {
|
||||
@@ -90,7 +86,7 @@ export default function IndianRiverContactPage() {
|
||||
{[
|
||||
{
|
||||
label: "Address",
|
||||
value: brandSettings?.invoice_business_address ?? "Indian River Region, Florida",
|
||||
value: "Indian River Region, Florida",
|
||||
icon: <path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />,
|
||||
iconPath: <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||
},
|
||||
@@ -102,7 +98,7 @@ export default function IndianRiverContactPage() {
|
||||
},
|
||||
{
|
||||
label: "Email",
|
||||
value: brandSettings?.invoice_business_email ?? "Info@indianriverdirect.com",
|
||||
value: "Info@indianriverdirect.com",
|
||||
icon: <path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />,
|
||||
iconPath: null
|
||||
},
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function IndianRiverStopsList({ stops, brandName, brandSlug }: Pr
|
||||
{upcomingStops.map((stop) => (
|
||||
<Link
|
||||
key={stop.id}
|
||||
href={`/${brandSlug}/stops/${stop.slug}`}
|
||||
href={`/${brandSlug}/stops/${stop.id}`}
|
||||
className="stop-card group block bg-white rounded-2xl p-5 shadow-sm ring-1 ring-stone-200/60 transition-all duration-300 hover:shadow-lg hover:ring-orange-200"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
|
||||
+2
-2
@@ -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 { getStorefrontStopBySlug } from "@/actions/storefront";
|
||||
import { getStorefrontStopById } from "@/actions/storefront";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
type Product = {
|
||||
@@ -53,7 +53,7 @@ export default function StopPage() {
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const result = await getStorefrontStopBySlug(slug);
|
||||
const result = await getStorefrontStopById(slug);
|
||||
if (!result) return;
|
||||
|
||||
setStop(result.stop as unknown as Stop);
|
||||
@@ -9,10 +9,6 @@ import { getStorefrontWholesaleSettings } from "@/actions/storefront";
|
||||
|
||||
type BrandSettings = {
|
||||
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;
|
||||
};
|
||||
|
||||
type FormState = {
|
||||
@@ -72,7 +68,7 @@ export default function TuxedoContactPage() {
|
||||
</div>
|
||||
<h3 className="text-base font-bold text-stone-950 mb-3">Farm Address</h3>
|
||||
<p className="text-sm text-stone-500 leading-relaxed">
|
||||
{brandSettings?.invoice_business_address ?? "59751 David Road, Olathe, CO 81425"}
|
||||
59751 David Road, Olathe, CO 81425
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-200/60 text-center">
|
||||
@@ -96,8 +92,8 @@ export default function TuxedoContactPage() {
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-base font-bold text-stone-950 mb-3">Email</h3>
|
||||
<a href={`mailto:${brandSettings?.invoice_business_email ?? "orders@tuxedocorn.com"}`} className="text-sm text-stone-500 hover:text-emerald-700 transition-colors">
|
||||
{brandSettings?.invoice_business_email ?? "orders@tuxedocorn.com"}
|
||||
<a href="mailto:orders@tuxedocorn.com" className="text-sm text-stone-500 hover:text-emerald-700 transition-colors">
|
||||
orders@tuxedocorn.com
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -86,7 +86,7 @@ export default function TuxedoStopsList({ stops, brandName, brandSlug }: Props)
|
||||
{upcomingStops.map((stop) => (
|
||||
<Link
|
||||
key={stop.id}
|
||||
href={`/${brandSlug}/stops/${stop.slug}`}
|
||||
href={`/${brandSlug}/stops/${stop.id}`}
|
||||
className="stop-card group block bg-white rounded-2xl p-5 shadow-sm ring-1 ring-stone-200/60 transition-all duration-300 hover:shadow-lg hover:ring-emerald-200"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
|
||||
@@ -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 { getStorefrontStopBySlug } from "@/actions/storefront";
|
||||
import { getStorefrontStopById } from "@/actions/storefront";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
type Product = {
|
||||
@@ -52,7 +52,7 @@ export default function StopPage() {
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const result = await getStorefrontStopBySlug(slug);
|
||||
const result = await getStorefrontStopById(slug);
|
||||
if (!result) return;
|
||||
|
||||
setStop(result.stop as unknown as Stop);
|
||||
@@ -2,7 +2,7 @@ import Link from "next/link";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
type StopCardProps = {
|
||||
slug: string;
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: string;
|
||||
@@ -13,7 +13,7 @@ type StopCardProps = {
|
||||
};
|
||||
|
||||
export default function StopCard({
|
||||
slug, city, state, date, time, location,
|
||||
id, city, state, date, time, location,
|
||||
brandSlug = "tuxedo", brandAccent = "green",
|
||||
}: StopCardProps) {
|
||||
const ctaBg =
|
||||
@@ -25,7 +25,7 @@ export default function StopCard({
|
||||
|
||||
return (
|
||||
<div className="group overflow-hidden rounded-3xl bg-white ring-1 ring-stone-200/60 transition-all duration-300 hover:-translate-y-1 hover:shadow-xl hover:shadow-black/8 hover:ring-stone-300">
|
||||
<Link href={`/${brandSlug}/stops/${slug}`} className="block p-7">
|
||||
<Link href={`/${brandSlug}/stops/${id}`} className="block p-7">
|
||||
{/* Location */}
|
||||
<div className="flex items-start gap-4 mb-5">
|
||||
<div className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl transition-colors ${brandAccent === "blue" ? "bg-blue-50 group-hover:bg-blue-100" : brandAccent === "green" ? "bg-emerald-50 group-hover:bg-emerald-100" : "bg-orange-50 group-hover:bg-orange-100"}`}>
|
||||
@@ -56,7 +56,7 @@ export default function StopCard({
|
||||
{/* CTA */}
|
||||
<div className="px-7 pb-7 pt-1">
|
||||
<Link
|
||||
href={`/${brandSlug}/stops/${slug}`}
|
||||
href={`/${brandSlug}/stops/${id}`}
|
||||
className={`block w-full rounded-2xl ${ctaBg} text-white px-5 py-3.5 text-center font-bold text-sm uppercase tracking-wider transition-colors`}
|
||||
>
|
||||
Shop This Stop
|
||||
|
||||
Reference in New Issue
Block a user