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:
@@ -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(),
|
||||
});
|
||||
Reference in New Issue
Block a user