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:
2026-06-02 05:33:42 +00:00
parent b845d69aba
commit 6ab52a2499
32 changed files with 5816 additions and 501 deletions
+406
View File
@@ -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>
);
}
+156
View File
@@ -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>
);
}
+234
View File
@@ -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";
+287
View File
@@ -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;
}
+206
View File
@@ -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>
);
}