Initial commit - Route Commerce platform

This commit is contained in:
2026-06-01 19:40:55 +00:00
commit 53a9671461
617 changed files with 106132 additions and 0 deletions
+60
View File
@@ -0,0 +1,60 @@
/**
* Service-layer admin user creation via Supabase REST API.
* Uses apikey-only authentication — no Authorization header (which fails on
* Vercel Edge due to raw JWT chars +, /, = in the token).
*/
export async function createAdminUser(
userId: string,
role: string,
brandId: string | null
): Promise<Record<string, unknown> | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !serviceKey) return null;
const body = JSON.stringify({
user_id: userId,
role,
brand_id: brandId,
active: true,
can_manage_products: role === "platform_admin",
can_manage_stops: role === "platform_admin",
can_manage_orders: true,
can_manage_pickup: role !== "store_employee",
can_manage_messages: role === "platform_admin",
can_manage_refunds: role === "platform_admin",
can_manage_users: role === "platform_admin",
can_manage_water_log: role === "platform_admin",
can_manage_reports: role === "platform_admin",
can_manage_settings: role === "platform_admin",
must_change_password: false,
});
// Use apikey-only — no Authorization header to avoid Vercel Edge JWT rejection
const res = await fetch(`${supabaseUrl}/rest/v1/admin_users`, {
method: "POST",
headers: {
apikey: serviceKey,
"Content-Type": "application/json",
Prefer: "return=representation",
},
body,
});
if (res.status === 409) {
// User already exists — fetch and return
const existing = await fetch(
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${userId}&limit=1`,
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
);
const data = existing.ok ? await existing.json().catch(() => null) : null;
return (Array.isArray(data) && data.length > 0) ? data[0] as Record<string, unknown> : null;
}
if (!res.ok) {
return null;
}
const data = await res.json();
return (Array.isArray(data) ? data[0] : data) as Record<string, unknown>;
}
+18
View File
@@ -0,0 +1,18 @@
// Shared AdminUser type — safe to import from both server and client components
export type AdminUser = {
id?: string;
user_id: string;
brand_id: string | null;
role: "platform_admin" | "brand_admin" | "store_employee" | "staff";
can_manage_products: boolean;
can_manage_stops: boolean;
can_manage_orders: boolean;
can_manage_pickup: boolean;
can_manage_messages: boolean;
can_manage_refunds: boolean;
can_manage_users: boolean;
can_manage_water_log: boolean;
can_manage_reports: boolean;
can_manage_settings: boolean;
must_change_password?: boolean;
};
+128
View File
@@ -0,0 +1,128 @@
import { cookies } from "next/headers";
export type AdminUser = {
id: string;
user_id: string;
brand_id: string | null;
role: string;
active: boolean;
can_manage_products: boolean;
can_manage_stops: boolean;
can_manage_orders: boolean;
can_manage_pickup: boolean;
can_manage_messages: boolean;
can_manage_refunds: boolean;
can_manage_users: boolean;
can_manage_water_log: boolean;
can_manage_reports: boolean;
can_manage_settings: boolean;
must_change_password: boolean;
};
export async function getAdminUser(): Promise<AdminUser | null> {
const cookieStore = await cookies();
// ── Mock data mode for UI review ─────────────────────────────────
if (process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true") {
return buildDevAdmin("platform_admin");
}
// ── Dev session bypass (enabled in all envs for demo) ──────────────
// Allow dev cookies via: document.cookie = "dev_session=platform_admin; path=/"
if (process.env.NODE_ENV === "development") {
const dev = cookieStore.get("dev_session")?.value;
if (dev === "platform_admin" || dev === "brand_admin" || dev === "store_employee") {
return buildDevAdmin(dev);
}
}
// ── Main auth: rc_auth_uid (new) or rc_uid (legacy) cookie set by /api/login ─
const uid = cookieStore.get("rc_auth_uid")?.value ?? cookieStore.get("rc_uid")?.value;
if (!uid) return null;
if (!process.env.SUPABASE_SERVICE_ROLE_KEY || !process.env.NEXT_PUBLIC_SUPABASE_URL) {
return null;
}
// Lookup admin_users by Supabase auth user id
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
let adminUsers: unknown[] = [];
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${uid}&limit=1`,
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
);
if (res.ok) {
const data = await res.json().catch(() => []);
adminUsers = Array.isArray(data) ? data : [];
}
} catch (e) {
// fetch failed silently
}
// First login — auto-create platform_admin via SECURITY DEFINER RPC
if (adminUsers.length === 0) {
// Check if uid is a valid UUID before trying to insert
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!UUID_REGEX.test(uid)) return null;
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_admin_user`,
{
method: "POST",
headers: { apikey: serviceKey, "Content-Type": "application/json", Prefer: "return=representation" },
body: JSON.stringify({ p_user_id: uid }),
}
);
if (res.ok) {
const inserted = await res.json().catch(() => null);
if (inserted && inserted.length > 0) {
return buildAdminUser(inserted[0] as Record<string, unknown>);
}
}
} catch (e) {
// RPC failed silently
}
return null;
}
const admin = adminUsers[0] as Record<string, unknown>;
if (!admin.active) return null;
return buildAdminUser(admin);
}
function buildDevAdmin(role: string): AdminUser {
const base = { id: "dev", user_id: "dev", brand_id: null, role, active: true, must_change_password: false };
if (role === "store_employee") {
return { ...base, can_manage_products: false, can_manage_stops: false, can_manage_orders: true,
can_manage_pickup: true, can_manage_messages: false, can_manage_refunds: false,
can_manage_users: false, can_manage_water_log: false, can_manage_reports: false, can_manage_settings: false };
}
return { ...base, can_manage_products: true, can_manage_stops: true, can_manage_orders: true,
can_manage_pickup: true, can_manage_messages: true, can_manage_refunds: true,
can_manage_users: true, can_manage_water_log: true, can_manage_reports: true, can_manage_settings: true };
}
function buildAdminUser(r: Record<string, unknown>): AdminUser {
const role = r.role as string;
const base = { id: r.id as string, user_id: r.user_id as string, brand_id: r.brand_id as string | null,
role, active: r.active as boolean, must_change_password: Boolean(r.must_change_password) };
if (role === "platform_admin") {
return { ...base, can_manage_products: true, can_manage_stops: true, can_manage_orders: true,
can_manage_pickup: true, can_manage_messages: true, can_manage_refunds: true,
can_manage_users: true, can_manage_water_log: true, can_manage_reports: true, can_manage_settings: true };
}
if (role === "store_employee") {
return { ...base, can_manage_products: false, can_manage_stops: false, can_manage_orders: true,
can_manage_pickup: true, can_manage_messages: false, can_manage_refunds: false,
can_manage_users: false, can_manage_water_log: false, can_manage_reports: false, can_manage_settings: false };
}
return { ...base, can_manage_products: Boolean(r.can_manage_products), can_manage_stops: Boolean(r.can_manage_stops),
can_manage_orders: Boolean(r.can_manage_orders), can_manage_pickup: Boolean(r.can_manage_pickup),
can_manage_messages: Boolean(r.can_manage_messages), can_manage_refunds: Boolean(r.can_manage_refunds),
can_manage_users: Boolean(r.can_manage_users), can_manage_water_log: Boolean(r.can_manage_water_log),
can_manage_reports: Boolean(r.can_manage_reports), can_manage_settings: Boolean(r.can_manage_settings) };
}
+14
View File
@@ -0,0 +1,14 @@
// AI provider model definitions — shared between client and server
export type AIProvider = "openai" | "anthropic" | "google" | "xai" | "custom";
export const PROVIDER_MODELS: Record<Exclude<AIProvider, "custom">, string[]> = {
openai: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-3.5-turbo"],
anthropic: ["claude-3-5-sonnet-20241002", "claude-3-5-sonnet-20250611", "claude-3-opus-20240229", "claude-3-haiku-20240307"],
google: ["gemini-2.0-flash", "gemini-1.5-pro", "gemini-1.5-flash", "gemini-pro"],
xai: ["grok-2", "grok-2-mini", "grok-1.5"],
};
export function getModelsForProvider(provider: Exclude<AIProvider, "custom">): string[] {
return PROVIDER_MODELS[provider] ?? [];
}
+299
View File
@@ -0,0 +1,299 @@
// Route Commerce Platform — Billing Logic
// Central helpers for subscription creation, status sync, and feature management
import "server-only";
import { ADDONS, type PlanTierKey, type AddonKey } from "./pricing";
import { svcHeaders } from "@/lib/svc-headers";
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// ── Subscription status types ──────────────────────────────────────────────────
export type SubscriptionStatus =
| "active"
| "past_due"
| "canceled"
| "trialing"
| "incomplete"
| "unpaid";
export interface BrandSubscription {
brand_id: string;
stripe_subscription_id: string | null;
stripe_subscription_status: SubscriptionStatus | null;
stripe_current_period_end: string | null;
stripe_customer_id: string | null;
plan_tier: PlanTierKey;
}
// ── RPC call helper ───────────────────────────────────────────────────────────
async function rpc<T = unknown>(
fn: string,
body: Record<string, unknown>
): Promise<T> {
const res = await fetch(`${SUPABASE_URL}/rest/v1/rpc/${fn}`, {
method: "POST",
headers: { ...svcHeaders(SERVICE_KEY), "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`RPC ${fn} failed: ${err}`);
}
return res.json() as Promise<T>;
}
// ── Feature sync ─────────────────────────────────────────────────────────────
// When a subscription changes, sync the plan tier + all add-on feature flags
export async function syncSubscriptionFeatures(
brandId: string,
subscriptionItems: Array<{ priceId: string; enabled: boolean }>
): Promise<void> {
// Determine plan tier from subscription items
const priceToTier: Record<string, PlanTierKey> = {
[process.env.STRIPE_PRICE_STARTER ?? ""]: "starter",
[process.env.STRIPE_PRICE_FARM ?? ""]: "farm",
[process.env.STRIPE_PRICE_ENTERPRISE ?? ""]: "enterprise",
};
const priceToAddon: Record<string, AddonKey> = {
[process.env.STRIPE_PRICE_HARVEST_REACH ?? ""]: "harvest_reach",
[process.env.STRIPE_PRICE_WHOLESALE_PORTAL ?? ""]: "wholesale_portal",
[process.env.STRIPE_PRICE_WATER_LOG ?? ""]: "water_log",
[process.env.STRIPE_PRICE_AI_TOOLS ?? ""]: "ai_tools",
[process.env.STRIPE_PRICE_SQUARE_SYNC ?? ""]: "square_sync",
[process.env.STRIPE_PRICE_SMS_CAMPAIGNS ?? ""]: "sms_campaigns",
};
// Update plan tier
const tierItem = subscriptionItems.find((item) => priceToTier[item.priceId]);
if (tierItem) {
const newTier = priceToTier[tierItem.priceId];
if (newTier) {
await rpc("update_brand_plan_tier", { p_brand_id: brandId, p_plan_tier: newTier });
}
}
// Sync add-on features
for (const [priceId, addonKey] of Object.entries(priceToAddon)) {
if (!priceId) continue;
const item = subscriptionItems.find((i) => i.priceId === priceId);
const enabled = item?.enabled ?? false;
await rpc("set_brand_feature", { p_brand_id: brandId, p_feature_key: addonKey, p_enabled: enabled });
}
}
// ── Subscription creation ─────────────────────────────────────────────────────
export async function createOrUpdateSubscription(
brandId: string,
priceKey: string,
billingCycle: "monthly" | "annual"
): Promise<{ subscriptionId: string; clientSecret: string }> {
const stripeKey = process.env.STRIPE_SECRET_KEY;
if (!stripeKey) throw new Error("STRIPE_SECRET_KEY not configured");
const Stripe = (await import("stripe")).default;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
// Get brand's Stripe customer
const brands = await rpc<Array<{ stripe_customer_id: string | null }>>(
"get_brand_subscription",
{ p_brand_id: brandId }
);
const brandData = brands[0] as unknown as BrandSubscription | undefined;
const customerId = brandData?.stripe_customer_id;
if (!customerId) throw new Error("No Stripe customer for brand. Complete Stripe setup first.");
// Get price ID
const priceId = getPriceId(priceKey, billingCycle);
if (!priceId) throw new Error(`No price configured for ${priceKey} (${billingCycle})`);
// Check if brand already has an active subscription — update it instead of creating new
const existingSubId = (brandData as unknown as { stripe_subscription_id?: string })?.stripe_subscription_id;
let subscription;
if (existingSubId) {
// Add or update the subscription item
const existing = await stripe.subscriptions.retrieve(existingSubId);
const existingItem = existing.items.data.find((item) => {
const planPrices = [
process.env.STRIPE_PRICE_STARTER ?? "",
process.env.STRIPE_PRICE_FARM ?? "",
process.env.STRIPE_PRICE_ENTERPRISE ?? "",
];
return planPrices.includes(item.price.id);
});
if (existingItem) {
subscription = await stripe.subscriptions.update(existingSubId, {
items: [{ id: existingItem.id, price: priceId }],
proration_behavior: "create_prorations",
});
} else {
subscription = await stripe.subscriptions.update(existingSubId, {
items: [{ price: priceId }],
});
}
} else {
subscription = await stripe.subscriptions.create({
customer: customerId,
items: [{ price: priceId }],
payment_behavior: "default_incomplete",
payment_settings: { save_default_payment_method: "on_subscription" },
expand: ["latest_invoice.payment_intent"],
metadata: { brand_id: brandId, price_key: priceKey, billing: billingCycle },
});
}
// Save subscription ID to brand
const subData = subscription as unknown as { id: string; status: string; current_period_end: number };
await rpc("set_brand_subscription", {
p_brand_id: brandId,
p_subscription_id: subData.id,
p_status: subData.status,
p_current_period_end: new Date(subData.current_period_end * 1000).toISOString(),
});
const latestInvoice = subscription.latest_invoice as { payment_intent?: { client_secret?: string } } | null;
const invoiceOrNull = typeof subscription.latest_invoice !== "string" ? latestInvoice : null;
const paymentIntent = invoiceOrNull?.payment_intent as { client_secret?: string } | undefined;
const clientSecret = paymentIntent?.client_secret ?? null;
return { subscriptionId: subscription.id, clientSecret: clientSecret ?? "" };
}
// ── Cancel subscription ──────────────────────────────────────────────────────
export async function cancelBrandSubscription(
brandId: string,
priceKey?: string
): Promise<void> {
const stripeKey = process.env.STRIPE_SECRET_KEY;
if (!stripeKey) throw new Error("STRIPE_SECRET_KEY not configured");
const brandData = await rpc<BrandSubscription[]>("get_brand_subscription", { p_brand_id: brandId });
const brand = brandData[0] as unknown as BrandSubscription | undefined;
if (!brand?.stripe_subscription_id) throw new Error("No active subscription to cancel");
const Stripe = (await import("stripe")).default;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
if (priceKey) {
// Cancel only a specific add-on item, not the whole subscription
const sub = await stripe.subscriptions.retrieve(brand.stripe_subscription_id);
const targetPriceId = getPriceId(priceKey, "monthly");
const item = sub.items.data.find((i) => i.price.id === targetPriceId);
if (item) {
await stripe.subscriptions.update(brand.stripe_subscription_id, {
items: [{ id: item.id, deleted: true }],
proration_behavior: "none",
});
// Disable the feature flag
const addonKey = getAddonKeyFromPriceKey(priceKey);
if (addonKey) {
await rpc("set_brand_feature", { p_brand_id: brandId, p_feature_key: addonKey, p_enabled: false });
}
}
} else {
// Cancel entire subscription
await stripe.subscriptions.cancel(brand.stripe_subscription_id);
await rpc("set_brand_subscription", {
p_brand_id: brandId,
p_subscription_id: "",
p_status: "canceled",
p_current_period_end: null,
});
// Disable all add-on features
for (const addonKey of Object.keys(ADDONS) as AddonKey[]) {
await rpc("set_brand_feature", { p_brand_id: brandId, p_feature_key: addonKey, p_enabled: false });
}
}
}
// ── Past due notification ─────────────────────────────────────────────────────
export async function sendPastDueNotification(brandId: string): Promise<void> {
const brandData = await rpc<Array<{ name: string }>>("get_brand_subscription", { p_brand_id: brandId });
const brand = brandData[0] as unknown as { name?: string } | undefined;
const brandName = brand?.name ?? "your brand";
const adminEmail = await getAdminEmail(brandId);
await rpc("enqueue_notification", {
p_brand_id: brandId,
p_email_to: adminEmail ?? "team@cielohermosa.com",
p_subject: `Payment Failed — ${brandName}`,
p_body_html: `
<h2>Payment Failed</h2>
<p>We were unable to charge your card for the Cielo Hermosa platform subscription.</p>
<p>Please update your payment method within 7 days to avoid service interruption.</p>
<p><a href="${process.env.NEXT_PUBLIC_SITE_URL}/admin/settings/billing">Update Payment Method →</a></p>
`,
p_body_text: `Payment failed for ${brandName}. Please update your payment method at ${process.env.NEXT_PUBLIC_SITE_URL}/admin/settings/billing to avoid service interruption.`,
});
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function getPriceId(priceKey: string, billingCycle: "monthly" | "annual"): string | null {
if (billingCycle === "annual") {
return process.env[`STRIPE_PRICE_${priceKey.toUpperCase()}_ANNUAL`] ?? null;
}
return process.env[`STRIPE_PRICE_${priceKey.toUpperCase()}`] ?? null;
}
function getAddonKeyFromPriceKey(priceKey: string): AddonKey | null {
const map: Record<string, AddonKey> = {
harvest_reach: "harvest_reach",
wholesale_portal: "wholesale_portal",
water_log: "water_log",
ai_tools: "ai_tools",
square_sync: "square_sync",
sms_campaigns: "sms_campaigns",
};
return map[priceKey] ?? null;
}
async function getAdminEmail(brandId: string): Promise<string | null> {
try {
const data = await rpc<{ notification_email?: string; from_email?: string }>(
"get_wholesale_settings",
{ p_brand_id: brandId }
);
return data?.notification_email ?? data?.from_email ?? null;
} catch {
return null;
}
}
// ── Price ID resolution (for webhook) ────────────────────────────────────────
export function resolvePriceKeyFromPriceId(priceId: string): { type: "plan" | "addon"; key: string } | null {
const planMap: Record<string, string> = {
[process.env.STRIPE_PRICE_STARTER ?? ""]: "starter",
[process.env.STRIPE_PRICE_FARM ?? ""]: "farm",
[process.env.STRIPE_PRICE_ENTERPRISE ?? ""]: "enterprise",
};
if (planMap[priceId]) return { type: "plan", key: planMap[priceId] };
const addonMap: Record<string, AddonKey> = {
[process.env.STRIPE_PRICE_HARVEST_REACH ?? ""]: "harvest_reach",
[process.env.STRIPE_PRICE_WHOLESALE_PORTAL ?? ""]: "wholesale_portal",
[process.env.STRIPE_PRICE_WATER_LOG ?? ""]: "water_log",
[process.env.STRIPE_PRICE_AI_TOOLS ?? ""]: "ai_tools",
[process.env.STRIPE_PRICE_SQUARE_SYNC ?? ""]: "square_sync",
[process.env.STRIPE_PRICE_SMS_CAMPAIGNS ?? ""]: "sms_campaigns",
};
if (addonMap[priceId]) return { type: "addon", key: addonMap[priceId] };
return null;
}
+340
View File
@@ -0,0 +1,340 @@
import type { ContactImportEntry } from "@/actions/communications/contacts";
import { normalizePhone, normalizeEmail } from "./csv-parser";
// ── Column name variant lists ────────────────────────────────────────────────
const EMAIL_COLUMNS = new Set([
"email",
"emailaddress",
"email_address",
"e-mail",
"contactemail",
"contact email",
"customeremail",
"customer email",
]);
const PHONE_COLUMNS = new Set([
"phone",
"phonenumber",
"phone_number",
"mobilephone",
"mobile",
"cell",
"cellphone",
"smsphone",
"sms phone",
"customerphone",
"customer phone",
"telephone",
"tel",
]);
const FIRST_NAME_COLUMNS = new Set([
"firstname",
"first_name",
"fname",
"givenname",
"given name",
]);
const LAST_NAME_COLUMNS = new Set([
"lastname",
"last_name",
"lname",
"surname",
"familyname",
"family name",
]);
const FULL_NAME_COLUMNS = new Set([
"name",
"fullname",
"full_name",
"contactname",
"contact name",
"customername",
"customer name",
]);
const TAGS_COLUMNS = new Set([
"tags",
"tag",
"groups",
"segment",
"segments",
"audience",
"category",
"categories",
"label",
"labels",
]);
const EMAIL_OPT_IN_COLUMNS = new Set([
"emailoptin",
"email_opt_in",
"subscribed",
"email subscribed",
"subscriptionstatus",
"subscription status",
"marketingpermission",
"marketing permission",
"acceptsmarketing",
"accepts marketing",
"emailmarketingconsent",
"email marketing consent",
"emailconsent",
"marketing_email",
]);
const SMS_OPT_IN_COLUMNS = new Set([
"smsoptin",
"sms_opt_in",
"smssubscribed",
"sms subscribed",
"smsmarketing",
"sms marketing",
"smsconsent",
"sms_consent",
"smsmarketingconsent",
"mobileoptin",
"mobile_opt_in",
]);
const EXTERNAL_ID_COLUMNS = new Set([
"externalid",
"external_id",
"shopifyid",
"shopify_id",
"woo_id",
"quickbooksid",
"quickbooks_id",
"customer_id",
]);
// ── Normalization ────────────────────────────────────────────────────────────
function normalizeHeader(header: string): string {
return header
.toLowerCase()
.replace(/[_\-]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
export type ImportField =
| "email"
| "phone"
| "first_name"
| "last_name"
| "full_name"
| "tags"
| "email_opt_in"
| "sms_opt_in"
| "external_id"
| null;
export type ColumnMapping = {
csvColumn: string;
field: ImportField;
sampleValues: string[];
};
const FIELD_COLUMNS: Record<string, Set<string>> = {
email: EMAIL_COLUMNS,
phone: PHONE_COLUMNS,
first_name: FIRST_NAME_COLUMNS,
last_name: LAST_NAME_COLUMNS,
full_name: FULL_NAME_COLUMNS,
tags: TAGS_COLUMNS,
email_opt_in: EMAIL_OPT_IN_COLUMNS,
sms_opt_in: SMS_OPT_IN_COLUMNS,
external_id: EXTERNAL_ID_COLUMNS,
};
export function detectColumnMappings(
headers: string[],
rows: string[][]
): ColumnMapping[] {
const mappings: ColumnMapping[] = headers.map((csvColumn) => {
const normalized = normalizeHeader(csvColumn);
let field: ImportField = null;
let bestScore = 0;
for (const [f, variants] of Object.entries(FIELD_COLUMNS)) {
// Exact normalized match
if (variants.has(normalized)) {
const score = 1;
if (score > bestScore) {
bestScore = score;
field = f as ImportField;
}
}
}
return { csvColumn, field, sampleValues: [] };
});
// Collect sample values and handle disambiguation
for (const mapping of mappings) {
const colIdx = headers.indexOf(mapping.csvColumn);
if (colIdx === -1 || mapping.field === null) continue;
const samples: string[] = [];
for (const row of rows) {
const val = row[colIdx] ?? "";
if (val !== "" && samples.length < 3) {
samples.push(val);
}
}
mapping.sampleValues = samples;
}
// Disambiguation: if two columns mapped to same field, keep the one with more samples
const fieldCounts = new Map<string, ColumnMapping[]>();
for (const m of mappings) {
if (m.field !== null) {
if (!fieldCounts.has(m.field)) fieldCounts.set(m.field, []);
fieldCounts.get(m.field)!.push(m);
}
}
for (const [, ms] of fieldCounts) {
if (ms.length > 1) {
ms.sort((a, b) => b.sampleValues.length - a.sampleValues.length);
for (let i = 1; i < ms.length; i++) {
ms[i].field = null; // demote secondary matches
}
}
}
return mappings;
}
export function mapRowToContactEntry(
row: string[],
mappings: ColumnMapping[]
): {
entry: ContactImportEntry;
normalizedEmail: string | null;
normalizedPhone: string | null;
} {
const entry: ContactImportEntry = {};
let normalizedEmail: string | null = null;
let normalizedPhone: string | null = null;
for (const mapping of mappings) {
if (mapping.field === null) continue;
const colIdx = mappings.indexOf(mapping);
const raw = row[colIdx] ?? "";
switch (mapping.field) {
case "email": {
const cleaned = normalizeEmail(raw);
entry.email = cleaned || undefined;
normalizedEmail = cleaned || null;
break;
}
case "phone": {
const { normalized, original } = normalizePhone(raw);
entry.phone = original || undefined;
normalizedPhone = normalized || null;
break;
}
case "first_name":
entry.first_name = raw || undefined;
break;
case "last_name":
entry.last_name = raw || undefined;
break;
case "full_name":
entry.full_name = raw || undefined;
break;
case "tags":
entry.tags = raw
? raw.split(/[;:,]/).map((t) => t.trim()).filter(Boolean)
: undefined;
break;
case "email_opt_in":
entry.email_opt_in =
raw === "true" || raw === "1" || raw === "yes" || raw === "y" || raw === "on";
break;
case "sms_opt_in":
entry.sms_opt_in =
raw === "true" || raw === "1" || raw === "yes" || raw === "y" || raw === "on";
break;
case "external_id":
entry.external_id = raw || undefined;
break;
}
}
return { entry, normalizedEmail, normalizedPhone };
}
export type ImportPreviewResult = {
mappings: ColumnMapping[];
totalRows: number;
validRows: number;
skippedRows: number;
duplicateRows: number;
skippedReasons: { rowIndex: number; reason: string }[];
sampleRows: ContactImportEntry[];
ignoredColumns: string[];
warnings: string[];
};
export function buildImportPreview(
headers: string[],
rows: string[][]
): ImportPreviewResult {
const mappings = detectColumnMappings(headers, rows);
const ignoredColumns = mappings
.filter((m) => m.field === null)
.map((m) => m.csvColumn);
const entries: ContactImportEntry[] = [];
const skippedReasons: { rowIndex: number; reason: string }[] = [];
const seenEmails = new Set<string>();
const seenPhones = new Set<string>();
let duplicateRows = 0;
for (let i = 0; i < rows.length; i++) {
const { entry, normalizedEmail, normalizedPhone } = mapRowToContactEntry(rows[i], mappings);
const hasEmail = !!(normalizedEmail ?? entry.email);
const hasPhone = !!normalizedPhone;
if (!hasEmail && !hasPhone) {
skippedReasons.push({ rowIndex: i, reason: "No email or phone" });
continue;
}
// Dedupe within file
let isDuplicate = false;
if (normalizedEmail && seenEmails.has(normalizedEmail)) isDuplicate = true;
if (normalizedPhone && seenPhones.has(normalizedPhone)) isDuplicate = true;
if (isDuplicate) {
duplicateRows++;
skippedReasons.push({ rowIndex: i, reason: "Duplicate in file" });
continue;
}
if (normalizedEmail) seenEmails.add(normalizedEmail);
if (normalizedPhone) seenPhones.add(normalizedPhone);
entries.push(entry);
}
return {
mappings,
totalRows: rows.length,
validRows: entries.length,
skippedRows: rows.length - entries.length,
duplicateRows,
skippedReasons,
sampleRows: entries.slice(0, 5),
ignoredColumns,
warnings: [],
};
}
+181
View File
@@ -0,0 +1,181 @@
import Papa from "papaparse";
export type ParsedCSV = {
headers: string[];
rows: string[][];
};
const MAX_ROWS_WARN = 10_000;
const MAX_ROWS_HARD = 50_000;
function stripBOM(s: string): string {
return s.replace(/^/, "");
}
function normalizePhone(phone: string): { normalized: string; original: string } {
const trimmed = phone.trim();
// Strip common formatting chars but keep digits and leading +
const stripped = trimmed.replace(/[\s\-().[\]]/g, "");
// If we ended up with mostly digits (with optional leading +), keep it
if (/^\+?\d{7,15}$/.test(stripped)) {
return { normalized: stripped, original: trimmed };
}
// Uncertain — preserve original, normalized = trimmed
return { normalized: trimmed, original: trimmed };
}
function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
function trimField(s: string): string {
return s.trim();
}
export type ParseResult = {
csv: ParsedCSV;
totalRows: number;
skippedRows: number;
warnings: string[];
};
export function parseCSVWithLimits(
text: string
): ParseResult {
const clean = stripBOM(text);
const warnings: string[] = [];
const result = Papa.parse<string[]>(clean, {
skipEmptyLines: true,
delimitersToGuess: [",", ";", "\t", "|"],
});
const allRows = result.data;
if (allRows.length === 0) {
return { csv: { headers: [], rows: [] }, totalRows: 0, skippedRows: 0, warnings: ["Empty file"] };
}
const headers = (allRows[0] ?? []).map((h) => h.trim());
const dataRows = allRows.slice(1);
const totalRows = dataRows.length;
const skippedRows = 0;
if (totalRows > MAX_ROWS_HARD) {
return {
csv: { headers: [], rows: [] },
totalRows: 0,
skippedRows: 0,
warnings: [`File has ${totalRows.toLocaleString()} rows. Maximum supported is ${MAX_ROWS_HARD.toLocaleString()}.`],
};
}
if (totalRows > MAX_ROWS_WARN) {
warnings.push(
`Large file detected: ${totalRows.toLocaleString()} rows. Processing may be slow.`
);
}
const rows: string[][] = dataRows.map((rawFields) =>
rawFields.map((f) => trimField(f))
);
return { csv: { headers, rows }, totalRows, skippedRows, warnings };
}
/**
* Parse a single CSV field, advancing the index pointer.
* Handles quoted fields (commas inside quotes are content, "" → ")
*/
function parseField(s: string, start: number): { value: string; end: number } {
let i = start;
if (s[i] !== '"') {
// Unquoted field — read until comma or end
let value = "";
while (i < s.length && s[i] !== ",") {
value += s[i];
i++;
}
return { value: trimField(value), end: i };
}
// Quoted field
i++; // skip opening quote
let value = "";
while (i < s.length) {
if (s[i] === '"') {
if (i + 1 < s.length && s[i + 1] === '"') {
// Escaped quote — add one quote and skip both
value += '"';
i += 2;
} else if (i + 1 < s.length && s[i + 1] === ",") {
// Closing quote followed by comma — end of field
i += 2; // skip quote and comma
return { value, end: i };
} else if (i + 1 === s.length) {
// Closing quote at end of string
i++;
return { value, end: i };
} else {
// Unexpected quote — treat as literal
value += s[i];
i++;
}
} else {
value += s[i];
i++;
}
}
return { value, end: i };
}
/**
* Parse one CSV row (line), returning an array of field values.
* The row may or may not end with a trailing comma.
*/
function parseRow(line: string): string[] {
if (line.length === 0) return [];
const fields: string[] = [];
let i = 0;
while (i < line.length) {
const { value, end } = parseField(line, i);
fields.push(value);
i = end;
// Stop if we've consumed the whole string, or if the next char is a newline
if (i >= line.length) break;
// If we're not at a comma, we've likely finished parsing the last field
if (line[i] !== ",") break;
i++; // skip comma and move to next field
}
return fields;
}
/**
* Parse CSV using the built-in RFC 4180 parser (used directly without PapaParse for direct parsing).
* For use when PapaParse is not available.
*/
export function parseCSVDirect(text: string): ParsedCSV {
const clean = text.replace(/\r\n?/g, "\n").replace(/^/, "").trim();
const lines = clean.split("\n");
if (lines.length === 0) return { headers: [], rows: [] };
if (lines.length === 1) return { headers: parseRow(lines[0]), rows: [] };
const headers = parseRow(lines[0]);
const rows: string[][] = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (line.trim() === "") continue; // skip blank lines
rows.push(parseRow(line));
}
return { headers, rows };
}
export { normalizePhone, normalizeEmail };
+275
View File
@@ -0,0 +1,275 @@
// Pure CSV parsing utilities — no server-side logic
export type ParsedProductRow = {
name: string;
description: string;
price: number;
type: string;
active: boolean;
image_url?: string;
_rowIndex: number;
_warnings: string[];
};
export function parseProductCSV(csvText: string): {
success: true;
rows: ParsedProductRow[];
totalRows: number;
errors: { row: number; error: string }[];
} | {
success: false;
error: string;
} {
const lines = csvText.trim().split(/\r?\n/);
if (lines.length < 2) {
return { success: false, error: "CSV must have a header row and at least one data row" };
}
const header = lines[0].split(",").map((h) => h.trim().toLowerCase());
const required = ["name", "price", "type"];
for (const col of required) {
if (!header.includes(col)) {
return { success: false, error: `Missing required column: ${col}` };
}
}
const nameIdx = header.indexOf("name");
const descIdx = header.indexOf("description");
const priceIdx = header.indexOf("price");
const typeIdx = header.indexOf("type");
const activeIdx = header.indexOf("active");
const imgIdx = header.indexOf("image_url");
const rows: ParsedProductRow[] = [];
const errors: { row: number; error: string }[] = [];
for (let i = 1; i < lines.length; i++) {
const cols = lines[i].split(",").map((c) => c.trim());
const name = cols[nameIdx] ?? "";
if (!name) {
errors.push({ row: i + 1, error: "Name is required" });
continue;
}
const priceStr = cols[priceIdx] ?? "";
const price = parseFloat(priceStr);
if (isNaN(price) || price < 0) {
errors.push({ row: i + 1, error: `Invalid price: ${priceStr}` });
continue;
}
const type = cols[typeIdx] ?? "";
if (!["Pickup", "Shipping", "Pickup & Shipping"].includes(type)) {
errors.push({ row: i + 1, error: `Invalid type: ${type}` });
continue;
}
const active = activeIdx >= 0
? (cols[activeIdx]?.toLowerCase() !== "false")
: true;
const warnings: string[] = [];
if (descIdx >= 0 && !cols[descIdx]) warnings.push("description was empty");
if (imgIdx >= 0 && !cols[imgIdx]) warnings.push("image_url was empty");
rows.push({
name,
description: descIdx >= 0 ? cols[descIdx] ?? "" : "",
price,
type,
active,
image_url: imgIdx >= 0 ? cols[imgIdx] : undefined,
_rowIndex: i + 1,
_warnings: warnings,
});
}
return { success: true, rows, totalRows: lines.length - 1, errors };
}
// ── Stop CSV parser ─────────────────────────────────────────────────────────
export type ParsedStopRow = {
city: string;
state: string;
location: string;
date: string;
time: string;
address?: string;
zip?: string;
notes?: string;
_rowIndex: number;
_warnings: string[];
};
/**
* Flexible CSV parser for stop schedules.
* Auto-detects common column names: city/town, state, location/address,
* date, time, zip, notes.
*/
export function parseStopCSV(csvText: string): {
success: true;
rows: ParsedStopRow[];
totalRows: number;
errors: { row: number; error: string }[];
} | {
success: false;
error: string;
} {
const lines = csvText.trim().split(/\r?\n/);
if (lines.length < 2) {
return { success: false, error: "CSV must have a header row and at least one data row" };
}
// Build column index map — common synonyms supported
const rawHeader = lines[0].split(",").map((h) => h.trim());
const header = rawHeader.map((h) => h.toLowerCase());
function idx(...names: string[]): number {
for (const n of names) {
const i = header.indexOf(n);
if (i >= 0) return i;
}
return -1;
}
const cityIdx = idx("city", "town", "location");
const stateIdx = idx("state", "st");
const locIdx = idx("location", "place", "stop name", "stop", "name");
const dateIdx = idx("date", "day", "pickup date", "pickupdate");
const timeIdx = idx("time", "hours", "pickup time", "pickuptime", "time slot");
const addrIdx = idx("address", "street", "street address", "addr");
const zipIdx = idx("zip", "zipcode", "zip code", "postal");
const notesIdx = idx("notes", "note", "instructions", "driver notes");
const missing = [cityIdx, stateIdx, locIdx, dateIdx, timeIdx].filter((i) => i < 0);
if (missing.length > 0) {
return {
success: false,
error: "CSV is missing required columns. Need: city, state, location, date, time. Found: " + rawHeader.join(", "),
};
}
const rows: ParsedStopRow[] = [];
const errors: { row: number; error: string }[] = [];
for (let i = 1; i < lines.length; i++) {
const raw = lines[i].split(",").map((c) => c.trim());
const city = raw[cityIdx] ?? "";
const state = raw[stateIdx] ?? "";
const location = raw[locIdx] ?? "";
const date = raw[dateIdx] ?? "";
const time = raw[timeIdx] ?? "";
const address = addrIdx >= 0 ? raw[addrIdx] : undefined;
const zip = zipIdx >= 0 ? raw[zipIdx] : undefined;
const notes = notesIdx >= 0 ? raw[notesIdx] : undefined;
const warnings: string[] = [];
if (!city) warnings.push("city is empty");
if (!state) warnings.push("state is empty");
if (!location) warnings.push("location is empty");
if (!date) warnings.push("date is empty");
if (!time) warnings.push("time is empty");
rows.push({
city,
state,
location,
date,
time,
address,
zip,
notes,
_rowIndex: i + 1,
_warnings: warnings,
});
}
return { success: true, rows, totalRows: lines.length - 1, errors };
}
export type ParsedOrderRow = {
customer_name: string;
customer_email: string;
customer_phone: string;
stop_id: string;
items: Array<{ product_id: string; quantity: number; fulfillment: string }>;
_rowIndex: number;
_warnings: string[];
};
export function parseOrderCSV(csvText: string): {
success: true;
rows: ParsedOrderRow[];
totalRows: number;
errors: { row: number; error: string }[];
} | {
success: false;
error: string;
} {
const lines = csvText.trim().split(/\r?\n/);
if (lines.length < 2) {
return { success: false, error: "CSV must have a header row and at least one data row" };
}
const header = lines[0].split(",").map((h) => h.trim().toLowerCase());
const required = ["customer_name", "customer_email", "stop_id", "product_id", "quantity", "fulfillment"];
for (const col of required) {
if (!header.includes(col)) {
return { success: false, error: `Missing required column: ${col}` };
}
}
const nameIdx = header.indexOf("customer_name");
const emailIdx = header.indexOf("customer_email");
const phoneIdx = header.indexOf("customer_phone");
const stopIdx = header.indexOf("stop_id");
const prodIdx = header.indexOf("product_id");
const qtyIdx = header.indexOf("quantity");
const fulfillIdx = header.indexOf("fulfillment");
const rows: ParsedOrderRow[] = [];
const errors: { row: number; error: string }[] = [];
for (let i = 1; i < lines.length; i++) {
const cols = lines[i].split(",").map((c) => c.trim());
const customer_name = cols[nameIdx] ?? "";
const customer_email = cols[emailIdx] ?? "";
const stop_id = cols[stopIdx] ?? "";
const product_id = cols[prodIdx] ?? "";
const quantity = parseInt(cols[qtyIdx] ?? "0", 10);
const fulfillment = cols[fulfillIdx] ?? "pickup";
if (!customer_name) {
errors.push({ row: i + 1, error: "Customer name is required" });
continue;
}
if (!stop_id) {
errors.push({ row: i + 1, error: "Stop ID is required" });
continue;
}
if (!product_id) {
errors.push({ row: i + 1, error: "Product ID is required" });
continue;
}
if (isNaN(quantity) || quantity < 1) {
errors.push({ row: i + 1, error: `Invalid quantity: ${cols[qtyIdx]}` });
continue;
}
rows.push({
customer_name,
customer_email,
customer_phone: phoneIdx >= 0 ? cols[phoneIdx] ?? "" : "",
stop_id,
items: [{ product_id, quantity, fulfillment }],
_rowIndex: i + 1,
_warnings: [],
});
}
return { success: true, rows, totalRows: lines.length - 1, errors };
}
+63
View File
@@ -0,0 +1,63 @@
// Data service that falls back to mock data when Supabase is unavailable
// Set NEXT_PUBLIC_USE_MOCK_DATA=true in .env.local to enable
import { mockProducts, mockStops, mockOrders, mockWorkers, mockTasks, mockCustomers, mockBrandSettings, mockBrands } from "./mock-data";
const useMockData = () => process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true" || !process.env.NEXT_PUBLIC_SUPABASE_URL?.includes("supabase.co");
export async function getProducts(brandId?: string | null) {
if (useMockData()) {
return brandId ? mockProducts.filter(p => p.brand_id === brandId) : mockProducts;
}
// Real Supabase implementation would go here
return [];
}
export async function getStops(brandId?: string | null) {
if (useMockData()) {
return brandId ? mockStops.filter(s => s.brand_id === brandId) : mockStops;
}
return [];
}
export async function getOrders(brandId?: string | null) {
if (useMockData()) {
return brandId ? mockOrders.filter(o => o.stops?.brand_id === brandId || !brandId) : mockOrders;
}
return [];
}
export async function getWorkers(brandId?: string | null) {
if (useMockData()) {
return mockWorkers;
}
return [];
}
export async function getTasks(brandId?: string | null) {
if (useMockData()) {
return mockTasks;
}
return [];
}
export async function getCustomers(brandId?: string | null) {
if (useMockData()) {
return mockCustomers;
}
return [];
}
export async function getBrandSettings(brandId?: string) {
if (useMockData()) {
return { ...mockBrandSettings, brand_id: brandId ?? "brand-tuxedo" };
}
return null;
}
export async function getBrands() {
if (useMockData()) {
return mockBrands;
}
return [];
}
+90
View File
@@ -0,0 +1,90 @@
// Centralized date formatting utilities for Route Commerce
// All display dates in the admin use these helpers — no raw toLocaleString inline
/**
* Format an ISO date string as "Jan 5, 2025"
*/
export function formatDate(iso: string | null | undefined): string {
if (!iso) return "—";
const d = new Date(iso);
if (isNaN(d.getTime())) return "—";
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
/**
* Format an ISO datetime string as "Jan 5, 2025 at 2:30 PM"
*/
export function formatDateTime(iso: string | null | undefined): string {
if (!iso) return "—";
const d = new Date(iso);
if (isNaN(d.getTime())) return "—";
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) +
" at " +
d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
}
/**
* Format an ISO date string as YYYY-MM-DD (for form inputs, export filenames)
*/
export function formatDateISO(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
return d.toISOString().slice(0, 10);
}
/**
* Format as YYYY-MM-DDTHH:MM for datetime-local input min values
*/
export function formatDateTimeLocal(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
return d.toISOString().slice(0, 16);
}
/**
* Relative time: "5m ago", "2h ago", "3d ago"
*/
export function timeAgo(iso: string | null | undefined): string {
if (!iso) return "Never";
const diff = Date.now() - new Date(iso).getTime();
if (diff < 0) return "Just now";
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return "Just now";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
const months = Math.floor(days / 30);
if (months < 12) return `${months}mo ago`;
return `${Math.floor(months / 12)}y ago`;
}
/**
* Month name from 0-based index (for selector options)
*/
export function getMonthName(monthIndex: number): string {
return new Date(0, monthIndex).toLocaleString("en-US", { month: "long" });
}
/**
* Short month name: "Jan", "Feb"
*/
export function getMonthNameShort(monthIndex: number): string {
return new Date(0, monthIndex).toLocaleString("en-US", { month: "short" });
}
/**
* Format a date for display in a label/range: "Jan 5 — Jan 12, 2025"
*/
export function formatDateRange(startISO: string, endISO: string): string {
const start = new Date(startISO);
const end = new Date(endISO);
if (isNaN(start.getTime()) || isNaN(end.getTime())) return `${startISO}${endISO}`;
const startStr = start.toLocaleDateString("en-US", { month: "short", day: "numeric" });
const endStr = end.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
return `${startStr}${endStr}`;
}
+476
View File
@@ -0,0 +1,476 @@
/**
* Resend Email Service — Route Commerce / Tuxedo Corn
*
* Branded transactional email templates for Tuxedo Corn storefront.
* All emails use the Olathe Sweet + Tuxedo Corn brand identity.
*
* Env vars required:
* RESEND_API_KEY — Resend API key
* FROM_EMAIL — e.g. "Tuxedo Corn <no-reply@tuxedocorn.com>"
*/
const FROM_EMAIL = process.env.FROM_EMAIL ?? "Tuxedo Corn <no-reply@tuxedocorn.com>";
const RESEND_API_KEY = process.env.RESEND_API_KEY ?? "";
// ─────────────────────────────────────────────────────────────────────────────
// Shared email send function
// ─────────────────────────────────────────────────────────────────────────────
async function sendEmail(opts: {
to: string;
subject: string;
html: string;
text?: string;
from?: string;
}): Promise<boolean> {
if (!RESEND_API_KEY) {
return false;
}
try {
const res = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${RESEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: opts.from ?? FROM_EMAIL,
to: opts.to,
subject: opts.subject,
html: opts.html,
text: opts.text ?? opts.subject,
}),
});
if (!res.ok) {
return false;
}
return res.ok;
} catch (e) {
return false;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Order Receipt — sent to customer after successful checkout
// ─────────────────────────────────────────────────────────────────────────────
export type OrderReceiptData = {
customerName: string;
customerEmail: string;
orderId: string;
items: Array<{
name: string;
quantity: number;
price: number;
}>;
subtotal: number;
taxAmount: number;
total: number;
fulfillment: "pickup" | "ship";
stopCity?: string;
stopState?: string;
stopDate?: string;
stopTime?: string;
stopLocation?: string;
brandName?: string;
};
export async function sendOrderReceiptEmail(data: OrderReceiptData): Promise<boolean> {
const pickupInfo = data.fulfillment === "pickup" && data.stopDate;
const itemRows = data.items
.map(
(item) => `
<tr>
<td style="padding: 10px 0; border-bottom: 1px solid #e7e5e4; color: #1c1917; font-size: 14px;">
${item.name} <span style="color: #78716c;">× ${item.quantity}</span>
</td>
<td style="padding: 10px 0; border-bottom: 1px solid #e7e5e4; text-align: right; color: #1c1917; font-size: 14px; font-weight: 600;">
$${(item.price * item.quantity).toFixed(2)}
</td>
</tr>`
)
.join("");
const pickupBlock = pickupInfo
? `
<div style="background: #fafaf9; border: 1px solid #e7e5e4; border-radius: 12px; padding: 20px 24px; margin: 24px 0;">
<p style="margin: 0 0 12px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; color: #78716c;">Pickup Details</p>
<p style="margin: 0; font-size: 15px; font-weight: 700; color: #1c1917;">${data.stopCity}, ${data.stopState}</p>
<p style="margin: 4px 0 0; font-size: 14px; color: #57534e;">${data.stopDate}${data.stopTime ? ` · ${data.stopTime}` : ""}</p>
${data.stopLocation ? `<p style="margin: 4px 0 0; font-size: 13px; color: #78716c;">${data.stopLocation}</p>` : ""}
</div>`
: `<div style="background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 12px; padding: 20px 24px; margin: 24px 0;">
<p style="margin: 0; font-size: 14px; font-weight: 600; color: #166534;">Shipping to your door — cooler boxes ship after the season ends.</p>
</div>`;
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Order Confirmed — Tuxedo Corn</title>
</head>
<body style="margin: 0; padding: 0; background: #fafaf9; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1c1917;">
<div style="max-width: 600px; margin: 0 auto; padding: 32px 16px;">
<!-- Header -->
<div style="background: #1c1917; border-radius: 16px; padding: 32px; text-align: center; margin-bottom: 24px;">
<img src="https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png"
alt="Olathe Sweet" style="height: 40px; width: auto; object-fit: contain; margin-bottom: 16px; filter: brightness(0) invert(1); opacity: 0.9;" />
<h1 style="margin: 0; font-size: 28px; font-weight: 800; color: #ffffff; letter-spacing: -0.02em;">Order Confirmed</h1>
<p style="margin: 8px 0 0; font-size: 14px; color: rgba(255,255,255,0.6);">Order #${data.orderId.slice(0, 8).toUpperCase()}</p>
</div>
<!-- Body -->
<div style="background: #ffffff; border-radius: 16px; padding: 32px; border: 1px solid #e7e5e4;">
<p style="margin: 0 0 4px; font-size: 16px; font-weight: 600; color: #1c1917;">Hi ${data.customerName.split(" ")[0]},</p>
<p style="margin: 0 0 24px; font-size: 14px; color: #57534e; line-height: 1.6;">
Great news — your order is confirmed and we're getting it ready.
${pickupInfo ? "Your corn will be hand-picked fresh the morning of your stop." : "We'll ship your cooler boxes after the season ends."}
</p>
<!-- Order summary table -->
<table style="width: 100%; border-collapse: collapse; margin-bottom: 8px;">
<thead>
<tr>
<th style="text-align: left; padding: 8px 0; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #78716c; border-bottom: 2px solid #e7e5e4;">Item</th>
<th style="text-align: right; padding: 8px 0; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #78716c; border-bottom: 2px solid #e7e5e4;">Total</th>
</tr>
</thead>
<tbody>
${itemRows}
</tbody>
</table>
<!-- Totals -->
<div style="border-top: 2px solid #e7e5e4; padding-top: 16px; margin-top: 8px;">
<div style="display: flex; justify-content: space-between; margin: 4px 0;">
<span style="font-size: 14px; color: #57534e;">Subtotal</span>
<span style="font-size: 14px; font-weight: 600; color: #1c1917;">$${data.subtotal.toFixed(2)}</span>
</div>
${data.taxAmount > 0 ? `
<div style="display: flex; justify-content: space-between; margin: 4px 0;">
<span style="font-size: 14px; color: #57534e;">Tax</span>
<span style="font-size: 14px; font-weight: 600; color: #1c1917;">$${data.taxAmount.toFixed(2)}</span>
</div>` : ""}
<div style="display: flex; justify-content: space-between; margin: 8px 0 0; padding-top: 12px; border-top: 1px solid #e7e5e4;">
<span style="font-size: 16px; font-weight: 800; color: #1c1917;">Total Paid</span>
<span style="font-size: 16px; font-weight: 800; color: #1c1917;">$${data.total.toFixed(2)}</span>
</div>
</div>
<!-- Pickup / Shipping details -->
${pickupBlock}
<!-- Olathe Sweet callout -->
<div style="background: #fafaf9; border-radius: 10px; padding: 16px 20px; margin-top: 24px; display: flex; align-items: center; gap: 12px;">
<img src="https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png"
alt="Olathe Sweet" style="height: 28px; width: auto; object-fit: contain; opacity: 0.7; flex-shrink: 0;" />
<p style="margin: 0; font-size: 12px; color: #78716c; line-height: 1.5;">
Grown in Olathe, Colorado — Non-GMO · Hand-Picked · Farm-Direct
</p>
</div>
</div>
<!-- Footer -->
<div style="text-align: center; margin-top: 24px;">
<p style="margin: 0; font-size: 12px; color: #a8a29e;">
Questions? Reply to this email or contact us at <a href="mailto:hello@tuxedocorn.com" style="color: #78716c;">hello@tuxedocorn.com</a>
</p>
<p style="margin: 8px 0 0; font-size: 11px; color: #d6d3d1;">
© ${new Date().getFullYear()} Tuxedo Corn · 59751 David Road, Olathe, CO 81425
</p>
</div>
</div>
</body>
</html>`;
const text = `Order Confirmed — Tuxedo Corn (Order #${data.orderId.slice(0, 8).toUpperCase()})
Hi ${data.customerName.split(" ")[0]},
Your order is confirmed.${pickupInfo
? `\n\nPickup: ${data.stopCity}, ${data.stopState}\nDate: ${data.stopDate}${data.stopTime ? ` at ${data.stopTime}` : ""}\nLocation: ${data.stopLocation ?? ""}`
: "\n\nShipping: Cooler boxes ship after the season ends."}
Items:
${data.items.map((i) => ` ${i.name} × ${i.quantity}$${(i.price * i.quantity).toFixed(2)}`).join("\n")}
Subtotal: $${data.subtotal.toFixed(2)}${data.taxAmount > 0 ? `\nTax: $${data.taxAmount.toFixed(2)}` : ""}
Total: $${data.total.toFixed(2)}
Grown in Olathe, Colorado — Non-GMO · Hand-Picked · Farm-Direct
Questions? Reply to this email or contact hello@tuxedocorn.com`;
return sendEmail({
to: data.customerEmail,
subject: `Your Order is Confirmed — Tuxedo Corn #${data.orderId.slice(0, 8).toUpperCase()}`,
html,
text,
});
}
// ─────────────────────────────────────────────────────────────────────────────
// Welcome Email — sent to new admin or wholesale users
// ─────────────────────────────────────────────────────────────────────────────
export type WelcomeEmailData = {
to: string;
name: string;
role: "brand_admin" | "wholesale_buyer" | "store_employee";
brandName?: string;
tempPassword?: string;
setupUrl?: string;
};
export async function sendWelcomeEmail(data: WelcomeEmailData): Promise<boolean> {
const roleLabel =
data.role === "brand_admin" ? "Admin"
: data.role === "wholesale_buyer" ? "Wholesale Buyer"
: "Store Employee";
const brandBlock = data.brandName
? `<p style="margin: 4px 0 0; font-size: 14px; color: #57534e;">Brand: <strong>${data.brandName}</strong></p>`
: "";
const nextSteps = data.role === "brand_admin" ? `
<div style="background: #fafaf9; border-radius: 12px; padding: 20px 24px; margin: 24px 0;">
<p style="margin: 0 0 12px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; color: #78716c;">Your first steps</p>
<ol style="margin: 0; padding: 0 0 0 20px; font-size: 14px; color: #57534e; line-height: 2;">
<li>Visit <strong>/admin/settings/brand</strong> to add your company info and logos</li>
<li>Add your first product under <strong>Products</strong></li>
<li>Create a stop under <strong>Tours & Stops</strong></li>
<li>Configure payments under <strong>Settings → Payments</strong></li>
</ol>
</div>`
: data.role === "wholesale_buyer" ? `
<div style="background: #fafaf9; border-radius: 12px; padding: 20px 24px; margin: 24px 0;">
<p style="margin: 0 0 12px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; color: #78716c;">What you can do</p>
<ul style="margin: 0; padding: 0 0 0 20px; font-size: 14px; color: #57534e; line-height: 2;">
<li>Browse and order products at your assigned wholesale pricing</li>
<li>Track your order history and upcoming deliveries</li>
<li>Manage billing with net-30 terms (if approved)</li>
</ul>
</div>`
: "";
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Welcome to Tuxedo Corn</title>
</head>
<body style="margin: 0; padding: 0; background: #fafaf9; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1c1917;">
<div style="max-width: 600px; margin: 0 auto; padding: 32px 16px;">
<!-- Header -->
<div style="background: #1c1917; border-radius: 16px; padding: 32px; text-align: center; margin-bottom: 24px;">
<img src="https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/logo.png"
alt="Tuxedo Corn" style="height: 36px; width: auto; object-fit: contain; margin-bottom: 16px;" />
<h1 style="margin: 0; font-size: 26px; font-weight: 800; color: #ffffff; letter-spacing: -0.02em;">Welcome, ${data.name.split(" ")[0]}</h1>
<p style="margin: 8px 0 0; font-size: 14px; color: rgba(255,255,255,0.6);">Your ${roleLabel} account is ready</p>
${brandBlock}
</div>
<!-- Body -->
<div style="background: #ffffff; border-radius: 16px; padding: 32px; border: 1px solid #e7e5e4;">
<p style="margin: 0 0 16px; font-size: 14px; color: #57534e; line-height: 1.7;">
Your account has been created on the Route Commerce platform and is now active for <strong>${data.brandName ?? "this brand"}</strong>.
</p>
${nextSteps}
<div style="margin-top: 24px; padding: 16px 20px; background: #f0fdf4; border-radius: 10px;">
<p style="margin: 0; font-size: 13px; color: #166534; line-height: 1.6;">
<strong>Need help?</strong> Reply to this email or reach us at <a href="mailto:hello@tuxedocorn.com" style="color: #15803d;">hello@tuxedocorn.com</a> — we respond same business day.
</p>
</div>
</div>
<!-- Footer -->
<div style="text-align: center; margin-top: 24px;">
<p style="margin: 0; font-size: 12px; color: #a8a29e;">
© ${new Date().getFullYear()} Tuxedo Corn · Route Commerce Platform
</p>
</div>
</div>
</body>
</html>`;
const text = `Welcome to Tuxedo Corn, ${data.name.split(" ")[0]}
Your ${roleLabel} account is now active${data.brandName ? ` for ${data.brandName}` : ""}.
${data.role === "brand_admin" ? `
First steps:
1. Visit /admin/settings/brand — add company info and logos
2. Add your first product
3. Create a stop under Tours & Stops
4. Configure payments under Settings → Payments
` : data.role === "wholesale_buyer" ? `
You can now:
- Browse and order products at your wholesale pricing
- Track your order history and upcoming deliveries
- Manage billing with net-30 terms (if approved)
` : ""}
Need help? Reply to this email or contact hello@tuxedocorn.com
`;
return sendEmail({
to: data.to,
subject: `Welcome to Tuxedo Corn — ${roleLabel} Account Ready`,
html,
text,
});
}
// ─────────────────────────────────────────────────────────────────────────────
// Password Reset Email — triggered by Supabase auth (custom template via Resend)
// Note: Supabase handles the reset flow by default; this can be used as a
// custom template if you configure Resend as the SMTP provider in Supabase.
// ─────────────────────────────────────────────────────────────────────────────
export type PasswordResetData = {
to: string;
name: string;
resetUrl: string;
};
export async function sendPasswordResetEmail(data: PasswordResetData): Promise<boolean> {
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reset Your Password</title>
</head>
<body style="margin: 0; padding: 0; background: #fafaf9; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1c1917;">
<div style="max-width: 600px; margin: 0 auto; padding: 32px 16px;">
<div style="background: #1c1917; border-radius: 16px; padding: 32px; text-align: center; margin-bottom: 24px;">
<img src="https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/logo.png"
alt="Tuxedo Corn" style="height: 32px; width: auto; object-fit: contain; margin-bottom: 16px;" />
<h1 style="margin: 0; font-size: 24px; font-weight: 800; color: #ffffff;">Reset Your Password</h1>
</div>
<div style="background: #ffffff; border-radius: 16px; padding: 32px; border: 1px solid #e7e5e4;">
<p style="margin: 0 0 16px; font-size: 14px; color: #57534e; line-height: 1.7;">
Hi <strong>${data.name.split(" ")[0]}</strong>, we received a request to reset your password.
Click the button below to set a new password. This link expires in 1 hour.
</p>
<div style="text-align: center; margin: 28px 0;">
<a href="${data.resetUrl}"
style="display: inline-block; background: #16a34a; color: #ffffff; text-decoration: none; font-weight: 700; font-size: 15px; padding: 14px 32px; border-radius: 10px;">
Reset Password
</a>
</div>
<p style="margin: 0; font-size: 13px; color: #a8a29e; text-align: center; line-height: 1.6;">
If you didn't request this, you can safely ignore this email.<br />
This link expires in 1 hour and can only be used once.
</p>
</div>
<div style="text-align: center; margin-top: 24px;">
<p style="margin: 0; font-size: 12px; color: #a8a29e;">
© ${new Date().getFullYear()} Tuxedo Corn · Route Commerce Platform
</p>
</div>
</div>
</body>
</html>`;
const text = `Reset Your Password — Tuxedo Corn
Hi ${data.name.split(" ")[0]},
We received a request to reset your password. Click the link below to set a new password:
${data.resetUrl}
This link expires in 1 hour and can only be used once.
If you didn't request this, you can safely ignore this email.`;
return sendEmail({
to: data.to,
subject: "Reset Your Password — Tuxedo Corn",
html,
text,
});
}
// ─────────────────────────────────────────────────────────────────────────────
// Operational Alert — future-proof alert for low stock, system events, etc.
// ─────────────────────────────────────────────────────────────────────────────
export type OperationalAlertData = {
to: string;
severity: "info" | "warning" | "critical";
title: string;
message: string;
brandName?: string;
actionUrl?: string;
actionLabel?: string;
};
export async function sendOperationalAlert(data: OperationalAlertData): Promise<boolean> {
const severityConfig = {
info: { bg: "#1e3a5f", label: "Notice" },
warning: { bg: "#b45309", label: "Warning" },
critical: { bg: "#991b1b", label: "Critical" },
};
const cfg = severityConfig[data.severity] ?? severityConfig.info;
const brandBadge = data.brandName
? `<span style="display: inline-block; background: rgba(255,255,255,0.15); padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 600; margin-top: 8px;">${data.brandName}</span>`
: "";
const actionBlock = (data.actionUrl && data.actionLabel)
? `<div style="text-align: center; margin: 28px 0 0;">
<a href="${data.actionUrl}"
style="display: inline-block; background: #ffffff; color: ${cfg.bg}; text-decoration: none; font-weight: 700; font-size: 14px; padding: 12px 28px; border-radius: 8px;">
${data.actionLabel}
</a>
</div>`
: "";
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${data.title}</title>
</head>
<body style="margin: 0; padding: 0; background: #fafaf9; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1c1917;">
<div style="max-width: 600px; margin: 0 auto; padding: 32px 16px;">
<div style="background: ${cfg.bg}; border-radius: 16px; padding: 32px; text-align: center; margin-bottom: 24px;">
<p style="margin: 0 0 4px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.15em; color: rgba(255,255,255,0.7);">${cfg.label}</p>
<h1 style="margin: 0; font-size: 22px; font-weight: 800; color: #ffffff; letter-spacing: -0.02em;">${data.title}</h1>
${brandBadge}
</div>
<div style="background: #ffffff; border-radius: 16px; padding: 32px; border: 1px solid #e7e5e4;">
<p style="margin: 0; font-size: 15px; color: #57534e; line-height: 1.7;">${data.message}</p>
${actionBlock}
</div>
<div style="text-align: center; margin-top: 24px;">
<p style="margin: 0; font-size: 12px; color: #a8a29e;">
Route Commerce Platform · Powered by Tuxedo Corn
</p>
</div>
</div>
</body>
</html>`;
const text = `[${cfg.label.toUpperCase()}] ${data.title}
${data.message}
${data.actionUrl ? `${data.actionLabel}: ${data.actionUrl}` : ""}`;
return sendEmail({
to: data.to,
subject: `[${cfg.label}] ${data.title}`,
html,
text,
});
}
+369
View File
@@ -0,0 +1,369 @@
// Professional pre-built email template library
// Variables: {{first_name}}, {{company_name}}, {{stop_name}},
// {{pickup_date}}, {{order_total}}, {{balance_due}}, {{due_date}},
// {{item_summary}}, {{custom_message}}
export type EmailTemplate = {
id: string;
name: string;
description: string;
subject: string;
body_text: string;
body_html: string;
};
export const BUILT_IN_TEMPLATES: EmailTemplate[] = [
{
id: "order_confirmation",
name: "Order Confirmation",
description: "Professional order confirmation with item summary and pickup details",
subject: "Your Order is Confirmed {{company_name}}",
body_text: `Dear {{first_name}},
Your order has been confirmed and is being prepared for pickup.
{{item_summary}}
Pickup Date: {{pickup_date}}
Location: {{stop_name}}
Thank you for choosing {{company_name}}. We'll see you soon!
The {{company_name}} Team`,
body_html: `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f8fafc; margin: 0; padding: 20px; }
.container { max-width: 600px; margin: 0 auto; background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.header { background: #1e3a5f; color: #fff; padding: 28px 32px; }
.header h1 { margin: 0; font-size: 22px; font-weight: 700; }
.header p { margin: 6px 0 0; opacity: 0.85; font-size: 14px; }
.body { padding: 28px 32px; color: #334155; font-size: 15px; line-height: 1.6; }
.greeting { font-size: 16px; font-weight: 600; margin-bottom: 16px; }
.info-grid { background: #f1f5f9; border-radius: 10px; padding: 16px 20px; margin: 20px 0; }
.info-row { display: flex; justify-content: space-between; padding: 4px 0; font-size: 14px; }
.info-label { color: #64748b; }
.info-value { font-weight: 600; text-align: right; }
.items { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 12px 16px; margin: 16px 0; font-size: 14px; white-space: pre-wrap; }
.message { background: #fff7ed; border-left: 4px solid #f97316; padding: 12px 16px; border-radius: 0 8px 8px 0; margin: 16px 0; font-size: 14px; }
.footer { background: #f1f5f9; padding: 20px 32px; font-size: 13px; color: #64748b; text-align: center; }
.cta { display: inline-block; background: #1e3a5f; color: #fff; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; margin-top: 16px; }
</style></head>
<body>
<div class="container">
<div class="header">
<h1>Order Confirmed ✓</h1>
<p>Thank you for your order with {{company_name}}</p>
</div>
<div class="body">
<div class="greeting">Hi {{first_name}},</div>
<p>Great news — your order is confirmed and we're getting it ready for you!</p>
<div class="info-grid">
<div class="info-row"><span class="info-label">Pickup Date</span><span class="info-value">{{pickup_date}}</span></div>
<div class="info-row"><span class="info-label">Location</span><span class="info-value">{{stop_name}}</span></div>
<div class="info-row"><span class="info-label">Order Total</span><span class="info-value">{{order_total}}</span></div>
</div>
{{#if item_summary}}
<div class="items">{{item_summary}}</div>
{{/if}}
{{#if custom_message}}
<div class="message">{{custom_message}}</div>
{{/if}}
<p>We'll send you a reminder as your pickup date approaches. See you soon!</p>
<p style="margin-top:24px">— The {{company_name}} Team</p>
</div>
<div class="footer">© {{company_name}} · Questions? Reply to this email</div>
</div>
</body>
</html>`,
},
{
id: "pickup_reminder",
name: "Pickup Reminder",
description: "Friendly reminder before pickup date with key details",
subject: "Reminder: Your pickup is {{pickup_date}} {{company_name}}",
body_text: `Hi {{first_name}},
This is a friendly reminder that your order is scheduled for pickup:
📅 Date: {{pickup_date}}
📍 Location: {{stop_name}}
💰 Balance Due: {{balance_due}}
{{#if custom_message}}
{{custom_message}}
{{/if}}
See you soon!
{{company_name}} Team`,
body_html: `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f8fafc; margin: 0; padding: 20px; }
.container { max-width: 600px; margin: 0 auto; background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.header { background: #0369a1; color: #fff; padding: 28px 32px; text-align: center; }
.header h1 { margin: 0; font-size: 22px; font-weight: 700; }
.header p { margin: 6px 0 0; opacity: 0.85; font-size: 14px; }
.body { padding: 28px 32px; color: #334155; font-size: 15px; line-height: 1.6; }
.greeting { font-size: 16px; font-weight: 600; margin-bottom: 16px; }
.reminder-box { background: #f0f9ff; border: 2px solid #0ea5e9; border-radius: 12px; padding: 20px; margin: 20px 0; text-align: center; }
.reminder-box .date { font-size: 24px; font-weight: 800; color: #0369a1; }
.reminder-box .location { font-size: 14px; color: #64748b; margin-top: 4px; }
.info-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #e2e8f0; font-size: 14px; }
.info-row:last-child { border-bottom: none; }
.info-label { color: #64748b; }
.info-value { font-weight: 600; text-align: right; }
.balance { background: #fff7ed; border-radius: 10px; padding: 16px 20px; margin: 16px 0; text-align: center; }
.balance .amount { font-size: 28px; font-weight: 800; color: #ea580c; }
.balance .label { font-size: 12px; color: #64748b; margin-top: 2px; }
.message { background: #f0fdf4; border-left: 4px solid #22c55e; padding: 12px 16px; border-radius: 0 8px 8px 0; margin: 16px 0; font-size: 14px; }
.footer { background: #f1f5f9; padding: 20px 32px; font-size: 13px; color: #64748b; text-align: center; }
</style></head>
<body>
<div class="container">
<div class="header">
<h1>⏰ Pickup Reminder</h1>
<p>Your order is waiting for you!</p>
</div>
<div class="body">
<div class="greeting">Hi {{first_name}},</div>
<p>Just a quick reminder — your order is scheduled for pickup soon!</p>
<div class="reminder-box">
<div class="date">{{pickup_date}}</div>
<div class="location">{{stop_name}}</div>
</div>
<div class="info-row"><span class="info-label">Company</span><span class="info-value">{{company_name}}</span></div>
{{#if balance_due}}
<div class="balance">
<div class="amount">{{balance_due}}</div>
<div class="label">Balance Due at Pickup</div>
</div>
{{/if}}
{{#if custom_message}}
<div class="message">{{custom_message}}</div>
{{/if}}
<p style="margin-top:24px">See you soon!<br>— {{company_name}} Team</p>
</div>
<div class="footer">© {{company_name}} · Questions? Reply to this email</div>
</div>
</body>
</html>`,
},
{
id: "promotional",
name: "Promotional / Newsletter",
description: "Marketing newsletter with headline, body, and call to action",
subject: "{{custom_subject}}",
body_text: `{{first_name}},
{{custom_message}}
{{#if cta_text}}
{{cta_text}}: {{cta_url}}
{{/if}}
Thanks for being a valued customer!
{{company_name}} Team`,
body_html: `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f8fafc; margin: 0; padding: 20px; }
.container { max-width: 600px; margin: 0 auto; background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.header { background: #7c3aed; color: #fff; padding: 32px; text-align: center; }
.header .tag { background: rgba(255,255,255,0.2); display: inline-block; padding: 4px 12px; border-radius: 20px; font-size: 12px; font-weight: 600; margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
.header h1 { margin: 0; font-size: 26px; font-weight: 800; line-height: 1.2; }
.body { padding: 28px 32px; color: #334155; font-size: 15px; line-height: 1.7; }
.body p { margin: 0 0 16px; }
.divider { border: none; border-top: 2px solid #f1f5f9; margin: 24px 0; }
.cta-wrap { text-align: center; margin: 28px 0; }
.cta { display: inline-block; background: #7c3aed; color: #fff; padding: 14px 32px; border-radius: 10px; text-decoration: none; font-weight: 700; font-size: 15px; }
.cta:hover { background: #6d28d9; }
.footer { background: #f1f5f9; padding: 20px 32px; font-size: 13px; color: #64748b; text-align: center; }
@media (max-width: 480px) {
.header, .body { padding: 20px; }
.header h1 { font-size: 22px; }
}
</style></head>
<body>
<div class="container">
<div class="header">
{{#if tag}}
<div class="tag">{{tag}}</div>
{{/if}}
<h1>{{headline}}</h1>
</div>
<div class="body">
<p>Hi {{first_name}},</p>
<p>{{custom_message}}</p>
{{#if cta_text}}
<div class="cta-wrap">
<a href="{{cta_url}}" class="cta">{{cta_text}}</a>
</div>
{{/if}}
<hr class="divider">
<p style="font-size:13px; color:#94a3b8">You're receiving this because you're a {{company_name}} customer.</p>
</div>
<div class="footer">© {{company_name}} · <a href="{{unsubscribe_url}}" style="color:#64748b">Unsubscribe</a></div>
</div>
</body>
</html>`,
},
{
id: "thank_you",
name: "Thank You / Follow-Up",
description: "Post-pickup thank you with follow-up call to action",
subject: "Thanks for picking up with {{company_name}}!",
body_text: `Dear {{first_name}},
Thank you for picking up your order from {{stop_name}}!
We hope everything was exactly as you expected. If you have any questions or feedback, please don't hesitate to reach out.
{{#if custom_message}}
{{custom_message}}
{{/if}}
We appreciate your business and look forward to seeing you again soon!
Warm regards,
{{company_name}} Team`,
body_html: `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f8fafc; margin: 0; padding: 20px; }
.container { max-width: 600px; margin: 0 auto; background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.header { background: #15803d; color: #fff; padding: 28px 32px; }
.header h1 { margin: 0; font-size: 22px; font-weight: 700; }
.header p { margin: 6px 0 0; opacity: 0.85; font-size: 14px; }
.body { padding: 28px 32px; color: #334155; font-size: 15px; line-height: 1.6; }
.greeting { font-size: 16px; font-weight: 600; margin-bottom: 16px; }
.highlight { background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 10px; padding: 16px 20px; margin: 20px 0; }
.highlight p { margin: 0; font-size: 14px; color: #166534; }
.message-box { background: #f8fafc; border-left: 4px solid #64748b; padding: 12px 16px; border-radius: 0 8px 8px 0; margin: 16px 0; font-size: 14px; }
.footer { background: #f1f5f9; padding: 20px 32px; font-size: 13px; color: #64748b; text-align: center; }
</style></head>
<body>
<div class="container">
<div class="header">
<h1>Thank You! 🙏</h1>
<p>Your pickup from {{stop_name}} is complete</p>
</div>
<div class="body">
<div class="greeting">Dear {{first_name}},</div>
<p>Thank you for picking up your order with us. We hope everything was exactly as you expected!</p>
<div class="highlight">
<p>Questions or feedback? Reply to this email — we read every message.</p>
</div>
{{#if custom_message}}
<div class="message-box">{{custom_message}}</div>
{{/if}}
<p style="margin-top:24px">We appreciate your business and look forward to seeing you again!<br><br>Warm regards,<br><strong>{{company_name}} Team</strong></p>
</div>
<div class="footer">© {{company_name}}</div>
</div>
</body>
</html>`,
},
{
id: "deposit_request",
name: "Deposit Request",
description: "Professional deposit request for wholesale customers",
subject: "Deposit Required {{company_name}} Order",
body_text: `Dear {{first_name}},
Your order with {{company_name}} is almost ready! To confirm and schedule production, we need a deposit to get started.
Order Total: {{order_total}}
Deposit Required: {{balance_due}}
Due By: {{due_date}}
{{#if custom_message}}
{{custom_message}}
{{/if}}
To pay your deposit, simply reply to this email or contact us directly.
Thank you,
{{company_name}} Team`,
body_html: `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f8fafc; margin: 0; padding: 20px; }
.container { max-width: 600px; margin: 0 auto; background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.header { background: #b45309; color: #fff; padding: 28px 32px; }
.header h1 { margin: 0; font-size: 22px; font-weight: 700; }
.header p { margin: 6px 0 0; opacity: 0.85; font-size: 14px; }
.body { padding: 28px 32px; color: #334155; font-size: 15px; line-height: 1.6; }
.greeting { font-size: 16px; font-weight: 600; margin-bottom: 16px; }
.amount-box { background: #fffbeb; border: 2px solid #f59e0b; border-radius: 12px; padding: 24px; margin: 20px 0; text-align: center; }
.amount-box .deposit { font-size: 36px; font-weight: 800; color: #b45309; }
.amount-box .label { font-size: 13px; color: #92400e; margin-top: 4px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
.amount-box .total { font-size: 14px; color: #64748b; margin-top: 8px; }
.due-date { background: #fef2f2; border: 1px solid #fecaca; border-radius: 10px; padding: 14px 20px; margin: 16px 0; text-align: center; font-size: 14px; color: #991b1b; font-weight: 600; }
.message { background: #f8fafc; border-left: 4px solid #64748b; padding: 12px 16px; border-radius: 0 8px 8px 0; margin: 16px 0; font-size: 14px; }
.footer { background: #f1f5f9; padding: 20px 32px; font-size: 13px; color: #64748b; text-align: center; }
</style></head>
<body>
<div class="container">
<div class="header">
<h1>Deposit Required</h1>
<p>Confirm your order with {{company_name}}</p>
</div>
<div class="body">
<div class="greeting">Hi {{first_name}},</div>
<p>Your order is in the queue! To confirm and schedule production, we need a deposit to get things started.</p>
<div class="amount-box">
<div class="deposit">{{balance_due}}</div>
<div class="label">Deposit Required</div>
<div class="total">Order Total: {{order_total}}</div>
</div>
{{#if due_date}}
<div class="due-date">⏰ Please pay by {{due_date}}</div>
{{/if}}
{{#if custom_message}}
<div class="message">{{custom_message}}</div>
{{/if}}
<p style="margin-top:24px">To pay your deposit, simply reply to this email or contact us directly. We accept cash, check, and wire transfer.<br><br>Thank you,<br><strong>{{company_name}} Team</strong></p>
</div>
<div class="footer">© {{company_name}} · Questions? Reply to this email</div>
</div>
</body>
</html>`,
},
];
// Render a template with given variables (simple mustache-like substitution)
export function renderTemplate(
template: EmailTemplate,
variables: Record<string, string>
): { subject: string; body_text: string; body_html: string } {
function sub(text: string): string {
return text.replace(/\{\{(\w+)\}\}/g, (match, key) => variables[key] ?? match);
}
return {
subject: sub(template.subject),
body_text: sub(template.body_text),
body_html: sub(template.body_html),
};
}
// Variables available in the template editor
export const TEMPLATE_VARIABLES = [
{ key: "first_name", label: "First Name", example: "Jane" },
{ key: "company_name", label: "Company Name", example: "Route Commerce" },
{ key: "stop_name", label: "Stop Name", example: "Tuxedo Warehouse" },
{ key: "pickup_date", label: "Pickup Date", example: "May 15, 2026" },
{ key: "order_total", label: "Order Total", example: "$450.00" },
{ key: "balance_due", label: "Balance Due", example: "$225.00" },
{ key: "due_date", label: "Due Date", example: "May 10, 2026" },
{ key: "item_summary", label: "Item Summary", example: "10× Tuxedo Set, 5× Shirt" },
{ key: "custom_message", label: "Custom Message", example: "See you at the stop!" },
{ key: "cta_text", label: "CTA Button Text", example: "Shop Now" },
{ key: "cta_url", label: "CTA URL", example: "https://..." },
{ key: "tag", label: "Newsletter Tag", example: "New Arrivals" },
{ key: "headline", label: "Headline", example: "Spring Collection is Here!" },
{ key: "unsubscribe_url", label: "Unsubscribe URL", example: "https://..." },
] as const;
+81
View File
@@ -0,0 +1,81 @@
import ExcelJS from "exceljs";
export type ParsedSheet = {
headers: string[];
rows: string[][];
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function parseExcelBuffer(input: any): Promise<{
headers: string[];
rows: string[][];
}> {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(input);
const sheet = workbook.getWorksheet(1);
if (!sheet) {
return { headers: [], rows: [] };
}
const headers: string[] = [];
const rows: string[][] = [];
sheet.eachRow((row, rowIndex) => {
const values = row.values as (string | number | Date | null | undefined)[];
const rowData = values.map((v) => {
if (v === null || v === undefined) return "";
if (v instanceof Date) return v.toISOString().split("T")[0];
return String(v).trim();
});
if (rowIndex === 1) {
// Header row
headers.push(...rowData);
} else {
// Skip empty rows
if (rowData.some((v) => v !== "")) {
rows.push(rowData);
}
}
});
return { headers, rows };
}
/**
* Parse CSV/TSV/TXT text into headers + rows.
* Auto-detects delimiter by checking first few lines.
*/
export function parseTextBuffer(rawText: string): ParsedSheet {
// Normalize line endings
const text = rawText.replace(/\r\n/g, "\n").replace(/\r/g, "").trim();
const lines = text.split("\n").filter((l) => l.trim() !== "");
if (lines.length === 0) return { headers: [], rows: [] };
// Detect delimiter
const firstLine = lines[0];
const delimiter = detectDelimiter(firstLine);
const headers = firstLine.split(delimiter).map((h) => h.trim().replace(/^["']|["']$/g, ""));
const rows = lines.slice(1).map((line) =>
line.split(delimiter).map((v) => v.trim().replace(/^["']|["']$/g, ""))
);
return { headers, rows };
}
function detectDelimiter(line: string): string {
const delimiters = [",", "\t", ";", "|"];
let best = ",";
let maxCount = 0;
for (const d of delimiters) {
const count = (line.match(new RegExp(`\\${d}`, "g")) ?? []).length;
if (count > maxCount) {
maxCount = count;
best = d;
}
}
return best;
}
+202
View File
@@ -0,0 +1,202 @@
/**
* Feature flags / add-on licensing system for Route Commerce platform.
*
* Add-ons can be enabled per brand via the `brand_features` table or
* via environment-variable defaults for self-hosted/white-label deployments.
*
* To add a new add-on:
* 1. Add the feature key to BrandFeatureKey
* 2. Add a display entry to ADDON_CATALOG
* 3. Use isFeatureEnabled(brandId, "key") to gate UI elements
*/
import { svcHeaders } from "@/lib/svc-headers";
export type BrandFeatureKey =
| "harvest_reach"
| "wholesale_portal"
| "water_log"
| "ai_tools"
| "square_sync"
| "sms_campaigns"
| "route_trace";
export type FeatureTier = "core" | "add-on" | "enterprise";
export type BrandFeature = {
key: BrandFeatureKey;
name: string;
description: string;
tier: FeatureTier;
icon: string;
badge?: string;
badgeVariant?: "default" | "blue" | "violet" | "amber";
adminRoute: string;
addOnPrice?: string;
};
// Add-on catalog — used to render upgrade prompts and settings UI
export const ADDON_CATALOG: Record<BrandFeatureKey, BrandFeature> = {
harvest_reach: {
key: "harvest_reach",
name: "Harvest Reach",
description: "Email and SMS marketing campaigns, audience segments, stop blast messaging, and automated customer outreach.",
tier: "add-on",
icon: "📧",
badge: "Popular",
badgeVariant: "violet",
adminRoute: "/admin/communications",
},
wholesale_portal: {
key: "wholesale_portal",
name: "Wholesale Portal",
description: "Standalone B2B ordering portal for approved wholesale buyers with custom pricing, credit limits, and net-30 billing.",
tier: "add-on",
icon: "🏪",
badge: "B2B",
badgeVariant: "blue",
adminRoute: "/admin/wholesale",
addOnPrice: "Contact us",
},
water_log: {
key: "water_log",
name: "Water Log",
description: "Irrigation tracking, headgate measurements, and water usage reporting for agricultural operations.",
tier: "add-on",
icon: "💧",
badge: "Ag",
badgeVariant: "blue",
adminRoute: "/admin/water-log",
addOnPrice: "Contact us",
},
ai_tools: {
key: "ai_tools",
name: "AI Intelligence Pack",
description: "AI-powered campaign writer, product copywriter, report explainer, pricing advisor, customer insights, demand forecasting, and route optimization.",
tier: "add-on",
icon: "🤖",
badge: "AI",
badgeVariant: "violet",
adminRoute: "/admin/settings/ai",
addOnPrice: "OpenAI API required",
},
square_sync: {
key: "square_sync",
name: "Square Inventory Sync",
description: "Two-way sync between Route Commerce and Square Point of Sale. Import products and sync inventory counts automatically.",
tier: "add-on",
icon: "◼️",
adminRoute: "/admin/settings/square-sync",
addOnPrice: "Square account required",
},
sms_campaigns: {
key: "sms_campaigns",
name: "SMS Campaigns",
description: "Text message marketing and transactional SMS alerts via Twilio. Requires Harvest Reach.",
tier: "add-on",
icon: "💬",
adminRoute: "/admin/communications",
addOnPrice: "Twilio account required",
},
route_trace: {
key: "route_trace",
name: "Route Trace",
description: "Full supply chain traceability with lot tracking, QR-enabled thermal stickers, and one-up/one-down compliance reporting.",
tier: "add-on",
icon: "🌱",
badge: "Compliance",
badgeVariant: "default",
adminRoute: "/admin/route-trace",
addOnPrice: "$49/mo",
},
};
// Core features that cannot be disabled
export const CORE_FEATURES: BrandFeatureKey[] = [
// Core platform features (always enabled)
// These are not add-ons — the platform doesn't function without them
];
// Environment variable fallback for self-hosted / white-label deployments
// Format: NEXT_PUBLIC_ENABLED_ADDONS=harvest_reach,wholesale_portal,water_log,ai_tools
function getEnvEnabledAddons(): BrandFeatureKey[] {
if (typeof window === "undefined") {
const env = process.env.NEXT_PUBLIC_ENABLED_ADDONS ?? "";
if (!env) return [];
return env.split(",").filter(Boolean) as BrandFeatureKey[];
}
return [];
}
// Cache for brand feature lookup (server-side only, 5-min TTL)
const brandFeatureCache = new Map<string, { promise: Promise<Record<BrandFeatureKey, boolean>>; ts: number }>();
const CACHE_TTL_MS = 5 * 60 * 1000;
/**
* Check if a brand has a specific add-on feature enabled.
* Falls back to env-var defaults if no per-brand setting exists.
*
* Usage in server components:
* const { isFeatureEnabled } = await import("@/lib/feature-flags");
* const enabled = await isFeatureEnabled(brandId, "harvest_reach");
*
* Usage in client components (reads from props/context):
* Pass `enabledFeatures` from server component as prop.
*/
export async function isFeatureEnabled(
brandId: string,
feature: BrandFeatureKey
): Promise<boolean> {
// Check env-var override first (white-label / self-hosted)
const envEnabled = getEnvEnabledAddons();
if (envEnabled.includes(feature)) return true;
// Per-brand check via RPC (5-min cache)
const cacheKey = brandId;
const now = Date.now();
const cached = brandFeatureCache.get(cacheKey);
if (cached && now - cached.ts < CACHE_TTL_MS) {
const features = await cached.promise;
return features[feature] ?? false;
}
brandFeatureCache.set(
cacheKey,
{ promise: fetchBrandFeatures(brandId).then((f) => (f ?? {} as Record<BrandFeatureKey, boolean>)), ts: now }
);
const features = await brandFeatureCache.get(cacheKey)!.promise;
return features[feature] ?? false;
}
async function fetchBrandFeatures(
brandId: string
): Promise<Record<BrandFeatureKey, boolean> | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !supabaseKey) return null;
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_brand_features`,
{
method: "POST",
headers: {
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
},
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!res.ok) return null;
const data = await res.json();
return data ?? null;
} catch {
return null;
}
}
/**
* Invalidate cached features for a brand (call after feature toggle).
*/
export function invalidateBrandFeatureCache(brandId: string): void {
brandFeatureCache.delete(brandId);
}
+39
View File
@@ -0,0 +1,39 @@
/**
* Shared date/time formatting utilities for Route Commerce.
* All dates are stored as TIMESTAMPTZ in PostgreSQL (UTC).
* Display dates in MM/DD/YYYY format in US locale.
*/
export function formatDate(date: string | Date | null | undefined): string {
if (!date) return "—";
const d = typeof date === "string" ? new Date(date) : date;
if (isNaN(d.getTime())) return "—";
return d.toLocaleDateString("en-US", {
month: "2-digit",
day: "2-digit",
year: "numeric",
});
}
export function formatDateTime(date: string | Date | null | undefined): string {
if (!date) return "—";
const d = typeof date === "string" ? new Date(date) : date;
if (isNaN(d.getTime())) return "—";
return d.toLocaleDateString("en-US", {
month: "2-digit",
day: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export function formatTime(date: string | Date | null | undefined): string {
if (!date) return "—";
const d = typeof date === "string" ? new Date(date) : date;
if (isNaN(d.getTime())) return "—";
return d.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
});
}
+199
View File
@@ -0,0 +1,199 @@
// Mock data for UI review without Supabase
// Enable by setting NEXT_PUBLIC_USE_MOCK_DATA=true in .env.local
export const mockBrands = [
{ id: "brand-tuxedo", name: "Tuxedo Corn", slug: "tuxedo", accent_color: "#22c55e", active: true },
{ id: "brand-ird", name: "Indian River Direct", slug: "indian-river-direct", accent_color: "#f97316", active: true },
];
export const mockProducts = [
// Tuxedo Corn products
{ id: "prod-tux-1", name: "Olathe Sweet Dozen", price: 35.00, description: "Twelve ears of our signature Olathe Sweet corn, hand-picked at peak ripeness.", type: "corn", image_url: "https://images.unsplash.com/photo-1551754655-cd27e38d2076?w=600&h=600&fit=crop", shipping_type: "all", brand_id: "brand-tuxedo", is_active: true, pickup_type: "shed" },
{ id: "prod-tux-2", name: "Family Bundle", price: 95.00, description: "Thirty-six ears of premium Olathe Sweet, perfect for large gatherings.", type: "corn", image_url: "https://images.unsplash.com/photo-1601493700631-2b16ec4b4716?w=600&h=600&fit=crop", shipping_type: "all", brand_id: "brand-tuxedo", is_active: true, pickup_type: "shed" },
{ id: "prod-tux-3", name: "Cooler Box — 18 Ears", price: 58.00, description: "Pre-cooled, pre-packed cooler box ready for pickup at any stop.", type: "corn", image_url: "https://images.unsplash.com/photo-1601493700631-2b16ec4b4716?w=600&h=600&fit=crop", shipping_type: "pickup", brand_id: "brand-tuxedo", is_active: true, pickup_type: "scheduled_stop" },
{ id: "prod-tux-4", name: "Corn & Peach Combo", price: 75.00, description: "Six ears of Olathe Sweet paired with tree-ripened peaches.", type: "combo", image_url: "https://images.unsplash.com/photo-1464305795204-6f5bbfc7fb81?w=600&h=600&fit=crop", shipping_type: "all", brand_id: "brand-tuxedo", is_active: true, pickup_type: "shed" },
// Indian River Direct products
{ id: "prod-ird-peach-2026", name: "Peaches - 2026 Pre-Order", price: 55.00, description: "25 lb box of Freestone Peaches from Titan Farms.", type: "peaches", image_url: "https://cdn.shopify.com/s/files/1/0506/2908/3294/files/Untitleddesign.png", shipping_type: "pickup", brand_id: "brand-ird", is_active: true, pickup_type: "scheduled_stop", seasonal: true, season_start: "June", season_end: "August", preorder: true },
{ id: "prod-ird-pecans", name: "Pecans", price: 13.00, description: "Premium 1 lb bag of pecans from Ellis Brothers Pecans, Georgia.", type: "nuts", image_url: "https://cdn.shopify.com/s/files/1/0506/2908/3294/files/Pecans---INDIANRIVER-42.jpg", shipping_type: "pickup", brand_id: "brand-ird", is_active: true, pickup_type: "scheduled_stop", pickup_only: true },
{ id: "prod-ird-citrus-box", name: "Citrus Truckload Box", price: 45.00, description: "Navel oranges, ruby red grapefruit, and tangerines.", type: "citrus", image_url: "https://images.unsplash.com/photo-1547514701-42782101795e?w=600&h=600&fit=crop", shipping_type: "pickup", brand_id: "brand-ird", is_active: true, pickup_type: "scheduled_stop", seasonal: true, season_start: "November", season_end: "April" },
];
export const mockStops = [
// Tuxedo Corn stops
{ id: "stop-1", city: "Denver", state: "CO", date: "2026-06-01", time: "10:00 AM - 2:00 PM", location: "Union Station Plaza", slug: "denver-union-station", brand_id: "brand-tuxedo", is_public: true, active: true },
{ id: "stop-2", city: "Boulder", state: "CO", date: "2026-06-02", time: "9:00 AM - 1:00 PM", location: "Pearl Street Mall", slug: "boulder-pearl", brand_id: "brand-tuxedo", is_public: true, active: true },
{ id: "stop-3", city: "Colorado Springs", state: "CO", date: "2026-06-03", time: "10:00 AM - 3:00 PM", location: "Garden of the Gods", slug: "cos-garden-of-gods", brand_id: "brand-tuxedo", is_public: true, active: true },
{ id: "stop-4", city: "Fort Collins", state: "CO", date: "2026-06-04", time: "11:00 AM - 4:00 PM", location: "Old Town Square", slug: "fort-collins-old-town", brand_id: "brand-tuxedo", is_public: true, active: true },
// Indian River Direct stops
{ id: "stop-5", city: "Miami", state: "FL", date: "2026-06-05", time: "8:00 AM - 12:00 PM", location: "Cocoanut Grove Market", slug: "miami-coconut-grove", brand_id: "brand-ird", is_public: true, active: true },
{ id: "stop-6", city: "West Palm Beach", state: "FL", date: "2026-06-06", time: "9:00 AM - 1:00 PM", location: "Antique Row", slug: "west-palm-beach", brand_id: "brand-ird", is_public: true, active: true },
{ id: "stop-7", city: "Fort Lauderdale", state: "FL", date: "2026-06-07", time: "10:00 AM - 2:00 PM", location: "Las Olas Boulevard", slug: "ft-lauderdale", brand_id: "brand-ird", is_public: true, active: true },
];
export const mockOrders = [
{
id: "order-1",
customer_name: "John Smith",
customer_email: "john@example.com",
customer_phone: "+1-555-0101",
status: "pending",
subtotal: 140.00,
pickup_complete: false,
created_at: "2026-05-28T10:00:00Z",
payment_processor: "stripe",
stop_id: "stop-1",
brand_id: "brand-tuxedo",
order_items: [{ id: "item-1", product_id: "prod-tux-1", quantity: 2, price: 35.00, products: mockProducts[0] }],
stops: mockStops[0],
},
{
id: "order-2",
customer_name: "Jane Doe",
customer_email: "jane@example.com",
customer_phone: "+1-555-0102",
status: "pending",
subtotal: 80.00,
pickup_complete: false,
created_at: "2026-05-28T11:30:00Z",
payment_processor: "stripe",
stop_id: "stop-1",
brand_id: "brand-tuxedo",
order_items: [{ id: "item-2", product_id: "prod-tux-3", quantity: 1, price: 58.00, products: mockProducts[2] }],
stops: mockStops[0],
},
{
id: "order-3",
customer_name: "Bob Wilson",
customer_email: "bob@example.com",
customer_phone: "+1-555-0103",
status: "picked_up",
subtotal: 210.00,
pickup_complete: true,
pickup_completed_at: "2026-05-27T14:00:00Z",
created_at: "2026-05-27T09:00:00Z",
payment_processor: "stripe",
stop_id: "stop-2",
brand_id: "brand-tuxedo",
order_items: [{ id: "item-3", product_id: "prod-tux-2", quantity: 2, price: 95.00, products: mockProducts[1] }],
stops: mockStops[1],
},
{
id: "order-4",
customer_name: "Sarah Johnson",
customer_email: "sarah@example.com",
customer_phone: "+1-555-0104",
status: "paid",
subtotal: 55.00,
pickup_complete: false,
created_at: "2026-05-29T08:00:00Z",
payment_processor: "stripe",
stop_id: "stop-5",
brand_id: "brand-ird",
order_items: [{ id: "item-4", product_id: "prod-ird-peach-2026", quantity: 1, price: 55.00, products: mockProducts[4] }],
stops: mockStops[4],
},
];
export const mockWorkers = [
{ id: "worker-1", name: "Mike Johnson", pin: "1234", role: "worker", is_active: true, language: "en", brand_id: "brand-tuxedo" },
{ id: "worker-2", name: "Maria Garcia", pin: "5678", role: "time_admin", is_active: true, language: "es", brand_id: "brand-tuxedo" },
{ id: "worker-3", name: "James Wilson", pin: "9012", role: "worker", is_active: true, language: "en", brand_id: "brand-tuxedo" },
];
export const mockTasks = [
{ id: "task-1", name_en: "Picking", name_es: "Recoleccion", unit: "hours", sort_order: 1, brand_id: "brand-tuxedo" },
{ id: "task-2", name_en: "Packing", name_es: "Empacado", unit: "pieces", sort_order: 2, brand_id: "brand-tuxedo" },
{ id: "task-3", name_en: "Loading", name_es: "Carga", unit: "hours", sort_order: 3, brand_id: "brand-tuxedo" },
];
export const mockTimeEntries = [
{ id: "time-1", worker_id: "worker-1", task_id: "task-1", hours: 4.5, date: "2026-05-28", brand_id: "brand-tuxedo" },
{ id: "time-2", worker_id: "worker-1", task_id: "task-2", hours: 3, date: "2026-05-28", brand_id: "brand-tuxedo" },
{ id: "time-3", worker_id: "worker-2", task_id: "task-1", hours: 6, date: "2026-05-28", brand_id: "brand-tuxedo" },
];
export const mockCustomers = [
{ id: "cust-1", name: "Fresh Foods Co", email: "orders@freshfoods.com", company: "Fresh Foods Co", is_wholesale: true, brand_id: "brand-tuxedo" },
{ id: "cust-2", name: "Farm Market", email: "buy@farmmarket.com", company: "Farm Market", is_wholesale: true, brand_id: "brand-tuxedo" },
];
export const mockBrandSettings = {
brand_name: "Tuxedo Corn",
pay_period: "weekly",
daily_overtime_threshold: 8,
weekly_overtime_threshold: 40,
notification_emails: ["admin@tuxedocorn.com"],
notification_phones: [],
brand_id: "brand-tuxedo",
logo_url: null,
logo_url_dark: null,
hero_image_url: null,
hero_tagline: null,
custom_footer_text: null,
email: "admin@tuxedocorn.com",
phone: "970-555-1234",
show_zip_search: true,
show_schedule_pdf: true,
show_wholesale_link: true,
about_headline: "Tuxedo Corn",
about_subheadline: "Premium Olathe Sweet Sweet Corn — Grown in Colorado Since 1982",
invoice_business_name: "Tuxedo Corn LLC",
invoice_business_address: "123 Farm Road, Olathe, CO 81425",
invoice_business_phone: "970-555-1234",
invoice_business_email: "orders@tuxedocorn.com",
invoice_business_website: "www.tuxedocorn.com",
};
export const mockUsers = [
{ id: "user-1", email: "admin@tuxedocorn.com", role: "brand_admin", brand_id: "brand-tuxedo" },
{ id: "user-2", email: "worker@tuxedocorn.com", role: "store_employee", brand_id: "brand-tuxedo" },
];
export const mockCommunications = {
campaigns: [
{ id: "camp-1", name: "Summer Kickoff", subject: "Corn Season is Here!", status: "sent", sent_count: 150, created_at: "2026-05-01T10:00:00Z" },
{ id: "camp-2", name: "Peach Pre-Order", subject: "Pre-order Your Peaches Now", status: "draft", sent_count: 0, created_at: "2026-05-28T10:00:00Z" },
],
templates: [
{ id: "temp-1", name: "Stop Reminder", subject: "Pickup Reminder", content: "Don't forget your pickup tomorrow!", created_at: "2026-01-01T10:00:00Z" },
{ id: "temp-2", name: "Order Confirmation", subject: "Your Order is Confirmed", content: "Thank you for your order!", created_at: "2026-01-01T10:00:00Z" },
],
contacts: [
{ id: "contact-1", email: "customer1@example.com", name: "Alice Brown", subscribed: true, brand_id: "brand-tuxedo" },
{ id: "contact-2", email: "customer2@example.com", name: "Bob Green", subscribed: true, brand_id: "brand-tuxedo" },
{ id: "contact-3", email: "customer3@example.com", name: "Carol White", subscribed: false, brand_id: "brand-tuxedo" },
],
segments: [
{ id: "seg-1", name: "Active Customers", description: "Customers who ordered in the last 30 days", count: 45 },
{ id: "seg-2", name: "Wholesale Buyers", description: "All wholesale customers", count: 12 },
],
};
export const mockReports = {
sales: [
{ date: "2026-05-25", revenue: 1250.00, orders: 18 },
{ date: "2026-05-26", revenue: 980.00, orders: 14 },
{ date: "2026-05-27", revenue: 2100.00, orders: 28 },
{ date: "2026-05-28", revenue: 1750.00, orders: 22 },
{ date: "2026-05-29", revenue: 890.00, orders: 11 },
],
};
// Helper to get data by table name
const tableDataMap: Record<string, unknown[]> = {
brands: mockBrands,
products: mockProducts,
stops: mockStops,
orders: mockOrders,
workers: mockWorkers,
tasks: mockTasks,
time_entries: mockTimeEntries,
customers: mockCustomers,
brand_settings: [mockBrandSettings],
users: mockUsers,
};
export function getMockTableData(tableName: string): unknown[] {
return tableDataMap[tableName] || [];
}
+202
View File
@@ -0,0 +1,202 @@
// Route Commerce Platform — Pricing Configuration
// All pricing constants used across billing UI, server actions, and webhooks
export const PLAN_TIERS = {
starter: {
label: "Starter",
color: "bg-slate-100 text-slate-700",
badge: null,
monthlyPrice: 49,
annualPrice: 441, // ~25% off
description: "Everything you need to get started",
highlighted: false,
features: [
"Products catalog",
"Stops management (up to 10/month)",
"Orders processing",
"Basic Pickup",
"1 admin user",
"Up to 25 products",
"Email support",
],
limits: { max_users: 1, max_stops_monthly: 10, max_products: 25 },
},
farm: {
label: "Farm",
color: "bg-blue-100 text-blue-700",
badge: "Most Popular",
monthlyPrice: 149,
annualPrice: 1341,
description: "For growing farm businesses",
highlighted: true,
features: [
"Everything in Starter",
"Wholesale Portal",
"Harvest Reach (Email & SMS)",
"Unlimited stops",
"Unlimited products",
"Multi-user (up to 5)",
"Priority support",
"Advanced reporting",
],
limits: { max_users: 5, max_stops_monthly: -1, max_products: -1 },
},
enterprise: {
label: "Enterprise",
color: "bg-violet-100 text-violet-700",
badge: null,
monthlyPrice: 399,
annualPrice: 3591, // $399/mo * 12 * 0.75
description: "For established operations that need more",
highlighted: false,
features: [
"Everything in Farm",
"AI Intelligence Pack",
"SMS Campaigns",
"Square Inventory Sync",
"Water Log",
"Unlimited users",
"Unlimited brands",
"Custom development",
"Dedicated support",
"SLA guarantee",
"Advanced reporting",
],
limits: { max_users: -1, max_stops_monthly: -1, max_products: -1 },
},
} as const;
export type PlanTierKey = keyof typeof PLAN_TIERS;
// ── Add-ons ──────────────────────────────────────────────────────────────────
export const ADDONS = {
harvest_reach: {
label: "Harvest Reach",
icon: "📧",
description: "Email & SMS marketing campaigns",
monthlyPrice: 79,
annualPrice: 711,
featureKey: "harvest_reach",
stripePriceEnv: "STRIPE_PRICE_HARVEST_REACH",
},
wholesale_portal: {
label: "Wholesale Portal",
icon: "🏪",
description: "B2B buyer portal with custom pricing",
monthlyPrice: 99,
annualPrice: 891,
featureKey: "wholesale_portal",
stripePriceEnv: "STRIPE_PRICE_WHOLESALE_PORTAL",
},
water_log: {
label: "Water Log",
icon: "💧",
description: "Irrigation tracking & water usage",
monthlyPrice: 39,
annualPrice: 351,
featureKey: "water_log",
stripePriceEnv: "STRIPE_PRICE_WATER_LOG",
},
ai_tools: {
label: "AI Intelligence Pack",
icon: "🤖",
description: "Campaign writer, pricing advisor, forecasting",
monthlyPrice: 59,
annualPrice: 531,
featureKey: "ai_tools",
stripePriceEnv: "STRIPE_PRICE_AI_TOOLS",
},
square_sync: {
label: "Square Inventory Sync",
icon: "◼️",
description: "Sync products, orders, inventory with Square",
monthlyPrice: 39,
annualPrice: 351,
featureKey: "square_sync",
stripePriceEnv: "STRIPE_PRICE_SQUARE_SYNC",
},
sms_campaigns: {
label: "SMS Campaigns",
icon: "💬",
description: "Text message marketing & notifications",
monthlyPrice: 29,
annualPrice: 261,
featureKey: "sms_campaigns",
stripePriceEnv: "STRIPE_PRICE_SMS_CAMPAIGNS",
},
} as const;
export type AddonKey = keyof typeof ADDONS;
// ── Feature list per tier (for comparison table) ────────────────────────────
export const TIER_FEATURE_MATRIX: Record<PlanTierKey, string[]> = {
starter: [
"Products catalog",
"Stops management (10/mo)",
"Orders processing",
"Basic Pickup",
"1 admin user",
"Up to 25 products",
"Email support",
"Standard reporting",
],
farm: [
"Everything in Starter",
"Wholesale Portal",
"Harvest Reach (Email & SMS)",
"Unlimited stops",
"Unlimited products",
"Multi-user (5)",
"Priority support",
"Advanced reporting",
],
enterprise: [
"Everything in Farm",
"AI Intelligence Pack",
"SMS Campaigns",
"Square Inventory Sync",
"Water Log",
"Unlimited users",
"Unlimited brands",
"Custom development",
"SLA guarantee",
"Dedicated support",
],
};
// ── Utility functions ────────────────────────────────────────────────────────
export function formatPrice(amount: number | null): string {
if (amount === null) return "Custom";
return `$${amount.toFixed(0)}`;
}
export function formatPricePerMonth(amount: number | null): string {
if (amount === null) return "Custom";
return `$${amount}/mo`;
}
export function getPlanMonthlyPrice(tier: PlanTierKey): number | null {
return PLAN_TIERS[tier]?.monthlyPrice ?? null;
}
export function getPlanAnnualPrice(tier: PlanTierKey): number | null {
return PLAN_TIERS[tier]?.annualPrice ?? null;
}
export function getAddonMonthlyPrice(addon: AddonKey): number {
return ADDONS[addon]?.monthlyPrice ?? 0;
}
export function getAddonAnnualPrice(addon: AddonKey): number {
return ADDONS[addon]?.annualPrice ?? 0;
}
export function calculateAnnualSavings(monthlyTotal: number): number {
return Math.round(monthlyTotal * 0.25); // 25% annual discount
}
export const BILLING_COMPANY_NAME = "Cielo Hermosa, LLC";
export const BILLING_EMAIL = "billing@cielohermosa.com";
+100
View File
@@ -0,0 +1,100 @@
// Pure CSV export utilities — no server directives, safe for client import
export type ReportsSummary = {
gross_sales: number; total_orders: number; avg_order_value: number;
pickup_orders: number; shipping_orders: number;
pending_pickups: number; completed_pickups: number;
contacts_added: number; campaigns_sent: number; messages_logged: number;
};
export type DateRange = { start: string; end: string };
export function escapeCSVValue(value: unknown): string {
if (value === null || value === undefined) return "";
const str = String(value);
if (str.includes('"') || str.includes(",") || str.includes("\n") || str.includes("\r")) {
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
}
function toCSV(headers: string[], rows: Record<string, unknown>[]): string {
const headerRow = headers.join(",");
const dataRows = rows.map((row) =>
headers.map((h) => escapeCSVValue(row[h])).join(",")
);
return [headerRow, ...dataRows].join("\r\n");
}
export type OrderByStop = {
stop_name: string; city: string; state: string; date: string;
order_count: number; gross_sales: number; pending_count: number; completed_count: number;
};
export type SalesByProduct = {
product_name: string; units_sold: number; gross_revenue: number; avg_price: number;
};
export type PickupStatusByStop = {
stop_name: string; city: string; date: string;
total_orders: number; pending: number; completed: number; canceled: number;
};
export type ContactGrowthRow = {
date: string; new_contacts: number; imports: number; total: number;
};
export type FulfillmentRow = {
fulfillment_type: string; order_count: number; revenue: number; pct_of_total: number;
};
export type CampaignActivityRow = {
campaign_name: string; status: string; campaign_type: string;
sent_at: string | null; messages_logged: number;
};
export function exportOrdersByStop(data: OrderByStop[]): string {
return toCSV(["stop_name", "city", "state", "date", "order_count", "gross_sales", "pending_count", "completed_count"], data);
}
export function exportSalesByProduct(data: SalesByProduct[]): string {
return toCSV(["product_name", "units_sold", "gross_revenue", "avg_price"], data);
}
export function exportPickupStatus(data: PickupStatusByStop[]): string {
return toCSV(["stop_name", "city", "date", "total_orders", "pending", "completed", "canceled"], data);
}
export function exportContactGrowth(data: ContactGrowthRow[]): string {
return toCSV(["date", "new_contacts", "imports", "total"], data);
}
export function exportFulfillment(data: FulfillmentRow[]): string {
return toCSV(["fulfillment_type", "order_count", "revenue", "pct_of_total"], data);
}
export function exportCampaignActivity(data: CampaignActivityRow[]): string {
return toCSV(["campaign_name", "status", "campaign_type", "sent_at", "messages_logged"], data);
}
// ── Tax reports ───────────────────────────────────────────────────────────────
export type TaxOrderRow = {
order_id: string;
date: string;
customer_name: string;
city: string;
state: string;
taxable_amount: number;
tax_amount: number;
tax_rate: number;
tax_location: string;
};
export type TaxByStateRow = {
state: string;
total_tax: number;
gross_sales: number;
order_count: number;
};
export function exportTaxReport(data: TaxOrderRow[]): string {
return toCSV(
["order_id", "date", "customer_name", "city", "state", "taxable_amount", "tax_amount", "tax_rate", "tax_location"],
data
);
}
export function exportTaxByState(data: TaxByStateRow[]): string {
return toCSV(["state", "total_tax", "gross_sales", "order_count"], data);
}
+11
View File
@@ -0,0 +1,11 @@
export const YIELD_UNIT_OPTIONS = [
{ value: "lbs", label: "lbs", abbr: "lbs" },
{ value: "bushel", label: "Bushel", abbr: "bu" },
{ value: "box", label: "Box", abbr: "bx" },
{ value: "sack", label: "Sack", abbr: "sk" },
{ value: "crate", label: "Crate", abbr: "cr" },
{ value: "bin", label: "Bin", abbr: "bn" },
{ value: "pallet", label: "Pallet", abbr: "plt" },
{ value: "custom", label: "Custom", abbr: "" },
] as const;
export type YieldUnit = typeof YIELD_UNIT_OPTIONS[number]["value"];
+264
View File
@@ -0,0 +1,264 @@
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
import { getMockTableData } from "./mock-data";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
// Auto-enable mock mode when no Supabase URL is configured (demo/deployment without backend)
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true" || !supabaseUrl || !supabaseUrl.includes("supabase.co");
// Mock query builder that supports all common Supabase methods
class MockQueryBuilder {
private data: unknown[];
private tableName: string;
private filters: { column: string; value: unknown; op: string }[] = [];
private selectColumns: string = "*";
private orderColumn?: string;
private orderDirection?: "asc" | "desc";
private limitValue?: number;
constructor(tableName: string) {
this.tableName = tableName;
this.data = [...(getMockTableData(tableName) || [])];
}
select(columns: string = "*") {
this.selectColumns = columns;
return this;
}
eq(column: string, value: unknown) {
this.filters.push({ column, value, op: "eq" });
return this;
}
neq(column: string, value: unknown) {
this.filters.push({ column, value, op: "neq" });
return this;
}
gt(column: string, value: unknown) {
this.filters.push({ column, value, op: "gt" });
return this;
}
gte(column: string, value: unknown) {
this.filters.push({ column, value, op: "gte" });
return this;
}
lt(column: string, value: unknown) {
this.filters.push({ column, value, op: "lt" });
return this;
}
lte(column: string, value: unknown) {
this.filters.push({ column, value, op: "lte" });
return this;
}
is(column: string, value: unknown) {
this.filters.push({ column, value, op: "is" });
return this;
}
like(column: string, value: string) {
this.filters.push({ column, value, op: "like" });
return this;
}
ilike(column: string, value: string) {
this.filters.push({ column, value, op: "ilike" });
return this;
}
in(column: string, values: unknown[]) {
this.filters.push({ column, value: values, op: "in" });
return this;
}
order(column: string, options?: { ascending?: boolean; nullsFirst?: boolean }) {
this.orderColumn = column;
this.orderDirection = options?.ascending === false ? "desc" : "asc";
return this;
}
limit(count: number) {
this.limitValue = count;
return this;
}
range(min: number, max: number) {
// For pagination mock
return this;
}
single() {
return this.executeSingle();
}
then(resolve: (value: unknown) => void, _reject?: (reason?: unknown) => void) {
const result = this.execute();
resolve(result);
}
async executeSingle() {
const result = this.execute();
if (result.data && Array.isArray(result.data) && result.data.length > 0) {
return { data: result.data[0], error: null };
}
return { data: null, error: null };
}
execute() {
let filtered = [...this.data];
// Apply filters
for (const filter of this.filters) {
filtered = filtered.filter((row: any) => {
const rowValue = row[filter.column];
switch (filter.op) {
case "eq":
return rowValue === filter.value;
case "neq":
return rowValue !== filter.value;
case "gt":
return (rowValue as number) > (filter.value as number);
case "gte":
return (rowValue as number) >= (filter.value as number);
case "lt":
return (rowValue as number) < (filter.value as number);
case "lte":
return (rowValue as number) <= (filter.value as number);
case "is":
if (filter.value === null) return rowValue === null;
if (filter.value === undefined) return rowValue === undefined;
return rowValue === filter.value;
case "like":
return typeof rowValue === "string" && rowValue.includes((filter.value as string).replace(/%/g, ""));
case "ilike":
return typeof rowValue === "string" && rowValue.toLowerCase().includes((filter.value as string).replace(/%/g, "").toLowerCase());
case "in":
return Array.isArray(filter.value) && filter.value.includes(rowValue);
default:
return true;
}
});
}
// Apply ordering
if (this.orderColumn) {
filtered.sort((a: any, b: any) => {
const aVal = a[this.orderColumn!];
const bVal = b[this.orderColumn!];
if (aVal < bVal) return this.orderDirection === "desc" ? 1 : -1;
if (aVal > bVal) return this.orderDirection === "desc" ? -1 : 1;
return 0;
});
}
// Apply limit
if (this.limitValue !== undefined) {
filtered = filtered.slice(0, this.limitValue);
}
return { data: filtered, error: null };
}
}
// Mock insert/update/delete builders
class MockMutationBuilder {
private tableName: string;
private data: Record<string, unknown> | Record<string, unknown>[];
constructor(tableName: string, data: Record<string, unknown> | Record<string, unknown>[]) {
this.tableName = tableName;
this.data = data;
}
select() {
return new MockQueryBuilder(this.tableName);
}
then(resolve: (value: unknown) => void) {
const items = Array.isArray(this.data) ? this.data : [this.data];
const returning = items.map((item, i) => ({
...item,
id: item.id || `generated-${Date.now()}-${i}`,
}));
resolve({ data: returning, error: null });
}
}
// Mock storage builder
class MockStorageBuilder {
from(bucket: string) {
return {
upload: async (path: string, _file: unknown) => {
return { data: { path }, error: null };
},
download: async (path: string) => {
return { data: new Blob(), error: null };
},
remove: async (paths: string[]) => {
return { data: { paths }, error: null };
},
list: async () => {
return { data: [], error: null };
},
};
}
}
// Create mock client
function createMockClient() {
return {
from: (table: string) => new MockQueryBuilder(table),
insert: (data: Record<string, unknown> | Record<string, unknown>[]) => new MockMutationBuilder("unknown", data),
update: (data: Record<string, unknown>) => new MockMutationBuilder("unknown", data),
delete: () => ({
eq: () => ({ then: (resolve: (value: unknown) => void) => resolve({ data: null, error: null }) }),
in: () => ({ then: (resolve: (value: unknown) => void) => resolve({ data: null, error: null }) }),
}),
storage: new MockStorageBuilder(),
auth: {
getSession: async () => ({ data: { session: null }, error: null }),
getUser: async () => ({ data: { user: null }, error: null }),
signInWithPassword: async () => ({ data: { user: null, session: null }, error: null }),
signOut: async () => ({ error: null }),
onAuthStateChange: () => ({ data: { subscription: { unsubscribe: () => {} } } }),
},
channel: () => ({
on: () => ({ subscribe: () => ({}) }),
subscribe: () => ({}),
}),
};
}
// Real Supabase client creation
function getSupabase(): SupabaseClient {
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error(
`Missing Supabase env vars: ${[!supabaseUrl && "NEXT_PUBLIC_SUPABASE_URL", !supabaseAnonKey && "NEXT_PUBLIC_SUPABASE_ANON_KEY"].filter(Boolean).join(", ")}. ` +
"Check Vercel environment variables for Production environment. " +
"Node env: " + (process.env.NODE_ENV ?? "unknown")
);
}
return createClient(supabaseUrl, supabaseAnonKey);
}
// Create proxy that routes to real or mock client
let realSupabase: SupabaseClient | null = null;
if (!useMockData) {
try {
realSupabase = getSupabase();
} catch {
// Will use mock below
}
}
export const supabase: SupabaseClient = useMockData || !realSupabase
? createMockClient() as unknown as SupabaseClient
: realSupabase;
export { supabaseUrl, supabaseAnonKey, useMockData };
+25
View File
@@ -0,0 +1,25 @@
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export function createClient(request: NextRequest) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = NextResponse.next({ request });
return createServerClient(supabaseUrl, supabaseAnonKey, {
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet, headers) {
cookiesToSet.forEach(({ name, value, options }) => {
response.cookies.set(name, value, options);
});
Object.entries(headers).forEach(([key, value]) => {
response.headers.set(key, value);
});
},
},
});
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Safe service-headers for Vercel Edge Runtime.
*
* Supabase REST accepts `apikey` as a standalone auth header — no
* Authorization: Bearer needed. This avoids all JWT header validation
* issues on Vercel Edge Runtime where +, /, = cause "Headers.append:
* ...is an invalid header value".
*
* The apikey header alone is sufficient for service-role access to
* the Supabase REST API.
*/
export function svcHeaders(serviceKey: string): Record<string, string> {
return {
apikey: serviceKey,
};
}
+193
View File
@@ -0,0 +1,193 @@
/**
* Water Log reporting - shapes entries for export, sync, and display.
*/
export type WaterLogReportRow = {
logged_at: string;
headgate_name: string;
user_name: string;
user_role: string;
measurement: number;
unit: string;
notes: string | null;
submitted_via: string;
};
export type WaterLogFilter = {
dateFrom?: string;
dateTo?: string;
headgateId?: string;
userId?: string;
submittedVia?: string;
};
export type IrrigationSeasonSettings = {
seasonStartMonth: number;
seasonStartDay: number;
seasonEndMonth: number;
seasonEndDay: number;
};
export type DailyReportOptions = {
recipientName?: string;
seasonLabel?: string;
sendEvenIfEmpty?: boolean;
previewMode?: boolean;
};
export const WATER_LOG_DISPLAY_REFRESH_MS = 30_000;
export function getDisplayAgeLabel(minutes: number | null): string {
if (minutes === null) return "never";
if (minutes < 1) return "just now";
if (minutes < 60) return Math.floor(minutes) + "m ago";
if (minutes < 120) return "1h ago";
if (minutes < 1440) return Math.floor(minutes / 60) + "h ago";
return Math.floor(minutes / 1440) + "d ago";
}
export function getDisplayAgeColor(minutes: number | null): "green" | "yellow" | "red" {
if (minutes === null) return "red";
if (minutes < 30) return "green";
if (minutes < 120) return "yellow";
return "red";
}
export function shapeWaterLogEntry(entry: {
id: string;
logged_at: string;
headgate_name: string;
user_name: string;
measurement: number;
unit: string;
notes: string | null;
submitted_via: string;
user_id?: string;
[key: string]: unknown;
}): WaterLogReportRow {
return {
logged_at: entry.logged_at,
headgate_name: entry.headgate_name,
user_name: entry.user_name,
user_role: (entry.user_role as string) ?? "irrigator",
measurement: entry.measurement,
unit: entry.unit,
notes: entry.notes ?? null,
submitted_via: entry.submitted_via,
};
}
export function filterWaterLogEntries(
rows: WaterLogReportRow[],
filters: WaterLogFilter
): WaterLogReportRow[] {
return rows.filter((row) => {
if (filters.dateFrom && row.logged_at < filters.dateFrom) return false;
if (filters.dateTo) {
const toEnd = filters.dateTo + "T23:59:59.999Z";
if (row.logged_at > toEnd) return false;
}
if (filters.headgateId && row.headgate_name !== filters.headgateId) return false;
if (filters.userId && row.user_name !== filters.userId) return false;
if (filters.submittedVia && row.submitted_via !== filters.submittedVia) return false;
return true;
});
}
function csvEncode(value: string): string {
return '"' + value.replace(/"/g, '""') + '"';
}
export function waterLogToCSV(rows: WaterLogReportRow[]): string {
const headers = ["When", "Headgate", "User", "Role", "Measurement", "Unit", "Notes", "Via"];
const lines: string[] = [headers.join(",")];
for (let i = 0; i < rows.length; i++) {
const r = rows[i];
const cells = [
csvEncode(r.logged_at),
csvEncode(r.headgate_name),
csvEncode(r.user_name),
csvEncode(r.user_role),
String(r.measurement),
csvEncode(r.unit),
csvEncode(r.notes ?? ""),
csvEncode(r.submitted_via),
];
lines.push(cells.join(","));
}
return lines.join("\n");
}
export function downloadWaterLogCSV(filename: string, rows: WaterLogReportRow[]): void {
const csv = waterLogToCSV(rows);
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.setAttribute("href", url);
link.setAttribute("download", filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
export function isInIrrigationSeason(
date: Date,
settings: IrrigationSeasonSettings = {
seasonStartMonth: 3,
seasonStartDay: 15,
seasonEndMonth: 10,
seasonEndDay: 15,
}
): boolean {
const month = date.getMonth() + 1;
const day = date.getDate();
const start = settings.seasonStartMonth * 100 + settings.seasonStartDay;
const end = settings.seasonEndMonth * 100 + settings.seasonEndDay;
const current = month * 100 + day;
if (start <= end) {
return current >= start && current <= end;
} else {
return current >= start || current <= end;
}
}
export function formatDailyWaterReport(
rows: WaterLogReportRow[],
opts: DailyReportOptions = {}
): string | null {
const today = new Date();
const months = ["January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"];
const dateLabel = months[today.getMonth()] + " " + today.getDate() + ", " + today.getFullYear();
const count = rows.length;
if (count === 0) {
if (!opts.sendEvenIfEmpty) return null;
return "Water Log Summary - " + dateLabel + "\nNo entries reported today.\n\nSent by Route Commerce Water Log";
}
let total = 0;
for (let j = 0; j < rows.length; j++) {
total += rows[j].measurement;
}
const lines: string[] = [
"Water Log Summary - " + dateLabel,
count + " " + (count === 1 ? "entry" : "entries") + " reported today (" + total.toFixed(2) + " total)",
"",
];
for (let k = 0; k < rows.length; k++) {
const row = rows[k];
const time = new Date(row.logged_at).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
let entryLine = time + " | " + row.user_name + " | " + row.headgate_name + " | " + row.measurement + " " + row.unit;
if (row.notes) {
entryLine += ' - "' + row.notes + '"';
}
lines.push(entryLine);
}
lines.push("", "Sent by Route Commerce Water Log");
return lines.join("\n");
}