Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions app/(pages)/trade/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { BUSINESS_EMAIL } from "@/lib/site";
export const metadata: Metadata = {
title: "Trade & Wholesale",
description:
"Stock Deep Dive Brewing Co beer at your bar, restaurant, hotel, or retail location. Submit a trade inquiry for Saba, Sint Maarten / Saint Martin, and nearby islands.",
"Carry island-brewed Deep Dive beer at your bar, restaurant, hotel, or shop. We supply trade accounts on Saba, Sint Maarten / Saint Martin, and nearby islands.",
keywords: [
"Deep Dive Brewing wholesale",
"trade inquiry Saba",
Expand All @@ -22,7 +22,7 @@ export const metadata: Metadata = {
openGraph: {
title: "Trade & Wholesale | Deep Dive Brewing Co",
description:
"Stock Deep Dive Brewing Co beer at your bar, restaurant, hotel, or retail location. Submit a trade inquiry.",
"Carry island-brewed Deep Dive beer at your bar, restaurant, hotel, or shop. We supply trade accounts on Saba, Sint Maarten / Saint Martin, and nearby islands.",
url: "/trade",
images: [
{
Expand All @@ -37,7 +37,7 @@ export const metadata: Metadata = {
card: "summary_large_image",
title: "Trade & Wholesale | Deep Dive Brewing Co",
description:
"Stock Deep Dive Brewing Co beer at your bar, restaurant, hotel, or retail location. Submit a trade inquiry.",
"Carry island-brewed Deep Dive beer at your bar, restaurant, hotel, or shop. We supply trade accounts on Saba, Sint Maarten / Saint Martin, and nearby islands.",
images: ["/photos/og-default.jpg"],
},
};
Expand All @@ -58,24 +58,24 @@ export default function TradePage() {
Trade & Wholesale
</h1>
<p className="mt-3 max-w-180 text-muted-foreground">
Interested in carrying Deep Dive Brewing Co at your bar, restaurant,
hotel, or retail location? We partner with accounts across Saba,
Sint Maarten / Saint Martin, and the surrounding islands.
We brew on Saba and work with bars, restaurants, hotels, shops, and
distributors across Saba, Sint Maarten / Saint Martin, and nearby
islands. Tell us about your business and we&apos;ll take it from there.
</p>
</div>

<section className="mb-12 rounded-lg border border-stone bg-stone/20 p-5">
<h2 className="text-lg font-semibold text-ink">What to expect</h2>
<ul className="mt-3 list-disc space-y-2 pl-5 text-sm text-muted-foreground">
<li>Core and seasonal beers in keg and can formats where available.</li>
<li>Reliable supply from our Saba brewery.</li>
<li>Local delivery on Saba and shipping coordination to SXM.</li>
<li>Marketing support, staff notes, and tap handle assets on request.</li>
<li>Core and seasonal beers in kegs and cans, where available.</li>
<li>Steady supply from our brewery on Saba.</li>
<li>Local delivery on Saba and coordinated shipping to SXM.</li>
<li>Marketing support, staff notes, and tap handles on request.</li>
</ul>
</section>

<section className="rounded-lg border border-stone bg-paper p-6 md:p-8">
<h2 className="mb-6 text-2xl font-bold tracking-tight">Get in Touch</h2>
<h2 className="mb-6 text-2xl font-bold tracking-tight">Send an inquiry</h2>
<TradeInquiryForm />
</section>

Expand Down
2 changes: 1 addition & 1 deletion app/api/trade-inquiry/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export async function POST(req: NextRequest) {
} catch (error) {
logError("trade_inquiry.unexpected", error, { requestId });
return NextResponse.json(
{ ok: false, error: "Failed to submit inquiry." },
{ ok: false, error: "Something went wrong on our end. Please try again." },
{ status: 500 }
);
}
Expand Down
20 changes: 10 additions & 10 deletions components/trade-inquiry-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export function TradeInquiryForm() {
const data = (await res.json()) as { ok?: boolean; error?: string };

if (!res.ok || !data.ok) {
throw new Error(data.error || "Submit failed");
throw new Error(data.error || "Something went wrong. Please try again.");
}

setStatus("success");
Expand Down Expand Up @@ -99,9 +99,9 @@ export function TradeInquiryForm() {
aria-live="polite"
className="rounded-lg border border-moss/30 bg-moss/5 p-8 text-center outline-none focus-visible:ring-2 focus-visible:ring-moss/50"
>
<p className="font-semibold text-ink">Thank you for your inquiry.</p>
<p className="font-semibold text-ink">Thanks for reaching out.</p>
<p className="mt-2 text-sm text-muted-foreground">
We&apos;ll be in touch soon.
Your inquiry is in our inbox. The Deep Dive team will follow up.
</p>
</div>
);
Expand All @@ -125,15 +125,15 @@ export function TradeInquiryForm() {

<div className="grid gap-6 sm:grid-cols-2">
<FormField
label="Business Name"
label="Business name"
name="businessName"
autoComplete="organization"
value={formData.businessName}
onChange={handleChange}
required
/>
<FormField
label="Contact Name"
label="Your name"
name="contactName"
autoComplete="name"
value={formData.contactName}
Expand All @@ -150,7 +150,7 @@ export function TradeInquiryForm() {
required
/>
<FormField
label="Phone / WhatsApp"
label="Phone / WhatsApp (optional)"
name="phoneOrWhatsapp"
type="tel"
autoComplete="tel"
Expand All @@ -161,7 +161,7 @@ export function TradeInquiryForm() {

<div>
<label htmlFor="venueType" className="mb-2 block text-sm font-medium text-ink">
Venue Type
Business type
<span aria-hidden="true" className="text-ember"> *</span>
</label>
<select
Expand All @@ -172,7 +172,7 @@ export function TradeInquiryForm() {
required
className="min-h-[44px] w-full rounded-md border border-stone bg-paper px-3 py-2 text-sm text-ink outline-none focus:ring-2 focus:ring-ocean/50"
>
<option value="">Select type...</option>
<option value="">Choose one...</option>
{venueTypes.map((type) => (
<option key={type} value={type.toLowerCase()}>
{type}
Expand All @@ -183,7 +183,7 @@ export function TradeInquiryForm() {

<div>
<label htmlFor="message" className="mb-2 block text-sm font-medium text-ink">
Message
Message (optional)
</label>
<textarea
id="message"
Expand Down Expand Up @@ -211,7 +211,7 @@ export function TradeInquiryForm() {
</div>

<Button type="submit" disabled={status === "submitting"} className="h-11 min-h-[44px] px-6">
{status === "submitting" ? "Sending..." : "Submit Inquiry"}
{status === "submitting" ? "Sending..." : "Send inquiry"}
</Button>
</form>
);
Expand Down
6 changes: 3 additions & 3 deletions docs/admin/trade-inquiries.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ The form asks for the following information:

| Field | Required | Notes |
|---|---|---|
| **Business Name** | Yes | The bar, restaurant, hotel, retail store, or distributor. |
| **Contact Name** | Yes | The person requesting the trade partnership. |
| **Business name** | Yes | The bar, restaurant, hotel, retail store, or distributor. |
| **Your name** | Yes | The person requesting the trade partnership. |
| **Email** | Yes | Used as the reply-to address. |
| **Phone / WhatsApp** | No | Optional contact number. |
| **Venue Type** | Yes | One of: Bar, Restaurant, Hotel, Retail, Distributor, Other. |
| **Business type** | Yes | One of: Bar, Restaurant, Hotel, Retail, Distributor, Other. |
| **Message** | No | Any extra details the submitter wants to share. |

There is also a hidden honeypot field named `website`. Humans never see it. If it is filled, the submission is treated as spam and silently discarded.
Expand Down
31 changes: 25 additions & 6 deletions lib/trade-leads-common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,27 @@ export const TRADE_LEAD_FIELD_LIMITS = {
message: 4000,
} as const;

export function tradeLeadFieldTooLong(input: TradeLeadInput): string | null {
export function tradeLeadFieldTooLong(
input: TradeLeadInput
): keyof TradeLeadInput | null {
for (const [field, max] of Object.entries(TRADE_LEAD_FIELD_LIMITS)) {
if (input[field as keyof TradeLeadInput].length > max) return field;
if (input[field as keyof TradeLeadInput].length > max)
return field as keyof TradeLeadInput;
}
return null;
}

// Customer-facing names for validation messages — the raw API field keys
// (camelCase) must never reach the submitter.
const TRADE_LEAD_FIELD_LABELS: Record<keyof TradeLeadInput, string> = {
businessName: "business name",
contactName: "contact name",
email: "email",
phoneOrWhatsapp: "phone/WhatsApp",
venueType: "business type",
message: "message",
};

// --- Retention (owner policy, #59) ---
// Trade inquiries are retained for up to 24 months after the last meaningful
// activity, absent a legitimate business/legal/accounting/dispute/security
Expand Down Expand Up @@ -170,8 +184,7 @@ export async function handleTradeInquiry(
status: 400,
body: {
ok: false,
error:
"Missing required fields: businessName, contactName, email, venueType.",
error: "Please fill in all required fields.",
},
};
}
Expand All @@ -187,7 +200,10 @@ export async function handleTradeInquiry(
if (oversized) {
return {
status: 400,
body: { ok: false, error: `Field exceeds maximum length: ${oversized}.` },
body: {
ok: false,
error: `Please shorten the ${TRADE_LEAD_FIELD_LABELS[oversized]} field.`,
},
};
}

Expand Down Expand Up @@ -222,7 +238,10 @@ export async function handleTradeInquiry(
// trade_inquiry.persistence_failed — respond generically.
return {
status: 500,
body: { ok: false, error: "Failed to submit inquiry." },
body: {
ok: false,
error: "Something went wrong on our end. Please try again.",
},
};
}

Expand Down
16 changes: 8 additions & 8 deletions smoke-tests/accessibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,22 +182,22 @@ test("trade form exposes labels, autocomplete, and required state", async ({
}) => {
await page.goto("/trade");

await expect(page.getByLabel("Business Name")).toHaveAttribute(
await expect(page.getByLabel("Business name")).toHaveAttribute(
"autocomplete",
"organization"
);
await expect(page.getByLabel("Contact Name")).toHaveAttribute(
await expect(page.getByLabel("Your name")).toHaveAttribute(
"autocomplete",
"name"
);
const email = page.getByLabel("Email");
await expect(email).toHaveAttribute("autocomplete", "email");
await expect(email).toHaveAttribute("required", "");
await expect(page.getByLabel("Phone / WhatsApp")).toHaveAttribute(
await expect(page.getByLabel("Phone / WhatsApp (optional)")).toHaveAttribute(
"autocomplete",
"tel"
);
await expect(page.getByLabel("Venue Type")).toHaveAttribute("required", "");
await expect(page.getByLabel("Business type")).toHaveAttribute("required", "");

// The honeypot field exists but is unreachable by keyboard and hidden
// from assistive technology (sr-only keeps a clipped 1px box, so assert
Expand All @@ -222,11 +222,11 @@ test("trade form announces submission errors in a status region", async ({
);
await page.goto("/trade");

await page.getByLabel("Business Name").fill("Smoke Test Tavern");
await page.getByLabel("Contact Name").fill("Tester");
await page.getByLabel("Business name").fill("Smoke Test Tavern");
await page.getByLabel("Your name").fill("Tester");
await page.getByLabel("Email").fill("tester@example.com");
await page.getByLabel("Venue Type").selectOption("bar");
await page.getByRole("button", { name: "Submit Inquiry" }).click();
await page.getByLabel("Business type").selectOption("bar");
await page.getByRole("button", { name: "Send inquiry" }).click();

const statusRegion = page.getByRole("status");
await expect(statusRegion).toContainText("Failed to submit inquiry.");
Expand Down
21 changes: 12 additions & 9 deletions smoke-tests/analytics.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,13 +307,16 @@ test("trade inquiry success fires only after a successful server response", asyn
);
await page.goto("/trade");

await page.getByLabel(/Business Name/).fill("Audit Test Co");
await page.getByLabel(/Contact Name/).fill("Test Person");
await page.getByLabel(/Business name/).fill("Audit Test Co");
await page.getByLabel(/Your name/).fill("Test Person");
await page.getByLabel(/Email/).fill("audit-pii-check@example.com");
await page.getByLabel(/Venue Type/).selectOption("bar");
await page.getByRole("button", { name: "Submit Inquiry" }).click();
await page.getByLabel(/Business type/).selectOption("bar");
await page.getByRole("button", { name: "Send inquiry" }).click();

await expect(page.getByText(/inquiry/i).first()).toBeVisible();
// The success state replaces the form and confirms follow-up.
await expect(
page.getByRole("status").filter({ hasText: /follow up/i })
).toBeVisible();
await waitForEvents(page, "trade_form_start", 1);
await waitForEvents(page, "trade_form_success", 1);
expect(events(await getDataLayer(page), "trade_form_error")).toHaveLength(0);
Expand All @@ -334,12 +337,12 @@ test("trade inquiry failure does not fire success and sends no form PII", async
);
await page.goto("/trade");

await page.getByLabel(/Business Name/).fill("Audit Test Co");
await page.getByLabel(/Contact Name/).fill("Test Person");
await page.getByLabel(/Business name/).fill("Audit Test Co");
await page.getByLabel(/Your name/).fill("Test Person");
await page.getByLabel(/Email/).fill("audit-pii-check@example.com");
await page.getByLabel(/Venue Type/).selectOption("bar");
await page.getByLabel(/Business type/).selectOption("bar");
await page.getByLabel(/Message/).fill("secret business details here");
await page.getByRole("button", { name: "Submit Inquiry" }).click();
await page.getByRole("button", { name: "Send inquiry" }).click();

await expect(page.getByRole("status").first()).toBeVisible();
await waitForEvents(page, "trade_form_error", 1);
Expand Down
10 changes: 5 additions & 5 deletions smoke-tests/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,15 @@ test("trade page renders the inquiry form", async ({ page }) => {
await expect(
page.getByRole("heading", { name: "What to expect" })
).toBeVisible();
await expect(page.getByLabel("Business Name")).toBeVisible();
await expect(page.getByLabel("Contact Name")).toBeVisible();
await expect(page.getByLabel("Business name")).toBeVisible();
await expect(page.getByLabel("Your name")).toBeVisible();
await expect(page.getByLabel("Email")).toBeVisible();
await expect(page.getByLabel("Message")).toBeVisible();
await expect(page.getByLabel("Message (optional)")).toBeVisible();
await expect(
page.getByRole("button", { name: "Submit Inquiry" })
page.getByRole("button", { name: "Send inquiry" })
).toBeEnabled();
// Client-side interactivity works without touching the real API.
const businessName = page.getByLabel("Business Name");
const businessName = page.getByLabel("Business name");
await businessName.fill("Smoke Test Tavern");
await expect(businessName).toHaveValue("Smoke Test Tavern");
});
Expand Down
16 changes: 12 additions & 4 deletions tests/lib/trade-leads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ describe("handleTradeInquiry", () => {
deps
);
assert.strictEqual(result.status, 400);
assert.match(result.body.error ?? "", /Missing required fields/);
assert.match(result.body.error ?? "", /required fields/);
assert.deepStrictEqual(calls, []);
});

Expand All @@ -274,7 +274,7 @@ describe("handleTradeInquiry", () => {
deps
);
assert.strictEqual(result.status, 400);
assert.match(result.body.error ?? "", /Missing required fields/);
assert.match(result.body.error ?? "", /required fields/);
assert.deepStrictEqual(calls, []);
});

Expand All @@ -286,7 +286,12 @@ describe("handleTradeInquiry", () => {
deps
);
assert.strictEqual(result.status, 400);
assert.match(result.body.error ?? "", /Field exceeds maximum length: email/);
// The customer-facing message names the field by its form label, never
// the camelCase API key.
assert.strictEqual(
result.body.error,
"Please shorten the email field."
);
assert.deepStrictEqual(calls, []);
});

Expand Down Expand Up @@ -336,7 +341,10 @@ describe("handleTradeInquiry", () => {
const result = await handleTradeInquiry(body, context, deps);
assert.deepStrictEqual(result, {
status: 500,
body: { ok: false, error: "Failed to submit inquiry." },
body: {
ok: false,
error: "Something went wrong on our end. Please try again.",
},
});
});

Expand Down
Loading