916ad39176
Deploy to route.crispygoat.com / deploy (push) Failing after 3m1s
- Add MinIO/S3-compatible storage client (src/lib/storage.ts) with uploadObject, deleteObject, presigned URL helpers, and BUCKETS constant - Wire product images, brand logos, and water log photos to MinIO via the new storage client - Migrate forgot-password to Neon Auth (remove Supabase /auth/v1/recover call) - Migrate send-scheduled cron to direct Postgres + Resend (remove Supabase Edge Function proxy) - Add logoUrl to email types (OrderReceipt, Welcome, PasswordReset) and pass brand_settings.logo_url from all call sites - Update email templates to use dynamic logoUrl instead of hardcoded Supabase bucket URLs - Remove hardcoded Supabase URLs from TuxedoVideoHero, TuxedoAboutPage, TimeTrackingFieldClient; use brand_settings props + local public/ fallback - Download brand logos (3) and tuxedo-hero.mp4 (36MB) from Supabase bucket to public/ for local development - Add MinIO env vars to .env.example (endpoint, access key, secret, buckets) - Fix TimeTrackingFieldClient to destructure logoUrl and brandAccent props - Fix admin/users.ts logoUrl type (null → undefined for optional string) - Remove stale sb- cookie from wholesale-auth - Migrate tuxedo/about page to remove supabase import and use pool query for wholesale_settings lookup
124 lines
3.9 KiB
TypeScript
124 lines
3.9 KiB
TypeScript
"use server";
|
||
|
||
import { pool } from "@/lib/db";
|
||
|
||
// Stripe API version type
|
||
type StripeApiVersion = "2026-05-27.dahlia";
|
||
|
||
/**
|
||
* Creates a Stripe PaymentIntent for the supplied cart so the browser
|
||
* can confirm the payment with embedded Stripe Elements (Apple Pay /
|
||
* Google Pay / card) without redirecting to a hosted page.
|
||
*
|
||
* Mirrors `createRetailStripeCheckoutSession` in `retail-checkout.ts`:
|
||
* the PaymentIntent is the embedded equivalent of the Checkout Session.
|
||
* Order creation itself still happens on `/checkout/success` so the
|
||
* webhooks and order pipeline don't need to know which path the buyer
|
||
* used.
|
||
*/
|
||
type LineItem = {
|
||
id: string;
|
||
name: string;
|
||
price: number;
|
||
quantity: number;
|
||
};
|
||
|
||
type CustomerInfo = {
|
||
name?: string;
|
||
email?: string;
|
||
};
|
||
|
||
type CreatePaymentIntentResult =
|
||
| { success: true; clientSecret: string; paymentIntentId: string; amount: number }
|
||
| { success: false; error: string };
|
||
|
||
export async function createRetailPaymentIntent(
|
||
items: LineItem[],
|
||
customer: CustomerInfo,
|
||
brandId: string | null,
|
||
stopId: string | null,
|
||
shippingAddress?: { state?: string; postal_code?: string; city?: string } | null
|
||
): Promise<CreatePaymentIntentResult> {
|
||
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
||
if (!stripeKey) {
|
||
return { success: false, error: "Stripe not configured on this server." };
|
||
}
|
||
|
||
if (!Array.isArray(items) || items.length === 0) {
|
||
return { success: false, error: "Cart is empty." };
|
||
}
|
||
|
||
const Stripe = (await import("stripe")).default;
|
||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
|
||
|
||
// Compute the subtotal in cents. We don't compute sales tax here —
|
||
// Stripe's `automatic_tax` would be ideal but requires address collection
|
||
// client-side; for this single-product corn box the price is tax-inclusive
|
||
// (see Tuxedo Corn product card) so we treat the line total as final.
|
||
const amount = items.reduce((sum, item) => {
|
||
const qty = Math.max(1, Math.floor(item.quantity || 1));
|
||
const unit = Math.max(0, Math.round(Number(item.price) * 100));
|
||
return sum + unit * qty;
|
||
}, 0);
|
||
|
||
if (amount <= 0) {
|
||
return { success: false, error: "Cart total must be greater than $0." };
|
||
}
|
||
|
||
// Pull the brand name for Stripe receipts + metadata
|
||
let brandName = "Route Commerce";
|
||
if (brandId) {
|
||
try {
|
||
const brandRes = await pool.query<{ name: string }>(
|
||
"SELECT name FROM brands WHERE id = $1 LIMIT 1",
|
||
[brandId]
|
||
);
|
||
if (brandRes.rows[0]?.name) brandName = brandRes.rows[0].name;
|
||
} catch {
|
||
// ignore — use default
|
||
}
|
||
}
|
||
|
||
// Build a short human-readable description for the Stripe dashboard
|
||
const description = items
|
||
.slice(0, 3)
|
||
.map((i) => `${i.quantity}× ${i.name}`)
|
||
.join(", ");
|
||
|
||
try {
|
||
const intent = await stripe.paymentIntents.create({
|
||
amount,
|
||
currency: "usd",
|
||
automatic_payment_methods: { enabled: true },
|
||
receipt_email: customer.email || undefined,
|
||
description,
|
||
metadata: {
|
||
brand_id: brandId ?? "unknown",
|
||
brand_name: brandName,
|
||
stop_id: stopId ?? "",
|
||
customer_name: customer.name ?? "",
|
||
customer_email: customer.email ?? "",
|
||
shipping_state: shippingAddress?.state ?? "",
|
||
shipping_postal_code: shippingAddress?.postal_code ?? "",
|
||
shipping_city: shippingAddress?.city ?? "",
|
||
item_count: String(items.reduce((s, i) => s + i.quantity, 0)),
|
||
source: "storefront_express",
|
||
},
|
||
});
|
||
|
||
if (!intent.client_secret) {
|
||
return { success: false, error: "Stripe did not return a client secret." };
|
||
}
|
||
|
||
return {
|
||
success: true,
|
||
clientSecret: intent.client_secret,
|
||
paymentIntentId: intent.id,
|
||
amount: intent.amount,
|
||
};
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : "Failed to create payment intent.";
|
||
return { success: false, error: message };
|
||
}
|
||
}
|