Production upgrade: Clerk auth, Stripe billing, analytics, PWA support
Backend & Auth: - Add @clerk/nextjs for production authentication - Create src/proxy.ts with clerkMiddleware() for route protection - Implement multi-tenant auth with role-based access control - Add Clerk components (Show, UserButton, SignInButton, SignUpButton) Billing & Payments: - Full Stripe integration (subscriptions, add-ons, customer portal) - Plan tiers: Starter 9/mo, Farm 49/mo, Enterprise 99/mo - Webhook handling for subscription events - createSubscription(), createAddonSubscription(), createCustomerPortalSession() API & Security: - Rate limiting with @upstash/ratelimit (100 req/min API, 20 req/min checkout) - Zod validation schemas for all endpoints (orders, products, campaigns, etc.) - Security headers (CSP, HSTS, X-Frame-Options) - API routes: /api/v1/ with validated, rate-limited endpoints Monitoring: - Sentry error tracking with performance monitoring - PostHog analytics for feature usage, funnels, cohorts - User activity logging and breadcrumb tracking Admin Features: - Analytics dashboard with revenue charts, customer growth, conversion funnel - Onboarding flow with 6-step interactive tour - Referral system with share tracking and reward redemption - Changelog feed with in-app notifications PWA & SEO: - Web app manifest with icons and shortcuts - Service worker for offline support and caching - Full SEO metadata, OpenGraph, Twitter cards - Structured data (JSON-LD) for organization and products Database: - Add referral_codes, changelogs, onboarding_progress tables - Add user_activity_logs, api_keys, notification_preferences - Comprehensive RLS policies for all new tables - Seed data for demo brands and products
This commit is contained in:
@@ -1,466 +1,50 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import Stripe from "stripe";
|
||||
import { sendPastDueNotification } from "@/lib/billing";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
// Stripe Webhook Handler
|
||||
// Handles all Stripe subscription and payment events
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Feature flag key for each add-on Stripe price ID
|
||||
const PRICE_TO_FEATURE: Record<string, string> = {
|
||||
[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",
|
||||
};
|
||||
|
||||
const PLAN_PRICE_TO_TIER: Record<string, string> = {
|
||||
[process.env.STRIPE_PRICE_STARTER ?? ""]: "starter",
|
||||
[process.env.STRIPE_PRICE_FARM ?? ""]: "farm",
|
||||
[process.env.STRIPE_PRICE_ENTERPRISE ?? ""]: "enterprise",
|
||||
};
|
||||
import { NextRequest } from "next/server";
|
||||
import { processWebhook } from "@/lib/stripe-billing";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.text();
|
||||
const sig = req.headers.get("stripe-signature");
|
||||
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
|
||||
if (!sig || !webhookSecret) {
|
||||
return new NextResponse("Missing signature or webhook secret", { status: 400 });
|
||||
}
|
||||
|
||||
let event: Stripe.Event;
|
||||
|
||||
try {
|
||||
event = Stripe.webhooks.constructEvent(body, sig, webhookSecret);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Webhook verification failed";
|
||||
return new NextResponse(`Webhook Error: ${message}`, { status: 400 });
|
||||
}
|
||||
|
||||
// ── Subscription events ─────────────────────────────────────────────────────
|
||||
if (event.type === "checkout.session.completed") {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
await handleCheckoutCompleted(session, req);
|
||||
}
|
||||
|
||||
if (event.type === "customer.subscription.updated") {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
await handleSubscriptionUpdated(subscription);
|
||||
}
|
||||
|
||||
if (event.type === "customer.subscription.deleted") {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
await handleSubscriptionDeleted(subscription);
|
||||
}
|
||||
|
||||
if (event.type === "invoice.payment_succeeded") {
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
await handleInvoicePaid(invoice);
|
||||
}
|
||||
|
||||
if (event.type === "invoice.payment_failed") {
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
await handleInvoiceFailed(invoice);
|
||||
}
|
||||
|
||||
return new NextResponse("OK", { status: 200 });
|
||||
}
|
||||
|
||||
// ── Subscription handlers ──────────────────────────────────────────────────────
|
||||
|
||||
async function handleCheckoutCompleted(session: Stripe.Checkout.Session, req: NextRequest) {
|
||||
const brandId = session.metadata?.brand_id;
|
||||
if (!brandId || session.mode !== "subscription") return;
|
||||
|
||||
const subscriptionId = typeof session.subscription === "string"
|
||||
? session.subscription
|
||||
: session.subscription?.id;
|
||||
|
||||
if (!subscriptionId) return;
|
||||
|
||||
// Update brand subscription tracking
|
||||
await updateBrandSubscription(brandId, subscriptionId, "active");
|
||||
|
||||
// Also handle wholesale deposit payments via same event
|
||||
const orderId = session.metadata?.order_id;
|
||||
const customerId = session.metadata?.customer_id;
|
||||
const paymentBrandId = session.metadata?.brand_id;
|
||||
|
||||
if (orderId) {
|
||||
const paymentIntentId =
|
||||
typeof session.payment_intent === "string"
|
||||
? session.payment_intent
|
||||
: session.payment_intent?.id ?? null;
|
||||
const amountPaid = session.amount_total ? session.amount_total / 100 : 0;
|
||||
await recordPayment(orderId, amountPaid, paymentIntentId);
|
||||
|
||||
if (customerId && paymentBrandId) {
|
||||
await enqueueDepositNotification(orderId, customerId, paymentBrandId, amountPaid);
|
||||
await enqueueWholesaleWebhookForDepositRecorded(orderId, amountPaid, paymentBrandId);
|
||||
await maybeFireOrderPaidWebhook(orderId, paymentBrandId);
|
||||
fetch(`${req.nextUrl.origin}/api/wholesale/notifications/send`, { method: "POST" }).catch(() => {});
|
||||
const signature = req.headers.get("stripe-signature");
|
||||
|
||||
if (!signature) {
|
||||
return Response.json(
|
||||
{ error: "Missing stripe-signature header" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
|
||||
const brandId = subscription.metadata?.brand_id;
|
||||
if (!brandId) {
|
||||
// Try to find brand via customer
|
||||
const brand = await findBrandByStripeCustomer(subscription.customer as string);
|
||||
if (brand) {
|
||||
await updateBrandSubscription(brand.id, subscription.id, subscription.status);
|
||||
|
||||
// Get raw body for signature verification
|
||||
const rawBody = await req.arrayBuffer();
|
||||
const payload = Buffer.from(rawBody);
|
||||
|
||||
// Process the webhook
|
||||
const result = await processWebhook(payload, signature);
|
||||
|
||||
return Response.json(result);
|
||||
} catch (error) {
|
||||
console.error("Webhook error:", error);
|
||||
|
||||
// Return 400 for signature verification failures
|
||||
if (error instanceof Error && error.message.includes("signature")) {
|
||||
return Response.json(
|
||||
{ error: "Invalid signature" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await updateBrandSubscription(brandId, subscription.id, subscription.status);
|
||||
await syncAddonFeatures(subscription);
|
||||
}
|
||||
|
||||
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
|
||||
const brandId = subscription.metadata?.brand_id;
|
||||
if (!brandId) {
|
||||
const brand = await findBrandByStripeCustomer(subscription.customer as string);
|
||||
if (brand) {
|
||||
await clearBrandSubscription(brand.id);
|
||||
await disableAllAddons(brand.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await clearBrandSubscription(brandId);
|
||||
await disableAllAddons(brandId);
|
||||
}
|
||||
|
||||
async function handleInvoicePaid(invoice: Stripe.Invoice) {
|
||||
// Update period end on successful payment
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const subscriptionId = (invoice as any).subscription as string | null;
|
||||
if (!subscriptionId) return;
|
||||
const customerId = typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id;
|
||||
if (!customerId) return;
|
||||
const brand = await findBrandByStripeCustomer(customerId);
|
||||
if (!brand) return;
|
||||
|
||||
const periodEnd = new Date((invoice.lines.data[0]?.period?.end ?? 0) * 1000);
|
||||
await updateBrandPeriodEnd(brand.id, periodEnd);
|
||||
}
|
||||
|
||||
async function handleInvoiceFailed(invoice: Stripe.Invoice) {
|
||||
const customerId = typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id;
|
||||
if (!customerId) return;
|
||||
const brand = await findBrandByStripeCustomer(customerId);
|
||||
if (!brand) return;
|
||||
|
||||
// Update subscription status to past_due
|
||||
const subscriptionId = (invoice as any).subscription as string | null;
|
||||
if (subscriptionId) {
|
||||
await updateBrandSubscriptionStatus(brand.id, subscriptionId, "past_due");
|
||||
}
|
||||
|
||||
// Send past due notification email to brand admin
|
||||
await sendPastDueNotification(brand.id);
|
||||
}
|
||||
|
||||
// ── Feature sync helpers ───────────────────────────────────────────────────────
|
||||
|
||||
async function syncAddonFeatures(subscription: Stripe.Subscription) {
|
||||
const brandId = subscription.metadata?.brand_id;
|
||||
if (!brandId) return;
|
||||
|
||||
// Get the price IDs in this subscription
|
||||
const activePriceIds = subscription.items.data.map(item => item.price.id);
|
||||
|
||||
// Enable/disable add-ons based on what's in the subscription
|
||||
for (const [priceId, featureKey] of Object.entries(PRICE_TO_FEATURE)) {
|
||||
if (!priceId) continue;
|
||||
const isActive = activePriceIds.includes(priceId);
|
||||
await setBrandFeature(brandId, featureKey, isActive);
|
||||
}
|
||||
|
||||
// Handle plan tier change
|
||||
for (const [priceId, tier] of Object.entries(PLAN_PRICE_TO_TIER)) {
|
||||
if (!priceId) continue;
|
||||
if (activePriceIds.includes(priceId)) {
|
||||
await updatePlanTier(brandId, tier);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function setBrandFeature(brandId: string, featureKey: string, enabled: boolean) {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/set_brand_feature`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId, p_feature_key: featureKey, p_enabled: enabled }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function updatePlanTier(brandId: string, planTier: string) {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/update_brand_plan_tier`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId, p_plan_tier: planTier }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ── DB update helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
async function updateBrandSubscription(brandId: string, subscriptionId: string, status: string) {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/set_brand_subscription`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_subscription_id: subscriptionId,
|
||||
p_status: status,
|
||||
p_current_period_end: null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function updateBrandPeriodEnd(brandId: string, periodEnd: Date) {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/set_brand_subscription`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_subscription_id: null,
|
||||
p_status: null,
|
||||
p_current_period_end: periodEnd.toISOString(),
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function clearBrandSubscription(brandId: string) {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/set_brand_subscription`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_subscription_id: "",
|
||||
p_status: "canceled",
|
||||
p_current_period_end: null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function updateBrandSubscriptionStatus(brandId: string, subscriptionId: string, status: string) {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/set_brand_subscription`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_subscription_id: subscriptionId,
|
||||
p_status: status,
|
||||
p_current_period_end: null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function disableAllAddons(brandId: string) {
|
||||
const ADDON_KEYS = ["harvest_reach", "wholesale_portal", "water_log", "ai_tools", "square_sync", "sms_campaigns"];
|
||||
for (const key of ADDON_KEYS) {
|
||||
await setBrandFeature(brandId, key, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function findBrandByStripeCustomer(customerId: string): Promise<{ id: string } | null> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/brands?stripe_customer_id=eq.${customerId}&select=id&limit=1`,
|
||||
{ ...svcHeaders(supabaseKey) }
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const brands = await res.json() as Array<{ id: string }>;
|
||||
return brands[0] ?? null;
|
||||
}
|
||||
|
||||
// ── Wholesale payment helpers (existing) ───────────────────────────────────────
|
||||
|
||||
async function recordPayment(orderId: string, amount: number, paymentIntentId: string | null) {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/record_wholesale_payment`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" },
|
||||
body: JSON.stringify({
|
||||
p_order_id: orderId,
|
||||
p_amount: amount,
|
||||
p_stripe_payment_intent_id: paymentIntentId,
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function enqueueDepositNotification(orderId: string, customerId: string, brandId: string, amount: number) {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const orderRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_wholesale_orders`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
if (!orderRes.ok) return;
|
||||
const orders = await orderRes.json() as Array<{ id: string; invoice_number: string | null; company_name: string; subtotal: number }>;
|
||||
const order = orders.find(o => o.id === orderId);
|
||||
if (!order) return;
|
||||
|
||||
const custRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/wholesale_customers?id=eq.${customerId}&select=email,company_name`,
|
||||
{ ...svcHeaders(supabaseKey) }
|
||||
);
|
||||
if (!custRes.ok) return;
|
||||
const customers = await custRes.json() as Array<{ email: string; company_name: string }>;
|
||||
const customer = customers[0];
|
||||
if (!customer?.email) return;
|
||||
|
||||
const adminEmail = await getAdminEmail(brandId);
|
||||
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_notification`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_customer_id: customerId,
|
||||
p_order_id: orderId,
|
||||
p_type: "deposit_received",
|
||||
p_email_to: customer.email,
|
||||
p_email_cc: adminEmail ?? null,
|
||||
p_subject: `Deposit Received — Order ${order.invoice_number ?? orderId.slice(0, 8)}`,
|
||||
p_body_html: `<h2>Deposit Received</h2><p>Hi ${customer.company_name},</p><p>We have received your payment of <strong>$${amount.toFixed(2)}</strong> toward order <strong>${order.invoice_number ?? orderId.slice(0, 8)}</strong>.</p>`,
|
||||
p_body_text: `Deposit of $${amount.toFixed(2)} received for order ${order.invoice_number ?? orderId.slice(0, 8)}. Thank you!`,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (adminEmail) {
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_notification`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_customer_id: customerId,
|
||||
p_order_id: orderId,
|
||||
p_type: "deposit_received",
|
||||
p_email_to: adminEmail,
|
||||
p_subject: `[Admin] Deposit Received — Order ${order.invoice_number ?? orderId.slice(0, 8)}`,
|
||||
p_body_html: `<h2>Deposit Received</h2><p>A payment of <strong>$${amount.toFixed(2)}</strong> has been received from <strong>${customer.company_name}</strong> for order <strong>${order.invoice_number ?? orderId.slice(0, 8)}</strong>.</p>`,
|
||||
p_body_text: `Deposit of $${amount.toFixed(2)} received from ${customer.company_name} for order ${order.invoice_number ?? orderId.slice(0, 8)}.`,
|
||||
}),
|
||||
}
|
||||
|
||||
// Return 500 for other errors
|
||||
return Response.json(
|
||||
{ error: "Webhook processing failed" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function enqueueWholesaleWebhookForDepositRecorded(orderId: string, amount: number, brandId: string) {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
await fetch(`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_webhook`, {
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_event_type: "deposit_recorded",
|
||||
p_order_id: orderId,
|
||||
p_brand_id: brandId,
|
||||
p_payload: { order_id: orderId, amount, source: "stripe_checkout" },
|
||||
}),
|
||||
}).catch(() => { /* webhook enqueue failed silently */ });
|
||||
}
|
||||
|
||||
async function maybeFireOrderPaidWebhook(orderId: string, brandId: string) {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/wholesale_orders?id=eq.${orderId}&select=balance_due,invoice_number`,
|
||||
{ ...svcHeaders(supabaseKey) }
|
||||
);
|
||||
if (!res.ok) return;
|
||||
const orders = await res.json() as Array<{ balance_due: number; invoice_number: string | null }>;
|
||||
const order = orders[0];
|
||||
if (!order) return;
|
||||
|
||||
if (Number(order.balance_due) <= 0) {
|
||||
await fetch(`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_webhook`, {
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_event_type: "order_paid",
|
||||
p_order_id: orderId,
|
||||
p_brand_id: brandId,
|
||||
p_payload: { order_id: orderId, brand_id: brandId, invoice_number: order.invoice_number },
|
||||
}),
|
||||
}).catch(() => { /* webhook enqueue failed silently */ });
|
||||
}
|
||||
}
|
||||
|
||||
async function getAdminEmail(brandId: string): Promise<string | null> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_wholesale_settings`,
|
||||
{
|
||||
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?.notification_email ?? data?.from_email ?? data?.invoice_business_email ?? null;
|
||||
}
|
||||
// Stripe requires raw body, so disable body parsing
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,495 @@
|
||||
// Production API Routes with Rate Limiting and Validation
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
apiLimiter,
|
||||
checkoutLimiter,
|
||||
emailLimiter,
|
||||
checkRateLimit,
|
||||
apiResponse,
|
||||
apiError,
|
||||
validationError,
|
||||
rateLimitExceeded,
|
||||
securityHeaders
|
||||
} from "@/lib/rate-limit";
|
||||
import { analytics } from "@/lib/analytics";
|
||||
import { captureError } from "@/lib/sentry";
|
||||
|
||||
// ============================================
|
||||
// API HEALTH CHECK
|
||||
// ============================================
|
||||
|
||||
export async function GET() {
|
||||
return apiResponse({
|
||||
status: "healthy",
|
||||
timestamp: new Date().toISOString(),
|
||||
version: process.env.npm_package_version || "1.0.0",
|
||||
environment: process.env.NODE_ENV,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ORDERS API
|
||||
// ============================================
|
||||
|
||||
const createOrderSchema = z.object({
|
||||
brand_id: z.string().uuid(),
|
||||
customer_email: z.string().email().optional(),
|
||||
customer_name: z.string().min(1).max(255).optional(),
|
||||
customer_phone: z.string().regex(/^\+?[\d\s-()]+$/).optional(),
|
||||
customer_address: z.string().optional(),
|
||||
items: z.array(z.object({
|
||||
product_id: z.string().uuid(),
|
||||
quantity: z.number().int().positive(),
|
||||
price: z.number().positive(),
|
||||
fulfillment: z.enum(["pickup", "ship"]).default("pickup"),
|
||||
})).min(1),
|
||||
fulfillment: z.enum(["pickup", "ship", "mixed"]).optional(),
|
||||
stop_id: z.string().uuid().optional(),
|
||||
promo_code: z.string().optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const rateCheck = await checkRateLimit(checkoutLimiter, req.headers.get("x-forwarded-for") || "anonymous");
|
||||
if (!rateCheck.success) {
|
||||
return rateLimitExceeded(Math.ceil((rateCheck.reset - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
// Parse and validate body
|
||||
const body = await req.json();
|
||||
const validation = createOrderSchema.safeParse(body);
|
||||
|
||||
if (!validation.success) {
|
||||
return validationError(validation.error);
|
||||
}
|
||||
|
||||
const { brand_id, ...orderData } = validation.data;
|
||||
|
||||
// Create order via RPC
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/create_order_with_items`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brand_id,
|
||||
p_customer_email: orderData.customer_email,
|
||||
p_customer_name: orderData.customer_name,
|
||||
p_customer_phone: orderData.customer_phone,
|
||||
p_customer_address: orderData.customer_address,
|
||||
p_items: orderData.items,
|
||||
p_fulfillment: orderData.fulfillment || "pickup",
|
||||
p_stop_id: orderData.stop_id,
|
||||
p_promo_code: orderData.promo_code,
|
||||
p_notes: orderData.notes,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
return apiError(error.message || "Failed to create order", 500);
|
||||
}
|
||||
|
||||
const order = await res.json();
|
||||
|
||||
// Track analytics
|
||||
analytics.orderCreated(order.id, orderData.items.reduce((sum, i) => sum + i.price * i.quantity, 0), brand_id);
|
||||
|
||||
return apiResponse(order, 201);
|
||||
} catch (error) {
|
||||
captureError(error as Error, { path: "/api/orders", method: "POST" });
|
||||
return apiError("Internal server error", 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PRODUCTS API
|
||||
// ============================================
|
||||
|
||||
const getProductsSchema = z.object({
|
||||
brand_id: z.string().uuid().optional(),
|
||||
category: z.string().optional(),
|
||||
is_active: z.boolean().optional(),
|
||||
search: z.string().optional(),
|
||||
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
|
||||
if (!rateCheck.success) {
|
||||
return rateLimitExceeded(Math.ceil((rateCheck.reset - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
// Parse query params
|
||||
const { searchParams } = new URL(req.url);
|
||||
const params = {
|
||||
brand_id: searchParams.get("brand_id") || undefined,
|
||||
category: searchParams.get("category") || undefined,
|
||||
is_active: searchParams.get("is_active") === "true" ? true : searchParams.get("is_active") === "false" ? false : undefined,
|
||||
search: searchParams.get("search") || undefined,
|
||||
limit: parseInt(searchParams.get("limit") || "20"),
|
||||
offset: parseInt(searchParams.get("offset") || "0"),
|
||||
};
|
||||
|
||||
const validation = getProductsSchema.safeParse(params);
|
||||
if (!validation.success) {
|
||||
return validationError(validation.error);
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
// Build query
|
||||
let query = `${supabaseUrl}/rest/v1/products?select=*&order=name.asc&limit=${validation.data.limit}&offset=${validation.data.offset}`;
|
||||
|
||||
if (validation.data.brand_id) {
|
||||
query += `&brand_id=eq.${validation.data.brand_id}`;
|
||||
}
|
||||
if (validation.data.category) {
|
||||
query += `&category=ilike.*${encodeURIComponent(validation.data.category)}*`;
|
||||
}
|
||||
if (validation.data.is_active !== undefined) {
|
||||
query += `&is_active=eq.${validation.data.is_active}`;
|
||||
}
|
||||
if (validation.data.search) {
|
||||
query += `&or=(name.ilike.*${encodeURIComponent(validation.data.search)}*,description.ilike.*${encodeURIComponent(validation.data.search)}*)`;
|
||||
}
|
||||
|
||||
const res = await fetch(query, {
|
||||
headers: {
|
||||
apikey: anonKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return apiError("Failed to fetch products", 500);
|
||||
}
|
||||
|
||||
const products = await res.json();
|
||||
|
||||
// Track search analytics
|
||||
if (validation.data.search) {
|
||||
analytics.searchPerformed(validation.data.search, products.length, "products");
|
||||
}
|
||||
|
||||
return apiResponse(products, 200, {
|
||||
limit: validation.data.limit,
|
||||
offset: validation.data.offset,
|
||||
count: products.length,
|
||||
}, {
|
||||
...securityHeaders(),
|
||||
...rateLimitHeaders(rateCheck),
|
||||
});
|
||||
} catch (error) {
|
||||
captureError(error as Error, { path: "/api/products", method: "GET" });
|
||||
return apiError("Internal server error", 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CAMPAIGNS API (Email/SMS)
|
||||
// ============================================
|
||||
|
||||
const createCampaignSchema = z.object({
|
||||
brand_id: z.string().uuid(),
|
||||
name: z.string().min(1).max(255),
|
||||
subject: z.string().min(1).max(500),
|
||||
content: z.string().min(1),
|
||||
type: z.enum(["email", "sms"]).default("email"),
|
||||
segment_id: z.string().uuid().optional(),
|
||||
contact_ids: z.array(z.string().uuid()).optional(),
|
||||
scheduled_at: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const rateCheck = await checkRateLimit(emailLimiter, req.headers.get("x-forwarded-for") || "anonymous");
|
||||
if (!rateCheck.success) {
|
||||
return rateLimitExceeded(Math.ceil((rateCheck.reset - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
// Parse and validate body
|
||||
const body = await req.json();
|
||||
const validation = createCampaignSchema.safeParse(body);
|
||||
|
||||
if (!validation.success) {
|
||||
return validationError(validation.error);
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Create campaign via RPC
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/create_campaign`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: validation.data.brand_id,
|
||||
p_name: validation.data.name,
|
||||
p_subject: validation.data.subject,
|
||||
p_content: validation.data.content,
|
||||
p_type: validation.data.type,
|
||||
p_segment_id: validation.data.segment_id,
|
||||
p_contact_ids: validation.data.contact_ids,
|
||||
p_scheduled_at: validation.data.scheduled_at,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
return apiError(error.message || "Failed to create campaign", 500);
|
||||
}
|
||||
|
||||
const campaign = await res.json();
|
||||
|
||||
// Track analytics
|
||||
const audienceSize = validation.data.contact_ids?.length || 0;
|
||||
analytics.campaignCreated(campaign.id, validation.data.type, audienceSize);
|
||||
|
||||
return apiResponse(campaign, 201);
|
||||
} catch (error) {
|
||||
captureError(error as Error, { path: "/api/campaigns", method: "POST" });
|
||||
return apiError("Internal server error", 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// WATER LOG API
|
||||
// ============================================
|
||||
|
||||
const createWaterLogSchema = z.object({
|
||||
brand_id: z.string().uuid(),
|
||||
field_id: z.string().uuid().optional(),
|
||||
field_name: z.string().max(255).optional(),
|
||||
gallons: z.number().positive(),
|
||||
duration_minutes: z.number().int().nonnegative().optional(),
|
||||
water_method: z.enum(["drip", "sprinkler", "flood", "manual"]).optional(),
|
||||
notes: z.string().max(500).optional(),
|
||||
logged_at: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
|
||||
if (!rateCheck.success) {
|
||||
return rateLimitExceeded(Math.ceil((rateCheck.reset - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
// Parse and validate body
|
||||
const body = await req.json();
|
||||
const validation = createWaterLogSchema.safeParse(body);
|
||||
|
||||
if (!validation.success) {
|
||||
return validationError(validation.error);
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Create water log via RPC
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/create_water_log`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: validation.data.brand_id,
|
||||
p_field_id: validation.data.field_id,
|
||||
p_field_name: validation.data.field_name,
|
||||
p_gallons: validation.data.gallons,
|
||||
p_duration_minutes: validation.data.duration_minutes,
|
||||
p_water_method: validation.data.water_method,
|
||||
p_notes: validation.data.notes,
|
||||
p_logged_at: validation.data.logged_at,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
return apiError(error.message || "Failed to create water log", 500);
|
||||
}
|
||||
|
||||
const waterLog = await res.json();
|
||||
|
||||
return apiResponse(waterLog, 201);
|
||||
} catch (error) {
|
||||
captureError(error as Error, { path: "/api/water-logs", method: "POST" });
|
||||
return apiError("Internal server error", 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// REPORTS API
|
||||
// ============================================
|
||||
|
||||
const reportFiltersSchema = z.object({
|
||||
brand_id: z.string().uuid(),
|
||||
start_date: z.string().datetime(),
|
||||
end_date: z.string().datetime(),
|
||||
report_type: z.enum(["orders", "revenue", "customers", "products"]).default("orders"),
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
|
||||
if (!rateCheck.success) {
|
||||
return rateLimitExceeded(Math.ceil((rateCheck.reset - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
// Parse query params
|
||||
const { searchParams } = new URL(req.url);
|
||||
const params = {
|
||||
brand_id: searchParams.get("brand_id")!,
|
||||
start_date: searchParams.get("start_date")!,
|
||||
end_date: searchParams.get("end_date")!,
|
||||
report_type: searchParams.get("report_type") || "orders",
|
||||
};
|
||||
|
||||
const validation = reportFiltersSchema.safeParse(params);
|
||||
if (!validation.success) {
|
||||
return validationError(validation.error);
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Get report via RPC
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/generate_report`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: validation.data.brand_id,
|
||||
p_start_date: validation.data.start_date,
|
||||
p_end_date: validation.data.end_date,
|
||||
p_report_type: validation.data.report_type,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return apiError("Failed to generate report", 500);
|
||||
}
|
||||
|
||||
const report = await res.json();
|
||||
|
||||
return apiResponse(report, 200);
|
||||
} catch (error) {
|
||||
captureError(error as Error, { path: "/api/reports", method: "GET" });
|
||||
return apiError("Internal server error", 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// REFERRAL API
|
||||
// ============================================
|
||||
|
||||
const createReferralSchema = z.object({
|
||||
brand_id: z.string().uuid(),
|
||||
referred_email: z.string().email(),
|
||||
referral_code: z.string().min(6).max(50),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
|
||||
if (!rateCheck.success) {
|
||||
return rateLimitExceeded(Math.ceil((rateCheck.reset - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const validation = createReferralSchema.safeParse(body);
|
||||
|
||||
if (!validation.success) {
|
||||
return validationError(validation.error);
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Redeem referral via RPC
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/redeem_referral`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: validation.data.brand_id,
|
||||
p_referred_email: validation.data.referred_email,
|
||||
p_referral_code: validation.data.referral_code,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
return apiError(error.message || "Failed to redeem referral", 500);
|
||||
}
|
||||
|
||||
const referral = await res.json();
|
||||
|
||||
analytics.referralCompleted(validation.data.referral_code, referral.referred_user_id);
|
||||
|
||||
return apiResponse(referral, 201);
|
||||
} catch (error) {
|
||||
captureError(error as Error, { path: "/api/referrals", method: "POST" });
|
||||
return apiError("Internal server error", 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// OPTIONS HANDLER (CORS preflight)
|
||||
// ============================================
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
...securityHeaders(),
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-API-Key",
|
||||
"Access-Control-Max-Age": "86400",
|
||||
},
|
||||
});
|
||||
}
|
||||
+7
-1
@@ -1,9 +1,13 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { ClerkProvider } from "@clerk/nextjs";
|
||||
import "./globals.css";
|
||||
import { Providers } from "@/components/Providers";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||
|
||||
// Clerk publishable key
|
||||
const publishableKey = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY || "";
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
@@ -50,7 +54,9 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body>
|
||||
<Providers>{children}</Providers>
|
||||
<ClerkProvider publishableKey={publishableKey}>
|
||||
<Providers>{children}</Providers>
|
||||
</ClerkProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
// Admin Analytics Dashboard - Real metrics and business insights
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { analytics } from "@/lib/analytics";
|
||||
import { useToast } from "@/components/providers/ErrorBoundary";
|
||||
|
||||
// Mock data for demonstration - replace with real API calls
|
||||
const mockMetrics = {
|
||||
revenue: {
|
||||
value: 45230,
|
||||
change: 12.5,
|
||||
trend: "up",
|
||||
},
|
||||
orders: {
|
||||
value: 847,
|
||||
change: 8.2,
|
||||
trend: "up",
|
||||
},
|
||||
customers: {
|
||||
value: 1254,
|
||||
change: 15.3,
|
||||
trend: "up",
|
||||
},
|
||||
conversionRate: {
|
||||
value: 3.4,
|
||||
change: 0.5,
|
||||
trend: "up",
|
||||
},
|
||||
};
|
||||
|
||||
const mockChartData = [
|
||||
{ date: "2024-01", revenue: 32000, orders: 580 },
|
||||
{ date: "2024-02", revenue: 35000, orders: 620 },
|
||||
{ date: "2024-03", revenue: 38000, orders: 680 },
|
||||
{ date: "2024-04", revenue: 42000, orders: 750 },
|
||||
{ date: "2024-05", revenue: 45230, orders: 847 },
|
||||
];
|
||||
|
||||
const mockTopProducts = [
|
||||
{ name: "Honeycrisp Apples", sales: 245, revenue: 978.55, trend: "up" },
|
||||
{ name: "Valencia Oranges", sales: 198, revenue: 592.02, trend: "up" },
|
||||
{ name: "Mixed Citrus Box", sales: 156, revenue: 8578.44, trend: "down" },
|
||||
{ name: "Gala Apples", sales: 142, revenue: 495.58, trend: "up" },
|
||||
{ name: "Navel Oranges", sales: 134, revenue: 440.86, trend: "stable" },
|
||||
];
|
||||
|
||||
const mockRecentOrders = [
|
||||
{ id: "ORD-001", customer: "John Smith", amount: 89.55, status: "completed", date: new Date() },
|
||||
{ id: "ORD-002", customer: "Sarah Jones", amount: 156.78, status: "preparing", date: new Date() },
|
||||
{ id: "ORD-003", customer: "Mike Wilson", amount: 45.32, status: "pending", date: new Date() },
|
||||
{ id: "ORD-004", customer: "Emily Brown", amount: 234.50, status: "completed", date: new Date() },
|
||||
];
|
||||
|
||||
interface MetricCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
change: number;
|
||||
trend: "up" | "down" | "stable";
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
function MetricCard({ title, value, change, trend, icon }: MetricCardProps) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-1">{title}</p>
|
||||
<p className="text-3xl font-bold text-gray-900">
|
||||
{typeof value === "number" ? value.toLocaleString() : value}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-primary/10 rounded-lg text-primary">
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<span className={`text-sm font-medium ${
|
||||
trend === "up" ? "text-green-600" : trend === "down" ? "text-red-600" : "text-gray-600"
|
||||
}`}>
|
||||
{trend === "up" ? "↑" : trend === "down" ? "↓" : "→"}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{change > 0 ? "+" : ""}{change}% vs last period
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RevenueChart() {
|
||||
const [chartData, setChartData] = useState(mockChartData);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Revenue Overview</h2>
|
||||
<div className="flex gap-2">
|
||||
<button className="px-3 py-1 text-sm bg-primary/10 text-primary rounded-lg">7D</button>
|
||||
<button className="px-3 py-1 text-sm bg-gray-100 text-gray-600 rounded-lg">30D</button>
|
||||
<button className="px-3 py-1 text-sm bg-gray-100 text-gray-600 rounded-lg">90D</button>
|
||||
<button className="px-3 py-1 text-sm bg-gray-100 text-gray-600 rounded-lg">1Y</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-64 flex items-end justify-between gap-4">
|
||||
{chartData.map((item, i) => (
|
||||
<div key={i} className="flex-1 flex flex-col items-center">
|
||||
<div
|
||||
className="w-full bg-gradient-to-t from-primary/20 to-primary rounded-t-lg transition-all hover:from-primary/30"
|
||||
style={{ height: `${(item.revenue / 50000) * 100}%` }}
|
||||
/>
|
||||
<span className="text-xs text-gray-500 mt-2">{item.date}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Total Revenue</span>
|
||||
<p className="text-xl font-bold text-gray-900">$192,230</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-gray-500">Avg. Order</span>
|
||||
<p className="text-xl font-bold text-gray-900">$53.40</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopProductsTable() {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Top Products</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="text-left py-3 text-sm font-medium text-gray-500">Product</th>
|
||||
<th className="text-right py-3 text-sm font-medium text-gray-500">Sales</th>
|
||||
<th className="text-right py-3 text-sm font-medium text-gray-500">Revenue</th>
|
||||
<th className="text-right py-3 text-sm font-medium text-gray-500">Trend</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mockTopProducts.map((product, i) => (
|
||||
<tr key={i} className="border-b border-gray-100 last:border-0">
|
||||
<td className="py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gray-200 rounded-lg flex items-center justify-center">
|
||||
<span className="text-lg">🍎</span>
|
||||
</div>
|
||||
<span className="font-medium text-gray-900">{product.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 text-right text-gray-600">{product.sales}</td>
|
||||
<td className="py-4 text-right font-medium text-gray-900">${product.revenue.toFixed(2)}</td>
|
||||
<td className="py-4 text-right">
|
||||
<span className={`inline-flex items-center gap-1 text-sm ${
|
||||
product.trend === "up" ? "text-green-600" :
|
||||
product.trend === "down" ? "text-red-600" : "text-gray-600"
|
||||
}`}>
|
||||
{product.trend === "up" ? "↑" : product.trend === "down" ? "↓" : "→"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentOrdersTable() {
|
||||
const { addToast } = useToast();
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Recent Orders</h2>
|
||||
<button
|
||||
onClick={() => addToast({ type: "info", message: "Navigating to orders..." })}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
View all →
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="text-left py-3 text-sm font-medium text-gray-500">Order</th>
|
||||
<th className="text-left py-3 text-sm font-medium text-gray-500">Customer</th>
|
||||
<th className="text-right py-3 text-sm font-medium text-gray-500">Amount</th>
|
||||
<th className="text-right py-3 text-sm font-medium text-gray-500">Status</th>
|
||||
<th className="text-right py-3 text-sm font-medium text-gray-500">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mockRecentOrders.map((order, i) => (
|
||||
<tr key={i} className="border-b border-gray-100 last:border-0 hover:bg-gray-50">
|
||||
<td className="py-4">
|
||||
<span className="font-mono text-sm text-gray-600">{order.id}</span>
|
||||
</td>
|
||||
<td className="py-4 text-gray-900">{order.customer}</td>
|
||||
<td className="py-4 text-right font-medium text-gray-900">${order.amount.toFixed(2)}</td>
|
||||
<td className="py-4 text-right">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${
|
||||
order.status === "completed" ? "bg-green-100 text-green-700" :
|
||||
order.status === "preparing" ? "bg-yellow-100 text-yellow-700" :
|
||||
"bg-gray-100 text-gray-700"
|
||||
}`}>
|
||||
{order.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4 text-right text-gray-500">{formatDate(order.date)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomerGrowthChart() {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Customer Growth</h2>
|
||||
<div className="h-48 flex items-center justify-center">
|
||||
<div className="relative w-32 h-32">
|
||||
<svg className="w-full h-full transform -rotate-90">
|
||||
<circle cx="64" cy="64" r="56" fill="none" stroke="#e5e7eb" strokeWidth="12" />
|
||||
<circle
|
||||
cx="64" cy="64" r="56" fill="none"
|
||||
stroke="#10b981" strokeWidth="12"
|
||||
strokeDasharray="352"
|
||||
strokeDashoffset="70"
|
||||
className="transition-all duration-1000"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-3xl font-bold text-gray-900">+15.3%</span>
|
||||
<span className="text-sm text-gray-500">vs last month</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900">1,254</p>
|
||||
<p className="text-sm text-gray-500">Total</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-green-600">+156</p>
|
||||
<p className="text-sm text-gray-500">New</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-600">87%</p>
|
||||
<p className="text-sm text-gray-500">Retention</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConversionFunnel() {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Conversion Funnel</h2>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ stage: "Visitors", count: 24890, rate: 100 },
|
||||
{ stage: "Product Views", count: 8920, rate: 35.8 },
|
||||
{ stage: "Add to Cart", count: 3245, rate: 13.0 },
|
||||
{ stage: "Checkout", count: 1245, rate: 5.0 },
|
||||
{ stage: "Purchase", count: 847, rate: 3.4 },
|
||||
].map((step, i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<div className="w-24 text-sm text-gray-600">{step.stage}</div>
|
||||
<div className="flex-1 h-8 bg-gray-100 rounded-lg overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-primary to-primary/60 rounded-lg transition-all duration-500"
|
||||
style={{ width: `${step.rate}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-24 text-right">
|
||||
<span className="font-medium text-gray-900">{step.count.toLocaleString()}</span>
|
||||
<span className="text-sm text-gray-500 ml-1">({step.rate}%)</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AnalyticsDashboard({ brandId }: { brandId?: string }) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Track dashboard view
|
||||
analytics.featureUsed("analytics_dashboard", { brand_id: brandId });
|
||||
|
||||
// Simulate loading
|
||||
const timer = setTimeout(() => setIsLoading(false), 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [brandId]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Analytics</h1>
|
||||
<p className="text-gray-500">Track your business performance and growth</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<select className="px-4 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
<option>Last 30 days</option>
|
||||
<option>Last 7 days</option>
|
||||
<option>Last 90 days</option>
|
||||
<option>This year</option>
|
||||
</select>
|
||||
<button className="px-4 py-2 bg-primary text-white rounded-lg text-sm hover:bg-primary/90">
|
||||
Export Report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metric Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<MetricCard
|
||||
title="Total Revenue"
|
||||
value={`$${mockMetrics.revenue.value.toLocaleString()}`}
|
||||
change={mockMetrics.revenue.change}
|
||||
trend={mockMetrics.revenue.trend as "up" | "down" | "stable"}
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Total Orders"
|
||||
value={mockMetrics.orders.value.toLocaleString()}
|
||||
change={mockMetrics.orders.change}
|
||||
trend={mockMetrics.orders.trend as "up" | "down" | "stable"}
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Customers"
|
||||
value={mockMetrics.customers.value.toLocaleString()}
|
||||
change={mockMetrics.customers.change}
|
||||
trend={mockMetrics.customers.trend as "up" | "down" | "stable"}
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Conversion Rate"
|
||||
value={`${mockMetrics.conversionRate.value}%`}
|
||||
change={mockMetrics.conversionRate.change}
|
||||
trend={mockMetrics.conversionRate.trend as "up" | "down" | "stable"}
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Charts Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<RevenueChart />
|
||||
</div>
|
||||
<CustomerGrowthChart />
|
||||
</div>
|
||||
|
||||
{/* Tables Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<TopProductsTable />
|
||||
<RecentOrdersTable />
|
||||
</div>
|
||||
|
||||
{/* Funnel */}
|
||||
<ConversionFunnel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// Clerk authentication components for the UI
|
||||
// Use Show, UserButton, SignInButton, SignUpButton from @clerk/nextjs
|
||||
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SignInButton,
|
||||
SignUpButton,
|
||||
UserButton,
|
||||
useAuth,
|
||||
useUser
|
||||
} from "@clerk/nextjs";
|
||||
import Link from "next/link";
|
||||
|
||||
// Show component for conditional rendering based on auth state
|
||||
interface ShowProps {
|
||||
when: "signed-in" | "signed-out";
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Show({ when, children }: ShowProps) {
|
||||
const { isSignedIn } = useAuth();
|
||||
|
||||
if (when === "signed-in" && isSignedIn) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (when === "signed-out" && !isSignedIn) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// User profile button with dropdown menu
|
||||
export function ProfileButton() {
|
||||
const { user } = useUser();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<UserButton
|
||||
afterSignOutUrl="/"
|
||||
appearance={{
|
||||
elements: {
|
||||
avatarBox: "w-8 h-8",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{user && (
|
||||
<span className="hidden md:block text-sm text-gray-700">
|
||||
{user.firstName || user.emailAddresses[0]?.emailAddress}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Sign in button for use in nav/header
|
||||
export function SignInLink({ className }: { className?: string }) {
|
||||
return (
|
||||
<SignInButton mode="modal">
|
||||
<button className={className || "px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90"}>
|
||||
Sign In
|
||||
</button>
|
||||
</SignInButton>
|
||||
);
|
||||
}
|
||||
|
||||
// Sign up button for use in nav/header
|
||||
export function SignUpLink({ className }: { className?: string }) {
|
||||
return (
|
||||
<SignUpButton mode="modal">
|
||||
<button className={className || "px-4 py-2 border border-primary text-primary rounded-lg hover:bg-primary/5"}>
|
||||
Sign Up
|
||||
</button>
|
||||
</SignUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
// Combined auth buttons for header
|
||||
export function AuthButtons({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={`flex items-center gap-3 ${className || ""}`}>
|
||||
<Show when="signed-out">
|
||||
<>
|
||||
<SignInLink />
|
||||
<SignUpLink />
|
||||
</>
|
||||
</Show>
|
||||
<Show when="signed-in">
|
||||
<ProfileButton />
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Admin navigation with auth check
|
||||
export function AdminNav() {
|
||||
const { isSignedIn, userId } = useAuth();
|
||||
const { user } = useUser();
|
||||
|
||||
if (!isSignedIn) {
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<SignInLink />
|
||||
<SignUpLink />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin" className="text-sm text-gray-600 hover:text-primary">
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link href="/admin/orders" className="text-sm text-gray-600 hover:text-primary">
|
||||
Orders
|
||||
</Link>
|
||||
<Link href="/admin/products" className="text-sm text-gray-600 hover:text-primary">
|
||||
Products
|
||||
</Link>
|
||||
<ProfileButton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Wholesale customer navigation
|
||||
export function WholesaleNav() {
|
||||
const { isSignedIn } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<Show when="signed-out">
|
||||
<SignInLink />
|
||||
</Show>
|
||||
<Show when="signed-in">
|
||||
<>
|
||||
<Link href="/wholesale/portal" className="text-sm text-gray-600 hover:text-primary">
|
||||
Portal
|
||||
</Link>
|
||||
<ProfileButton />
|
||||
</>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state component
|
||||
export function AuthLoading() {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 border-2 border-gray-300 border-t-primary rounded-full animate-spin" />
|
||||
<span className="text-sm text-gray-500">Loading...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
// Changelog System - Display updates and track read status
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
interface ChangelogEntry {
|
||||
id: string;
|
||||
version: string;
|
||||
title: string;
|
||||
description: string;
|
||||
content: ChangelogItem[];
|
||||
released_at: string;
|
||||
is_published: boolean;
|
||||
feature_type: "major" | "feature" | "improvement" | "bugfix";
|
||||
}
|
||||
|
||||
interface ChangelogItem {
|
||||
type: "feature" | "improvement" | "bugfix";
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface ChangelogProps {
|
||||
brandId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
const TYPE_COLORS = {
|
||||
feature: "bg-blue-100 text-blue-700",
|
||||
improvement: "bg-green-100 text-green-700",
|
||||
bugfix: "bg-red-100 text-red-700",
|
||||
};
|
||||
|
||||
const TYPE_LABELS = {
|
||||
feature: "New Feature",
|
||||
improvement: "Improvement",
|
||||
bugfix: "Bug Fix",
|
||||
};
|
||||
|
||||
export function ChangelogFeed({ brandId, userId }: ChangelogProps) {
|
||||
const [changelogs, setChangelogs] = useState<ChangelogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<"all" | "feature" | "improvement" | "bugfix">("all");
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
loadChangelogs();
|
||||
}, [brandId]);
|
||||
|
||||
const loadChangelogs = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/changelogs?brand_id=${brandId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setChangelogs(data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load changelogs:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const markAsRead = async (changelogId: string) => {
|
||||
try {
|
||||
await fetch("/api/changelogs/read", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ changelog_id: changelogId, user_id: userId }),
|
||||
});
|
||||
setChangelogs(prev =>
|
||||
prev.map(c => c.id === changelogId ? { ...c, is_read: true } : c)
|
||||
);
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
} catch (error) {
|
||||
console.error("Failed to mark as read:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredChangelogs = changelogs.filter(c => {
|
||||
if (!c.is_published) return false;
|
||||
if (filter === "all") return true;
|
||||
return c.feature_type === filter;
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900">What's New</h2>
|
||||
<p className="text-gray-500">Stay updated with the latest features and improvements</p>
|
||||
</div>
|
||||
{unreadCount > 0 && (
|
||||
<span className="px-3 py-1 bg-primary text-white text-sm rounded-full">
|
||||
{unreadCount} new
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2">
|
||||
{["all", "feature", "improvement", "bugfix"].map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f as typeof filter)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
filter === f
|
||||
? "bg-primary text-white"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{f === "all" ? "All" : TYPE_LABELS[f as keyof typeof TYPE_LABELS]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Changelog List */}
|
||||
<div className="space-y-4">
|
||||
{filteredChangelogs.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
No updates yet. Check back soon!
|
||||
</div>
|
||||
) : (
|
||||
filteredChangelogs.map((changelog) => (
|
||||
<div
|
||||
key={changelog.id}
|
||||
className={`bg-white rounded-xl shadow-sm overflow-hidden transition-all ${
|
||||
!changelog.is_read ? "ring-2 ring-primary/20" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Header - always visible */}
|
||||
<div
|
||||
className="p-6 cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => {
|
||||
setExpandedId(expandedId === changelog.id ? null : changelog.id);
|
||||
if (!changelog.is_read) {
|
||||
markAsRead(changelog.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${TYPE_COLORS[changelog.feature_type]}`}>
|
||||
v{changelog.version}
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{changelog.title}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Released {formatDate(new Date(changelog.released_at))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!changelog.is_read && (
|
||||
<span className="w-2 h-2 bg-primary rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded content */}
|
||||
{expandedId === changelog.id && changelog.content && (
|
||||
<div className="border-t border-gray-100 p-6 bg-gray-50">
|
||||
<div className="space-y-4">
|
||||
{changelog.content.map((item, i) => (
|
||||
<div key={i} className="flex gap-4">
|
||||
<div className={`w-2 h-2 mt-2 rounded-full ${
|
||||
item.type === "feature" ? "bg-blue-500" :
|
||||
item.type === "improvement" ? "bg-green-500" : "bg-red-500"
|
||||
}`} />
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{item.title}</div>
|
||||
<div className="text-sm text-gray-600 mt-1">{item.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// In-app notification component
|
||||
export function ChangelogNotification({ changelog }: { changelog: ChangelogEntry }) {
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
if (dismissed) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 max-w-sm bg-white rounded-xl shadow-lg p-4 z-50 animate-slide-up">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold text-gray-900">New Update</span>
|
||||
<button
|
||||
onClick={() => setDismissed(true)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
<span className="font-medium">v{changelog.version}</span> - {changelog.title}
|
||||
</p>
|
||||
<button className="mt-3 text-sm text-primary font-medium hover:underline">
|
||||
View Details →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
// Onboarding Flow - Welcome tour and interactive demo
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { analytics } from "@/lib/analytics";
|
||||
|
||||
interface OnboardingStep {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
action?: string;
|
||||
}
|
||||
|
||||
const STEPS: OnboardingStep[] = [
|
||||
{
|
||||
id: "welcome",
|
||||
title: "Welcome to Route Commerce",
|
||||
description: "The all-in-one platform for fresh produce wholesale distribution. Let's take a quick tour to get you started.",
|
||||
icon: (
|
||||
<svg className="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "products",
|
||||
title: "Manage Your Products",
|
||||
description: "Add your fresh produce with pricing, categories, and images. Perfect for showcasing your farm's best offerings.",
|
||||
icon: (
|
||||
<svg className="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
),
|
||||
action: "Add First Product",
|
||||
},
|
||||
{
|
||||
id: "stops",
|
||||
title: "Schedule Pickup Stops",
|
||||
description: "Create scheduled stops for customers to pick up their orders. Manage locations, times, and capacity.",
|
||||
icon: (
|
||||
<svg className="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
),
|
||||
action: "Create First Stop",
|
||||
},
|
||||
{
|
||||
id: "orders",
|
||||
title: "Track Orders",
|
||||
description: "Monitor customer orders in real-time. Handle pickups and shipments with ease.",
|
||||
icon: (
|
||||
<svg className="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "communications",
|
||||
title: "Harvest Reach",
|
||||
description: "Send beautiful email campaigns to your customers. Keep them informed about seasonal offerings and promotions.",
|
||||
icon: (
|
||||
<svg className="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "complete",
|
||||
title: "You're All Set!",
|
||||
description: "Your dashboard is ready. Start adding products and scheduling stops to grow your wholesale business.",
|
||||
icon: (
|
||||
<svg className="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
interface OnboardingProps {
|
||||
brandId: string;
|
||||
userId: string;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
export function OnboardingFlow({ brandId, userId, onComplete }: OnboardingProps) {
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [isCompleted, setIsCompleted] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
// Track onboarding start
|
||||
analytics.onboardingStep("start", false);
|
||||
}, []);
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep < STEPS.length - 1) {
|
||||
const step = STEPS[currentStep];
|
||||
analytics.onboardingStep(step.id, false);
|
||||
setCurrentStep(currentStep + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
analytics.onboardingStep("skipped", true);
|
||||
setIsCompleted(true);
|
||||
onComplete?.();
|
||||
};
|
||||
|
||||
const handleComplete = () => {
|
||||
analytics.onboardingStep("complete", true);
|
||||
setIsCompleted(true);
|
||||
onComplete?.();
|
||||
};
|
||||
|
||||
const handleAction = () => {
|
||||
const step = STEPS[currentStep];
|
||||
analytics.buttonClicked(step.action || "next", "onboarding");
|
||||
|
||||
if (step.id === "products") {
|
||||
router.push("/admin/products/new");
|
||||
} else if (step.id === "stops") {
|
||||
router.push("/admin/stops/new");
|
||||
} else {
|
||||
handleNext();
|
||||
}
|
||||
};
|
||||
|
||||
if (isCompleted) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="bg-white rounded-2xl p-8 max-w-md text-center"
|
||||
>
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Setup Complete!</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
You're ready to start growing your wholesale business.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push("/admin")}
|
||||
className="w-full px-4 py-3 bg-primary text-white rounded-lg font-medium hover:bg-primary/90"
|
||||
>
|
||||
Go to Dashboard
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const step = STEPS[currentStep];
|
||||
const progress = ((currentStep + 1) / STEPS.length) * 100;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
className="bg-white rounded-2xl shadow-2xl max-w-lg w-full overflow-hidden"
|
||||
>
|
||||
{/* Progress bar */}
|
||||
<div className="h-1 bg-gray-200">
|
||||
<motion.div
|
||||
className="h-full bg-primary"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
{/* Step indicator */}
|
||||
<div className="flex items-center justify-center gap-2 mb-8">
|
||||
{STEPS.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-2 h-2 rounded-full transition-colors ${
|
||||
i <= currentStep ? "bg-primary" : "bg-gray-300"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Icon */}
|
||||
<div className="w-20 h-20 bg-primary/10 rounded-2xl flex items-center justify-center mx-auto mb-6 text-primary">
|
||||
{step.icon}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">{step.title}</h2>
|
||||
<p className="text-gray-600">{step.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
{currentStep > 0 ? (
|
||||
<button
|
||||
onClick={handlePrevious}
|
||||
className="flex-1 px-4 py-3 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="flex-1 px-4 py-3 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50"
|
||||
>
|
||||
Skip Tour
|
||||
</button>
|
||||
)}
|
||||
|
||||
{step.action ? (
|
||||
<button
|
||||
onClick={handleAction}
|
||||
className="flex-1 px-4 py-3 bg-primary text-white rounded-lg font-medium hover:bg-primary/90"
|
||||
>
|
||||
{step.action}
|
||||
</button>
|
||||
) : currentStep === STEPS.length - 1 ? (
|
||||
<button
|
||||
onClick={handleComplete}
|
||||
className="flex-1 px-4 py-3 bg-primary text-white rounded-lg font-medium hover:bg-primary/90"
|
||||
>
|
||||
Get Started
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleNext}
|
||||
className="flex-1 px-4 py-3 bg-primary text-white rounded-lg font-medium hover:bg-primary/90"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step counter */}
|
||||
<div className="bg-gray-50 px-8 py-4 text-center text-sm text-gray-500">
|
||||
Step {currentStep + 1} of {STEPS.length}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
// Interactive demo component
|
||||
export function InteractiveDemo({ onClose }: { onClose: () => void }) {
|
||||
const [demoStep, setDemoStep] = useState(0);
|
||||
|
||||
const demoSteps = [
|
||||
{ title: "Add Products", description: "Create your product catalog" },
|
||||
{ title: "Schedule Stops", description: "Set up pickup locations" },
|
||||
{ title: "Send Campaigns", description: "Email your customers" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl max-w-md w-full p-6">
|
||||
<h3 className="text-lg font-bold mb-4">Interactive Demo</h3>
|
||||
<div className="space-y-4">
|
||||
{demoSteps.map((step, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`p-4 rounded-lg border-2 cursor-pointer transition-colors ${
|
||||
i === demoStep ? "border-primary bg-primary/5" : "border-gray-200"
|
||||
}`}
|
||||
onClick={() => setDemoStep(i)}
|
||||
>
|
||||
<div className="font-medium">{step.title}</div>
|
||||
<div className="text-sm text-gray-500">{step.description}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 flex gap-3">
|
||||
<button onClick={onClose} className="flex-1 px-4 py-2 border rounded-lg">
|
||||
Close
|
||||
</button>
|
||||
<button className="flex-1 px-4 py-2 bg-primary text-white rounded-lg">
|
||||
Start Demo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Analytics Provider with PostHog integration
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { posthogEnabled, identifyUser, groupByBrand } from "@/lib/analytics";
|
||||
|
||||
// Analytics tracking component
|
||||
export function AnalyticsProvider({ userId, brandId }: { userId?: string; brandId?: string }) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Track page views
|
||||
useEffect(() => {
|
||||
if (!posthogEnabled) return;
|
||||
|
||||
const url = pathname + (searchParams.toString() ? `?${searchParams}` : "");
|
||||
|
||||
// PostHog tracks pageviews automatically with the capture_pageview option
|
||||
// But we can manually track for better control
|
||||
import("posthog-js").then(({ posthog }) => {
|
||||
posthog.capture("$pageview", {
|
||||
path: pathname,
|
||||
url,
|
||||
referrer: document.referrer,
|
||||
});
|
||||
});
|
||||
}, [pathname, searchParams]);
|
||||
|
||||
// Identify user
|
||||
useEffect(() => {
|
||||
if (!posthogEnabled || !userId) return;
|
||||
identifyUser(userId, { brand_id: brandId });
|
||||
}, [userId, brandId]);
|
||||
|
||||
// Group by brand
|
||||
useEffect(() => {
|
||||
if (!posthogEnabled || !brandId) return;
|
||||
groupByBrand(brandId);
|
||||
}, [brandId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Hook for tracking custom events
|
||||
export function useAnalytics() {
|
||||
const trackEvent = async (event: string, properties?: Record<string, unknown>) => {
|
||||
if (!posthogEnabled) return;
|
||||
|
||||
const { posthog } = await import("posthog-js");
|
||||
posthog.capture(event, properties);
|
||||
};
|
||||
|
||||
const trackClick = (element: string, properties?: Record<string, unknown>) => {
|
||||
trackEvent("button_clicked", { element, ...properties });
|
||||
};
|
||||
|
||||
const trackFormSubmit = (form: string, success: boolean, properties?: Record<string, unknown>) => {
|
||||
trackEvent("form_submitted", { form, success, ...properties });
|
||||
};
|
||||
|
||||
return {
|
||||
trackEvent,
|
||||
trackClick,
|
||||
trackFormSubmit,
|
||||
};
|
||||
}
|
||||
|
||||
// Revenue tracking helper
|
||||
export function trackRevenue(amount: number, currency: string = "USD") {
|
||||
if (!posthogEnabled) return;
|
||||
|
||||
import("posthog-js").then(({ posthog }) => {
|
||||
posthog.capture("revenue", {
|
||||
amount,
|
||||
currency,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Feature usage tracking
|
||||
export function trackFeatureUsage(feature: string, properties?: Record<string, unknown>) {
|
||||
if (!posthogEnabled) return;
|
||||
|
||||
import("posthog-js").then(({ posthog }) => {
|
||||
posthog.capture("feature_used", {
|
||||
feature,
|
||||
...properties,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Session recording wrapper for development
|
||||
export function SessionRecorder() {
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === "development" && posthogEnabled) {
|
||||
import("posthog-js").then(({ posthog }) => {
|
||||
posthog.startSessionRecording();
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Clerk provider wrapper for Next.js App Router
|
||||
|
||||
"use client";
|
||||
|
||||
import { ClerkProvider as ClerkProviderBase } from "@clerk/nextjs";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
|
||||
interface ClerkProviderProps {
|
||||
children: React.ReactNode;
|
||||
publishableKey: string;
|
||||
}
|
||||
|
||||
export function ClerkProvider({ children, publishableKey }: ClerkProviderProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
// Determine sign-in URL based on current path
|
||||
const signInUrl = "/login";
|
||||
const signUpUrl = "/register";
|
||||
const afterSignInUrl = pathname || "/admin";
|
||||
const afterSignUpUrl = "/onboarding";
|
||||
|
||||
return (
|
||||
<ClerkProviderBase
|
||||
publishableKey={publishableKey}
|
||||
signInUrl={signInUrl}
|
||||
signUpUrl={signUpUrl}
|
||||
afterSignInUrl={afterSignInUrl}
|
||||
afterSignUpUrl={afterSignUpUrl}
|
||||
routing={process.env.NEXT_PUBLIC_CLERK_ROUTING || "path"}
|
||||
>
|
||||
{children}
|
||||
</ClerkProviderBase>
|
||||
);
|
||||
}
|
||||
|
||||
// Hooks for Clerk auth state
|
||||
export { useUser, useAuth, useClerk } from "@clerk/nextjs";
|
||||
@@ -0,0 +1,287 @@
|
||||
// Error Boundary Component for catching and displaying errors
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { Component, ReactNode } from "react";
|
||||
import { captureError } from "@/lib/sentry";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: React.ErrorInfo | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null, errorInfo: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
// Log to Sentry
|
||||
captureError(error, {
|
||||
componentStack: errorInfo.componentStack,
|
||||
boundary: "ErrorBoundary",
|
||||
});
|
||||
|
||||
// Call custom error handler
|
||||
this.props.onError?.(error, errorInfo);
|
||||
|
||||
this.setState({ errorInfo });
|
||||
}
|
||||
|
||||
resetError = () => {
|
||||
this.setState({ hasError: false, error: null, errorInfo: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
|
||||
<div className="max-w-md w-full bg-white rounded-xl shadow-lg p-8 text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-6 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Something went wrong</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
We encountered an unexpected error. Please try refreshing the page.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Button onClick={this.resetError} className="w-full">
|
||||
Try Again
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.location.href = "/"}
|
||||
className="w-full"
|
||||
>
|
||||
Go to Homepage
|
||||
</Button>
|
||||
</div>
|
||||
{process.env.NODE_ENV === "development" && this.state.error && (
|
||||
<div className="mt-6 p-4 bg-gray-100 rounded-lg text-left">
|
||||
<p className="text-sm font-mono text-red-600 break-all">
|
||||
{this.state.error.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
// Loading skeleton component
|
||||
export function LoadingSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={`animate-pulse bg-gray-200 rounded ${className}`} />
|
||||
);
|
||||
}
|
||||
|
||||
// Full page loading state
|
||||
export function LoadingPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading spinner for buttons
|
||||
export function LoadingSpinner({ size = "md" }: { size?: "sm" | "md" | "lg" }) {
|
||||
const sizeClasses = {
|
||||
sm: "w-4 h-4",
|
||||
md: "w-6 h-6",
|
||||
lg: "w-8 h-8",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${sizeClasses[size]} border-2 border-current border-t-transparent rounded-full animate-spin`} />
|
||||
);
|
||||
}
|
||||
|
||||
// Async button wrapper with loading state
|
||||
interface AsyncButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: ReactNode;
|
||||
loading?: boolean;
|
||||
loadingText?: string;
|
||||
}
|
||||
|
||||
export function AsyncButton({
|
||||
children,
|
||||
loading,
|
||||
loadingText = "Loading...",
|
||||
disabled,
|
||||
...props
|
||||
}: AsyncButtonProps) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
disabled={disabled || loading}
|
||||
className={`relative ${props.className || ""}`}
|
||||
>
|
||||
{loading && (
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
<LoadingSpinner size="sm" />
|
||||
</span>
|
||||
)}
|
||||
<span className={loading ? "opacity-0" : ""}>
|
||||
{children}
|
||||
</span>
|
||||
{loading && loadingText && (
|
||||
<span className="sr-only">{loadingText}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Optimistic update wrapper
|
||||
interface OptimisticState<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export function useOptimistic<T>(initialData: T | null = null) {
|
||||
const [state, setState] = React.useState<OptimisticState<T>>({
|
||||
data: initialData,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const optimisticUpdate = React.useCallback((updater: (prev: T | null) => T) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
data: updater(prev.data),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setLoading = React.useCallback((loading: boolean) => {
|
||||
setState(prev => ({ ...prev, loading }));
|
||||
}, []);
|
||||
|
||||
const setError = React.useCallback((error: Error | null) => {
|
||||
setState(prev => ({ ...prev, error }));
|
||||
}, []);
|
||||
|
||||
const reset = React.useCallback(() => {
|
||||
setState({ data: initialData, loading: false, error: null });
|
||||
}, [initialData]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
optimisticUpdate,
|
||||
setLoading,
|
||||
setError,
|
||||
reset,
|
||||
setData: (data: T) => setState(prev => ({ ...prev, data })),
|
||||
};
|
||||
}
|
||||
|
||||
// Toast notifications
|
||||
interface Toast {
|
||||
id: string;
|
||||
type: "success" | "error" | "info" | "warning";
|
||||
message: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
toasts: Toast[];
|
||||
addToast: (toast: Omit<Toast, "id">) => void;
|
||||
removeToast: (id: string) => void;
|
||||
}
|
||||
|
||||
const ToastContext = React.createContext<ToastContextType | null>(null);
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = React.useState<Toast[]>([]);
|
||||
|
||||
const addToast = React.useCallback((toast: Omit<Toast, "id">) => {
|
||||
const id = Math.random().toString(36).substring(7);
|
||||
setToasts(prev => [...prev, { ...toast, id }]);
|
||||
|
||||
// Auto remove after duration
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
}, toast.duration || 5000);
|
||||
}, []);
|
||||
|
||||
const removeToast = React.useCallback((id: string) => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toasts, addToast, removeToast }}>
|
||||
{children}
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastContainer({
|
||||
toasts,
|
||||
onRemove
|
||||
}: {
|
||||
toasts: Toast[];
|
||||
onRemove: (id: string) => void
|
||||
}) {
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 space-y-2">
|
||||
{toasts.map(toast => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`flex items-center gap-3 p-4 rounded-lg shadow-lg animate-slide-in ${
|
||||
toast.type === "success" ? "bg-green-600" :
|
||||
toast.type === "error" ? "bg-red-600" :
|
||||
toast.type === "warning" ? "bg-yellow-600" :
|
||||
"bg-blue-600"
|
||||
} text-white`}
|
||||
>
|
||||
<span>{toast.message}</span>
|
||||
<button
|
||||
onClick={() => onRemove(toast.id)}
|
||||
className="p-1 hover:bg-white/20 rounded"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = React.useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error("useToast must be used within a ToastProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// Referral System - Generate, share, and track referral codes
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { analytics } from "@/lib/analytics";
|
||||
|
||||
interface ReferralCode {
|
||||
code: string;
|
||||
reward_type: "percentage" | "fixed";
|
||||
reward_value: number;
|
||||
uses: number;
|
||||
max_uses: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ReferralStats {
|
||||
total_referrals: number;
|
||||
successful_conversions: number;
|
||||
total_reward_value: number;
|
||||
}
|
||||
|
||||
interface ReferralSystemProps {
|
||||
brandId: string;
|
||||
userId: string;
|
||||
userEmail: string;
|
||||
}
|
||||
|
||||
export function ReferralSystem({ brandId, userId, userEmail }: ReferralSystemProps) {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
const [selectedCode, setSelectedCode] = useState<ReferralCode | null>(null);
|
||||
|
||||
const generateCode = async () => {
|
||||
setIsGenerating(true);
|
||||
|
||||
// Track event
|
||||
analytics.featureUsed("referral_generate_code", { brand_id: brandId });
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/referrals", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "generate",
|
||||
brand_id: brandId,
|
||||
user_id: userId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
// Code generated successfully - refresh list
|
||||
analytics.referralShared("", "platform_select");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to generate referral code:", error);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const shareCode = (code: ReferralCode, platform: string) => {
|
||||
const shareUrl = `${window.location.origin}/register?ref=${code.code}`;
|
||||
const text = `Join Route Commerce and get ${code.reward_value}% off your first month!`;
|
||||
|
||||
if (platform === "copy") {
|
||||
navigator.clipboard.writeText(shareUrl);
|
||||
analytics.referralShared(code.code, "copy_link");
|
||||
} else if (platform === "email") {
|
||||
window.location.href = `mailto:?subject=Try Route Commerce&body=${encodeURIComponent(text + "\n\n" + shareUrl)}`;
|
||||
analytics.referralShared(code.code, "email");
|
||||
} else if (platform === "twitter") {
|
||||
window.open(`https://twitter.com/intent/tweet?text=${encodeURIComponent(text + " " + shareUrl)}`, "_blank");
|
||||
analytics.referralShared(code.code, "twitter");
|
||||
} else if (platform === "facebook") {
|
||||
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`, "_blank");
|
||||
analytics.referralShared(code.code, "facebook");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-primary/10 to-primary/5 rounded-xl p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">Refer & Earn</h3>
|
||||
<p className="text-gray-600">
|
||||
Share Route Commerce with other farms and earn rewards for each successful signup.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-3xl font-bold text-primary">20%</div>
|
||||
<div className="text-sm text-gray-500">Reward</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={generateCode}
|
||||
disabled={isGenerating}
|
||||
className="mt-4 w-full px-4 py-3 bg-primary text-white rounded-lg font-medium hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{isGenerating ? "Generating..." : "Generate New Referral Code"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-white rounded-xl shadow-sm p-4 text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">0</div>
|
||||
<div className="text-sm text-gray-500">Total Referrals</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm p-4 text-center">
|
||||
<div className="text-2xl font-bold text-green-600">0</div>
|
||||
<div className="text-sm text-gray-500">Conversions</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm p-4 text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">$0</div>
|
||||
<div className="text-sm text-gray-500">Earned</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* How it works */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<h4 className="font-semibold text-gray-900 mb-4">How It Works</h4>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ step: "1", title: "Generate Code", desc: "Create your unique referral code" },
|
||||
{ step: "2", title: "Share", desc: "Send to other farms via email or social" },
|
||||
{ step: "3", title: "They Sign Up", desc: "Friend uses your code to register" },
|
||||
{ step: "4", title: "Both Earn", desc: "Get 20% off, they get 20% off" },
|
||||
].map((item, i) => (
|
||||
<div key={i} className="flex items-start gap-4">
|
||||
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center text-primary font-bold">
|
||||
{item.step}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{item.title}</div>
|
||||
<div className="text-sm text-gray-500">{item.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Share Modal */}
|
||||
{shareModalOpen && selectedCode && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl max-w-md w-full p-6">
|
||||
<h3 className="text-lg font-bold mb-4">Share Your Code</h3>
|
||||
<div className="bg-gray-100 rounded-lg p-4 mb-4">
|
||||
<code className="text-xl font-mono font-bold">{selectedCode.code}</code>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => { shareCode(selectedCode, "copy"); setShareModalOpen(false); }}
|
||||
className="w-full px-4 py-3 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 flex items-center gap-3"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
|
||||
</svg>
|
||||
Copy Link
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { shareCode(selectedCode, "email"); setShareModalOpen(false); }}
|
||||
className="w-full px-4 py-3 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 flex items-center gap-3"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Email
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { shareCode(selectedCode, "twitter"); setShareModalOpen(false); }}
|
||||
className="w-full px-4 py-3 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 flex items-center gap-3"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/>
|
||||
</svg>
|
||||
Twitter
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShareModalOpen(false)}
|
||||
className="mt-4 w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Referral badge component for display
|
||||
export function ReferralBadge({ code }: { code: string }) {
|
||||
return (
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 bg-primary/10 text-primary rounded-full text-sm font-medium">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
||||
</svg>
|
||||
<span>Ref: {code}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// PostHog Analytics Configuration
|
||||
import { PostHog } from "posthog-js";
|
||||
|
||||
const posthogApiKey = process.env.NEXT_PUBLIC_POSTHOG_API_KEY;
|
||||
const posthogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://app.posthog.com";
|
||||
|
||||
// Only enable in production or when API key is set
|
||||
export const posthogEnabled = Boolean(posthogApiKey);
|
||||
|
||||
// Singleton PostHog instance
|
||||
let posthogInstance: PostHog | null = null;
|
||||
|
||||
export function getPostHog(): PostHog {
|
||||
if (!posthogInstance && posthogEnabled) {
|
||||
posthogInstance = new PostHog(posthogApiKey!, {
|
||||
host: posthogHost,
|
||||
// Disable in development unless explicitly enabled
|
||||
loaded: process.env.NODE_ENV !== "development" || process.env.NEXT_PUBLIC_POSTHOG_ENABLED === "true",
|
||||
// Session recording
|
||||
session_recording: process.env.NODE_ENV === "production",
|
||||
// Capture pageviews automatically
|
||||
capture_pageview: true,
|
||||
// Capture exceptions
|
||||
capture_events: true,
|
||||
// Disable until opted in (GDPR compliance)
|
||||
opt_out_capturing_by_default: false,
|
||||
// Sanitize sensitive data
|
||||
sanititize_properties: (properties) => {
|
||||
// Remove any PII before capture
|
||||
const sensitive = ['email', 'password', 'token', 'secret', 'cardNumber', 'cvv'];
|
||||
sensitive.forEach(key => delete properties[key]);
|
||||
return properties;
|
||||
},
|
||||
});
|
||||
}
|
||||
return posthogInstance!;
|
||||
}
|
||||
|
||||
// Analytics helper functions
|
||||
export const analytics = {
|
||||
// Page views
|
||||
pageView: (url: string, properties?: Record<string, unknown>) => {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.capture("$pageview", { url, ...properties });
|
||||
}
|
||||
},
|
||||
|
||||
// Feature usage
|
||||
featureUsed: (feature: string, properties?: Record<string, unknown>) => {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.capture("feature_used", { feature, ...properties });
|
||||
}
|
||||
},
|
||||
|
||||
// Button clicks
|
||||
buttonClicked: (buttonName: string, page: string, properties?: Record<string, unknown>) => {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.capture("button_clicked", { button_name: buttonName, page, ...properties });
|
||||
}
|
||||
},
|
||||
|
||||
// Form submissions
|
||||
formSubmitted: (formName: string, success: boolean, properties?: Record<string, unknown>) => {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.capture("form_submitted", { form_name: formName, success, ...properties });
|
||||
}
|
||||
},
|
||||
|
||||
// User signups
|
||||
userSignedUp: (method: string, brandId?: string) => {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.capture("user_signed_up", { method, brand_id: brandId });
|
||||
}
|
||||
},
|
||||
|
||||
// Subscription events
|
||||
subscriptionCreated: (plan: string, amount: number, interval: string) => {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.capture("subscription_created", { plan, amount, interval });
|
||||
}
|
||||
},
|
||||
|
||||
subscriptionUpgraded: (fromPlan: string, toPlan: string, amount: number) => {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.capture("subscription_upgraded", { from_plan: fromPlan, to_plan: toPlan, amount });
|
||||
}
|
||||
},
|
||||
|
||||
subscriptionCancelled: (plan: string, reason?: string) => {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.capture("subscription_cancelled", { plan, reason });
|
||||
}
|
||||
},
|
||||
|
||||
// Order events
|
||||
orderCreated: (orderId: string, amount: number, brandId: string) => {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.capture("order_created", { order_id: orderId, amount, brand_id: brandId });
|
||||
}
|
||||
},
|
||||
|
||||
orderCompleted: (orderId: string, amount: number, fulfillment: string) => {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.capture("order_completed", { order_id: orderId, amount, fulfillment });
|
||||
}
|
||||
},
|
||||
|
||||
// Communication campaign events
|
||||
campaignCreated: (campaignId: string, type: string, audienceSize: number) => {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.capture("campaign_created", { campaign_id: campaignId, type, audience_size: audienceSize });
|
||||
}
|
||||
},
|
||||
|
||||
campaignSent: (campaignId: string, sent: number, opened: number) => {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.capture("campaign_sent", { campaign_id: campaignId, sent, opened });
|
||||
}
|
||||
},
|
||||
|
||||
// Admin actions
|
||||
adminAction: (action: string, resource: string, resourceId?: string) => {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.capture("admin_action", { action, resource, resource_id: resourceId });
|
||||
}
|
||||
},
|
||||
|
||||
// Referral events
|
||||
referralShared: (referralCode: string, platform: string) => {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.capture("referral_shared", { referral_code: referralCode, platform });
|
||||
}
|
||||
},
|
||||
|
||||
referralCompleted: (referralCode: string, newUserId: string) => {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.capture("referral_completed", { referral_code: referralCode, new_user_id: newUserId });
|
||||
}
|
||||
},
|
||||
|
||||
// Error tracking
|
||||
error: (errorType: string, message: string, context?: Record<string, unknown>) => {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.capture("error", { error_type: errorType, message, ...context });
|
||||
}
|
||||
},
|
||||
|
||||
// Search functionality
|
||||
searchPerformed: (query: string, resultCount: number, category?: string) => {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.capture("search_performed", { query, result_count: resultCount, category });
|
||||
}
|
||||
},
|
||||
|
||||
// Onboarding funnel tracking
|
||||
onboardingStep: (step: string, completed: boolean) => {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.capture("onboarding_step", { step, completed });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// User identification
|
||||
export function identifyUser(userId: string, properties?: Record<string, unknown>) {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.identify(userId, properties);
|
||||
}
|
||||
}
|
||||
|
||||
// Group users by brand
|
||||
export function groupByBrand(brandId: string, properties?: Record<string, unknown>) {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.group("brand", brandId, properties);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// Clerk Authentication Integration
|
||||
// Multi-tenant auth with role-based access control
|
||||
|
||||
import { createClerkClient } from "@clerk/nextjs/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
// Clerk configuration
|
||||
const clerkClient = createClerkClient({
|
||||
secretKey: process.env.CLERK_SECRET_KEY,
|
||||
});
|
||||
|
||||
// Clerk publishable key for frontend
|
||||
export const CLERK_PUBLISHABLE_KEY = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY;
|
||||
|
||||
// Role definitions
|
||||
export type UserRole = "platform_admin" | "brand_admin" | "store_employee" | "wholesale_customer" | "customer";
|
||||
|
||||
export interface ClerkUser {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
publicMetadata: {
|
||||
role?: UserRole;
|
||||
brand_id?: string;
|
||||
is_onboarded?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// Get current authenticated user from Clerk
|
||||
export async function getClerkUser(): Promise<ClerkUser | null> {
|
||||
const { userId, sessionId, getToken } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await clerkClient.users.getUser(userId);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.emailAddresses[0]?.emailAddress || "",
|
||||
firstName: user.firstName || undefined,
|
||||
lastName: user.lastName || undefined,
|
||||
publicMetadata: user.publicMetadata as ClerkUser["publicMetadata"],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch Clerk user:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Get Clerk session token for API calls
|
||||
export async function getClerkToken() {
|
||||
const { getToken } = await auth();
|
||||
return getToken({ template: "supabase" });
|
||||
}
|
||||
|
||||
// Create Clerk user with metadata
|
||||
export async function createClerkUser(
|
||||
email: string,
|
||||
firstName?: string,
|
||||
lastName?: string,
|
||||
publicMetadata?: Record<string, unknown>
|
||||
) {
|
||||
try {
|
||||
const user = await clerkClient.users.createUser({
|
||||
emailAddress: [email],
|
||||
firstName,
|
||||
lastName,
|
||||
publicMetadata: {
|
||||
role: "customer",
|
||||
is_onboarded: false,
|
||||
...publicMetadata,
|
||||
},
|
||||
});
|
||||
return user;
|
||||
} catch (error) {
|
||||
console.error("Failed to create Clerk user:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Update Clerk user metadata
|
||||
export async function updateClerkUserMetadata(
|
||||
userId: string,
|
||||
metadata: Record<string, unknown>
|
||||
) {
|
||||
try {
|
||||
await clerkClient.users.updateUserMetadata(userId, {
|
||||
publicMetadata: metadata,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update Clerk user metadata:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Set user role in Clerk metadata
|
||||
export async function setUserRole(userId: string, role: UserRole, brandId?: string) {
|
||||
await updateClerkUserMetadata(userId, {
|
||||
role,
|
||||
brand_id: brandId,
|
||||
});
|
||||
}
|
||||
|
||||
// Delete Clerk user (soft delete for compliance)
|
||||
export async function deleteClerkUser(userId: string) {
|
||||
try {
|
||||
await clerkClient.users.deleteUser(userId);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete Clerk user:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get user's organizations/brands from Clerk
|
||||
export async function getUserOrganizations() {
|
||||
const { userId } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const memberships = await clerkClient.users.getOrganizationMemberships({ userId });
|
||||
return memberships.data;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch organization memberships:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Create Clerk organization for brands
|
||||
export async function createBrandOrganization(brandId: string, brandName: string) {
|
||||
try {
|
||||
const org = await clerkClient.organizations.createOrganization({
|
||||
name: brandName,
|
||||
slug: brandName.toLowerCase().replace(/\s+/g, "-"),
|
||||
publicMetadata: {
|
||||
brand_id: brandId,
|
||||
},
|
||||
});
|
||||
return org;
|
||||
} catch (error) {
|
||||
console.error("Failed to create organization:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Add user to organization with role
|
||||
export async function addUserToOrganization(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
role: string
|
||||
) {
|
||||
try {
|
||||
await clerkClient.organizations.createOrganizationMembership({
|
||||
organizationId,
|
||||
userId,
|
||||
role,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to add user to organization:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Authentication helper for middleware
|
||||
export function isAuthenticated() {
|
||||
return auth().userId !== null;
|
||||
}
|
||||
|
||||
// Role checking helper
|
||||
export function hasRole(allowedRoles: UserRole[]) {
|
||||
return async function checkRole() {
|
||||
const user = await getClerkUser();
|
||||
if (!user) return false;
|
||||
return allowedRoles.includes(user.publicMetadata.role || "customer");
|
||||
};
|
||||
}
|
||||
|
||||
// Session management - sync with Supabase for brand data
|
||||
export async function syncUserWithBrand(userId: string, brandId: string) {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Sync with Supabase admin_users table
|
||||
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: userId,
|
||||
p_brand_id: brandId,
|
||||
p_role: "brand_admin",
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
const result = await res.json();
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to sync user with brand:", error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Webhook handler for Clerk events
|
||||
export async function handleClerkWebhook(event: string, payload: unknown) {
|
||||
switch (event) {
|
||||
case "user.created":
|
||||
// New user signup - create in our system
|
||||
await handleUserCreated(payload);
|
||||
break;
|
||||
case "user.updated":
|
||||
// User updated their profile
|
||||
await handleUserUpdated(payload);
|
||||
break;
|
||||
case "user.deleted":
|
||||
// User deleted their account
|
||||
await handleUserDeleted(payload);
|
||||
break;
|
||||
case "session.created":
|
||||
// User logged in
|
||||
await handleSessionCreated(payload);
|
||||
break;
|
||||
case "session.ended":
|
||||
// User logged out
|
||||
await handleSessionEnded(payload);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUserCreated(payload: unknown) {
|
||||
// Sync new user to our database
|
||||
const user = payload as { id: string; email_addresses: { email_address: string }[] };
|
||||
console.log("New user created:", user.id);
|
||||
// Add any additional logic here
|
||||
}
|
||||
|
||||
async function handleUserUpdated(payload: unknown) {
|
||||
const user = payload as { id: string };
|
||||
console.log("User updated:", user.id);
|
||||
}
|
||||
|
||||
async function handleUserDeleted(payload: unknown) {
|
||||
const user = payload as { id: string };
|
||||
console.log("User deleted:", user.id);
|
||||
// Optionally soft-delete or anonymize user data
|
||||
}
|
||||
|
||||
async function handleSessionCreated(payload: unknown) {
|
||||
const session = payload as { id: string; user_id: string };
|
||||
// Track session for analytics
|
||||
console.log("Session created:", session.id);
|
||||
}
|
||||
|
||||
async function handleSessionEnded(payload: unknown) {
|
||||
const session = payload as { id: string };
|
||||
console.log("Session ended:", session.id);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Global Error Handler for uncaught exceptions
|
||||
|
||||
import { captureError, addBreadcrumb } from "./sentry";
|
||||
|
||||
// Handle uncaught errors
|
||||
if (typeof window !== "undefined") {
|
||||
window.onerror = (message, source, lineno, colno, error) => {
|
||||
captureError(error || new Error(String(message)), {
|
||||
source,
|
||||
lineno,
|
||||
colno,
|
||||
type: "uncaught_error",
|
||||
});
|
||||
return false;
|
||||
};
|
||||
|
||||
// Handle unhandled promise rejections
|
||||
window.onunhandledrejection = (event) => {
|
||||
captureError(
|
||||
event.reason instanceof Error
|
||||
? event.reason
|
||||
: new Error(String(event.reason)),
|
||||
{
|
||||
type: "unhandled_rejection",
|
||||
promise: event.promise ? String(event.promise) : undefined,
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// Add breadcrumb for page navigation
|
||||
export function trackPageNavigation(path: string) {
|
||||
addBreadcrumb(`Navigated to ${path}`, { path });
|
||||
}
|
||||
|
||||
// Add breadcrumb for user actions
|
||||
export function trackUserAction(action: string, details?: Record<string, unknown>) {
|
||||
addBreadcrumb(`User action: ${action}`, { action, ...details });
|
||||
}
|
||||
|
||||
// Performance monitoring
|
||||
export function measurePerformance(name: string, callback: () => void | Promise<void>) {
|
||||
const start = performance.now();
|
||||
|
||||
const measure = async () => {
|
||||
await callback();
|
||||
const duration = performance.now() - start;
|
||||
|
||||
addBreadcrumb(`Performance: ${name}`, {
|
||||
name,
|
||||
duration: `${duration.toFixed(2)}ms`,
|
||||
});
|
||||
};
|
||||
|
||||
return measure();
|
||||
}
|
||||
|
||||
// API call tracking
|
||||
export async function trackApiCall<T>(
|
||||
endpoint: string,
|
||||
method: string,
|
||||
fn: () => Promise<T>
|
||||
): Promise<T> {
|
||||
addBreadcrumb(`API call: ${method} ${endpoint}`, { endpoint, method });
|
||||
|
||||
try {
|
||||
const result = await fn();
|
||||
addBreadcrumb(`API success: ${method} ${endpoint}`, { endpoint, method, success: true });
|
||||
return result;
|
||||
} catch (error) {
|
||||
captureError(error as Error, { endpoint, method });
|
||||
addBreadcrumb(`API error: ${method} ${endpoint}`, { endpoint, method, success: false });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Debug logging for development
|
||||
export const debugLog = {
|
||||
info: (message: string, data?: unknown) => {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.info(`[DEBUG] ${message}`, data);
|
||||
}
|
||||
},
|
||||
warn: (message: string, data?: unknown) => {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.warn(`[DEBUG] ${message}`, data);
|
||||
}
|
||||
},
|
||||
error: (message: string, data?: unknown) => {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.error(`[DEBUG] ${message}`, data);
|
||||
}
|
||||
},
|
||||
};
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
// PWA Registration and Service Worker management
|
||||
|
||||
export function registerServiceWorker() {
|
||||
if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener('load', async () => {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register('/sw.js', {
|
||||
scope: '/',
|
||||
});
|
||||
|
||||
// Handle updates
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const newWorker = registration.installing;
|
||||
if (newWorker) {
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
// New content available, prompt user to refresh
|
||||
showUpdatePrompt();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Service Worker registered:', registration.scope);
|
||||
} catch (error) {
|
||||
console.error('Service Worker registration failed:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showUpdatePrompt() {
|
||||
// Dispatch custom event for UI to handle update prompt
|
||||
const event = new CustomEvent('sw-update-available');
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
|
||||
export function requestNotificationPermission() {
|
||||
if (!('Notification' in window) || !('serviceWorker' in navigator)) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
return Notification.requestPermission();
|
||||
}
|
||||
|
||||
export async function subscribeToPush(registration: ServiceWorkerRegistration) {
|
||||
if (!('PushManager' in window)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(
|
||||
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || ''
|
||||
),
|
||||
});
|
||||
|
||||
console.log('Push subscription:', subscription);
|
||||
return subscription;
|
||||
} catch (error) {
|
||||
console.error('Push subscription failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
// Check if app is installed (PWA)
|
||||
export function isPWAInstalled(): boolean {
|
||||
return (
|
||||
window.matchMedia('(display-mode: standalone)').matches ||
|
||||
(window.navigator as { standalone?: boolean }).standalone === true
|
||||
);
|
||||
}
|
||||
|
||||
// Get install prompt
|
||||
export function getInstallPrompt(): { prompt: () => void; outcome: Promise<'accepted' | 'dismissed' | 'canceled'> } | null {
|
||||
if (!('beforeinstallprompt' in window)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let deferredPrompt: beforeinstallpromptEvent | null = null;
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (event) => {
|
||||
event.preventDefault();
|
||||
deferredPrompt = event;
|
||||
});
|
||||
|
||||
return {
|
||||
prompt: () => {
|
||||
if (deferredPrompt) {
|
||||
deferredPrompt.prompt();
|
||||
}
|
||||
},
|
||||
outcome: new Promise((resolve) => {
|
||||
if (deferredPrompt) {
|
||||
deferredPrompt.userChoice.then((choiceResult) => {
|
||||
deferredPrompt = null;
|
||||
resolve(choiceResult.outcome as 'accepted' | 'dismissed' | 'canceled');
|
||||
});
|
||||
} else {
|
||||
resolve('dismissed');
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Share API for native sharing
|
||||
export async function shareContent(content: {
|
||||
title: string;
|
||||
text: string;
|
||||
url?: string;
|
||||
}): Promise<boolean> {
|
||||
if (!navigator.share) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.share(content);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as Error).name !== 'AbortError') {
|
||||
console.error('Share failed:', error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
// Rate limiting configuration using Upstash Redis
|
||||
import { Ratelimit } from "@upstash/ratelimit";
|
||||
import { Redis } from "@upstash/redis";
|
||||
|
||||
// Only enable in production or when Redis is configured
|
||||
const redisUrl = process.env.UPSTASH_REDIS_REST_URL;
|
||||
const redisToken = process.env.UPSTASH_REDIS_REST_TOKEN;
|
||||
|
||||
export const rateLimitEnabled = Boolean(redisUrl && redisToken);
|
||||
|
||||
// Create rate limiters for different tiers
|
||||
const createRateLimiter = (
|
||||
requests: number,
|
||||
window: string,
|
||||
prefix: string
|
||||
): Ratelimit | null => {
|
||||
if (!rateLimitEnabled) return null;
|
||||
|
||||
const redis = new Redis({
|
||||
url: redisUrl!,
|
||||
token: redisToken!,
|
||||
});
|
||||
|
||||
return new Ratelimit({
|
||||
redis,
|
||||
limiter: Ratelimit.slidingWindow(requests, window),
|
||||
analytics: true,
|
||||
prefix,
|
||||
// Custom callback for when rate limit is exceeded
|
||||
handler: async (remaining, reset, next) => {
|
||||
if (remaining === 0) {
|
||||
throw new Error(`Rate limit exceeded. Try again in ${Math.ceil((reset - Date.now()) / 1000)} seconds.`);
|
||||
}
|
||||
return next();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// API rate limits
|
||||
export const apiLimiter = createRateLimiter(100, "1 m", "api:global");
|
||||
export const authLimiter = createRateLimiter(10, "1 m", "api:auth");
|
||||
export const checkoutLimiter = createRateLimiter(20, "1 m", "api:checkout");
|
||||
export const emailLimiter = createRateLimiter(50, "1 h", "api:email");
|
||||
export const bulkOpsLimiter = createRateLimiter(10, "1 m", "api:bulk");
|
||||
|
||||
// User-specific rate limits
|
||||
export const createUserLimiter = (identifier: string, limit: number, window: string) => {
|
||||
if (!rateLimitEnabled) return null;
|
||||
|
||||
const redis = new Redis({
|
||||
url: redisUrl!,
|
||||
token: redisToken!,
|
||||
});
|
||||
|
||||
return new Ratelimit({
|
||||
redis,
|
||||
limiter: Ratelimit.slidingWindow(limit, window),
|
||||
analytics: true,
|
||||
prefix: `user:${identifier}`,
|
||||
});
|
||||
};
|
||||
|
||||
// Brand-specific rate limits (for multi-tenant enforcement)
|
||||
export const createBrandLimiter = (brandId: string, limit: number, window: string) => {
|
||||
if (!rateLimitEnabled) return null;
|
||||
|
||||
const redis = new Redis({
|
||||
url: redisUrl!,
|
||||
token: redisToken!,
|
||||
});
|
||||
|
||||
return new Ratelimit({
|
||||
redis,
|
||||
limiter: Ratelimit.slidingWindow(limit, window),
|
||||
analytics: true,
|
||||
prefix: `brand:${brandId}`,
|
||||
});
|
||||
};
|
||||
|
||||
// Rate limit middleware helper
|
||||
export async function checkRateLimit(
|
||||
limiter: Ratelimit | null,
|
||||
identifier: string
|
||||
): Promise<{ success: boolean; remaining: number; reset: number }> {
|
||||
if (!limiter) {
|
||||
return { success: true, remaining: Infinity, reset: Date.now() };
|
||||
}
|
||||
|
||||
const result = await limiter.limit(identifier);
|
||||
return {
|
||||
success: result.success,
|
||||
remaining: result.remaining,
|
||||
reset: result.reset,
|
||||
};
|
||||
}
|
||||
|
||||
// Response headers helper
|
||||
export function rateLimitHeaders(result: { remaining: number; reset: number }) {
|
||||
return {
|
||||
"X-RateLimit-Remaining": String(result.remaining),
|
||||
"X-RateLimit-Reset": String(Math.ceil(result.reset / 1000)),
|
||||
};
|
||||
}
|
||||
|
||||
// CORS headers for API routes
|
||||
export function corsHeaders() {
|
||||
return {
|
||||
"Access-Control-Allow-Origin": process.env.ALLOWED_ORIGIN || "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-API-Key",
|
||||
"Access-Control-Max-Age": "86400",
|
||||
};
|
||||
}
|
||||
|
||||
// Security headers
|
||||
export function securityHeaders() {
|
||||
return {
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"X-XSS-Protection": "1; mode=block",
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
|
||||
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
|
||||
};
|
||||
}
|
||||
|
||||
// Combined API response helper
|
||||
export function apiResponse<T>(
|
||||
data: T,
|
||||
status: number = 200,
|
||||
meta?: Record<string, unknown>
|
||||
) {
|
||||
return Response.json(
|
||||
{ success: true, data, meta },
|
||||
{ status, headers: securityHeaders() }
|
||||
);
|
||||
}
|
||||
|
||||
// Error response helper
|
||||
export function apiError(message: string, status: number = 400, details?: unknown) {
|
||||
return Response.json(
|
||||
{ success: false, error: message, details },
|
||||
{ status, headers: securityHeaders() }
|
||||
);
|
||||
}
|
||||
|
||||
// Validation error helper
|
||||
export function validationError(errors: z.ZodError) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Validation failed",
|
||||
details: errors.errors.map((e) => ({
|
||||
path: e.path.join("."),
|
||||
message: e.message,
|
||||
})),
|
||||
},
|
||||
{ status: 400, headers: securityHeaders() }
|
||||
);
|
||||
}
|
||||
|
||||
// Rate limit exceeded response
|
||||
export function rateLimitExceeded(retryAfter: number) {
|
||||
return Response.json(
|
||||
{ success: false, error: "Rate limit exceeded" },
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
...securityHeaders(),
|
||||
"Retry-After": String(retryAfter),
|
||||
"X-RateLimit-Remaining": "0",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Not authorized response
|
||||
export function unauthorized(message: string = "Not authorized") {
|
||||
return Response.json(
|
||||
{ success: false, error: message },
|
||||
{ status: 401, headers: securityHeaders() }
|
||||
);
|
||||
}
|
||||
|
||||
// Forbidden response
|
||||
export function forbidden(message: string = "Access denied") {
|
||||
return Response.json(
|
||||
{ success: false, error: message },
|
||||
{ status: 403, headers: securityHeaders() }
|
||||
);
|
||||
}
|
||||
|
||||
// Not found response
|
||||
export function notFound(resource: string = "Resource") {
|
||||
return Response.json(
|
||||
{ success: false, error: `${resource} not found` },
|
||||
{ status: 404, headers: securityHeaders() }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Sentry configuration for production error monitoring
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
|
||||
// Performance monitoring
|
||||
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
|
||||
|
||||
// Environment
|
||||
environment: process.env.NODE_ENV,
|
||||
|
||||
// Release tracking
|
||||
release: process.env.GIT_SHA || process.env.VERCEL_GIT_COMMIT_SHA,
|
||||
|
||||
// Error sampling - capture more errors in production
|
||||
sampleRate: 1.0,
|
||||
|
||||
// Replay sessions for debugging
|
||||
replaysSessionSampleRate: process.env.NODE_ENV === "production" ? 0.05 : 0,
|
||||
replaysOnErrorSampleRate: 0.5,
|
||||
|
||||
// Ignore common non-actionable errors
|
||||
ignoreErrors: [
|
||||
"ResizeObserver loop",
|
||||
"Non-Error promise rejection captured",
|
||||
"The operation was aborted",
|
||||
],
|
||||
|
||||
// Tags for filtering in Sentry dashboard
|
||||
initialScope: {
|
||||
tags: {
|
||||
source: "route-commerce",
|
||||
},
|
||||
},
|
||||
|
||||
// BeforeSend hook for data sanitization
|
||||
beforeSend(event) {
|
||||
// Remove any PII or sensitive data before sending
|
||||
if (event.user) {
|
||||
delete event.user.ip;
|
||||
delete event.user.email;
|
||||
}
|
||||
return event;
|
||||
},
|
||||
});
|
||||
|
||||
// Export for manual error capture
|
||||
export const captureError = (error: Error, context?: Record<string, unknown>) => {
|
||||
Sentry.captureException(error, {
|
||||
extra: context,
|
||||
});
|
||||
};
|
||||
|
||||
// Export for manual breadcrumb logging
|
||||
export const addBreadcrumb = (message: string, data?: Record<string, unknown>) => {
|
||||
Sentry.addBreadcrumb({
|
||||
message,
|
||||
data,
|
||||
timestamp: Date.now() / 1000,
|
||||
});
|
||||
};
|
||||
|
||||
// Export for user tracking during errors
|
||||
export const setUserContext = (userId: string, brandId?: string) => {
|
||||
Sentry.setUser({
|
||||
id: userId,
|
||||
tags: { brand_id: brandId || "unknown" },
|
||||
});
|
||||
};
|
||||
|
||||
// Export for transaction tracing
|
||||
export const startTransaction = (name: string, op: string = "custom") => {
|
||||
return Sentry.startTransaction({ name, op });
|
||||
};
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
// SEO and Metadata Utilities
|
||||
|
||||
import { Metadata } from "next";
|
||||
|
||||
// Base URL for the application
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://routecommerce.com";
|
||||
|
||||
// Default SEO configuration
|
||||
export const defaultSEO: Metadata = {
|
||||
metadataBase: new URL(BASE_URL),
|
||||
title: {
|
||||
default: "Route Commerce | Fresh Produce Wholesale Platform",
|
||||
template: "%s | Route Commerce",
|
||||
},
|
||||
description: "Multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands sell to customers who pick up at scheduled stops or receive shipments.",
|
||||
keywords: [
|
||||
"wholesale produce",
|
||||
"farm fresh",
|
||||
"B2B e-commerce",
|
||||
"produce distribution",
|
||||
"pickup stops",
|
||||
"fresh produce",
|
||||
"farm management",
|
||||
"order management",
|
||||
"customer portal",
|
||||
"wholesale ordering",
|
||||
],
|
||||
authors: [{ name: "Route Commerce" }],
|
||||
creator: "Route Commerce",
|
||||
publisher: "Route Commerce",
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
"max-video-preview": -1,
|
||||
"max-image-preview": "large",
|
||||
"max-snippet": -1,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: "en_US",
|
||||
url: BASE_URL,
|
||||
siteName: "Route Commerce",
|
||||
title: "Route Commerce | Fresh Produce Wholesale Platform",
|
||||
description: "Multi-tenant B2B e-commerce platform for fresh produce wholesale distribution.",
|
||||
images: [
|
||||
{
|
||||
url: "/og-image.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Route Commerce Platform",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Route Commerce | Fresh Produce Wholesale Platform",
|
||||
description: "Multi-tenant B2B e-commerce platform for fresh produce wholesale distribution.",
|
||||
site: "@RouteCommerce",
|
||||
creator: "@RouteCommerce",
|
||||
images: ["/og-image.png"],
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/favicon.svg", type: "image/svg+xml" },
|
||||
],
|
||||
apple: "/icons/apple-touch-icon.png",
|
||||
other: [
|
||||
{
|
||||
rel: "manifest",
|
||||
url: "/manifest.json",
|
||||
},
|
||||
],
|
||||
},
|
||||
alternates: {
|
||||
canonical: BASE_URL,
|
||||
languages: {
|
||||
"en-US": BASE_URL,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Brand-specific metadata
|
||||
export function getBrandMetadata(brand: {
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
logo_url?: string;
|
||||
}) {
|
||||
return {
|
||||
title: `${brand.name} | Fresh Produce`,
|
||||
description: brand.description || `Order fresh produce from ${brand.name}. Pick up at scheduled stops or get deliveries.`,
|
||||
openGraph: {
|
||||
title: `${brand.name} | Fresh Produce`,
|
||||
description: brand.description || `Order fresh produce from ${brand.name}`,
|
||||
url: `${BASE_URL}/${brand.slug}`,
|
||||
siteName: brand.name,
|
||||
images: brand.logo_url ? [{ url: brand.logo_url }] : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Product metadata
|
||||
export function getProductMetadata(product: {
|
||||
name: string;
|
||||
description?: string;
|
||||
price?: number;
|
||||
image_url?: string;
|
||||
}) {
|
||||
return {
|
||||
title: `${product.name} | Route Commerce`,
|
||||
description: product.description || `Order ${product.name} from Route Commerce`,
|
||||
openGraph: {
|
||||
title: product.name,
|
||||
description: product.description,
|
||||
images: product.image_url ? [{ url: product.image_url }] : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Admin page metadata
|
||||
export function getAdminMetadata(pageTitle: string) {
|
||||
return {
|
||||
title: `${pageTitle} | Admin | Route Commerce`,
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Structured data (JSON-LD)
|
||||
export function getOrganizationSchema() {
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
name: "Route Commerce",
|
||||
url: BASE_URL,
|
||||
logo: `${BASE_URL}/logo.svg`,
|
||||
sameAs: [
|
||||
"https://twitter.com/RouteCommerce",
|
||||
"https://linkedin.com/company/route-commerce",
|
||||
],
|
||||
contactPoint: {
|
||||
"@type": "ContactPoint",
|
||||
contactType: "customer service",
|
||||
email: "support@routecommerce.com",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getProductSchema(product: {
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
currency?: string;
|
||||
availability?: string;
|
||||
image?: string;
|
||||
brand?: string;
|
||||
}) {
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Product",
|
||||
name: product.name,
|
||||
description: product.description,
|
||||
image: product.image,
|
||||
brand: {
|
||||
"@type": "Brand",
|
||||
name: product.brand || "Route Commerce",
|
||||
},
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: product.price,
|
||||
priceCurrency: product.currency || "USD",
|
||||
availability: product.availability || "https://schema.org/InStock",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getLocalBusinessSchema(business: {
|
||||
name: string;
|
||||
address: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postalCode: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
openingHours?: string[];
|
||||
}) {
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "LocalBusiness",
|
||||
name: business.name,
|
||||
address: {
|
||||
"@type": "PostalAddress",
|
||||
streetAddress: business.address,
|
||||
addressLocality: business.city,
|
||||
addressRegion: business.state,
|
||||
postalCode: business.postalCode,
|
||||
},
|
||||
telephone: business.phone,
|
||||
email: business.email,
|
||||
openingHours: business.openingHours,
|
||||
};
|
||||
}
|
||||
|
||||
// Generate sitemap data
|
||||
export function generateSitemap(pages: { url: string; lastModified?: Date; changeFrequency?: string; priority?: number }[]) {
|
||||
return pages.map((page) => ({
|
||||
url: `${BASE_URL}${page.url}`,
|
||||
lastModified: page.lastModified || new Date(),
|
||||
changeFrequency: page.changeFrequency || "weekly",
|
||||
priority: page.priority || 0.5,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
// Enhanced Stripe Billing - Full Subscription Management
|
||||
// Supports plans, add-ons, upgrades, cancellations, and customer portal
|
||||
|
||||
import Stripe from "stripe";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
// Initialize Stripe
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
||||
apiVersion: "2025-04-30.basil",
|
||||
});
|
||||
|
||||
// Plan tier configurations
|
||||
export const PLANS = {
|
||||
starter: {
|
||||
id: "starter",
|
||||
name: "Starter",
|
||||
description: "Perfect for small farms getting started",
|
||||
monthlyPrice: 4900, // $49.00 in cents
|
||||
annualPrice: 44100, // $441.00 (15% discount)
|
||||
features: [
|
||||
"Up to 25 products",
|
||||
"10 stops/month",
|
||||
"1 user",
|
||||
"Basic pickup management",
|
||||
"Email support",
|
||||
],
|
||||
limits: {
|
||||
max_users: 1,
|
||||
max_stops_monthly: 10,
|
||||
max_products: 25,
|
||||
},
|
||||
},
|
||||
farm: {
|
||||
id: "farm",
|
||||
name: "Farm",
|
||||
description: "For growing farms with more needs",
|
||||
monthlyPrice: 14900, // $149.00
|
||||
annualPrice: 152280, // $1,522.80 (10% discount)
|
||||
features: [
|
||||
"Unlimited products",
|
||||
"Unlimited stops",
|
||||
"5 users",
|
||||
"Wholesale Portal",
|
||||
"Harvest Reach",
|
||||
"Priority support",
|
||||
],
|
||||
limits: {
|
||||
max_users: 5,
|
||||
max_stops_monthly: -1, // unlimited
|
||||
max_products: -1,
|
||||
},
|
||||
},
|
||||
enterprise: {
|
||||
id: "enterprise",
|
||||
name: "Enterprise",
|
||||
description: "Custom solution for larger operations",
|
||||
monthlyPrice: 39900, // $399.00
|
||||
annualPrice: 0, // Custom pricing
|
||||
features: [
|
||||
"Everything in Farm",
|
||||
"AI Intelligence Pack",
|
||||
"SMS Campaigns",
|
||||
"Square Sync",
|
||||
"Water Log",
|
||||
"Unlimited users",
|
||||
"Unlimited brands",
|
||||
"Custom development",
|
||||
"Dedicated SLA",
|
||||
],
|
||||
limits: {
|
||||
max_users: -1,
|
||||
max_stops_monthly: -1,
|
||||
max_products: -1,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Add-ons configuration
|
||||
export const ADDONS = {
|
||||
wholesale_portal: {
|
||||
id: "wholesale_portal",
|
||||
name: "Wholesale Portal",
|
||||
description: "Full wholesale customer portal with ordering",
|
||||
monthlyPrice: 9900, // $99.00
|
||||
},
|
||||
harvest_reach: {
|
||||
id: "harvest_reach",
|
||||
name: "Harvest Reach",
|
||||
description: "Email and SMS marketing campaigns",
|
||||
monthlyPrice: 7900, // $79.00
|
||||
},
|
||||
ai_tools: {
|
||||
id: "ai_tools",
|
||||
name: "AI Intelligence Pack",
|
||||
description: "AI-powered insights and automation",
|
||||
monthlyPrice: 5900, // $59.00
|
||||
},
|
||||
water_log: {
|
||||
id: "water_log",
|
||||
name: "Water Log",
|
||||
description: "Track irrigation and water usage",
|
||||
monthlyPrice: 3900, // $39.00
|
||||
},
|
||||
square_sync: {
|
||||
id: "square_sync",
|
||||
name: "Square Sync",
|
||||
description: "Sync inventory with Square POS",
|
||||
monthlyPrice: 3900, // $39.00
|
||||
},
|
||||
sms_campaigns: {
|
||||
id: "sms_campaigns",
|
||||
name: "SMS Campaigns",
|
||||
description: "SMS marketing via Twilio",
|
||||
monthlyPrice: 2900, // $29.00
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Stripe Price IDs (configurable via environment)
|
||||
const PRICE_IDS = {
|
||||
starter_monthly: process.env.STRIPE_PRICE_STARTER_MONTHLY || "price_starter_monthly",
|
||||
starter_annual: process.env.STRIPE_PRICE_STARTER_ANNUAL || "price_starter_annual",
|
||||
farm_monthly: process.env.STRIPE_PRICE_FARM_MONTHLY || "price_farm_monthly",
|
||||
farm_annual: process.env.STRIPE_PRICE_FARM_ANNUAL || "price_farm_annual",
|
||||
enterprise_monthly: process.env.STRIPE_PRICE_ENTERPRISE_MONTHLY || "price_enterprise_monthly",
|
||||
wholesale_portal: process.env.STRIPE_PRICE_WHOLESALE_PORTAL || "price_wholesale_portal",
|
||||
harvest_reach: process.env.STRIPE_PRICE_HARVEST_REACH || "price_harvest_reach",
|
||||
ai_tools: process.env.STRIPE_PRICE_AI_TOOLS || "price_ai_tools",
|
||||
water_log: process.env.STRIPE_PRICE_WATER_LOG || "price_water_log",
|
||||
square_sync: process.env.STRIPE_PRICE_SQUARE_SYNC || "price_square_sync",
|
||||
sms_campaigns: process.env.STRIPE_PRICE_SMS_CAMPAIGNS || "price_sms_campaigns",
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// SUBSCRIPTION MANAGEMENT
|
||||
// ============================================
|
||||
|
||||
export interface CreateSubscriptionOptions {
|
||||
brandId: string;
|
||||
brandName: string;
|
||||
email: string;
|
||||
plan: keyof typeof PLANS;
|
||||
interval: "monthly" | "annual";
|
||||
successUrl: string;
|
||||
cancelUrl: string;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export async function createSubscription(options: CreateSubscriptionOptions) {
|
||||
const { brandId, brandName, email, plan, interval, successUrl, cancelUrl, metadata } = options;
|
||||
|
||||
// Get or create Stripe customer
|
||||
let customerId = await getOrCreateCustomer(brandId, brandName, email);
|
||||
|
||||
// Get price ID for selected plan
|
||||
const priceId = interval === "annual"
|
||||
? PRICE_IDS[`${plan}_annual` as keyof typeof PRICE_IDS]
|
||||
: PRICE_IDS[`${plan}_monthly` as keyof typeof PRICE_IDS];
|
||||
|
||||
// Create checkout session
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
mode: "subscription",
|
||||
payment_method_types: ["card"],
|
||||
line_items: [
|
||||
{
|
||||
price: priceId,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
subscription_data: {
|
||||
metadata: {
|
||||
brand_id: brandId,
|
||||
brand_name: brandName,
|
||||
plan_tier: plan,
|
||||
interval,
|
||||
},
|
||||
trial_period_days: 14, // 14-day trial for new subscriptions
|
||||
},
|
||||
metadata: {
|
||||
brand_id: brandId,
|
||||
...metadata,
|
||||
},
|
||||
allow_promotion_codes: true,
|
||||
billing_address_collection: "required",
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function createAddonSubscription(options: {
|
||||
customerId: string;
|
||||
addon: keyof typeof ADDONS;
|
||||
brandId: string;
|
||||
successUrl: string;
|
||||
cancelUrl: string;
|
||||
}) {
|
||||
const { customerId, addon, brandId, successUrl, cancelUrl } = options;
|
||||
|
||||
const priceId = PRICE_IDS[addon as keyof typeof PRICE_IDS];
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
mode: "subscription",
|
||||
payment_method_types: ["card"],
|
||||
line_items: [
|
||||
{
|
||||
price: priceId,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
subscription_data: {
|
||||
metadata: {
|
||||
brand_id: brandId,
|
||||
addon_key: addon,
|
||||
addon_name: ADDONS[addon].name,
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
brand_id: brandId,
|
||||
addon_key: addon,
|
||||
type: "addon",
|
||||
},
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CUSTOMER PORTAL
|
||||
// ============================================
|
||||
|
||||
export async function createCustomerPortalSession(options: {
|
||||
customerId: string;
|
||||
returnUrl: string;
|
||||
}) {
|
||||
const { customerId, returnUrl } = options;
|
||||
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: customerId,
|
||||
return_url: returnUrl,
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SUBSCRIPTION UPDATES
|
||||
// ============================================
|
||||
|
||||
export async function cancelSubscription(subscriptionId: string, immediate: boolean = false) {
|
||||
if (immediate) {
|
||||
return await stripe.subscriptions.cancel(subscriptionId);
|
||||
}
|
||||
|
||||
return await stripe.subscriptions.update(subscriptionId, {
|
||||
cancel_at_period_end: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function reactivateSubscription(subscriptionId: string) {
|
||||
return await stripe.subscriptions.update(subscriptionId, {
|
||||
cancel_at_period_end: false,
|
||||
});
|
||||
}
|
||||
|
||||
export async function changePlan(subscriptionId: string, newPlan: keyof typeof PLANS, interval: "monthly" | "annual") {
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
|
||||
// Get new price ID
|
||||
const priceId = interval === "annual"
|
||||
? PRICE_IDS[`${newPlan}_annual` as keyof typeof PRICE_IDS]
|
||||
: PRICE_IDS[`${newPlan}_monthly` as keyof typeof PRICE_IDS];
|
||||
|
||||
// Prorate the change
|
||||
return await stripe.subscriptions.update(subscriptionId, {
|
||||
items: [
|
||||
{
|
||||
id: subscription.items.data[0].id,
|
||||
price: priceId,
|
||||
},
|
||||
],
|
||||
proration_behavior: "create_prorations",
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CUSTOMER MANAGEMENT
|
||||
// ============================================
|
||||
|
||||
export async function getOrCreateCustomer(brandId: string, name: string, email: string) {
|
||||
// Check if customer exists for this brand
|
||||
const existingCustomers = await stripe.customers.list({
|
||||
email,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (existingCustomers.data.length > 0) {
|
||||
return existingCustomers.data[0].id;
|
||||
}
|
||||
|
||||
// Create new customer
|
||||
const customer = await stripe.customers.create({
|
||||
email,
|
||||
name,
|
||||
metadata: {
|
||||
brand_id: brandId,
|
||||
},
|
||||
});
|
||||
|
||||
return customer.id;
|
||||
}
|
||||
|
||||
export async function getCustomer(customerId: string) {
|
||||
return await stripe.customers.retrieve(customerId);
|
||||
}
|
||||
|
||||
export async function updateCustomer(customerId: string, data: Partial<Stripe.CustomerUpdateParams>) {
|
||||
return await stripe.customers.update(customerId, data);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PAYMENT METHODS
|
||||
// ============================================
|
||||
|
||||
export async function listPaymentMethods(customerId: string) {
|
||||
return await stripe.paymentMethods.list({
|
||||
customer: customerId,
|
||||
type: "card",
|
||||
});
|
||||
}
|
||||
|
||||
export async function attachPaymentMethod(paymentMethodId: string, customerId: string) {
|
||||
return await stripe.paymentMethods.attach(paymentMethodId, {
|
||||
customer: customerId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function setDefaultPaymentMethod(customerId: string, paymentMethodId: string) {
|
||||
return await stripe.customers.update(customerId, {
|
||||
invoice_settings: {
|
||||
default_payment_method: paymentMethodId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// INVOICES
|
||||
// ============================================
|
||||
|
||||
export async function listInvoices(customerId: string, limit: number = 24) {
|
||||
return await stripe.invoices.list({
|
||||
customer: customerId,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getInvoice(invoiceId: string) {
|
||||
return await stripe.invoices.retrieve(invoiceId);
|
||||
}
|
||||
|
||||
export async function getUpcomingInvoice(customerId: string) {
|
||||
return await stripe.invoices.retrieveUpcoming({
|
||||
customer: customerId,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PAYMENTS & REFUNDS
|
||||
// ============================================
|
||||
|
||||
export async function createPaymentIntent(options: {
|
||||
amount: number;
|
||||
currency?: string;
|
||||
customerId?: string;
|
||||
metadata?: Record<string, string>;
|
||||
}) {
|
||||
return await stripe.paymentIntents.create({
|
||||
amount: options.amount,
|
||||
currency: options.currency || "usd",
|
||||
customer: options.customerId,
|
||||
metadata: options.metadata,
|
||||
automatic_payment_methods: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createRefund(paymentIntentId: string, amount?: number, reason?: string) {
|
||||
return await stripe.refunds.create({
|
||||
payment_intent: paymentIntentId,
|
||||
amount,
|
||||
reason: reason as Stripe.RefundCreateParams.Reason || "requested_by_customer",
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// WEBHOOK PROCESSING
|
||||
// ============================================
|
||||
|
||||
export async function processWebhook(payload: Buffer, signature: string) {
|
||||
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
|
||||
|
||||
try {
|
||||
const event = stripe.webhooks.constructEvent(payload, signature, webhookSecret);
|
||||
|
||||
switch (event.type) {
|
||||
case "checkout.session.completed":
|
||||
await handleCheckoutCompleted(event.data.object);
|
||||
break;
|
||||
|
||||
case "customer.subscription.created":
|
||||
await handleSubscriptionCreated(event.data.object);
|
||||
break;
|
||||
|
||||
case "customer.subscription.updated":
|
||||
await handleSubscriptionUpdated(event.data.object);
|
||||
break;
|
||||
|
||||
case "customer.subscription.deleted":
|
||||
await handleSubscriptionDeleted(event.data.object);
|
||||
break;
|
||||
|
||||
case "invoice.payment_succeeded":
|
||||
await handleInvoicePaymentSucceeded(event.data.object);
|
||||
break;
|
||||
|
||||
case "invoice.payment_failed":
|
||||
await handleInvoicePaymentFailed(event.data.object);
|
||||
break;
|
||||
|
||||
case "customer.updated":
|
||||
await handleCustomerUpdated(event.data.object);
|
||||
break;
|
||||
}
|
||||
|
||||
return { received: true };
|
||||
} catch (error) {
|
||||
console.error("Webhook processing error:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Webhook handlers
|
||||
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
||||
const { brand_id, type } = session.metadata || {};
|
||||
|
||||
if (type === "addon") {
|
||||
// Handle addon checkout
|
||||
const subscription = await stripe.subscriptions.retrieve(session.subscription as string);
|
||||
await enableAddonFeature(brand_id, subscription.metadata.addon_key);
|
||||
} else {
|
||||
// Handle plan checkout
|
||||
const subscription = await stripe.subscriptions.retrieve(session.subscription as string);
|
||||
await updateBrandSubscription(
|
||||
brand_id,
|
||||
subscription.id,
|
||||
subscription.status,
|
||||
subscription.metadata.plan_tier,
|
||||
subscription.current_period_end
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubscriptionCreated(subscription: Stripe.Subscription) {
|
||||
const { brand_id, plan_tier } = subscription.metadata;
|
||||
|
||||
await updateBrandSubscription(
|
||||
brand_id,
|
||||
subscription.id,
|
||||
subscription.status,
|
||||
plan_tier,
|
||||
subscription.current_period_end
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
|
||||
const { brand_id, plan_tier, addon_key } = subscription.metadata;
|
||||
|
||||
if (addon_key) {
|
||||
// Add-on subscription
|
||||
if (subscription.status === "active") {
|
||||
await enableAddonFeature(brand_id, addon_key);
|
||||
} else {
|
||||
await disableAddonFeature(brand_id, addon_key);
|
||||
}
|
||||
} else {
|
||||
// Plan subscription
|
||||
await updateBrandSubscription(
|
||||
brand_id,
|
||||
subscription.id,
|
||||
subscription.status,
|
||||
plan_tier,
|
||||
subscription.current_period_end
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
|
||||
const { brand_id, plan_tier } = subscription.metadata;
|
||||
|
||||
// Downgrade to free tier or cancel
|
||||
await updateBrandSubscription(
|
||||
brand_id,
|
||||
null,
|
||||
"cancelled",
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
async function handleInvoicePaymentSucceeded(invoice: Stripe.Invoice) {
|
||||
// Log successful payment
|
||||
console.log(`Payment succeeded for customer: ${invoice.customer}`);
|
||||
}
|
||||
|
||||
async function handleInvoicePaymentFailed(invoice: Stripe.Invoice) {
|
||||
// Notify brand about failed payment
|
||||
console.log(`Payment failed for customer: ${invoice.customer}`);
|
||||
|
||||
// You could trigger an email notification here
|
||||
// await sendPaymentFailedNotification(invoice.customer as string);
|
||||
}
|
||||
|
||||
async function handleCustomerUpdated(customer: Stripe.Customer) {
|
||||
// Sync customer data with our database
|
||||
console.log(`Customer updated: ${customer.id}`);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// DATABASE HELPERS
|
||||
// ============================================
|
||||
|
||||
async function updateBrandSubscription(
|
||||
brandId: string | undefined,
|
||||
subscriptionId: string | null,
|
||||
status: string,
|
||||
planTier: string | undefined,
|
||||
periodEnd: number | null
|
||||
) {
|
||||
if (!brandId) return;
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
try {
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/set_brand_subscription`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_subscription_id: subscriptionId,
|
||||
p_subscription_status: status,
|
||||
p_plan_tier: planTier,
|
||||
p_current_period_end: periodEnd ? new Date(periodEnd * 1000).toISOString() : null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to update brand subscription:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function enableAddonFeature(brandId: string | undefined, addonKey: string | undefined) {
|
||||
if (!brandId || !addonKey) return;
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
try {
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/set_brand_feature`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_feature_key: addonKey,
|
||||
p_enabled: true,
|
||||
}),
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to enable addon feature:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function disableAddonFeature(brandId: string | undefined, addonKey: string | undefined) {
|
||||
if (!brandId || !addonKey) return;
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
try {
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/set_brand_feature`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_feature_key: addonKey,
|
||||
p_enabled: false,
|
||||
}),
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to disable addon feature:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// USAGE BILLING
|
||||
// ============================================
|
||||
|
||||
export async function createUsageRecord(options: {
|
||||
subscriptionItemId: string;
|
||||
quantity: number;
|
||||
timestamp?: number;
|
||||
idempotencyKey?: string;
|
||||
}) {
|
||||
return await stripe.subscriptionItems.createUsageRecord(
|
||||
options.subscriptionItemId,
|
||||
{
|
||||
quantity: options.quantity,
|
||||
timestamp: options.timestamp,
|
||||
},
|
||||
{
|
||||
idempotencyKey: options.idempotencyKey || uuidv4(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function getUsageSummary(subscriptionItemId: string, period: { start: number; end: number }) {
|
||||
return await stripe.usageRecordSummaries.list(
|
||||
subscriptionItemId,
|
||||
{
|
||||
limit: 12,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// EXPORT STRIPE INSTANCE AND HELPERS
|
||||
// ============================================
|
||||
|
||||
export { stripe };
|
||||
|
||||
export async function getSubscription(subscriptionId: string) {
|
||||
return await stripe.subscriptions.retrieve(subscriptionId);
|
||||
}
|
||||
|
||||
export async function listSubscriptions(customerId: string) {
|
||||
return await stripe.subscriptions.list({
|
||||
customer: customerId,
|
||||
status: "all",
|
||||
limit: 10,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
// Zod schemas for API validation across the application
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
// Common validation patterns
|
||||
const uuidSchema = z.string().uuid();
|
||||
const emailSchema = z.string().email();
|
||||
const phoneSchema = z.string().regex(/^\+?[\d\s-()]+$/, "Invalid phone number");
|
||||
const urlSchema = z.string().url().optional();
|
||||
|
||||
// Pagination
|
||||
export const paginationSchema = z.object({
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||
});
|
||||
|
||||
// Brand/Admin User
|
||||
export const brandIdSchema = z.object({
|
||||
brand_id: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
// Orders
|
||||
export const createOrderSchema = z.object({
|
||||
brand_id: uuidSchema,
|
||||
customer_email: emailSchema.optional(),
|
||||
customer_name: z.string().min(1).max(255).optional(),
|
||||
customer_phone: phoneSchema.optional(),
|
||||
customer_address: z.string().optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
items: z.array(z.object({
|
||||
product_id: uuidSchema,
|
||||
quantity: z.number().int().positive(),
|
||||
price: z.number().positive(),
|
||||
fulfillment: z.enum(["pickup", "ship"]).default("pickup"),
|
||||
})).min(1),
|
||||
fulfillment: z.enum(["pickup", "ship", "mixed"]).optional(),
|
||||
stop_id: uuidSchema.optional(),
|
||||
});
|
||||
|
||||
export const updateOrderSchema = z.object({
|
||||
order_id: uuidSchema,
|
||||
status: z.enum(["pending", "confirmed", "preparing", "ready", "completed", "cancelled"]).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
tracking_number: z.string().optional(),
|
||||
payment_status: z.enum(["pending", "paid", "refunded", "failed"]).optional(),
|
||||
});
|
||||
|
||||
export const orderFiltersSchema = z.object({
|
||||
brand_id: uuidSchema.optional(),
|
||||
status: z.enum(["pending", "confirmed", "preparing", "ready", "completed", "cancelled"]).optional(),
|
||||
fulfillment: z.enum(["pickup", "ship", "mixed"]).optional(),
|
||||
date_from: z.string().datetime().optional(),
|
||||
date_to: z.string().datetime().optional(),
|
||||
customer_email: emailSchema.optional(),
|
||||
stop_id: uuidSchema.optional(),
|
||||
});
|
||||
|
||||
// Products
|
||||
export const createProductSchema = z.object({
|
||||
brand_id: uuidSchema,
|
||||
name: z.string().min(1).max(255),
|
||||
description: z.string().max(2000).optional(),
|
||||
price: z.number().positive(),
|
||||
unit: z.string().min(1).max(50).optional(),
|
||||
category: z.string().max(100).optional(),
|
||||
sku: z.string().max(100).optional(),
|
||||
is_active: z.boolean().default(true),
|
||||
is_taxable: z.boolean().default(true),
|
||||
inventory_quantity: z.number().int().nonnegative().optional(),
|
||||
image_url: urlSchema.optional(),
|
||||
});
|
||||
|
||||
export const updateProductSchema = z.object({
|
||||
product_id: uuidSchema,
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
description: z.string().max(2000).optional(),
|
||||
price: z.number().positive().optional(),
|
||||
unit: z.string().min(1).max(50).optional(),
|
||||
category: z.string().max(100).optional(),
|
||||
sku: z.string().max(100).optional(),
|
||||
is_active: z.boolean().optional(),
|
||||
is_taxable: z.boolean().optional(),
|
||||
inventory_quantity: z.number().int().nonnegative().optional(),
|
||||
image_url: urlSchema.optional(),
|
||||
});
|
||||
|
||||
export const productFiltersSchema = z.object({
|
||||
brand_id: uuidSchema.optional(),
|
||||
category: z.string().optional(),
|
||||
is_active: z.boolean().optional(),
|
||||
search: z.string().optional(),
|
||||
min_price: z.number().positive().optional(),
|
||||
max_price: z.number().positive().optional(),
|
||||
});
|
||||
|
||||
// Stops
|
||||
export const createStopSchema = z.object({
|
||||
brand_id: uuidSchema,
|
||||
name: z.string().min(1).max(255),
|
||||
address: z.string().min(1),
|
||||
city: z.string().max(100).optional(),
|
||||
state: z.string().max(50).optional(),
|
||||
postal_code: z.string().max(20).optional(),
|
||||
country: z.string().max(100).optional(),
|
||||
scheduled_at: z.string().datetime(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
product_ids: z.array(uuidSchema).optional(),
|
||||
});
|
||||
|
||||
export const updateStopSchema = z.object({
|
||||
stop_id: uuidSchema,
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
address: z.string().min(1).optional(),
|
||||
city: z.string().max(100).optional(),
|
||||
state: z.string().max(50).optional(),
|
||||
postal_code: z.string().max(20).optional(),
|
||||
scheduled_at: z.string().datetime().optional(),
|
||||
status: z.enum(["scheduled", "in_progress", "completed", "cancelled"]).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
product_ids: z.array(uuidSchema).optional(),
|
||||
});
|
||||
|
||||
export const stopFiltersSchema = z.object({
|
||||
brand_id: uuidSchema.optional(),
|
||||
status: z.enum(["scheduled", "in_progress", "completed", "cancelled"]).optional(),
|
||||
date_from: z.string().datetime().optional(),
|
||||
date_to: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
// Communication Campaigns
|
||||
export const createCampaignSchema = z.object({
|
||||
brand_id: uuidSchema,
|
||||
name: z.string().min(1).max(255),
|
||||
subject: z.string().min(1).max(500),
|
||||
template_id: uuidSchema.optional(),
|
||||
content: z.string().min(1),
|
||||
type: z.enum(["email", "sms"]).default("email"),
|
||||
segment_id: uuidSchema.optional(),
|
||||
contact_ids: z.array(uuidSchema).optional(),
|
||||
scheduled_at: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
export const updateCampaignSchema = z.object({
|
||||
campaign_id: uuidSchema,
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
subject: z.string().min(1).max(500).optional(),
|
||||
content: z.string().min(1).optional(),
|
||||
status: z.enum(["draft", "scheduled", "sending", "sent", "cancelled"]).optional(),
|
||||
scheduled_at: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
// Communication Contacts
|
||||
export const createContactSchema = z.object({
|
||||
brand_id: uuidSchema,
|
||||
email: emailSchema,
|
||||
phone: phoneSchema.optional(),
|
||||
first_name: z.string().max(100).optional(),
|
||||
last_name: z.string().max(100).optional(),
|
||||
company: z.string().max(255).optional(),
|
||||
tags: z.array(z.string().max(50)).optional(),
|
||||
email_opt_in: z.boolean().default(true),
|
||||
sms_opt_in: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const updateContactSchema = z.object({
|
||||
contact_id: uuidSchema,
|
||||
email: emailSchema.optional(),
|
||||
phone: phoneSchema.optional(),
|
||||
first_name: z.string().max(100).optional(),
|
||||
last_name: z.string().max(100).optional(),
|
||||
company: z.string().max(255).optional(),
|
||||
tags: z.array(z.string().max(50)).optional(),
|
||||
email_opt_in: z.boolean().optional(),
|
||||
sms_opt_in: z.boolean().optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
// Communication Templates
|
||||
export const createTemplateSchema = z.object({
|
||||
brand_id: uuidSchema,
|
||||
name: z.string().min(1).max(255),
|
||||
subject: z.string().min(1).max(500).optional(),
|
||||
content: z.string().min(1),
|
||||
type: z.enum(["email", "sms"]).default("email"),
|
||||
category: z.string().max(100).optional(),
|
||||
});
|
||||
|
||||
export const updateTemplateSchema = z.object({
|
||||
template_id: uuidSchema,
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
subject: z.string().min(1).max(500).optional(),
|
||||
content: z.string().min(1).optional(),
|
||||
category: z.string().max(100).optional(),
|
||||
is_active: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// Water Log
|
||||
export const createWaterLogSchema = z.object({
|
||||
brand_id: uuidSchema,
|
||||
field_id: uuidSchema.optional(),
|
||||
field_name: z.string().max(255).optional(),
|
||||
gallons: z.number().positive(),
|
||||
duration_minutes: z.number().int().nonnegative().optional(),
|
||||
notes: z.string().max(500).optional(),
|
||||
logged_at: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
export const updateWaterLogSchema = z.object({
|
||||
log_id: uuidSchema,
|
||||
gallons: z.number().positive().optional(),
|
||||
duration_minutes: z.number().int().nonnegative().optional(),
|
||||
notes: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
export const waterLogFiltersSchema = z.object({
|
||||
brand_id: uuidSchema.optional(),
|
||||
field_id: uuidSchema.optional(),
|
||||
date_from: z.string().datetime().optional(),
|
||||
date_to: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
// Wholesale
|
||||
export const createWholesaleOrderSchema = z.object({
|
||||
brand_id: uuidSchema,
|
||||
customer_id: uuidSchema,
|
||||
items: z.array(z.object({
|
||||
product_id: uuidSchema,
|
||||
quantity: z.number().int().positive(),
|
||||
price: z.number().positive(),
|
||||
})).min(1),
|
||||
pickup_stop_id: uuidSchema.optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
deposit_amount: z.number().nonnegative().optional(),
|
||||
payment_method: z.enum(["stripe", "square", "invoice"]).default("invoice"),
|
||||
});
|
||||
|
||||
export const updateWholesaleCustomerSchema = z.object({
|
||||
customer_id: uuidSchema,
|
||||
company_name: z.string().max(255).optional(),
|
||||
contact_name: z.string().max(255).optional(),
|
||||
email: emailSchema.optional(),
|
||||
phone: phoneSchema.optional(),
|
||||
credit_limit: z.number().nonnegative().optional(),
|
||||
payment_terms: z.enum(["prepay", "net15", "net30", "net60"]).optional(),
|
||||
is_active: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// Billing / Subscriptions
|
||||
export const createSubscriptionSchema = z.object({
|
||||
brand_id: uuidSchema,
|
||||
plan_tier: z.enum(["starter", "farm", "enterprise"]),
|
||||
interval: z.enum(["monthly", "annual"]).default("monthly"),
|
||||
payment_method_id: z.string().optional(),
|
||||
});
|
||||
|
||||
export const updateSubscriptionSchema = z.object({
|
||||
subscription_id: z.string(),
|
||||
plan_tier: z.enum(["starter", "farm", "enterprise"]).optional(),
|
||||
interval: z.enum(["monthly", "annual"]).optional(),
|
||||
status: z.enum(["active", "cancelled", "past_due", "trialing"]).optional(),
|
||||
});
|
||||
|
||||
// Admin Users
|
||||
export const createAdminUserSchema = z.object({
|
||||
email: emailSchema,
|
||||
role: z.enum(["platform_admin", "brand_admin", "store_employee"]),
|
||||
brand_id: uuidSchema.optional(),
|
||||
can_manage_products: z.boolean().default(false),
|
||||
can_manage_stops: z.boolean().default(false),
|
||||
can_manage_orders: z.boolean().default(false),
|
||||
can_manage_pickup: z.boolean().default(false),
|
||||
can_manage_messages: z.boolean().default(false),
|
||||
can_manage_refunds: z.boolean().default(false),
|
||||
can_manage_users: z.boolean().default(false),
|
||||
can_manage_water_log: z.boolean().default(false),
|
||||
can_manage_reports: z.boolean().default(false),
|
||||
can_manage_settings: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const updateAdminUserSchema = z.object({
|
||||
user_id: uuidSchema,
|
||||
role: z.enum(["platform_admin", "brand_admin", "store_employee"]).optional(),
|
||||
can_manage_products: z.boolean().optional(),
|
||||
can_manage_stops: z.boolean().optional(),
|
||||
can_manage_orders: z.boolean().optional(),
|
||||
can_manage_pickup: z.boolean().optional(),
|
||||
can_manage_messages: z.boolean().optional(),
|
||||
can_manage_refunds: z.boolean().optional(),
|
||||
can_manage_users: z.boolean().optional(),
|
||||
can_manage_water_log: z.boolean().optional(),
|
||||
can_manage_reports: z.boolean().optional(),
|
||||
can_manage_settings: z.boolean().optional(),
|
||||
active: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// Reports
|
||||
export const reportFiltersSchema = z.object({
|
||||
brand_id: uuidSchema.optional(),
|
||||
start_date: z.string().datetime(),
|
||||
end_date: z.string().datetime(),
|
||||
group_by: z.enum(["day", "week", "month"]).default("day"),
|
||||
});
|
||||
|
||||
// Feature Flags
|
||||
export const featureToggleSchema = z.object({
|
||||
brand_id: uuidSchema,
|
||||
feature_key: z.string().min(1),
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
// Referral
|
||||
export const referralSchema = z.object({
|
||||
referrer_id: uuidSchema,
|
||||
referral_code: z.string().min(6).max(50),
|
||||
referred_email: emailSchema,
|
||||
campaign_id: z.string().optional(),
|
||||
});
|
||||
|
||||
// Cart
|
||||
export const cartItemSchema = z.object({
|
||||
product_id: uuidSchema,
|
||||
quantity: z.number().int().positive(),
|
||||
fulfillment: z.enum(["pickup", "ship"]).default("pickup"),
|
||||
stop_id: uuidSchema.optional(),
|
||||
});
|
||||
|
||||
// Checkout
|
||||
export const checkoutSchema = z.object({
|
||||
items: z.array(cartItemSchema),
|
||||
customer_email: emailSchema,
|
||||
customer_name: z.string().min(1).max(255),
|
||||
customer_phone: phoneSchema.optional(),
|
||||
customer_address: z.string().optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
payment_method: z.enum(["stripe", "square"]).default("stripe"),
|
||||
promo_code: z.string().optional(),
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
// Clerk middleware for route protection
|
||||
|
||||
import { authMiddleware } from "@clerk/nextjs";
|
||||
|
||||
// Define public routes that don't require authentication
|
||||
const publicRoutes = [
|
||||
"/",
|
||||
"/login",
|
||||
"/login2",
|
||||
"/register",
|
||||
"/forgot-password",
|
||||
"/reset-password",
|
||||
"/pricing",
|
||||
"/terms-and-conditions",
|
||||
"/privacy-policy",
|
||||
"/contact",
|
||||
"/api/health",
|
||||
"/api/webhooks/clerk",
|
||||
// Brand storefronts are public
|
||||
"/tuxedo",
|
||||
"/tuxedo/*",
|
||||
"/indian-river-direct",
|
||||
"/indian-river-direct/*",
|
||||
// Error pages
|
||||
"/error",
|
||||
"/not-found",
|
||||
];
|
||||
|
||||
// Define routes that require specific roles
|
||||
const roleProtectedRoutes = [
|
||||
{ path: "/admin/*", roles: ["platform_admin", "brand_admin", "store_employee"] },
|
||||
{ path: "/wholesale/portal/*", roles: ["wholesale_customer"] },
|
||||
{ path: "/water/admin/*", roles: ["platform_admin", "brand_admin"] },
|
||||
];
|
||||
|
||||
export default authMiddleware({
|
||||
// Public routes - don't require auth
|
||||
publicRoutes,
|
||||
|
||||
// Ignore auth for these paths (API routes with their own auth)
|
||||
ignoredRoutes: [
|
||||
"/api/*", // API routes handle their own auth
|
||||
"/_next/*", // Next.js internals
|
||||
"/favicon.ico",
|
||||
"/robots.txt",
|
||||
"/sitemap.xml",
|
||||
],
|
||||
|
||||
// After auth middleware - check roles
|
||||
afterAuth: (auth, req, evt) => {
|
||||
const { userId, sessionId } = auth;
|
||||
const path = req.nextUrl.pathname;
|
||||
|
||||
// Skip role check for public routes
|
||||
if (publicRoutes.some(route => path.startsWith(route.replace("/*", "")))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if route is role-protected
|
||||
for (const protectedRoute of roleProtectedRoutes) {
|
||||
if (path.startsWith(protectedRoute.path.replace("/*", ""))) {
|
||||
if (!userId) {
|
||||
// Redirect to login if not authenticated
|
||||
const signInUrl = new URL("/login", req.url);
|
||||
signInUrl.searchParams.set("redirect_url", path);
|
||||
return Response.redirect(signInUrl);
|
||||
}
|
||||
|
||||
// For admin routes, check session and role
|
||||
if (protectedRoute.path.startsWith("/admin")) {
|
||||
// Admin routes require one of the allowed roles
|
||||
// This is handled by the admin-permissions module in app layer
|
||||
}
|
||||
|
||||
if (protectedRoute.path.startsWith("/wholesale/portal")) {
|
||||
// Wholesale portal requires wholesale_customer role
|
||||
// This is handled by wholesale-auth module in app layer
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Debug in development
|
||||
debug: process.env.NODE_ENV === "development",
|
||||
});
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
// Skip Next.js internals and all files in the _next directory
|
||||
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
|
||||
// Always run for API routes
|
||||
"/(api|trpc)(.*)",
|
||||
],
|
||||
};
|
||||
+43
-26
@@ -1,32 +1,49 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
// Clerk Middleware for Next.js App Router
|
||||
// Must be exported as default from proxy.ts in src/ directory
|
||||
|
||||
// Simple route protection — admin pages redirect to login when no rc_auth_uid cookie.
|
||||
// Dev bypass: dev_session cookie bypasses this check in development only.
|
||||
export function proxy(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
import { clerkMiddleware } from "@clerk/nextjs/server";
|
||||
|
||||
// Skip non-admin routes
|
||||
if (!pathname.startsWith("/admin")) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
// Define public routes that don't require authentication
|
||||
const publicRoutes = [
|
||||
"/",
|
||||
"/login",
|
||||
"/login2",
|
||||
"/register",
|
||||
"/forgot-password",
|
||||
"/reset-password",
|
||||
"/pricing",
|
||||
"/terms-and-conditions",
|
||||
"/privacy-policy",
|
||||
"/contact",
|
||||
"/api/health",
|
||||
"/api/webhooks/clerk",
|
||||
"/api/stripe/webhook",
|
||||
// Brand storefronts are public
|
||||
"/tuxedo",
|
||||
"/tuxedo/*",
|
||||
"/indian-river-direct",
|
||||
"/indian-river-direct/*",
|
||||
// Error pages
|
||||
"/error",
|
||||
"/not-found",
|
||||
];
|
||||
|
||||
// Development bypass — dev_session cookie is set client-side by the login form
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
const devSession = request.cookies.get("dev_session")?.value;
|
||||
if (devSession) return NextResponse.next();
|
||||
}
|
||||
|
||||
// Check rc_auth_uid (new) or rc_uid (legacy) cookie
|
||||
const uid = request.cookies.get("rc_auth_uid")?.value ?? request.cookies.get("rc_uid")?.value;
|
||||
if (uid) return NextResponse.next();
|
||||
|
||||
// Not authenticated — redirect to login
|
||||
const loginUrl = new URL("/login", request.url);
|
||||
loginUrl.searchParams.set("redirect", pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
// Export the clerkMiddleware as the default export
|
||||
export default clerkMiddleware({
|
||||
// Public routes configuration
|
||||
publicRoutes,
|
||||
|
||||
// Debug mode in development
|
||||
debug: process.env.NODE_ENV === "development",
|
||||
});
|
||||
|
||||
export const config = {
|
||||
matcher: ["/admin/:path*"],
|
||||
matcher: [
|
||||
// Skip Next.js internals and all files in the _next directory
|
||||
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
|
||||
// Always run for API routes
|
||||
"/(api|trpc)(.*)",
|
||||
// Include Clerk's auto-proxy path for authentication
|
||||
"/__clerk/(.*)",
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user