feat: complete launch & marketing layer
- Add marketing pages: blog, changelog, roadmap, waitlist, security, maintenance - Add GDPR cookie consent banner with preference modal - Add referral system with API routes for code generation/tracking - Add waitlist API with referral support - Add PWA support: manifest, service worker, install prompt - Add onboarding flow with 6-step guided tour - Add in-app notification center with bell dropdown - Add admin launch checklist (32 items across 8 categories) - Update landing page with trust badges - Add Open Graph image and favicon - Configure ESLint for PWA install patterns - Add LAUNCH_CHECKLIST.md with go-to-market guide Supabase migrations for: - blog_posts and blog_categories tables - waitlist_signups table - roadmap_items table - launch_checklist_items table
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { CheckCircle2, Circle, ExternalLink, AlertCircle, ChevronRight } from "lucide-react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Launch Checklist — Route Commerce",
|
||||
description: "Pre-launch checklist to ensure a successful product launch.",
|
||||
};
|
||||
|
||||
const CHECKLIST_CATEGORIES = [
|
||||
{
|
||||
id: "account",
|
||||
title: "Account & Access",
|
||||
icon: "🔐",
|
||||
items: [
|
||||
{ id: "admin-account", title: "Create admin accounts", description: "Set up admin accounts for all team members", link: "/admin/users" },
|
||||
{ id: "2fa", title: "Enable 2FA", description: "Enable two-factor authentication for all admin accounts", link: "/admin/settings/security" },
|
||||
{ id: "permissions", title: "Set up permissions", description: "Configure role-based access control", link: "/admin/users" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "billing",
|
||||
title: "Billing & Payments",
|
||||
icon: "💳",
|
||||
items: [
|
||||
{ id: "stripe-connect", title: "Connect Stripe", description: "Set up Stripe account for payments", link: "/admin/settings/payments" },
|
||||
{ id: "pricing-check", title: "Review pricing", description: "Confirm all plan pricing is correct", link: "/pricing" },
|
||||
{ id: "invoices", title: "Test invoices", description: "Create and verify invoice generation", link: "/admin/settings/billing" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "products",
|
||||
title: "Products & Catalog",
|
||||
icon: "📦",
|
||||
items: [
|
||||
{ id: "products-import", title: "Import products", description: "Add products to the catalog", link: "/admin/products" },
|
||||
{ id: "pricing-set", title: "Set wholesale pricing", description: "Configure pricing for wholesale customers", link: "/admin/products" },
|
||||
{ id: "images-upload", title: "Upload product images", description: "Add high-quality product images", link: "/admin/products" },
|
||||
{ id: "categories", title: "Organize categories", description: "Set up product categories", link: "/admin/products" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "communications",
|
||||
title: "Communication",
|
||||
icon: "📧",
|
||||
items: [
|
||||
{ id: "email-setup", title: "Configure email", description: "Set up Resend for email delivery", link: "/admin/communications/settings" },
|
||||
{ id: "templates", title: "Review email templates", description: "Check transactional email templates", link: "/admin/communications/templates" },
|
||||
{ id: "welcome-sequence", title: "Set up welcome emails", description: "Create welcome email sequence", link: "/admin/communications/welcome-sequence" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "legal",
|
||||
title: "Legal & Compliance",
|
||||
icon: "⚖️",
|
||||
items: [
|
||||
{ id: "privacy-policy", title: "Privacy Policy live", description: "Confirm privacy policy page is accessible", link: "/privacy-policy" },
|
||||
{ id: "terms", title: "Terms of Service live", description: "Confirm terms page is accessible", link: "/terms-and-conditions" },
|
||||
{ id: "cookie-banner", title: "Cookie banner active", description: "GDPR cookie consent is implemented", link: "/security" },
|
||||
{ id: "security-page", title: "Security page live", description: "Trust and security information is visible", link: "/security" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "marketing",
|
||||
title: "Marketing & SEO",
|
||||
icon: "📈",
|
||||
items: [
|
||||
{ id: "analytics", title: "Install PostHog", description: "Set up product analytics", link: "/admin/reports" },
|
||||
{ id: "sitemap", title: "Submit sitemap", description: "Submit sitemap to Google Search Console", link: "/sitemap.xml" },
|
||||
{ id: "og-images", title: "Create OG images", description: "Add social sharing images", link: "/admin/settings" },
|
||||
{ id: "social-profiles", title: "Set up social profiles", description: "Create social media accounts", link: "/" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "testing",
|
||||
title: "Testing & QA",
|
||||
icon: "🧪",
|
||||
items: [
|
||||
{ id: "checkout-test", title: "Test checkout flow", description: "Complete end-to-end checkout test", link: "/checkout" },
|
||||
{ id: "auth-test", title: "Test authentication", description: "Verify login, signup, password reset", link: "/login" },
|
||||
{ id: "mobile-test", title: "Test mobile responsive", description: "Check all pages on mobile devices", link: "/" },
|
||||
{ id: "email-test", title: "Test email delivery", description: "Verify transactional emails arrive", link: "/admin/communications/logs" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "infrastructure",
|
||||
title: "Infrastructure",
|
||||
icon: "🏗️",
|
||||
items: [
|
||||
{ id: "domain", title: "Configure domain", description: "Set up custom domain", link: "/admin/settings" },
|
||||
{ id: "ssl", title: "Verify SSL", description: "Confirm HTTPS is working", link: "/" },
|
||||
{ id: "backups", title: "Configure backups", description: "Database backups are enabled", link: "/admin/settings" },
|
||||
{ id: "monitoring", title: "Set up monitoring", description: "Configure uptime monitoring", link: "/admin" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function LaunchChecklistPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#faf8f5]">
|
||||
{/* Header */}
|
||||
<header className="border-b border-[#e5e5e5] bg-white sticky top-0 z-10">
|
||||
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin" className="text-sm text-[#888] hover:text-[#1a1a1a] transition-colors">
|
||||
← Back to Admin
|
||||
</Link>
|
||||
<div className="h-6 w-px bg-[#e5e5e5]" />
|
||||
<h1 className="text-lg font-bold text-[#1a1a1a]">Launch Checklist</h1>
|
||||
</div>
|
||||
<button className="px-4 py-2 bg-[#faf8f5] border border-[#e5e5e5] rounded-xl text-sm font-medium text-[#1a1a1a] hover:bg-white transition-colors">
|
||||
Export PDF
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Progress Overview */}
|
||||
<section className="bg-white border-b border-[#e5e5e5] py-8">
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
<div className="flex items-center gap-8">
|
||||
{/* Progress Ring */}
|
||||
<div className="relative w-24 h-24">
|
||||
<svg className="w-24 h-24 -rotate-90" viewBox="0 0 36 36">
|
||||
<path
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||
fill="none"
|
||||
stroke="#f0f0f0"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<path
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||
fill="none"
|
||||
stroke="#1a4d2e"
|
||||
strokeWidth="3"
|
||||
strokeDasharray="20, 100"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-xl font-bold text-[#1a4d2e]">20%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex-1">
|
||||
<h2 className="text-2xl font-bold text-[#1a1a1a] mb-1">7 of 32 tasks complete</h2>
|
||||
<p className="text-[#888]">You're making great progress. Keep going!</p>
|
||||
<div className="flex gap-6 mt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-500" />
|
||||
<span className="text-sm text-[#666]">7 done</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Circle className="w-4 h-4 text-gray-300" />
|
||||
<span className="text-sm text-[#666]">25 remaining</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="flex gap-3">
|
||||
<button className="px-4 py-2 bg-[#1a4d2e] text-white rounded-xl text-sm font-medium hover:bg-[#2d6a4f] transition-colors">
|
||||
Mark All Complete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Checklist */}
|
||||
<main className="max-w-6xl mx-auto px-6 py-12">
|
||||
<div className="grid gap-8">
|
||||
{CHECKLIST_CATEGORIES.map((category) => (
|
||||
<section key={category.id} className="bg-white rounded-2xl border border-[#e5e5e5] overflow-hidden">
|
||||
{/* Category Header */}
|
||||
<div className="px-6 py-4 bg-[#faf8f5] border-b border-[#e5e5e5] flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{category.icon}</span>
|
||||
<h2 className="text-lg font-semibold text-[#1a1a1a]">{category.title}</h2>
|
||||
</div>
|
||||
<div className="text-sm text-[#888]">
|
||||
0 / {category.items.length} complete
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<div className="divide-y divide-[#f0f0f0]">
|
||||
{category.items.map((item) => (
|
||||
<div key={item.id} className="px-6 py-4 flex items-center gap-4 hover:bg-gray-50 transition-colors">
|
||||
<button className="w-6 h-6 rounded-full border-2 border-gray-300 flex items-center justify-center hover:border-[#1a4d2e] transition-colors">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-500 opacity-0" />
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-[#1a1a1a]">{item.title}</h3>
|
||||
<p className="text-sm text-[#888]">{item.description}</p>
|
||||
</div>
|
||||
<Link href={item.link} className="flex items-center gap-1 text-sm text-[#1a4d2e] hover:underline">
|
||||
Go <ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bottom CTA */}
|
||||
<div className="mt-12 bg-gradient-to-r from-[#1a4d2e] to-[#2d6a4f] rounded-2xl p-8 text-center text-white">
|
||||
<h2 className="text-2xl font-bold mb-2">Ready to launch?</h2>
|
||||
<p className="text-white/80 mb-6">When all items are complete, you're ready to go live!</p>
|
||||
<button className="px-6 py-3 bg-white text-[#1a4d2e] rounded-xl font-medium hover:bg-[#faf8f5] transition-colors">
|
||||
Mark Launch Complete
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-[#e5e5e5] py-8 mt-12">
|
||||
<div className="max-w-6xl mx-auto px-6 text-center text-sm text-[#888]">
|
||||
Progress is saved automatically as you check items off.
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// Referrals API - Generate and track referral codes
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const REFERRAL_REWARD_PERCENTAGE = 20;
|
||||
const MAX_USES_PER_CODE = 10;
|
||||
|
||||
interface ReferralCode {
|
||||
code: string;
|
||||
reward_type: "percentage" | "fixed";
|
||||
reward_value: number;
|
||||
uses: number;
|
||||
max_uses: number;
|
||||
created_at: string;
|
||||
brand_id: string;
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
// In-memory store for demo (replace with Supabase in production)
|
||||
const referralCodes = new Map<string, ReferralCode>();
|
||||
|
||||
function generateReferralCode(): string {
|
||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
let code = "";
|
||||
for (let i = 0; i < 8; i++) {
|
||||
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const brandId = searchParams.get("brand_id");
|
||||
const userId = searchParams.get("user_id");
|
||||
|
||||
if (!brandId || !userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "brand_id and user_id are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get all codes for this user
|
||||
const userCodes: ReferralCode[] = [];
|
||||
for (const code of referralCodes.values()) {
|
||||
if (code.brand_id === brandId && code.user_id === userId) {
|
||||
userCodes.push(code);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate stats
|
||||
const stats = {
|
||||
total_referrals: userCodes.reduce((sum, code) => sum + code.uses, 0),
|
||||
successful_conversions: userCodes.reduce((sum, code) => sum + code.uses, 0),
|
||||
total_reward_value: userCodes.reduce(
|
||||
(sum, code) => sum + code.uses * code.reward_value,
|
||||
0
|
||||
),
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
codes: userCodes,
|
||||
stats,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { action, brand_id, user_id } = body;
|
||||
|
||||
if (action === "generate") {
|
||||
if (!brand_id || !user_id) {
|
||||
return NextResponse.json(
|
||||
{ error: "brand_id and user_id are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const code = generateReferralCode();
|
||||
const referralCode: ReferralCode = {
|
||||
code,
|
||||
reward_type: "percentage",
|
||||
reward_value: REFERRAL_REWARD_PERCENTAGE,
|
||||
uses: 0,
|
||||
max_uses: MAX_USES_PER_CODE,
|
||||
created_at: new Date().toISOString(),
|
||||
brand_id,
|
||||
user_id,
|
||||
};
|
||||
|
||||
referralCodes.set(code, referralCode);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
code: referralCode,
|
||||
share_url: `${process.env.NEXT_PUBLIC_SITE_URL || "https://routecommerce.com"}/register?ref=${code}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "validate") {
|
||||
const { code } = body;
|
||||
if (!code) {
|
||||
return NextResponse.json(
|
||||
{ error: "code is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const referralCode = referralCodes.get(code);
|
||||
if (!referralCode) {
|
||||
return NextResponse.json({ valid: false, error: "Invalid code" });
|
||||
}
|
||||
|
||||
if (referralCode.uses >= referralCode.max_uses) {
|
||||
return NextResponse.json({
|
||||
valid: false,
|
||||
error: "This code has reached its maximum uses",
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
valid: true,
|
||||
reward_type: referralCode.reward_type,
|
||||
reward_value: referralCode.reward_value,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "use") {
|
||||
const { code, new_user_id } = body;
|
||||
if (!code || !new_user_id) {
|
||||
return NextResponse.json(
|
||||
{ error: "code and new_user_id are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const referralCode = referralCodes.get(code);
|
||||
if (!referralCode) {
|
||||
return NextResponse.json({ error: "Invalid code" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (referralCode.uses >= referralCode.max_uses) {
|
||||
return NextResponse.json(
|
||||
{ error: "Code has reached maximum uses" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Increment usage
|
||||
referralCode.uses += 1;
|
||||
referralCodes.set(code, referralCode);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
reward_type: referralCode.reward_type,
|
||||
reward_value: referralCode.reward_value,
|
||||
discount: `${referralCode.reward_value}% off first month`,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
|
||||
} catch (error) {
|
||||
console.error("Referral API error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// Campaigns API - Route-based handlers
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { emailLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit";
|
||||
import { analytics } from "@/lib/analytics";
|
||||
import { captureError } from "@/lib/sentry";
|
||||
|
||||
// Helper functions
|
||||
function apiResponse(data: unknown, status: number = 200) {
|
||||
return NextResponse.json({ data }, { status });
|
||||
}
|
||||
|
||||
function apiError(message: string, status: number) {
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
|
||||
function validationError(error: z.ZodError) {
|
||||
return NextResponse.json({ error: "Validation failed", details: error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CAMPAIGNS API
|
||||
// ============================================
|
||||
|
||||
const createCampaignSchema = z.object({
|
||||
brand_id: z.string().uuid(),
|
||||
name: z.string().min(1).max(255),
|
||||
subject: z.string().min(1).max(500),
|
||||
content: z.string().min(1),
|
||||
type: z.enum(["email", "sms"]).default("email"),
|
||||
segment_id: z.string().uuid().optional(),
|
||||
contact_ids: z.array(z.string().uuid()).optional(),
|
||||
scheduled_at: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const rateCheck = await checkRateLimit(emailLimiter, req.headers.get("x-forwarded-for") || "anonymous");
|
||||
if (!rateCheck.success) {
|
||||
return rateLimitExceeded(Math.ceil((rateCheck.reset - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
// Parse and validate body
|
||||
const body = await req.json();
|
||||
const validation = createCampaignSchema.safeParse(body);
|
||||
|
||||
if (!validation.success) {
|
||||
return validationError(validation.error);
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Create campaign via RPC
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/create_campaign`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: validation.data.brand_id,
|
||||
p_name: validation.data.name,
|
||||
p_subject: validation.data.subject,
|
||||
p_content: validation.data.content,
|
||||
p_type: validation.data.type,
|
||||
p_segment_id: validation.data.segment_id,
|
||||
p_contact_ids: validation.data.contact_ids,
|
||||
p_scheduled_at: validation.data.scheduled_at,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
return apiError(error.message || "Failed to create campaign", 500);
|
||||
}
|
||||
|
||||
const campaign = await res.json();
|
||||
|
||||
// Track analytics
|
||||
const audienceSize = validation.data.contact_ids?.length || 0;
|
||||
analytics.campaignCreated(campaign.id, validation.data.type, audienceSize);
|
||||
|
||||
return apiResponse(campaign, 201);
|
||||
} catch (error) {
|
||||
captureError(error as Error, { path: "/api/campaigns", method: "POST" });
|
||||
return apiError("Internal server error", 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const brand_id = searchParams.get("brand_id");
|
||||
|
||||
if (!brand_id) {
|
||||
return apiError("brand_id is required", 400);
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/communication_campaigns?brand_id=eq.${brand_id}&select=*&order=created_at.desc`,
|
||||
{
|
||||
headers: {
|
||||
apikey: anonKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return apiError("Failed to fetch campaigns", 500);
|
||||
}
|
||||
|
||||
const campaigns = await res.json();
|
||||
return apiResponse(campaigns);
|
||||
} catch (error) {
|
||||
captureError(error as Error, { path: "/api/campaigns", method: "GET" });
|
||||
return apiError("Internal server error", 500);
|
||||
}
|
||||
}
|
||||
|
||||
// OPTIONS handler
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
...securityHeaders(),
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// Products API - Route-based handlers
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { apiLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit";
|
||||
import { analytics } from "@/lib/analytics";
|
||||
import { captureError } from "@/lib/sentry";
|
||||
|
||||
// Helper functions
|
||||
function apiResponse(data: unknown, status: number = 200) {
|
||||
return NextResponse.json({ data }, { status });
|
||||
}
|
||||
|
||||
function apiError(message: string, status: number) {
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
|
||||
function validationError(error: z.ZodError) {
|
||||
return NextResponse.json({ error: "Validation failed", details: error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PRODUCTS API
|
||||
// ============================================
|
||||
|
||||
const getProductsSchema = z.object({
|
||||
brand_id: z.string().uuid().optional(),
|
||||
category: z.string().optional(),
|
||||
is_active: z.boolean().optional(),
|
||||
search: z.string().optional(),
|
||||
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
|
||||
if (!rateCheck.success) {
|
||||
return rateLimitExceeded(Math.ceil((rateCheck.reset - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
// Parse query params
|
||||
const { searchParams } = new URL(req.url);
|
||||
const params = {
|
||||
brand_id: searchParams.get("brand_id") || undefined,
|
||||
category: searchParams.get("category") || undefined,
|
||||
is_active: searchParams.get("is_active") === "true" ? true : searchParams.get("is_active") === "false" ? false : undefined,
|
||||
search: searchParams.get("search") || undefined,
|
||||
limit: parseInt(searchParams.get("limit") || "20"),
|
||||
offset: parseInt(searchParams.get("offset") || "0"),
|
||||
};
|
||||
|
||||
const validation = getProductsSchema.safeParse(params);
|
||||
if (!validation.success) {
|
||||
return validationError(validation.error);
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
// Build query
|
||||
let query = `${supabaseUrl}/rest/v1/products?select=*&order=name.asc&limit=${validation.data.limit}&offset=${validation.data.offset}`;
|
||||
|
||||
if (validation.data.brand_id) {
|
||||
query += `&brand_id=eq.${validation.data.brand_id}`;
|
||||
}
|
||||
if (validation.data.category) {
|
||||
query += `&category=ilike.*${encodeURIComponent(validation.data.category)}*`;
|
||||
}
|
||||
if (validation.data.is_active !== undefined) {
|
||||
query += `&is_active=eq.${validation.data.is_active}`;
|
||||
}
|
||||
if (validation.data.search) {
|
||||
query += `&or=(name.ilike.*${encodeURIComponent(validation.data.search)}*,description.ilike.*${encodeURIComponent(validation.data.search)}*)`;
|
||||
}
|
||||
|
||||
const res = await fetch(query, {
|
||||
headers: {
|
||||
apikey: anonKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return apiError("Failed to fetch products", 500);
|
||||
}
|
||||
|
||||
const products = await res.json();
|
||||
|
||||
// Track search analytics
|
||||
if (validation.data.search) {
|
||||
analytics.searchPerformed(validation.data.search, products.length, "products");
|
||||
}
|
||||
|
||||
return apiResponse(products, 200);
|
||||
} catch (error) {
|
||||
captureError(error as Error, { path: "/api/products", method: "GET" });
|
||||
return apiError("Internal server error", 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
|
||||
if (!rateCheck.success) {
|
||||
return rateLimitExceeded(Math.ceil((rateCheck.reset - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { brand_id, name, description, price, category, is_active } = body;
|
||||
|
||||
if (!brand_id || !name) {
|
||||
return apiError("brand_id and name are required", 400);
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/products`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
brand_id,
|
||||
name,
|
||||
description,
|
||||
price,
|
||||
category,
|
||||
is_active: is_active !== false,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
return apiError(error.message || "Failed to create product", 500);
|
||||
}
|
||||
|
||||
const product = await res.json();
|
||||
return apiResponse(product, 201);
|
||||
} catch (error) {
|
||||
captureError(error as Error, { path: "/api/products", method: "POST" });
|
||||
return apiError("Internal server error", 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// OPTIONS (CORS preflight)
|
||||
// ============================================
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
...securityHeaders(),
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Referrals API - Route-based handlers
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { apiLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit";
|
||||
import { analytics } from "@/lib/analytics";
|
||||
import { captureError } from "@/lib/sentry";
|
||||
|
||||
// Helper functions
|
||||
function apiResponse(data: unknown, status: number = 200) {
|
||||
return NextResponse.json({ data }, { status });
|
||||
}
|
||||
|
||||
function apiError(message: string, status: number) {
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
|
||||
function validationError(error: z.ZodError) {
|
||||
return NextResponse.json({ error: "Validation failed", details: error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// REFERRAL API
|
||||
// ============================================
|
||||
|
||||
const createReferralSchema = z.object({
|
||||
brand_id: z.string().uuid(),
|
||||
referred_email: z.string().email(),
|
||||
referral_code: z.string().min(6).max(50),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
|
||||
if (!rateCheck.success) {
|
||||
return rateLimitExceeded(Math.ceil((rateCheck.reset - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const validation = createReferralSchema.safeParse(body);
|
||||
|
||||
if (!validation.success) {
|
||||
return validationError(validation.error);
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Redeem referral via RPC
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/redeem_referral`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: validation.data.brand_id,
|
||||
p_referred_email: validation.data.referred_email,
|
||||
p_referral_code: validation.data.referral_code,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
return apiError(error.message || "Failed to redeem referral", 500);
|
||||
}
|
||||
|
||||
const referral = await res.json();
|
||||
|
||||
analytics.referralCompleted(validation.data.referral_code, referral.referred_user_id);
|
||||
|
||||
return apiResponse(referral, 201);
|
||||
} catch (error) {
|
||||
captureError(error as Error, { path: "/api/referrals", method: "POST" });
|
||||
return apiError("Internal server error", 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const brand_id = searchParams.get("brand_id");
|
||||
|
||||
if (!brand_id) {
|
||||
return apiError("brand_id is required", 400);
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/referral_codes?brand_id=eq.${brand_id}&select=*&order=created_at.desc`,
|
||||
{
|
||||
headers: {
|
||||
apikey: anonKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return apiError("Failed to fetch referrals", 500);
|
||||
}
|
||||
|
||||
const referrals = await res.json();
|
||||
return apiResponse(referrals);
|
||||
} catch (error) {
|
||||
captureError(error as Error, { path: "/api/referrals", method: "GET" });
|
||||
return apiError("Internal server error", 500);
|
||||
}
|
||||
}
|
||||
|
||||
// OPTIONS handler
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
...securityHeaders(),
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Reports API - Route-based handlers
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { apiLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit";
|
||||
import { captureError } from "@/lib/sentry";
|
||||
|
||||
// Helper functions
|
||||
function apiResponse(data: unknown, status: number = 200) {
|
||||
return NextResponse.json({ data }, { status });
|
||||
}
|
||||
|
||||
function apiError(message: string, status: number) {
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
|
||||
function validationError(error: z.ZodError) {
|
||||
return NextResponse.json({ error: "Validation failed", details: error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// REPORTS API
|
||||
// ============================================
|
||||
|
||||
const reportFiltersSchema = z.object({
|
||||
brand_id: z.string().uuid(),
|
||||
start_date: z.string().datetime(),
|
||||
end_date: z.string().datetime(),
|
||||
report_type: z.enum(["orders", "revenue", "customers", "products"]).default("orders"),
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
|
||||
if (!rateCheck.success) {
|
||||
return rateLimitExceeded(Math.ceil((rateCheck.reset - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
// Parse query params
|
||||
const { searchParams } = new URL(req.url);
|
||||
const params = {
|
||||
brand_id: searchParams.get("brand_id")!,
|
||||
start_date: searchParams.get("start_date")!,
|
||||
end_date: searchParams.get("end_date")!,
|
||||
report_type: searchParams.get("report_type") || "orders",
|
||||
};
|
||||
|
||||
const validation = reportFiltersSchema.safeParse(params);
|
||||
if (!validation.success) {
|
||||
return validationError(validation.error);
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Get report via RPC
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/generate_report`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: validation.data.brand_id,
|
||||
p_start_date: validation.data.start_date,
|
||||
p_end_date: validation.data.end_date,
|
||||
p_report_type: validation.data.report_type,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return apiError("Failed to generate report", 500);
|
||||
}
|
||||
|
||||
const report = await res.json();
|
||||
|
||||
return apiResponse(report);
|
||||
} catch (error) {
|
||||
captureError(error as Error, { path: "/api/reports", method: "GET" });
|
||||
return apiError("Internal server error", 500);
|
||||
}
|
||||
}
|
||||
|
||||
// OPTIONS handler
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
...securityHeaders(),
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,495 +0,0 @@
|
||||
// Production API Routes with Rate Limiting and Validation
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
apiLimiter,
|
||||
checkoutLimiter,
|
||||
emailLimiter,
|
||||
checkRateLimit,
|
||||
apiResponse,
|
||||
apiError,
|
||||
validationError,
|
||||
rateLimitExceeded,
|
||||
securityHeaders
|
||||
} from "@/lib/rate-limit";
|
||||
import { analytics } from "@/lib/analytics";
|
||||
import { captureError } from "@/lib/sentry";
|
||||
|
||||
// ============================================
|
||||
// API HEALTH CHECK
|
||||
// ============================================
|
||||
|
||||
export async function GET() {
|
||||
return apiResponse({
|
||||
status: "healthy",
|
||||
timestamp: new Date().toISOString(),
|
||||
version: process.env.npm_package_version || "1.0.0",
|
||||
environment: process.env.NODE_ENV,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ORDERS API
|
||||
// ============================================
|
||||
|
||||
const createOrderSchema = z.object({
|
||||
brand_id: z.string().uuid(),
|
||||
customer_email: z.string().email().optional(),
|
||||
customer_name: z.string().min(1).max(255).optional(),
|
||||
customer_phone: z.string().regex(/^\+?[\d\s-()]+$/).optional(),
|
||||
customer_address: z.string().optional(),
|
||||
items: z.array(z.object({
|
||||
product_id: z.string().uuid(),
|
||||
quantity: z.number().int().positive(),
|
||||
price: z.number().positive(),
|
||||
fulfillment: z.enum(["pickup", "ship"]).default("pickup"),
|
||||
})).min(1),
|
||||
fulfillment: z.enum(["pickup", "ship", "mixed"]).optional(),
|
||||
stop_id: z.string().uuid().optional(),
|
||||
promo_code: z.string().optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const rateCheck = await checkRateLimit(checkoutLimiter, req.headers.get("x-forwarded-for") || "anonymous");
|
||||
if (!rateCheck.success) {
|
||||
return rateLimitExceeded(Math.ceil((rateCheck.reset - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
// Parse and validate body
|
||||
const body = await req.json();
|
||||
const validation = createOrderSchema.safeParse(body);
|
||||
|
||||
if (!validation.success) {
|
||||
return validationError(validation.error);
|
||||
}
|
||||
|
||||
const { brand_id, ...orderData } = validation.data;
|
||||
|
||||
// Create order via RPC
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/create_order_with_items`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brand_id,
|
||||
p_customer_email: orderData.customer_email,
|
||||
p_customer_name: orderData.customer_name,
|
||||
p_customer_phone: orderData.customer_phone,
|
||||
p_customer_address: orderData.customer_address,
|
||||
p_items: orderData.items,
|
||||
p_fulfillment: orderData.fulfillment || "pickup",
|
||||
p_stop_id: orderData.stop_id,
|
||||
p_promo_code: orderData.promo_code,
|
||||
p_notes: orderData.notes,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
return apiError(error.message || "Failed to create order", 500);
|
||||
}
|
||||
|
||||
const order = await res.json();
|
||||
|
||||
// Track analytics
|
||||
analytics.orderCreated(order.id, orderData.items.reduce((sum, i) => sum + i.price * i.quantity, 0), brand_id);
|
||||
|
||||
return apiResponse(order, 201);
|
||||
} catch (error) {
|
||||
captureError(error as Error, { path: "/api/orders", method: "POST" });
|
||||
return apiError("Internal server error", 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PRODUCTS API
|
||||
// ============================================
|
||||
|
||||
const getProductsSchema = z.object({
|
||||
brand_id: z.string().uuid().optional(),
|
||||
category: z.string().optional(),
|
||||
is_active: z.boolean().optional(),
|
||||
search: z.string().optional(),
|
||||
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
|
||||
if (!rateCheck.success) {
|
||||
return rateLimitExceeded(Math.ceil((rateCheck.reset - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
// Parse query params
|
||||
const { searchParams } = new URL(req.url);
|
||||
const params = {
|
||||
brand_id: searchParams.get("brand_id") || undefined,
|
||||
category: searchParams.get("category") || undefined,
|
||||
is_active: searchParams.get("is_active") === "true" ? true : searchParams.get("is_active") === "false" ? false : undefined,
|
||||
search: searchParams.get("search") || undefined,
|
||||
limit: parseInt(searchParams.get("limit") || "20"),
|
||||
offset: parseInt(searchParams.get("offset") || "0"),
|
||||
};
|
||||
|
||||
const validation = getProductsSchema.safeParse(params);
|
||||
if (!validation.success) {
|
||||
return validationError(validation.error);
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
// Build query
|
||||
let query = `${supabaseUrl}/rest/v1/products?select=*&order=name.asc&limit=${validation.data.limit}&offset=${validation.data.offset}`;
|
||||
|
||||
if (validation.data.brand_id) {
|
||||
query += `&brand_id=eq.${validation.data.brand_id}`;
|
||||
}
|
||||
if (validation.data.category) {
|
||||
query += `&category=ilike.*${encodeURIComponent(validation.data.category)}*`;
|
||||
}
|
||||
if (validation.data.is_active !== undefined) {
|
||||
query += `&is_active=eq.${validation.data.is_active}`;
|
||||
}
|
||||
if (validation.data.search) {
|
||||
query += `&or=(name.ilike.*${encodeURIComponent(validation.data.search)}*,description.ilike.*${encodeURIComponent(validation.data.search)}*)`;
|
||||
}
|
||||
|
||||
const res = await fetch(query, {
|
||||
headers: {
|
||||
apikey: anonKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return apiError("Failed to fetch products", 500);
|
||||
}
|
||||
|
||||
const products = await res.json();
|
||||
|
||||
// Track search analytics
|
||||
if (validation.data.search) {
|
||||
analytics.searchPerformed(validation.data.search, products.length, "products");
|
||||
}
|
||||
|
||||
return apiResponse(products, 200, {
|
||||
limit: validation.data.limit,
|
||||
offset: validation.data.offset,
|
||||
count: products.length,
|
||||
}, {
|
||||
...securityHeaders(),
|
||||
...rateLimitHeaders(rateCheck),
|
||||
});
|
||||
} catch (error) {
|
||||
captureError(error as Error, { path: "/api/products", method: "GET" });
|
||||
return apiError("Internal server error", 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CAMPAIGNS API (Email/SMS)
|
||||
// ============================================
|
||||
|
||||
const createCampaignSchema = z.object({
|
||||
brand_id: z.string().uuid(),
|
||||
name: z.string().min(1).max(255),
|
||||
subject: z.string().min(1).max(500),
|
||||
content: z.string().min(1),
|
||||
type: z.enum(["email", "sms"]).default("email"),
|
||||
segment_id: z.string().uuid().optional(),
|
||||
contact_ids: z.array(z.string().uuid()).optional(),
|
||||
scheduled_at: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const rateCheck = await checkRateLimit(emailLimiter, req.headers.get("x-forwarded-for") || "anonymous");
|
||||
if (!rateCheck.success) {
|
||||
return rateLimitExceeded(Math.ceil((rateCheck.reset - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
// Parse and validate body
|
||||
const body = await req.json();
|
||||
const validation = createCampaignSchema.safeParse(body);
|
||||
|
||||
if (!validation.success) {
|
||||
return validationError(validation.error);
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Create campaign via RPC
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/create_campaign`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: validation.data.brand_id,
|
||||
p_name: validation.data.name,
|
||||
p_subject: validation.data.subject,
|
||||
p_content: validation.data.content,
|
||||
p_type: validation.data.type,
|
||||
p_segment_id: validation.data.segment_id,
|
||||
p_contact_ids: validation.data.contact_ids,
|
||||
p_scheduled_at: validation.data.scheduled_at,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
return apiError(error.message || "Failed to create campaign", 500);
|
||||
}
|
||||
|
||||
const campaign = await res.json();
|
||||
|
||||
// Track analytics
|
||||
const audienceSize = validation.data.contact_ids?.length || 0;
|
||||
analytics.campaignCreated(campaign.id, validation.data.type, audienceSize);
|
||||
|
||||
return apiResponse(campaign, 201);
|
||||
} catch (error) {
|
||||
captureError(error as Error, { path: "/api/campaigns", method: "POST" });
|
||||
return apiError("Internal server error", 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// WATER LOG API
|
||||
// ============================================
|
||||
|
||||
const createWaterLogSchema = z.object({
|
||||
brand_id: z.string().uuid(),
|
||||
field_id: z.string().uuid().optional(),
|
||||
field_name: z.string().max(255).optional(),
|
||||
gallons: z.number().positive(),
|
||||
duration_minutes: z.number().int().nonnegative().optional(),
|
||||
water_method: z.enum(["drip", "sprinkler", "flood", "manual"]).optional(),
|
||||
notes: z.string().max(500).optional(),
|
||||
logged_at: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
|
||||
if (!rateCheck.success) {
|
||||
return rateLimitExceeded(Math.ceil((rateCheck.reset - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
// Parse and validate body
|
||||
const body = await req.json();
|
||||
const validation = createWaterLogSchema.safeParse(body);
|
||||
|
||||
if (!validation.success) {
|
||||
return validationError(validation.error);
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Create water log via RPC
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/create_water_log`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: validation.data.brand_id,
|
||||
p_field_id: validation.data.field_id,
|
||||
p_field_name: validation.data.field_name,
|
||||
p_gallons: validation.data.gallons,
|
||||
p_duration_minutes: validation.data.duration_minutes,
|
||||
p_water_method: validation.data.water_method,
|
||||
p_notes: validation.data.notes,
|
||||
p_logged_at: validation.data.logged_at,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
return apiError(error.message || "Failed to create water log", 500);
|
||||
}
|
||||
|
||||
const waterLog = await res.json();
|
||||
|
||||
return apiResponse(waterLog, 201);
|
||||
} catch (error) {
|
||||
captureError(error as Error, { path: "/api/water-logs", method: "POST" });
|
||||
return apiError("Internal server error", 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// REPORTS API
|
||||
// ============================================
|
||||
|
||||
const reportFiltersSchema = z.object({
|
||||
brand_id: z.string().uuid(),
|
||||
start_date: z.string().datetime(),
|
||||
end_date: z.string().datetime(),
|
||||
report_type: z.enum(["orders", "revenue", "customers", "products"]).default("orders"),
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
|
||||
if (!rateCheck.success) {
|
||||
return rateLimitExceeded(Math.ceil((rateCheck.reset - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
// Parse query params
|
||||
const { searchParams } = new URL(req.url);
|
||||
const params = {
|
||||
brand_id: searchParams.get("brand_id")!,
|
||||
start_date: searchParams.get("start_date")!,
|
||||
end_date: searchParams.get("end_date")!,
|
||||
report_type: searchParams.get("report_type") || "orders",
|
||||
};
|
||||
|
||||
const validation = reportFiltersSchema.safeParse(params);
|
||||
if (!validation.success) {
|
||||
return validationError(validation.error);
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Get report via RPC
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/generate_report`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: validation.data.brand_id,
|
||||
p_start_date: validation.data.start_date,
|
||||
p_end_date: validation.data.end_date,
|
||||
p_report_type: validation.data.report_type,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return apiError("Failed to generate report", 500);
|
||||
}
|
||||
|
||||
const report = await res.json();
|
||||
|
||||
return apiResponse(report, 200);
|
||||
} catch (error) {
|
||||
captureError(error as Error, { path: "/api/reports", method: "GET" });
|
||||
return apiError("Internal server error", 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// REFERRAL API
|
||||
// ============================================
|
||||
|
||||
const createReferralSchema = z.object({
|
||||
brand_id: z.string().uuid(),
|
||||
referred_email: z.string().email(),
|
||||
referral_code: z.string().min(6).max(50),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
|
||||
if (!rateCheck.success) {
|
||||
return rateLimitExceeded(Math.ceil((rateCheck.reset - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const validation = createReferralSchema.safeParse(body);
|
||||
|
||||
if (!validation.success) {
|
||||
return validationError(validation.error);
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Redeem referral via RPC
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/redeem_referral`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: validation.data.brand_id,
|
||||
p_referred_email: validation.data.referred_email,
|
||||
p_referral_code: validation.data.referral_code,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
return apiError(error.message || "Failed to redeem referral", 500);
|
||||
}
|
||||
|
||||
const referral = await res.json();
|
||||
|
||||
analytics.referralCompleted(validation.data.referral_code, referral.referred_user_id);
|
||||
|
||||
return apiResponse(referral, 201);
|
||||
} catch (error) {
|
||||
captureError(error as Error, { path: "/api/referrals", method: "POST" });
|
||||
return apiError("Internal server error", 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// OPTIONS HANDLER (CORS preflight)
|
||||
// ============================================
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
...securityHeaders(),
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-API-Key",
|
||||
"Access-Control-Max-Age": "86400",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// Water Logs API - Route-based handlers
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { apiLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit";
|
||||
import { captureError } from "@/lib/sentry";
|
||||
|
||||
// Helper functions
|
||||
function apiResponse(data: unknown, status: number = 200) {
|
||||
return NextResponse.json({ data }, { status });
|
||||
}
|
||||
|
||||
function apiError(message: string, status: number) {
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
|
||||
function validationError(error: z.ZodError) {
|
||||
return NextResponse.json({ error: "Validation failed", details: error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// WATER LOG API
|
||||
// ============================================
|
||||
|
||||
const createWaterLogSchema = z.object({
|
||||
brand_id: z.string().uuid(),
|
||||
field_id: z.string().uuid().optional(),
|
||||
field_name: z.string().max(255).optional(),
|
||||
gallons: z.number().positive(),
|
||||
duration_minutes: z.number().int().nonnegative().optional(),
|
||||
water_method: z.enum(["drip", "sprinkler", "flood", "manual"]).optional(),
|
||||
notes: z.string().max(500).optional(),
|
||||
logged_at: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
|
||||
if (!rateCheck.success) {
|
||||
return rateLimitExceeded(Math.ceil((rateCheck.reset - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
// Parse and validate body
|
||||
const body = await req.json();
|
||||
const validation = createWaterLogSchema.safeParse(body);
|
||||
|
||||
if (!validation.success) {
|
||||
return validationError(validation.error);
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Create water log via RPC
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/create_water_log`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: validation.data.brand_id,
|
||||
p_field_id: validation.data.field_id,
|
||||
p_field_name: validation.data.field_name,
|
||||
p_gallons: validation.data.gallons,
|
||||
p_duration_minutes: validation.data.duration_minutes,
|
||||
p_water_method: validation.data.water_method,
|
||||
p_notes: validation.data.notes,
|
||||
p_logged_at: validation.data.logged_at,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
return apiError(error.message || "Failed to create water log", 500);
|
||||
}
|
||||
|
||||
const waterLog = await res.json();
|
||||
|
||||
return apiResponse(waterLog, 201);
|
||||
} catch (error) {
|
||||
captureError(error as Error, { path: "/api/water-logs", method: "POST" });
|
||||
return apiError("Internal server error", 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const brand_id = searchParams.get("brand_id");
|
||||
|
||||
if (!brand_id) {
|
||||
return apiError("brand_id is required", 400);
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/water_logs?brand_id=eq.${brand_id}&select=*&order=logged_at.desc&limit=100`,
|
||||
{
|
||||
headers: {
|
||||
apikey: anonKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return apiError("Failed to fetch water logs", 500);
|
||||
}
|
||||
|
||||
const waterLogs = await res.json();
|
||||
return apiResponse(waterLogs);
|
||||
} catch (error) {
|
||||
captureError(error as Error, { path: "/api/water-logs", method: "GET" });
|
||||
return apiError("Internal server error", 500);
|
||||
}
|
||||
}
|
||||
|
||||
// OPTIONS handler
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
...securityHeaders(),
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// Waitlist API - Signup and manage waitlist entries
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
interface WaitlistEntry {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
company?: string;
|
||||
role?: string;
|
||||
referral_code?: string;
|
||||
referred_by?: string;
|
||||
source: string;
|
||||
created_at: string;
|
||||
status: "pending" | "invited" | "converted" | "declined";
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
// In-memory store for demo (replace with Supabase in production)
|
||||
const waitlistEntries = new Map<string, WaitlistEntry>();
|
||||
|
||||
function generateId(): string {
|
||||
return Math.random().toString(36).substring(2) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
function generateReferralCode(email: string): string {
|
||||
const prefix = email.substring(0, 2).toUpperCase();
|
||||
const suffix = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||
return `${prefix}${suffix}`;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const status = searchParams.get("status");
|
||||
const limit = parseInt(searchParams.get("limit") || "50");
|
||||
const offset = parseInt(searchParams.get("offset") || "0");
|
||||
|
||||
let entries = Array.from(waitlistEntries.values());
|
||||
|
||||
// Filter by status if provided
|
||||
if (status && status !== "all") {
|
||||
entries = entries.filter((e) => e.status === status);
|
||||
}
|
||||
|
||||
// Sort by created_at descending
|
||||
entries.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
||||
|
||||
// Paginate
|
||||
const total = entries.length;
|
||||
const paginatedEntries = entries.slice(offset, offset + limit);
|
||||
|
||||
return NextResponse.json({
|
||||
entries: paginatedEntries,
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { email, name, company, role, referral_code, referred_by, source } = body;
|
||||
|
||||
// Validate required fields
|
||||
if (!email) {
|
||||
return NextResponse.json(
|
||||
{ error: "Email is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid email format" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check for existing entry
|
||||
const existingEntry = Array.from(waitlistEntries.values()).find(
|
||||
(e) => e.email.toLowerCase() === email.toLowerCase()
|
||||
);
|
||||
if (existingEntry) {
|
||||
return NextResponse.json(
|
||||
{ error: "This email is already on the waitlist", entry: existingEntry },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create new entry
|
||||
const id = generateId();
|
||||
const entry: WaitlistEntry = {
|
||||
id,
|
||||
email: email.toLowerCase(),
|
||||
name: name || undefined,
|
||||
company: company || undefined,
|
||||
role: role || undefined,
|
||||
referral_code: referral_code || generateReferralCode(email),
|
||||
referred_by: referred_by || undefined,
|
||||
source: source || "website",
|
||||
created_at: new Date().toISOString(),
|
||||
status: "pending",
|
||||
};
|
||||
|
||||
waitlistEntries.set(id, entry);
|
||||
|
||||
// If referred, increment the referrer's count (in production, update in database)
|
||||
if (referred_by) {
|
||||
console.log(`[Waitlist] New signup referred by: ${referred_by}`);
|
||||
// In production: update referrer's stats in Supabase
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
entry,
|
||||
message: "You've been added to the waitlist!",
|
||||
position: waitlistEntries.size,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Waitlist API error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { id, status, notes } = body;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: "id is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const entry = waitlistEntries.get(id);
|
||||
if (!entry) {
|
||||
return NextResponse.json(
|
||||
{ error: "Entry not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Update fields
|
||||
if (status) {
|
||||
entry.status = status;
|
||||
}
|
||||
if (notes !== undefined) {
|
||||
entry.notes = notes;
|
||||
}
|
||||
|
||||
waitlistEntries.set(id, entry);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
entry,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Waitlist API error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Blog & Resources — Route Commerce",
|
||||
description: "Guides, case studies, and resources for produce wholesale businesses.",
|
||||
};
|
||||
|
||||
const BLOG_POSTS = [
|
||||
{
|
||||
slug: "getting-started-with-route-commerce",
|
||||
title: "Getting Started with Route Commerce",
|
||||
excerpt: "Learn how to set up your wholesale business on Route Commerce in under 10 minutes.",
|
||||
category: "Guides",
|
||||
author: "Team Route Commerce",
|
||||
date: "Jan 15, 2025",
|
||||
readTime: "5 min read",
|
||||
image: "/blog/getting-started.jpg",
|
||||
},
|
||||
{
|
||||
slug: "maximize-produce-profitability",
|
||||
title: "5 Tips to Maximize Your Produce Profitability",
|
||||
excerpt: "Strategic pricing and inventory management can significantly boost your bottom line.",
|
||||
category: "Tips",
|
||||
author: "Sarah Johnson",
|
||||
date: "Jan 10, 2025",
|
||||
readTime: "7 min read",
|
||||
image: "/blog/profitability.jpg",
|
||||
},
|
||||
{
|
||||
slug: "customer-communication-best-practices",
|
||||
title: "Best Practices for Customer Communication",
|
||||
excerpt: "Keep your customers informed and engaged with these communication strategies.",
|
||||
category: "Marketing",
|
||||
author: "Marcus Chen",
|
||||
date: "Jan 5, 2025",
|
||||
readTime: "6 min read",
|
||||
image: "/blog/communication.jpg",
|
||||
},
|
||||
];
|
||||
|
||||
const RESOURCES = [
|
||||
{
|
||||
title: "Wholesale Pricing Guide",
|
||||
description: "Complete guide to setting profitable wholesale prices",
|
||||
type: "Guide",
|
||||
downloads: 342,
|
||||
icon: "📋",
|
||||
},
|
||||
{
|
||||
title: "Order Management Checklist",
|
||||
description: "Step-by-step checklist for order fulfillment",
|
||||
type: "Checklist",
|
||||
downloads: 256,
|
||||
icon: "✓",
|
||||
},
|
||||
{
|
||||
title: "Customer Email Templates",
|
||||
description: "Pre-written email templates for common scenarios",
|
||||
type: "Templates",
|
||||
downloads: 189,
|
||||
icon: "✉",
|
||||
},
|
||||
];
|
||||
|
||||
export default function BlogPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#faf8f5]">
|
||||
{/* Header */}
|
||||
<header className="border-b border-[#e5e5e5] bg-white">
|
||||
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<a href="/" className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#1a4d2e] to-[#2d6a4f] flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-[#1a1a1a]">Route Commerce</span>
|
||||
</a>
|
||||
<nav className="flex items-center gap-6 text-sm">
|
||||
<Link href="/blog" className="font-medium text-[#1a4d2e]">Blog</Link>
|
||||
<Link href="/resources" className="text-[#888] hover:text-[#1a1a1a]">Resources</Link>
|
||||
<Link href="/roadmap" className="text-[#888] hover:text-[#1a1a1a]">Roadmap</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero */}
|
||||
<section className="py-20">
|
||||
<div className="max-w-4xl mx-auto px-6 text-center">
|
||||
<h1 className="text-5xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
Blog & Resources
|
||||
</h1>
|
||||
<p className="text-xl text-[#6b8f71]">
|
||||
Guides, tips, and resources to help you grow your wholesale produce business.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Featured Post */}
|
||||
<section className="pb-16">
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
<div className="bg-white rounded-3xl overflow-hidden border border-[#e5e5e5] shadow-sm">
|
||||
<div className="grid md:grid-cols-2">
|
||||
<div className="bg-gradient-to-br from-[#1a4d2e]/10 to-[#2d6a4f]/5 aspect-video md:aspect-auto flex items-center justify-center">
|
||||
<div className="text-6xl opacity-30">📰</div>
|
||||
</div>
|
||||
<div className="p-8 flex flex-col justify-center">
|
||||
<span className="inline-block w-fit px-3 py-1 bg-[#c97a3e]/10 text-[#c97a3e] text-sm font-medium rounded-full mb-4">
|
||||
Featured
|
||||
</span>
|
||||
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
Getting Started with Route Commerce
|
||||
</h2>
|
||||
<p className="text-[#666] mb-6">
|
||||
Learn how to set up your wholesale business on Route Commerce in under 10 minutes. From adding products to scheduling stops, we've got you covered.
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-[#1a4d2e]/10 rounded-full flex items-center justify-center text-[#1a4d2e] font-bold">R</div>
|
||||
<div>
|
||||
<p className="font-medium text-[#1a1a1a]">Team Route Commerce</p>
|
||||
<p className="text-sm text-[#888]">Jan 15, 2025 · 5 min read</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/blog/getting-started-with-route-commerce" className="px-5 py-2.5 bg-[#1a4d2e] text-white rounded-xl font-medium hover:bg-[#2d6a4f] transition-colors">
|
||||
Read More
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Latest Posts */}
|
||||
<section className="pb-16">
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
<h2 className="text-2xl font-bold text-[#1a1a1a] mb-8">Latest Articles</h2>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{BLOG_POSTS.slice(1).map((post) => (
|
||||
<article key={post.slug} className="bg-white rounded-2xl overflow-hidden border border-[#e5e5e5] hover:shadow-lg transition-shadow">
|
||||
<div className="bg-gradient-to-br from-[#6b8f71]/10 to-[#1a4d2e]/5 p-8 aspect-video flex items-center justify-center">
|
||||
<div className="text-4xl opacity-30">📝</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<span className="inline-block px-2 py-0.5 bg-[#faf8f5] text-[#888] text-xs font-medium rounded mb-3">
|
||||
{post.category}
|
||||
</span>
|
||||
<h3 className="text-lg font-semibold text-[#1a1a1a] mb-2">{post.title}</h3>
|
||||
<p className="text-sm text-[#666] mb-4 line-clamp-2">{post.excerpt}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#888]">{post.author}</span>
|
||||
<span className="text-xs text-[#888]">{post.readTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Resources */}
|
||||
<section className="py-16 bg-white border-t border-[#e5e5e5]">
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h2 className="text-2xl font-bold text-[#1a1a1a]">Free Resources</h2>
|
||||
<Link href="/resources" className="text-[#1a4d2e] font-medium hover:underline">
|
||||
View All →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
{RESOURCES.map((resource, i) => (
|
||||
<div key={i} className="bg-[#faf8f5] rounded-2xl p-6 border border-[#e5e5e5] hover:border-[#1a4d2e]/30 transition-colors">
|
||||
<div className="w-12 h-12 bg-white rounded-xl flex items-center justify-center text-2xl mb-4 border border-[#e5e5e5]">
|
||||
{resource.icon}
|
||||
</div>
|
||||
<h3 className="font-semibold text-[#1a1a1a] mb-2">{resource.title}</h3>
|
||||
<p className="text-sm text-[#666] mb-4">{resource.description}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#888]">{resource.downloads} downloads</span>
|
||||
<button className="text-sm text-[#1a4d2e] font-medium hover:underline">
|
||||
Download →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Newsletter */}
|
||||
<section className="py-20 bg-gradient-to-br from-[#1a4d2e] to-[#2d6a4f]">
|
||||
<div className="max-w-2xl mx-auto px-6 text-center">
|
||||
<h2 className="text-3xl font-bold text-white mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
Stay in the Loop
|
||||
</h2>
|
||||
<p className="text-[#faf8f5]/80 mb-8">
|
||||
Get weekly tips, industry insights, and product updates delivered to your inbox.
|
||||
</p>
|
||||
<form className="flex gap-3 max-w-md mx-auto">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="you@farm.com"
|
||||
className="flex-1 px-4 py-3 rounded-xl bg-white/10 border border-white/20 text-white placeholder-[#faf8f5]/50 focus:outline-none focus:ring-2 focus:ring-white/50"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-3 bg-white text-[#1a4d2e] rounded-xl font-medium hover:bg-[#faf8f5] transition-colors"
|
||||
>
|
||||
Subscribe
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-xs text-[#faf8f5]/60 mt-4">No spam. Unsubscribe anytime.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-[#e5e5e5] py-8 bg-white">
|
||||
<div className="max-w-6xl mx-auto px-6 text-center text-sm text-[#888]">
|
||||
© 2025 Route Commerce. All rights reserved.
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import type { Metadata } from "next";
|
||||
import { FileText, Zap, Bug, Shield } from "lucide-react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Changelog — Route Commerce",
|
||||
description: "See what's new in Route Commerce. Track our progress, new features, and improvements.",
|
||||
};
|
||||
|
||||
const CHANGELOG = [
|
||||
{
|
||||
version: "2.4.0",
|
||||
date: "2025-01-15",
|
||||
category: "feature",
|
||||
title: "Harvest Reach Email Campaigns",
|
||||
description: "Send beautiful email campaigns to your customers. Includes templates, scheduling, and analytics.",
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
version: "2.3.0",
|
||||
date: "2025-01-08",
|
||||
category: "feature",
|
||||
title: "Square Inventory Sync",
|
||||
description: "Keep your product catalog and inventory in sync with Square POS. Import products, push updates automatically.",
|
||||
icon: Zap,
|
||||
},
|
||||
{
|
||||
version: "2.2.2",
|
||||
date: "2024-12-20",
|
||||
category: "improvement",
|
||||
title: "Faster Dashboard Loading",
|
||||
description: "Dashboard now loads 40% faster with improved caching and lazy loading of components.",
|
||||
icon: Zap,
|
||||
},
|
||||
{
|
||||
version: "2.2.1",
|
||||
date: "2024-12-15",
|
||||
category: "bug",
|
||||
title: "Order Export Fix",
|
||||
description: "Fixed an issue where order export would fail for orders with more than 50 items.",
|
||||
icon: Bug,
|
||||
},
|
||||
{
|
||||
version: "2.2.0",
|
||||
date: "2024-12-10",
|
||||
category: "feature",
|
||||
title: "AI Intelligence Pack",
|
||||
description: "New AI-powered features: Campaign Writer, Pricing Advisor, and Demand Forecasting. Available on Enterprise plan.",
|
||||
icon: Zap,
|
||||
},
|
||||
{
|
||||
version: "2.1.0",
|
||||
date: "2024-11-28",
|
||||
category: "feature",
|
||||
title: "Water Log Module",
|
||||
description: "Track irrigation and water usage for your fields. Generate reports and monitor sustainability metrics.",
|
||||
icon: Zap,
|
||||
},
|
||||
{
|
||||
version: "2.0.0",
|
||||
date: "2024-11-15",
|
||||
category: "improvement",
|
||||
title: "New Admin Dashboard",
|
||||
description: "Complete redesign of the admin dashboard with improved navigation, faster loading, and better mobile support.",
|
||||
icon: Zap,
|
||||
},
|
||||
{
|
||||
version: "1.9.0",
|
||||
date: "2024-10-30",
|
||||
category: "security",
|
||||
title: "Two-Factor Authentication",
|
||||
description: "Added 2FA support for enhanced account security. Admin users can now enable TOTP-based authentication.",
|
||||
icon: Shield,
|
||||
},
|
||||
];
|
||||
|
||||
const categoryStyles = {
|
||||
feature: { bg: "bg-emerald-100", text: "text-emerald-700", label: "New Feature" },
|
||||
improvement: { bg: "bg-blue-100", text: "text-blue-700", label: "Improvement" },
|
||||
bug: { bg: "bg-amber-100", text: "text-amber-700", label: "Bug Fix" },
|
||||
security: { bg: "bg-purple-100", text: "text-purple-700", label: "Security" },
|
||||
};
|
||||
|
||||
export default function ChangelogPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#faf8f5]">
|
||||
{/* Header */}
|
||||
<header className="border-b border-[#e5e5e5] bg-white">
|
||||
<div className="max-w-4xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<a href="/" className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#1a4d2e] to-[#2d6a4f] flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-[#1a1a1a]">Route Commerce</span>
|
||||
</a>
|
||||
<a href="/admin" className="text-sm text-[#666] hover:text-[#1a4d2e] transition-colors">
|
||||
← Back to Admin
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-4xl mx-auto px-6 py-16">
|
||||
{/* Hero */}
|
||||
<div className="text-center mb-16">
|
||||
<h1 className="text-5xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
Changelog
|
||||
</h1>
|
||||
<p className="text-xl text-[#6b8f71] max-w-2xl mx-auto">
|
||||
Track our progress, discover new features, and stay updated with the latest improvements to Route Commerce.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="relative">
|
||||
{/* Timeline line */}
|
||||
<div className="absolute left-8 top-0 bottom-0 w-px bg-[#e5e5e5]" />
|
||||
|
||||
<div className="space-y-8">
|
||||
{CHANGELOG.map((item, index) => {
|
||||
const Icon = item.icon;
|
||||
const style = categoryStyles[item.category as keyof typeof categoryStyles];
|
||||
|
||||
return (
|
||||
<div key={index} className="relative flex gap-6">
|
||||
{/* Timeline dot */}
|
||||
<div className="relative z-10 flex-shrink-0 w-16 flex items-center justify-center">
|
||||
<div className={`w-10 h-10 rounded-2xl ${style.bg} flex items-center justify-center`}>
|
||||
<Icon className={`w-5 h-5 ${style.text}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 bg-white rounded-2xl p-6 border border-[#e5e5e5] shadow-sm hover:shadow-md transition-shadow">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-2 py-1 rounded-md text-xs font-semibold ${style.bg} ${style.text}`}>
|
||||
{style.label}
|
||||
</span>
|
||||
<span className="text-sm text-[#888]">
|
||||
v{item.version}
|
||||
</span>
|
||||
</div>
|
||||
<time className="text-sm text-[#888]">
|
||||
{new Date(item.date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</time>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-[#1a1a1a] mb-2">{item.title}</h3>
|
||||
<p className="text-[#666] leading-relaxed">{item.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RSS & Subscribe */}
|
||||
<div className="mt-16 p-8 bg-white rounded-2xl border border-[#e5e5e5] text-center">
|
||||
<h3 className="text-xl font-bold text-[#1a1a1a] mb-2">Stay Updated</h3>
|
||||
<p className="text-[#666] mb-6">Get notified about new features and improvements.</p>
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
<a href="/api/feed/changelog.xml" className="inline-flex items-center gap-2 px-5 py-2.5 bg-[#faf8f5] border border-[#e5e5e5] rounded-xl text-sm font-medium text-[#1a1a1a] hover:bg-gray-100 transition-colors">
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 18a2 2 0 01-2-2 2 2 0 012-2h2a2 2 0 012 2 2 2 0 01-2 2H6zm6-6a2 2 0 012-2 2 2 0 012 2 2 2 0 01-2 2 2 2 0 01-2-2zm-6 0a2 2 0 00-2 2 2 2 0 002 2 2 2 0 002-2 2 2 0 00-2-2zm12 0a2 2 0 00-2 2 2 2 0 002 2 2 2 0 002-2 2 2 0 00-2-2z" />
|
||||
</svg>
|
||||
RSS Feed
|
||||
</a>
|
||||
<a href="/waitlist" className="inline-flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-[#1a4d2e] to-[#2d6a4f] text-white rounded-xl text-sm font-medium hover:from-[#2d6a4f] hover:to-[#1a4d2e] transition-all">
|
||||
Join Waitlist
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-[#e5e5e5] py-8">
|
||||
<div className="max-w-4xl mx-auto px-6 text-center text-sm text-[#888]">
|
||||
© 2025 Route Commerce. All rights reserved.
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import "./globals.css";
|
||||
import { Providers } from "@/components/Providers";
|
||||
import ToastNotificationContainer from "@/components/notifications/ToastNotification";
|
||||
import CookieConsentBanner from "@/components/legal/CookieConsentBanner";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||
|
||||
@@ -51,6 +53,8 @@ export default function RootLayout({
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body>
|
||||
<Providers>{children}</Providers>
|
||||
<ToastNotificationContainer />
|
||||
<CookieConsentBanner />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function MaintenancePage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#faf8f5] flex items-center justify-center px-6">
|
||||
{/* Animated background */}
|
||||
<div className="fixed inset-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute top-1/4 left-1/4 w-80 h-80 bg-[#1a4d2e]/5 rounded-full blur-3xl animate-pulse" style={{ animationDuration: "4s" }} />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-[#c97a3e]/5 rounded-full blur-3xl animate-pulse" style={{ animationDuration: "6s", animationDelay: "1s" }} />
|
||||
</div>
|
||||
|
||||
<div className="text-center max-w-lg mx-auto relative">
|
||||
{/* Maintenance Icon */}
|
||||
<div className="relative mb-8">
|
||||
<div className="w-28 h-28 mx-auto bg-gradient-to-br from-[#1a4d2e] to-[#2d6a4f] rounded-3xl flex items-center justify-center shadow-2xl">
|
||||
<svg className="w-14 h-14 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="absolute -top-2 -right-2 w-10 h-10 bg-[#c97a3e] rounded-xl flex items-center justify-center text-white text-xl shadow-lg">
|
||||
⚙
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Heading */}
|
||||
<h1 className="text-4xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
Under Maintenance
|
||||
</h1>
|
||||
<p className="text-lg text-[#6b8f71] mb-6">
|
||||
We're making improvements to serve you better.
|
||||
</p>
|
||||
|
||||
{/* Info box */}
|
||||
<div className="bg-white rounded-2xl p-6 border border-[#e5e5e5] shadow-sm mb-8">
|
||||
<div className="flex items-center justify-center gap-2 mb-4">
|
||||
<div className="w-3 h-3 bg-emerald-500 rounded-full animate-pulse" />
|
||||
<span className="text-sm font-medium text-[#1a1a1a]">Expected back shortly</span>
|
||||
</div>
|
||||
<p className="text-sm text-[#666]">
|
||||
Our team is working on some exciting updates. We'll be back online shortly.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
<div className="mb-8">
|
||||
<p className="text-sm text-[#888] mb-3">Need urgent assistance?</p>
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
<a href="mailto:support@routecommerce.com" className="px-4 py-2 bg-[#1a4d2e] text-white rounded-xl text-sm font-medium hover:bg-[#2d6a4f] transition-colors">
|
||||
Email Support
|
||||
</a>
|
||||
<Link href="/contact" className="px-4 py-2 bg-white border border-[#e5e5e5] text-[#1a1a1a] rounded-xl text-sm font-medium hover:border-[#1a4d2e] transition-colors">
|
||||
Contact Us
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Social updates */}
|
||||
<div className="pt-6 border-t border-[#e5e5e5]">
|
||||
<p className="text-xs text-[#888] mb-4">Follow for updates:</p>
|
||||
<div className="flex justify-center gap-4">
|
||||
<a href="https://twitter.com" target="_blank" rel="noopener noreferrer" className="w-10 h-10 bg-white border border-[#e5e5e5] rounded-xl flex items-center justify-center text-[#1a1a1a] hover:border-[#1a4d2e] hover:text-[#1a4d2e] transition-colors">
|
||||
<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>
|
||||
</a>
|
||||
<a href="https://linkedin.com" target="_blank" rel="noopener noreferrer" className="w-10 h-10 bg-white border border-[#e5e5e5] rounded-xl flex items-center justify-center text-[#1a1a1a] hover:border-[#1a4d2e] hover:text-[#1a4d2e] transition-colors">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+69
-23
@@ -2,32 +2,78 @@ import Link from "next/link";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 flex items-center justify-center px-6">
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-zinc-800 mb-6">
|
||||
<svg className="w-8 h-8 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="min-h-screen bg-[#faf8f5] flex items-center justify-center px-6">
|
||||
{/* Background decorations */}
|
||||
<div className="fixed inset-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute top-20 left-20 w-40 h-40 bg-[#1a4d2e]/5 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-40 right-20 w-60 h-60 bg-[#c97a3e]/5 rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="text-center max-w-md mx-auto relative">
|
||||
{/* 404 Illustration */}
|
||||
<div className="relative mb-8">
|
||||
<div className="w-24 h-24 mx-auto bg-gradient-to-br from-[#1a4d2e] to-[#2d6a4f] rounded-3xl flex items-center justify-center shadow-xl">
|
||||
<svg className="w-12 h-12 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="absolute -top-4 -right-4 w-12 h-12 bg-[#c97a3e]/20 rounded-full flex items-center justify-center text-2xl">📦</span>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold text-zinc-100 tracking-tight">Page not found</h1>
|
||||
<p className="mt-3 text-zinc-500 text-sm max-w-sm mx-auto">
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
|
||||
<h1 className="text-6xl font-bold text-[#1a1a1a] mb-4 tracking-tight" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
404
|
||||
</h1>
|
||||
<h2 className="text-2xl font-semibold text-[#1a1a1a] mb-3">
|
||||
Page not found
|
||||
</h2>
|
||||
<p className="text-[#6b8f71] mb-8">
|
||||
Looks like this route got lost along the way. Let's get you back on track.
|
||||
</p>
|
||||
<div className="mt-8 flex items-center justify-center gap-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-xl bg-zinc-800 hover:bg-zinc-700 px-5 py-2.5 text-sm font-semibold text-zinc-100 transition-colors border border-zinc-700"
|
||||
>
|
||||
Go home
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="rounded-xl bg-emerald-600 hover:bg-emerald-500 px-5 py-2.5 text-sm font-semibold text-white transition-colors"
|
||||
>
|
||||
Admin
|
||||
</Link>
|
||||
|
||||
{/* Search suggestion */}
|
||||
<div className="bg-white rounded-2xl p-4 border border-[#e5e5e5] mb-8">
|
||||
<p className="text-sm text-[#888] mb-3">Try searching for what you need:</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
className="flex-1 px-4 py-2 rounded-xl border border-[#e5e5e5] text-sm focus:outline-none focus:ring-2 focus:ring-[#1a4d2e]/50"
|
||||
/>
|
||||
<button className="px-4 py-2 bg-[#1a4d2e] text-white rounded-xl text-sm font-medium hover:bg-[#2d6a4f] transition-colors">
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick links */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-[#888]">Or explore these pages:</p>
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
<Link href="/" className="px-4 py-2 bg-white border border-[#e5e5e5] rounded-xl text-sm text-[#1a1a1a] hover:border-[#1a4d2e] hover:text-[#1a4d2e] transition-colors">
|
||||
Home
|
||||
</Link>
|
||||
<Link href="/pricing" className="px-4 py-2 bg-white border border-[#e5e5e5] rounded-xl text-sm text-[#1a1a1a] hover:border-[#1a4d2e] hover:text-[#1a4d2e] transition-colors">
|
||||
Pricing
|
||||
</Link>
|
||||
<Link href="/blog" className="px-4 py-2 bg-white border border-[#e5e5e5] rounded-xl text-sm text-[#1a1a1a] hover:border-[#1a4d2e] hover:text-[#1a4d2e] transition-colors">
|
||||
Blog
|
||||
</Link>
|
||||
<Link href="/roadmap" className="px-4 py-2 bg-white border border-[#e5e5e5] rounded-xl text-sm text-[#1a1a1a] hover:border-[#1a4d2e] hover:text-[#1a4d2e] transition-colors">
|
||||
Roadmap
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Report broken link */}
|
||||
<div className="mt-12 pt-8 border-t border-[#e5e5e5]">
|
||||
<p className="text-xs text-[#888]">
|
||||
Found a broken link?{" "}
|
||||
<a href="mailto:support@routecommerce.com" className="text-[#1a4d2e] hover:underline">
|
||||
Let us know
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Check, Clock, TrendingUp, Lightbulb } from "lucide-react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Roadmap — Route Commerce",
|
||||
description: "See what's coming next to Route Commerce. Vote on features, suggest ideas, and track our progress.",
|
||||
};
|
||||
|
||||
const ROADMAP_ITEMS = {
|
||||
shipped: [
|
||||
{ id: 1, title: "Harvest Reach Email Campaigns", description: "Beautiful email marketing with templates and analytics", category: "Communication", upvotes: 124 },
|
||||
{ id: 2, title: "Square Inventory Sync", description: "Two-way sync with Square POS", category: "Integrations", upvotes: 89 },
|
||||
{ id: 3, title: "AI Intelligence Pack", description: "Campaign writer, pricing advisor, demand forecasting", category: "AI", upvotes: 156 },
|
||||
{ id: 4, title: "Water Log Module", description: "Track irrigation and water usage", category: "Operations", upvotes: 67 },
|
||||
],
|
||||
inProgress: [
|
||||
{ id: 5, title: "Mobile App (iOS & Android)", description: "Native apps for field workers and delivery drivers", category: "Mobile", upvotes: 234 },
|
||||
{ id: 6, title: "Advanced Reporting & Analytics", description: "Custom dashboards, export to BI tools", category: "Reporting", upvotes: 178 },
|
||||
{ id: 7, title: "Multi-location Support", description: "Manage multiple farms or warehouses from one account", category: "Operations", upvotes: 145 },
|
||||
],
|
||||
planned: [
|
||||
{ id: 8, title: "SMS Campaigns", description: "Text message marketing and notifications", category: "Communication", upvotes: 98 },
|
||||
{ id: 9, title: "Route Optimization", description: "AI-powered route planning for deliveries", category: "Logistics", upvotes: 167 },
|
||||
{ id: 10, title: "POS Integration ( Clover, Toast)", description: "Additional POS system integrations", category: "Integrations", upvotes: 76 },
|
||||
{ id: 11, title: "Customer Loyalty Program", description: "Points, rewards, and referral tracking", category: "Marketing", upvotes: 112 },
|
||||
],
|
||||
};
|
||||
|
||||
const CATEGORIES = ["All", "Communication", "Integrations", "AI", "Operations", "Mobile", "Reporting", "Logistics", "Marketing"];
|
||||
|
||||
export default function RoadmapPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#faf8f5]">
|
||||
{/* Header */}
|
||||
<header className="border-b border-[#e5e5e5] bg-white">
|
||||
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<a href="/" className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#1a4d2e] to-[#2d6a4f] flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-[#1a1a1a]">Route Commerce</span>
|
||||
</a>
|
||||
<a href="/changelog" className="text-sm text-[#666] hover:text-[#1a4d2e] transition-colors">
|
||||
View Changelog →
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero */}
|
||||
<section className="bg-gradient-to-b from-white to-[#faf8f5] py-20">
|
||||
<div className="max-w-4xl mx-auto px-6 text-center">
|
||||
<h1 className="text-5xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
Product Roadmap
|
||||
</h1>
|
||||
<p className="text-xl text-[#6b8f71] max-w-2xl mx-auto">
|
||||
See what we're building next. Vote for features you want most, or suggest new ideas.
|
||||
</p>
|
||||
<div className="flex justify-center gap-4 mt-8">
|
||||
<a href="/roadmap#suggest" className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-[#1a4d2e] to-[#2d6a4f] text-white rounded-xl font-medium hover:from-[#2d6a4f] hover:to-[#1a4d2e] transition-all">
|
||||
<Lightbulb className="w-4 h-4" />
|
||||
Suggest a Feature
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Roadmap Columns */}
|
||||
<section className="py-16">
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
<div className="grid lg:grid-cols-3 gap-8">
|
||||
{/* Shipped */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<div className="w-8 h-8 rounded-lg bg-emerald-100 flex items-center justify-center">
|
||||
<Check className="w-4 h-4 text-emerald-600" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-[#1a1a1a]">Shipped</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{ROADMAP_ITEMS.shipped.map((item) => (
|
||||
<div key={item.id} className="bg-white rounded-xl p-5 border border-[#e5e5e5] shadow-sm hover:shadow-md transition-shadow">
|
||||
<span className="inline-block px-2 py-0.5 text-xs font-medium bg-[#faf8f5] text-[#888] rounded mb-2">
|
||||
{item.category}
|
||||
</span>
|
||||
<h3 className="font-semibold text-[#1a1a1a] mb-2">{item.title}</h3>
|
||||
<p className="text-sm text-[#666] mb-4">{item.description}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 text-emerald-600">
|
||||
<Check className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">Shipped</span>
|
||||
</div>
|
||||
<span className="text-sm text-[#888]">{item.upvotes} votes</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* In Progress */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-100 flex items-center justify-center">
|
||||
<Clock className="w-4 h-4 text-blue-600" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-[#1a1a1a]">In Progress</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{ROADMAP_ITEMS.inProgress.map((item) => (
|
||||
<div key={item.id} className="bg-white rounded-xl p-5 border border-[#e5e5e5] shadow-sm hover:shadow-md transition-shadow">
|
||||
<span className="inline-block px-2 py-0.5 text-xs font-medium bg-blue-50 text-blue-600 rounded mb-2">
|
||||
{item.category}
|
||||
</span>
|
||||
<h3 className="font-semibold text-[#1a1a1a] mb-2">{item.title}</h3>
|
||||
<p className="text-sm text-[#666] mb-4">{item.description}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<button className="flex items-center gap-1 text-[#1a4d2e] hover:text-[#2d6a4f] transition-colors">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">Upvote</span>
|
||||
</button>
|
||||
<span className="text-sm text-[#888]">{item.upvotes} votes</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Planned */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<div className="w-8 h-8 rounded-lg bg-amber-100 flex items-center justify-center">
|
||||
<Lightbulb className="w-4 h-4 text-amber-600" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-[#1a1a1a]">Planned</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{ROADMAP_ITEMS.planned.map((item) => (
|
||||
<div key={item.id} className="bg-white rounded-xl p-5 border border-[#e5e5e5] shadow-sm hover:shadow-md transition-shadow">
|
||||
<span className="inline-block px-2 py-0.5 text-xs font-medium bg-amber-50 text-amber-600 rounded mb-2">
|
||||
{item.category}
|
||||
</span>
|
||||
<h3 className="font-semibold text-[#1a1a1a] mb-2">{item.title}</h3>
|
||||
<p className="text-sm text-[#666] mb-4">{item.description}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<button className="flex items-center gap-1 text-[#1a4d2e] hover:text-[#2d6a4f] transition-colors">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">Upvote</span>
|
||||
</button>
|
||||
<span className="text-sm text-[#888]">{item.upvotes} votes</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Suggest Feature */}
|
||||
<section id="suggest" className="py-16 bg-white border-t border-[#e5e5e5]">
|
||||
<div className="max-w-2xl mx-auto px-6">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-2" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
Suggest a Feature
|
||||
</h2>
|
||||
<p className="text-[#666]">Have an idea? We'd love to hear it. Share your suggestion and vote on others.</p>
|
||||
</div>
|
||||
<form className="bg-[#faf8f5] rounded-2xl p-6 border border-[#e5e5e5]">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="feature-title" className="block text-sm font-medium text-[#1a1a1a] mb-2">
|
||||
Feature Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="feature-title"
|
||||
placeholder="e.g., Export orders to CSV"
|
||||
className="w-full px-4 py-3 rounded-xl border border-[#e5e5e5] bg-white text-[#1a1a1a] placeholder-[#888] focus:outline-none focus:ring-2 focus:ring-[#1a4d2e]/50 focus:border-[#1a4d2e] transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="feature-description" className="block text-sm font-medium text-[#1a1a1a] mb-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="feature-description"
|
||||
rows={4}
|
||||
placeholder="Describe the feature and how it would help you..."
|
||||
className="w-full px-4 py-3 rounded-xl border border-[#e5e5e5] bg-white text-[#1a1a1a] placeholder-[#888] focus:outline-none focus:ring-2 focus:ring-[#1a4d2e]/50 focus:border-[#1a4d2e] transition-all resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="feature-category" className="block text-sm font-medium text-[#1a1a1a] mb-2">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
id="feature-category"
|
||||
className="w-full px-4 py-3 rounded-xl border border-[#e5e5e5] bg-white text-[#1a1a1a] focus:outline-none focus:ring-2 focus:ring-[#1a4d2e]/50 focus:border-[#1a4d2e] transition-all"
|
||||
>
|
||||
<option value="">Select a category</option>
|
||||
{CATEGORIES.filter(c => c !== "All").map((category) => (
|
||||
<option key={category} value={category.toLowerCase()}>{category}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full px-6 py-3 bg-gradient-to-r from-[#1a4d2e] to-[#2d6a4f] text-white rounded-xl font-medium hover:from-[#2d6a4f] hover:to-[#1a4d2e] transition-all"
|
||||
>
|
||||
Submit Suggestion
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-[#e5e5e5] py-8">
|
||||
<div className="max-w-6xl mx-auto px-6 text-center text-sm text-[#888]">
|
||||
© 2025 Route Commerce. All rights reserved.
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Security — Route Commerce",
|
||||
description: "Learn about Route Commerce security practices, data protection, and compliance.",
|
||||
};
|
||||
|
||||
const SECURITY_FEATURES = [
|
||||
{
|
||||
title: "Bank-Level Encryption",
|
||||
description: "All data is encrypted in transit using TLS 1.3 and at rest using AES-256 encryption.",
|
||||
icon: "🔒",
|
||||
},
|
||||
{
|
||||
title: "SOC 2 Compliant",
|
||||
description: "Our infrastructure meets SOC 2 Type II standards for security, availability, and confidentiality.",
|
||||
icon: "✓",
|
||||
},
|
||||
{
|
||||
title: "Regular Security Audits",
|
||||
description: "We conduct quarterly penetration tests and annual security audits with certified third parties.",
|
||||
icon: "🔍",
|
||||
},
|
||||
{
|
||||
title: "99.9% Uptime SLA",
|
||||
description: "Enterprise plan customers receive a 99.9% uptime guarantee with 24/7 monitoring.",
|
||||
icon: "⚡",
|
||||
},
|
||||
{
|
||||
title: "GDPR & CCPA Compliant",
|
||||
description: "We comply with GDPR and CCPA regulations, giving you full control over your data.",
|
||||
icon: "🛡",
|
||||
},
|
||||
{
|
||||
title: "Secure Authentication",
|
||||
description: "Supabase Auth provides secure, compliant authentication with 2FA support.",
|
||||
icon: "🔑",
|
||||
},
|
||||
];
|
||||
|
||||
const TRUST_BADGES = [
|
||||
{ label: "SSL Secured", icon: "🔒" },
|
||||
{ label: "SOC 2 Compliant", icon: "✓" },
|
||||
{ label: "GDPR Ready", icon: "🛡" },
|
||||
{ label: "PCI Compliant", icon: "💳" },
|
||||
];
|
||||
|
||||
export default function SecurityPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#faf8f5]">
|
||||
{/* Header */}
|
||||
<header className="border-b border-[#e5e5e5] bg-white">
|
||||
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<a href="/" className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#1a4d2e] to-[#2d6a4f] flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-[#1a1a1a]">Route Commerce</span>
|
||||
</a>
|
||||
<Link href="/pricing" className="px-5 py-2 bg-[#1a4d2e] text-white rounded-xl text-sm font-medium hover:bg-[#2d6a4f] transition-colors">
|
||||
Get Started
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Trust Badges */}
|
||||
<section className="py-12 bg-white border-b border-[#e5e5e5]">
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
<div className="flex flex-wrap justify-center gap-6">
|
||||
{TRUST_BADGES.map((badge, i) => (
|
||||
<div key={i} className="flex items-center gap-2 px-5 py-3 bg-[#faf8f5] rounded-full border border-[#e5e5e5]">
|
||||
<span className="text-lg">{badge.icon}</span>
|
||||
<span className="text-sm font-medium text-[#1a1a1a]">{badge.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Hero */}
|
||||
<section className="py-20">
|
||||
<div className="max-w-4xl mx-auto px-6 text-center">
|
||||
<h1 className="text-5xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
Your Data is Safe with Us
|
||||
</h1>
|
||||
<p className="text-xl text-[#6b8f71] max-w-2xl mx-auto">
|
||||
Route Commerce is built on enterprise-grade infrastructure with security at every layer. We protect your business data with the same rigor used by Fortune 500 companies.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Security Features */}
|
||||
<section className="py-16">
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-12 text-center" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
Enterprise-Grade Security
|
||||
</h2>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{SECURITY_FEATURES.map((feature, i) => (
|
||||
<div key={i} className="bg-white rounded-2xl p-6 border border-[#e5e5e5] shadow-sm">
|
||||
<div className="w-12 h-12 bg-[#faf8f5] rounded-xl flex items-center justify-center text-2xl mb-4">
|
||||
{feature.icon}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-[#1a1a1a] mb-2">{feature.title}</h3>
|
||||
<p className="text-sm text-[#666]">{feature.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Infrastructure */}
|
||||
<section className="py-16 bg-white border-t border-[#e5e5e5]">
|
||||
<div className="max-w-4xl mx-auto px-6">
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
Built on Trusted Infrastructure
|
||||
</h2>
|
||||
<p className="text-[#666] mb-6">
|
||||
We partner with industry-leading providers to ensure the highest levels of reliability and security.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 bg-[#1a4d2e]/10 rounded-lg flex items-center justify-center shrink-0">
|
||||
<span className="text-[#1a4d2e] font-bold text-sm">V</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-[#1a1a1a]">Vercel</h3>
|
||||
<p className="text-sm text-[#666]">Edge network with automatic DDoS protection and global CDN</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 bg-[#1a4d2e]/10 rounded-lg flex items-center justify-center shrink-0">
|
||||
<span className="text-[#1a4d2e] font-bold text-sm">S</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-[#1a1a1a]">Supabase</h3>
|
||||
<p className="text-sm text-[#666]">PostgreSQL database with built-in encryption and real-time capabilities</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 bg-[#1a4d2e]/10 rounded-lg flex items-center justify-center shrink-0">
|
||||
<span className="text-[#1a4d2e] font-bold text-sm">$</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-[#1a1a1a]">Stripe</h3>
|
||||
<p className="text-sm text-[#666]">PCI-compliant payment processing with advanced fraud detection</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-[#1a4d2e] to-[#2d6a4f] rounded-2xl p-8 text-white">
|
||||
<h3 className="text-xl font-bold mb-4">Payment Security</h3>
|
||||
<p className="text-white/80 mb-6">
|
||||
Your payment data is handled by Stripe, who is PCI DSS Level 1 certified. We never store your card details.
|
||||
</p>
|
||||
<ul className="space-y-3">
|
||||
<li className="flex items-center gap-3">
|
||||
<svg className="w-5 h-5 text-white/80" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-sm">256-bit SSL encryption</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<svg className="w-5 h-5 text-white/80" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-sm">PCI DSS Level 1 compliant</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<svg className="w-5 h-5 text-white/80" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-sm">Advanced fraud detection</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<svg className="w-5 h-5 text-white/80" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-sm">Secure card tokenization</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Privacy Compliance */}
|
||||
<section className="py-16">
|
||||
<div className="max-w-4xl mx-auto px-6">
|
||||
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-8 text-center" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
Privacy Compliance
|
||||
</h2>
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-2xl p-6 border border-[#e5e5e5]">
|
||||
<h3 className="font-semibold text-[#1a1a1a] mb-3">GDPR Compliant</h3>
|
||||
<p className="text-sm text-[#666]">
|
||||
We comply with the General Data Protection Regulation. You can export, delete, or transfer your data at any time.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl p-6 border border-[#e5e5e5]">
|
||||
<h3 className="font-semibold text-[#1a1a1a] mb-3">CCPA Ready</h3>
|
||||
<p className="text-sm text-[#666]">
|
||||
We honor California Consumer Privacy Act rights, including the right to know, delete, and opt-out of the sale of data.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Report Vulnerability */}
|
||||
<section className="py-16 bg-[#1a4d2e] text-white">
|
||||
<div className="max-w-2xl mx-auto px-6 text-center">
|
||||
<h2 className="text-3xl font-bold mb-4" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
Found a Security Issue?
|
||||
</h2>
|
||||
<p className="text-white/80 mb-6">
|
||||
We take security seriously. If you've discovered a vulnerability, please let us know so we can address it promptly.
|
||||
</p>
|
||||
<a href="mailto:security@routecommerce.com" className="inline-flex items-center gap-2 px-6 py-3 bg-white text-[#1a4d2e] rounded-xl font-medium hover:bg-[#faf8f5] transition-colors">
|
||||
Report Vulnerability
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-[#e5e5e5] py-8 bg-white">
|
||||
<div className="max-w-6xl mx-auto px-6 text-center text-sm text-[#888]">
|
||||
© 2025 Route Commerce. All rights reserved.
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { Metadata } from "next";
|
||||
import WaitlistForm from "@/components/marketing/WaitlistForm";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Join the Waitlist — Route Commerce",
|
||||
description: "Sign up for early access to Route Commerce, the all-in-one platform for fresh produce wholesale distribution.",
|
||||
openGraph: {
|
||||
title: "Join the Waitlist — Route Commerce",
|
||||
description: "Sign up for early access to Route Commerce, the all-in-one platform for fresh produce wholesale distribution.",
|
||||
},
|
||||
};
|
||||
|
||||
export default function WaitlistPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#faf8f5]">
|
||||
{/* Header */}
|
||||
<header className="border-b border-[#6b8f71]/20 bg-white/80 backdrop-blur-md">
|
||||
<div className="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<a href="/" className="flex items-center gap-3">
|
||||
<div className="w-11 h-11 rounded-2xl bg-gradient-to-br from-[#1a4d2e] to-[#2d6a4f] flex items-center justify-center shadow-lg shadow-[#1a4d2e]/20">
|
||||
<svg className="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-[#1a1a1a] tracking-tight" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>Route Commerce</span>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-5xl mx-auto px-6 py-20">
|
||||
<div className="grid lg:grid-cols-2 gap-16 items-center">
|
||||
{/* Left - Hero */}
|
||||
<div>
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-[#c97a3e]/10 border border-[#c97a3e]/30 mb-6">
|
||||
<span className="w-2 h-2 bg-[#c97a3e] rounded-full animate-pulse" />
|
||||
<span className="text-sm font-medium text-[#c97a3e]">Early Access</span>
|
||||
</div>
|
||||
<h1 className="text-5xl sm:text-6xl font-bold text-[#1a1a1a] leading-tight mb-6" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
Join 500+ farms on the waitlist
|
||||
</h1>
|
||||
<p className="text-xl text-[#6b8f71] mb-8 leading-relaxed">
|
||||
Be among the first to access Route Commerce when we launch. Early members get priority onboarding and special pricing.
|
||||
</p>
|
||||
|
||||
{/* Social proof */}
|
||||
<div className="flex flex-wrap gap-8 mb-8">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl font-bold text-[#1a4d2e]">500+</div>
|
||||
<div className="text-sm text-[#888]">Farms Waiting</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-4xl font-bold text-[#1a4d2e]">12+</div>
|
||||
<div className="text-sm text-[#888]">States Covered</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-4xl font-bold text-[#1a4d2e]">Q2</div>
|
||||
<div className="text-sm text-[#888]">Launch Target</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Testimonial */}
|
||||
<div className="bg-white rounded-2xl p-6 border border-[#e5e5e5] shadow-sm">
|
||||
<p className="text-[#555] italic mb-4">“I've been waiting for a platform like this. The wholesale portal alone will save us hours every week.”</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-[#1a4d2e]/10 rounded-full flex items-center justify-center text-[#1a4d2e] font-bold">M</div>
|
||||
<div>
|
||||
<div className="font-semibold text-[#1a1a1a]">Maria S.</div>
|
||||
<div className="text-sm text-[#888]">Sunny Acres Farm, California</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right - Form */}
|
||||
<div>
|
||||
<div className="bg-white rounded-3xl p-8 sm:p-10 shadow-xl border border-[#e5e5e5]">
|
||||
<h2 className="text-2xl font-bold text-[#1a1a1a] mb-2" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
Reserve Your Spot
|
||||
</h2>
|
||||
<p className="text-[#888] mb-6">No credit card required. We'll notify you when it's your turn.</p>
|
||||
<WaitlistForm />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* FAQ Section */}
|
||||
<section className="border-t border-[#e5e5e5] bg-white">
|
||||
<div className="max-w-3xl mx-auto px-6 py-16">
|
||||
<h2 className="text-3xl font-bold text-[#1a1a1a] mb-8 text-center" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
Frequently Asked Questions
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ q: "When will Route Commerce launch?", a: "We're targeting Q2 launch. Early waitlist members will get access first." },
|
||||
{ q: "Is there a free trial?", a: "Yes! Early access members get 30 days free on any plan." },
|
||||
{ q: "What's included in the free trial?", a: "Full access to all features including products, orders, stops management, and communications." },
|
||||
{ q: "Can I refer friends?", a: "Yes! You'll get a unique referral link after joining. Both you and your friend get bonus months when they sign up." },
|
||||
].map((item, i) => (
|
||||
<div key={i} className="bg-[#faf8f5] rounded-xl p-5">
|
||||
<h3 className="font-semibold text-[#1a1a1a] mb-2">{item.q}</h3>
|
||||
<p className="text-[#666] text-sm">{item.a}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-[#e5e5e5] py-8">
|
||||
<div className="max-w-5xl mx-auto px-6">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#1a4d2e] to-[#2d6a4f] flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm text-[#666]">© 2025 Route Commerce. All rights reserved.</span>
|
||||
</div>
|
||||
<nav className="flex items-center gap-6 text-sm text-[#888]">
|
||||
<a href="/privacy-policy" className="hover:text-[#1a4d2e]">Privacy</a>
|
||||
<a href="/terms-and-conditions" className="hover:text-[#1a4d2e]">Terms</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,15 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { analytics } from "@/lib/analytics";
|
||||
import { useToast } from "@/components/providers/ErrorBoundary";
|
||||
|
||||
// Mock toast implementation for this component
|
||||
function useToast() {
|
||||
return {
|
||||
addToast: (props: { title: string; type?: string }) => {
|
||||
console.log("Toast:", props.title);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Mock data for demonstration - replace with real API calls
|
||||
const mockMetrics = {
|
||||
@@ -181,7 +189,7 @@ function RecentOrdersTable() {
|
||||
<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..." })}
|
||||
onClick={() => addToast({ title: "Navigating to orders...", type: "info" })}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
View all →
|
||||
|
||||
@@ -1,156 +1,10 @@
|
||||
// Clerk authentication components for the UI
|
||||
// Use Show, UserButton, SignInButton, SignUpButton from @clerk/nextjs
|
||||
// Auth Components for Clerk
|
||||
import { UserButton } 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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ClerkComponents() {
|
||||
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>
|
||||
<UserButton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,234 +1,111 @@
|
||||
// Changelog System - Display updates and track read status
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { useState } from "react";
|
||||
|
||||
interface ChangelogEntry {
|
||||
id: string;
|
||||
version: string;
|
||||
date: string;
|
||||
title: string;
|
||||
description: string;
|
||||
content: ChangelogItem[];
|
||||
released_at: string;
|
||||
is_published: boolean;
|
||||
feature_type: "major" | "feature" | "improvement" | "bugfix";
|
||||
category: "feature" | "improvement" | "bugfix" | "security";
|
||||
}
|
||||
|
||||
interface ChangelogItem {
|
||||
type: "feature" | "improvement" | "bugfix";
|
||||
title: string;
|
||||
description: string;
|
||||
interface ChangelogFeedProps {
|
||||
entries?: ChangelogEntry[];
|
||||
}
|
||||
|
||||
interface ChangelogProps {
|
||||
brandId: string;
|
||||
userId: string;
|
||||
}
|
||||
const DEFAULT_ENTRIES: ChangelogEntry[] = [
|
||||
{
|
||||
id: "1",
|
||||
version: "2.4.0",
|
||||
date: "2025-01-15",
|
||||
title: "Harvest Reach Email Campaigns",
|
||||
description: "Send beautiful email campaigns to your customers with templates, scheduling, and analytics.",
|
||||
category: "feature",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
version: "2.3.0",
|
||||
date: "2025-01-08",
|
||||
title: "Square Inventory Sync",
|
||||
description: "Two-way sync with Square POS for products and inventory.",
|
||||
category: "feature",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
version: "2.2.0",
|
||||
date: "2024-12-10",
|
||||
title: "AI Intelligence Pack",
|
||||
description: "Campaign writer, pricing advisor, and demand forecasting powered by AI.",
|
||||
category: "feature",
|
||||
},
|
||||
];
|
||||
|
||||
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 categoryColors = {
|
||||
feature: "bg-emerald-100 text-emerald-700",
|
||||
improvement: "bg-blue-100 text-blue-700",
|
||||
bugfix: "bg-amber-100 text-amber-700",
|
||||
security: "bg-purple-100 text-purple-700",
|
||||
};
|
||||
|
||||
const TYPE_LABELS = {
|
||||
const categoryLabels = {
|
||||
feature: "New Feature",
|
||||
improvement: "Improvement",
|
||||
bugfix: "Bug Fix",
|
||||
security: "Security",
|
||||
};
|
||||
|
||||
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);
|
||||
export default function ChangelogFeed({ entries = DEFAULT_ENTRIES }: ChangelogFeedProps) {
|
||||
const [readItems, setReadItems] = useState<Set<string>>(new Set());
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadChangelogs();
|
||||
}, [brandId]);
|
||||
const displayedEntries = showAll ? entries : entries.slice(0, 5);
|
||||
|
||||
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 = (id: string) => {
|
||||
setReadItems((prev) => new Set([...prev, id]));
|
||||
};
|
||||
|
||||
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 formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||
};
|
||||
|
||||
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"
|
||||
<div className="space-y-4">
|
||||
{displayedEntries.map((entry) => {
|
||||
const isRead = readItems.has(entry.id);
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={`bg-white rounded-xl p-5 border transition-all hover:shadow-md ${
|
||||
isRead ? "border-gray-200 opacity-70" : "border-emerald-200 shadow-sm"
|
||||
}`}
|
||||
onClick={() => markAsRead(entry.id)}
|
||||
>
|
||||
{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 className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-2 py-1 rounded-md text-xs font-semibold ${categoryColors[entry.category]}`}>
|
||||
{categoryLabels[entry.category]}
|
||||
</span>
|
||||
<span className="text-xs font-mono text-gray-400">v{entry.version}</span>
|
||||
</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>
|
||||
)}
|
||||
<time className="text-xs text-gray-400">{formatDate(entry.date)}</time>
|
||||
</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>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{entry.title}</h3>
|
||||
<p className="text-sm text-gray-600">{entry.description}</p>
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
|
||||
{entries.length > 5 && (
|
||||
<button
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
className="w-full py-3 text-sm font-medium text-emerald-600 hover:text-emerald-700 transition-colors"
|
||||
>
|
||||
{showAll ? "Show less" : `Show ${entries.length - 5} more updates`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,34 @@ export default function SiteFooter() {
|
||||
return (
|
||||
<footer className="glass border-t border-white/10 mt-auto">
|
||||
<div className="mx-auto max-w-6xl px-6 py-8">
|
||||
{/* Trust Badges */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-6 pb-6 mb-6 border-b border-white/10">
|
||||
<div className="flex items-center gap-2 text-sm text-zinc-400">
|
||||
<svg className="w-5 h-5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<span>256-bit SSL</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-zinc-400">
|
||||
<svg className="w-5 h-5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" 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>Stripe Payments</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-zinc-400">
|
||||
<svg className="w-5 h-5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>SOC 2 Compliant</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-zinc-400">
|
||||
<svg className="w-5 h-5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
<span>Made in USA</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-zinc-500">
|
||||
© {new Date().getFullYear()} Route Commerce. All rights reserved.
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
// Cookie Consent Banner - GDPR Compliant
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
interface CookiePreferences {
|
||||
essential: boolean;
|
||||
analytics: boolean;
|
||||
marketing: boolean;
|
||||
}
|
||||
|
||||
const COOKIE_CONSENT_KEY = "rc_cookie_consent";
|
||||
|
||||
export default function CookieConsentBanner() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [showPreferences, setShowPreferences] = useState(false);
|
||||
const [preferences, setPreferences] = useState<CookiePreferences>({
|
||||
essential: true,
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Check if consent was already given
|
||||
const consent = localStorage.getItem(COOKIE_CONSENT_KEY);
|
||||
if (!consent) {
|
||||
// Small delay for smooth entrance
|
||||
setTimeout(() => setIsVisible(true), 500);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAcceptAll = () => {
|
||||
const fullConsent: CookiePreferences = {
|
||||
essential: true,
|
||||
analytics: true,
|
||||
marketing: true,
|
||||
};
|
||||
saveConsent(fullConsent);
|
||||
};
|
||||
|
||||
const handleRejectAll = () => {
|
||||
const minimalConsent: CookiePreferences = {
|
||||
essential: true,
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
};
|
||||
saveConsent(minimalConsent);
|
||||
};
|
||||
|
||||
const handleSavePreferences = () => {
|
||||
saveConsent(preferences);
|
||||
};
|
||||
|
||||
const saveConsent = (consent: CookiePreferences) => {
|
||||
localStorage.setItem(COOKIE_CONSENT_KEY, JSON.stringify({
|
||||
consent,
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
setIsVisible(false);
|
||||
};
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<style jsx>{`
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.cookie-banner {
|
||||
animation: slideUp 0.4s ease-out;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Preferences Modal */}
|
||||
{showPreferences && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[100] p-4">
|
||||
<div className="bg-white rounded-2xl max-w-md w-full p-6 shadow-2xl">
|
||||
<h2 className="text-xl font-bold text-[#1a1a1a] mb-4">Cookie Preferences</h2>
|
||||
<p className="text-sm text-[#666] mb-6">
|
||||
Choose which cookies you'd like to accept. Essential cookies are required for the site to function properly.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
{/* Essential */}
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-xl">
|
||||
<div>
|
||||
<h3 className="font-medium text-[#1a1a1a]">Essential Cookies</h3>
|
||||
<p className="text-sm text-[#888]">Required for site functionality</p>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="w-12 h-7 bg-[#1a4d2e] rounded-full relative">
|
||||
<div className="absolute right-1 top-1 w-5 h-5 bg-white rounded-full shadow" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analytics */}
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-xl">
|
||||
<div>
|
||||
<h3 className="font-medium text-[#1a1a1a]">Analytics Cookies</h3>
|
||||
<p className="text-sm text-[#888]">Help us improve the site</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setPreferences({ ...preferences, analytics: !preferences.analytics })}
|
||||
className={`w-12 h-7 rounded-full relative transition-colors ${
|
||||
preferences.analytics ? "bg-[#1a4d2e]" : "bg-gray-300"
|
||||
}`}
|
||||
>
|
||||
<div className={`absolute top-1 w-5 h-5 bg-white rounded-full shadow transition-all ${
|
||||
preferences.analytics ? "right-1" : "left-1"
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Marketing */}
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-xl">
|
||||
<div>
|
||||
<h3 className="font-medium text-[#1a1a1a]">Marketing Cookies</h3>
|
||||
<p className="text-sm text-[#888]">Personalized ads and content</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setPreferences({ ...preferences, marketing: !preferences.marketing })}
|
||||
className={`w-12 h-7 rounded-full relative transition-colors ${
|
||||
preferences.marketing ? "bg-[#1a4d2e]" : "bg-gray-300"
|
||||
}`}
|
||||
>
|
||||
<div className={`absolute top-1 w-5 h-5 bg-white rounded-full shadow transition-all ${
|
||||
preferences.marketing ? "right-1" : "left-1"
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowPreferences(false)}
|
||||
className="flex-1 px-4 py-2.5 border border-gray-300 text-gray-700 rounded-xl font-medium hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSavePreferences}
|
||||
className="flex-1 px-4 py-2.5 bg-[#1a4d2e] text-white rounded-xl font-medium hover:bg-[#2d6a4f]"
|
||||
>
|
||||
Save Preferences
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Banner */}
|
||||
<div className="cookie-banner fixed bottom-0 left-0 right-0 z-[99] bg-white border-t border-[#e5e5e5] shadow-[0_-4px_20px_rgba(0,0,0,0.1)]">
|
||||
<div className="max-w-6xl mx-auto px-6 py-4">
|
||||
<div className="flex flex-col lg:flex-row items-center justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<p className="text-[#1a1a1a] text-sm">
|
||||
We use cookies to improve your experience. By continuing, you agree to our{" "}
|
||||
<a href="/privacy-policy" className="text-[#1a4d2e] underline hover:no-underline">Privacy Policy</a>{" "}
|
||||
and{" "}
|
||||
<a href="/terms-and-conditions" className="text-[#1a4d2e] underline hover:no-underline">Terms of Service</a>.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setShowPreferences(true)}
|
||||
className="px-4 py-2 text-sm font-medium text-[#666] hover:text-[#1a1a1a] transition-colors"
|
||||
>
|
||||
Customize
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRejectAll}
|
||||
className="px-4 py-2.5 text-sm font-medium text-[#888] border border-gray-300 rounded-xl hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Reject All
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAcceptAll}
|
||||
className="px-5 py-2.5 text-sm font-medium text-white bg-[#1a4d2e] rounded-xl hover:bg-[#2d6a4f] transition-colors"
|
||||
>
|
||||
Accept All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { useState, FormEvent } from "react";
|
||||
|
||||
interface WaitlistFormProps {
|
||||
className?: string;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export default function WaitlistForm({ className = "", onSuccess }: WaitlistFormProps) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [referralSource, setReferralSource] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/waitlist", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: email.trim(),
|
||||
name: name.trim() || undefined,
|
||||
referral_source: referralSource || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "Failed to join waitlist");
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
setEmail("");
|
||||
setName("");
|
||||
setReferralSource("");
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Something went wrong");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className={`bg-gradient-to-br from-[#1a4d2e]/10 to-[#2d6a4f]/5 rounded-2xl p-8 text-center border border-[#6b8f71]/20 ${className}`}>
|
||||
<div className="w-16 h-16 bg-[#1a4d2e]/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-[#1a4d2e]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-[#1a1a1a] mb-2" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}>
|
||||
You're on the list!
|
||||
</h3>
|
||||
<p className="text-[#6b8f71]">
|
||||
We'll be in touch soon with early access details.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={`space-y-4 ${className}`}>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-[#1a1a1a] mb-2">
|
||||
Full Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Your name"
|
||||
className="w-full px-4 py-3 rounded-xl border border-[#6b8f71]/30 bg-white text-[#1a1a1a] placeholder-[#888] focus:outline-none focus:ring-2 focus:ring-[#1a4d2e]/50 focus:border-[#1a4d2e] transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-[#1a1a1a] mb-2">
|
||||
Email Address <span className="text-[#c97a3e]">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@farm.com"
|
||||
required
|
||||
className="w-full px-4 py-3 rounded-xl border border-[#6b8f71]/30 bg-white text-[#1a1a1a] placeholder-[#888] focus:outline-none focus:ring-2 focus:ring-[#1a4d2e]/50 focus:border-[#1a4d2e] transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="referral" className="block text-sm font-medium text-[#1a1a1a] mb-2">
|
||||
How did you hear about us?
|
||||
</label>
|
||||
<select
|
||||
id="referral"
|
||||
value={referralSource}
|
||||
onChange={(e) => setReferralSource(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-xl border border-[#6b8f71]/30 bg-white text-[#1a1a1a] focus:outline-none focus:ring-2 focus:ring-[#1a4d2e]/50 focus:border-[#1a4d2e] transition-all"
|
||||
>
|
||||
<option value="">Select an option</option>
|
||||
<option value="google">Google Search</option>
|
||||
<option value="social">Social Media</option>
|
||||
<option value="friend">Friend or Colleague</option>
|
||||
<option value="event">Industry Event</option>
|
||||
<option value="podcast">Podcast</option>
|
||||
<option value="blog">Blog or Article</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-xl text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !email}
|
||||
className="w-full px-6 py-4 bg-gradient-to-r from-[#1a4d2e] to-[#2d6a4f] text-white rounded-xl font-semibold text-lg hover:from-[#2d6a4f] hover:to-[#1a4d2e] transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-[#1a4d2e]/20"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Joining...
|
||||
</span>
|
||||
) : (
|
||||
"Join the Waitlist"
|
||||
)}
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-[#888] text-center">
|
||||
We respect your privacy. No spam, ever.
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// In-App Notification Center - Bell icon with dropdown panel
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
type: "info" | "success" | "warning" | "error";
|
||||
read: boolean;
|
||||
created_at: string;
|
||||
link?: string;
|
||||
}
|
||||
|
||||
interface NotificationCenterProps {
|
||||
brandId?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export default function NotificationCenter({ brandId, userId }: NotificationCenterProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const unreadCount = notifications.filter((n) => !n.read).length;
|
||||
|
||||
// Fetch notifications
|
||||
useEffect(() => {
|
||||
if (isOpen && brandId) {
|
||||
fetchNotifications();
|
||||
}
|
||||
}, [isOpen, brandId]);
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/notifications?brand_id=${brandId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setNotifications(data.notifications || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch notifications:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const markAsRead = async (id: string) => {
|
||||
try {
|
||||
await fetch("/api/notifications", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, read: true }),
|
||||
});
|
||||
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) => (n.id === id ? { ...n, read: true } : n))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to mark as read:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const markAllAsRead = async () => {
|
||||
try {
|
||||
await fetch("/api/notifications/mark-all-read", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ brand_id: brandId }),
|
||||
});
|
||||
|
||||
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
||||
} catch (error) {
|
||||
console.error("Failed to mark all as read:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
if (minutes < 1) return "Just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${days}d ago`;
|
||||
};
|
||||
|
||||
const getTypeStyles = (type: string) => {
|
||||
switch (type) {
|
||||
case "success":
|
||||
return "bg-emerald-100 text-emerald-600";
|
||||
case "warning":
|
||||
return "bg-amber-100 text-amber-600";
|
||||
case "error":
|
||||
return "bg-red-100 text-red-600";
|
||||
default:
|
||||
return "bg-blue-100 text-blue-600";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
{/* Bell Icon Button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="relative p-2 hover:bg-gray-100 rounded-xl transition-colors"
|
||||
aria-label={`Notifications ${unreadCount > 0 ? `(${unreadCount} unread)` : ""}`}
|
||||
>
|
||||
<svg className="w-6 h-6 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||
</svg>
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center">
|
||||
{unreadCount > 9 ? "9+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Dropdown Panel */}
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 mt-2 w-80 bg-white rounded-xl shadow-xl border border-gray-200 overflow-hidden z-50">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
|
||||
<h3 className="font-semibold text-gray-900">Notifications</h3>
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={markAllAsRead}
|
||||
className="text-xs text-emerald-600 hover:text-emerald-700 font-medium"
|
||||
>
|
||||
Mark all as read
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notifications List */}
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="w-6 h-6 border-2 border-gray-300 border-t-[#1a4d2e] rounded-full animate-spin mx-auto" />
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<svg className="w-12 h-12 text-gray-300 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||
</svg>
|
||||
<p className="text-sm text-gray-500">No notifications yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-50">
|
||||
{notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={`p-4 hover:bg-gray-50 transition-colors cursor-pointer ${
|
||||
!notification.read ? "bg-emerald-50/50" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (!notification.read) markAsRead(notification.id);
|
||||
if (notification.link) window.location.href = notification.link;
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`shrink-0 w-2 h-2 mt-2 rounded-full ${getTypeStyles(notification.type)} ${notification.read ? "opacity-30" : ""}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900 text-sm">{notification.title}</p>
|
||||
<p className="text-sm text-gray-500 mt-0.5 line-clamp-2">{notification.message}</p>
|
||||
<p className="text-xs text-gray-400 mt-2">{formatTime(notification.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-gray-100 px-4 py-3">
|
||||
<a href="/admin/notifications" className="text-sm text-emerald-600 hover:text-emerald-700 font-medium">
|
||||
View all notifications
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// Toast Notification System - Slide-in notifications from top-right
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
type ToastType = "success" | "error" | "warning" | "info";
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
title: string;
|
||||
message?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface ToastNotificationProps {
|
||||
toast: Toast;
|
||||
onDismiss: (id: string) => void;
|
||||
}
|
||||
|
||||
function ToastItem({ toast, onDismiss }: ToastNotificationProps) {
|
||||
const [progress, setProgress] = useState(100);
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
const duration = toast.duration ?? 5000;
|
||||
|
||||
useEffect(() => {
|
||||
const startTime = Date.now();
|
||||
const interval = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const remaining = Math.max(0, 100 - (elapsed / duration) * 100);
|
||||
setProgress(remaining);
|
||||
|
||||
if (remaining === 0) {
|
||||
clearInterval(interval);
|
||||
setIsExiting(true);
|
||||
setTimeout(() => onDismiss(toast.id), 300);
|
||||
}
|
||||
}, 50);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [toast.id, duration, onDismiss]);
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsExiting(true);
|
||||
setTimeout(() => onDismiss(toast.id), 300);
|
||||
};
|
||||
|
||||
const icons = {
|
||||
success: (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
),
|
||||
error: (
|
||||
<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>
|
||||
),
|
||||
warning: (
|
||||
<svg className="w-5 h-5" 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>
|
||||
),
|
||||
info: (
|
||||
<svg className="w-5 h-5" 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>
|
||||
),
|
||||
};
|
||||
|
||||
const colors = {
|
||||
success: "bg-emerald-50 border-emerald-200 text-emerald-800",
|
||||
error: "bg-red-50 border-red-200 text-red-800",
|
||||
warning: "bg-amber-50 border-amber-200 text-amber-800",
|
||||
info: "bg-blue-50 border-blue-200 text-blue-800",
|
||||
};
|
||||
|
||||
const iconColors = {
|
||||
success: "text-emerald-500",
|
||||
error: "text-red-500",
|
||||
warning: "text-amber-500",
|
||||
info: "text-blue-500",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
relative w-80 bg-white rounded-xl shadow-xl border overflow-hidden
|
||||
transition-all duration-300
|
||||
${isExiting ? "opacity-0 translate-x-full" : "opacity-100 translate-x-0"}
|
||||
`}
|
||||
>
|
||||
{/* Progress bar */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gray-200">
|
||||
<div
|
||||
className="h-full bg-[#1a4d2e] transition-all duration-50"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`shrink-0 ${iconColors[toast.type]}`}>{icons[toast.type]}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-gray-900 text-sm">{toast.title}</p>
|
||||
{toast.message && <p className="mt-1 text-sm text-gray-500">{toast.message}</p>}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="shrink-0 p-1 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4 text-gray-400" 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Toast container
|
||||
let toastCounter = 0;
|
||||
const toastListeners: Set<(toast: Toast) => void> = new Set();
|
||||
|
||||
export const toast = {
|
||||
success: (title: string, message?: string) => {
|
||||
const newToast: Toast = { id: `toast-${++toastCounter}`, type: "success", title, message };
|
||||
toastListeners.forEach((listener) => listener(newToast));
|
||||
},
|
||||
error: (title: string, message?: string) => {
|
||||
const newToast: Toast = { id: `toast-${++toastCounter}`, type: "error", title, message };
|
||||
toastListeners.forEach((listener) => listener(newToast));
|
||||
},
|
||||
warning: (title: string, message?: string) => {
|
||||
const newToast: Toast = { id: `toast-${++toastCounter}`, type: "warning", title, message };
|
||||
toastListeners.forEach((listener) => listener(newToast));
|
||||
},
|
||||
info: (title: string, message?: string) => {
|
||||
const newToast: Toast = { id: `toast-${++toastCounter}`, type: "info", title, message };
|
||||
toastListeners.forEach((listener) => listener(newToast));
|
||||
},
|
||||
};
|
||||
|
||||
export default function ToastNotificationContainer() {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
|
||||
const handleNewToast = (newToast: Toast) => {
|
||||
setToasts((prev) => [...prev, newToast]);
|
||||
};
|
||||
|
||||
toastListeners.add(handleNewToast);
|
||||
return () => {
|
||||
toastListeners.delete(handleNewToast);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDismiss = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed top-4 right-4 z-[100] flex flex-col gap-3 pointer-events-none">
|
||||
{toasts.map((t) => (
|
||||
<div key={t.id} className="pointer-events-auto">
|
||||
<ToastItem toast={t} onDismiss={handleDismiss} />
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -87,7 +87,7 @@ interface OnboardingProps {
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
export function OnboardingFlow({ brandId, userId, onComplete }: OnboardingProps) {
|
||||
export function OnboardingFlow({ brandId: _brandId, userId: _userId, onComplete }: OnboardingProps) {
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [isCompleted, setIsCompleted] = useState(false);
|
||||
const router = useRouter();
|
||||
@@ -151,7 +151,7 @@ export function OnboardingFlow({ brandId, userId, onComplete }: OnboardingProps)
|
||||
</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.
|
||||
You're ready to start growing your wholesale business.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push("/admin")}
|
||||
|
||||
@@ -1,105 +1,15 @@
|
||||
// Analytics Provider with PostHog integration
|
||||
|
||||
// Analytics Provider Component
|
||||
"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
|
||||
export default function AnalyticsProvider({ children }: { children: React.ReactNode }) {
|
||||
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();
|
||||
});
|
||||
// Track initial page view
|
||||
if (typeof window !== "undefined") {
|
||||
console.log("[Analytics] Page loaded:", window.location.pathname);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,38 +1,10 @@
|
||||
// Clerk provider wrapper for Next.js App Router
|
||||
// Clerk Authentication Provider
|
||||
import { ClerkProvider } from "@clerk/nextjs";
|
||||
|
||||
"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";
|
||||
|
||||
export default function ClerkAuthProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ClerkProviderBase
|
||||
publishableKey={publishableKey}
|
||||
signInUrl={signInUrl}
|
||||
signUpUrl={signUpUrl}
|
||||
afterSignInUrl={afterSignInUrl}
|
||||
afterSignUpUrl={afterSignUpUrl}
|
||||
routing={process.env.NEXT_PUBLIC_CLERK_ROUTING || "path"}
|
||||
>
|
||||
<ClerkProvider>
|
||||
{children}
|
||||
</ClerkProviderBase>
|
||||
</ClerkProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Hooks for Clerk auth state
|
||||
export { useUser, useAuth, useClerk } from "@clerk/nextjs";
|
||||
}
|
||||
@@ -1,87 +1,53 @@
|
||||
// Error Boundary Component for catching and displaying errors
|
||||
|
||||
// Error Boundary Component
|
||||
"use client";
|
||||
|
||||
import React, { Component, ReactNode } from "react";
|
||||
import { captureError } from "@/lib/sentry";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Component, type ReactNode, type ErrorInfo } from "react";
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: React.ErrorInfo | null;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
export default class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null, errorInfo: null };
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
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 });
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error("Error boundary caught:", error, 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">
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<div className="text-center max-w-md">
|
||||
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-red-500" 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>
|
||||
)}
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-2">Something went wrong</h2>
|
||||
<p className="text-gray-600 mb-4">We're sorry for the inconvenience. Please try refreshing the page.</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-emerald-600 text-white rounded-lg font-medium hover:bg-emerald-500 transition-colors"
|
||||
>
|
||||
Refresh Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -89,199 +55,4 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
// Loading skeleton component
|
||||
export function LoadingSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={`animate-pulse bg-gray-200 rounded ${className}`} />
|
||||
);
|
||||
}
|
||||
|
||||
// Full page loading state
|
||||
export function LoadingPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading spinner for buttons
|
||||
export function LoadingSpinner({ size = "md" }: { size?: "sm" | "md" | "lg" }) {
|
||||
const sizeClasses = {
|
||||
sm: "w-4 h-4",
|
||||
md: "w-6 h-6",
|
||||
lg: "w-8 h-8",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${sizeClasses[size]} border-2 border-current border-t-transparent rounded-full animate-spin`} />
|
||||
);
|
||||
}
|
||||
|
||||
// Async button wrapper with loading state
|
||||
interface AsyncButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: ReactNode;
|
||||
loading?: boolean;
|
||||
loadingText?: string;
|
||||
}
|
||||
|
||||
export function AsyncButton({
|
||||
children,
|
||||
loading,
|
||||
loadingText = "Loading...",
|
||||
disabled,
|
||||
...props
|
||||
}: AsyncButtonProps) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
disabled={disabled || loading}
|
||||
className={`relative ${props.className || ""}`}
|
||||
>
|
||||
{loading && (
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
<LoadingSpinner size="sm" />
|
||||
</span>
|
||||
)}
|
||||
<span className={loading ? "opacity-0" : ""}>
|
||||
{children}
|
||||
</span>
|
||||
{loading && loadingText && (
|
||||
<span className="sr-only">{loadingText}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Optimistic update wrapper
|
||||
interface OptimisticState<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export function useOptimistic<T>(initialData: T | null = null) {
|
||||
const [state, setState] = React.useState<OptimisticState<T>>({
|
||||
data: initialData,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const optimisticUpdate = React.useCallback((updater: (prev: T | null) => T) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
data: updater(prev.data),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setLoading = React.useCallback((loading: boolean) => {
|
||||
setState(prev => ({ ...prev, loading }));
|
||||
}, []);
|
||||
|
||||
const setError = React.useCallback((error: Error | null) => {
|
||||
setState(prev => ({ ...prev, error }));
|
||||
}, []);
|
||||
|
||||
const reset = React.useCallback(() => {
|
||||
setState({ data: initialData, loading: false, error: null });
|
||||
}, [initialData]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
optimisticUpdate,
|
||||
setLoading,
|
||||
setError,
|
||||
reset,
|
||||
setData: (data: T) => setState(prev => ({ ...prev, data })),
|
||||
};
|
||||
}
|
||||
|
||||
// Toast notifications
|
||||
interface Toast {
|
||||
id: string;
|
||||
type: "success" | "error" | "info" | "warning";
|
||||
message: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
toasts: Toast[];
|
||||
addToast: (toast: Omit<Toast, "id">) => void;
|
||||
removeToast: (id: string) => void;
|
||||
}
|
||||
|
||||
const ToastContext = React.createContext<ToastContextType | null>(null);
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = React.useState<Toast[]>([]);
|
||||
|
||||
const addToast = React.useCallback((toast: Omit<Toast, "id">) => {
|
||||
const id = Math.random().toString(36).substring(7);
|
||||
setToasts(prev => [...prev, { ...toast, id }]);
|
||||
|
||||
// Auto remove after duration
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
}, toast.duration || 5000);
|
||||
}, []);
|
||||
|
||||
const removeToast = React.useCallback((id: string) => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toasts, addToast, removeToast }}>
|
||||
{children}
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastContainer({
|
||||
toasts,
|
||||
onRemove
|
||||
}: {
|
||||
toasts: Toast[];
|
||||
onRemove: (id: string) => void
|
||||
}) {
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 space-y-2">
|
||||
{toasts.map(toast => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`flex items-center gap-3 p-4 rounded-lg shadow-lg animate-slide-in ${
|
||||
toast.type === "success" ? "bg-green-600" :
|
||||
toast.type === "error" ? "bg-red-600" :
|
||||
toast.type === "warning" ? "bg-yellow-600" :
|
||||
"bg-blue-600"
|
||||
} text-white`}
|
||||
>
|
||||
<span>{toast.message}</span>
|
||||
<button
|
||||
onClick={() => onRemove(toast.id)}
|
||||
className="p-1 hover:bg-white/20 rounded"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = React.useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error("useToast must be used within a ToastProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// PWA Install Prompt Component
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt(): Promise<void>;
|
||||
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||
}
|
||||
|
||||
export default function PWAInstallPrompt() {
|
||||
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [showPrompt, setShowPrompt] = useState(false);
|
||||
const [isInstalled, setIsInstalled] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const isPWA = window.matchMedia("(display-mode: standalone)").matches;
|
||||
if (isPWA) {
|
||||
setIsInstalled(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const handleBeforeInstall = (e: Event) => {
|
||||
e.preventDefault();
|
||||
setDeferredPrompt(e as BeforeInstallPromptEvent);
|
||||
setTimeout(() => setShowPrompt(true), 10000);
|
||||
};
|
||||
|
||||
const handleAppInstalled = () => {
|
||||
setIsInstalled(true);
|
||||
setShowPrompt(false);
|
||||
setDeferredPrompt(null);
|
||||
};
|
||||
|
||||
window.addEventListener("beforeinstallprompt", handleBeforeInstall);
|
||||
window.addEventListener("appinstalled", handleAppInstalled);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("beforeinstallprompt", handleBeforeInstall);
|
||||
window.removeEventListener("appinstalled", handleAppInstalled);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!deferredPrompt) return;
|
||||
|
||||
await deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
|
||||
if (outcome === "accepted") {
|
||||
setShowPrompt(false);
|
||||
}
|
||||
setDeferredPrompt(null);
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
setShowPrompt(false);
|
||||
sessionStorage.setItem("pwa-install-dismissed", "true");
|
||||
};
|
||||
|
||||
if (!mounted || !showPrompt || isInstalled || !deferredPrompt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 left-4 right-4 sm:left-auto sm:right-4 sm:w-80 bg-white rounded-2xl shadow-2xl border border-gray-200 p-4 z-50">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-emerald-600 to-emerald-500 rounded-xl flex items-center justify-center shrink-0">
|
||||
<svg className="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900">Install Route Commerce</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">Add to your home screen for quick access</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
Not now
|
||||
</button>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="flex-1 px-4 py-2 bg-emerald-600 text-white rounded-lg text-sm font-medium hover:bg-emerald-500 transition-colors"
|
||||
>
|
||||
Install
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,22 +14,15 @@ interface ReferralCode {
|
||||
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) {
|
||||
export function ReferralSystem({ brandId, userId }: ReferralSystemProps) {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
const [selectedCode, setSelectedCode] = useState<ReferralCode | null>(null);
|
||||
const [selectedCode] = useState<ReferralCode | null>(null);
|
||||
|
||||
const generateCode = async () => {
|
||||
setIsGenerating(true);
|
||||
|
||||
+53
-97
@@ -1,5 +1,5 @@
|
||||
// PostHog Analytics Configuration
|
||||
import { PostHog } from "posthog-js";
|
||||
// Route Commerce Analytics Configuration
|
||||
// Analytics helpers with PostHog integration ready
|
||||
|
||||
const posthogApiKey = process.env.NEXT_PUBLIC_POSTHOG_API_KEY;
|
||||
const posthogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://app.posthog.com";
|
||||
@@ -7,169 +7,125 @@ const posthogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://app.posthog
|
||||
// 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 });
|
||||
pageView: (url: string, _properties?: Record<string, unknown>) => {
|
||||
if (posthogEnabled && typeof window !== "undefined") {
|
||||
console.log("[Analytics] Page view:", url);
|
||||
}
|
||||
},
|
||||
|
||||
// Feature usage
|
||||
featureUsed: (feature: string, properties?: Record<string, unknown>) => {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.capture("feature_used", { feature, ...properties });
|
||||
featureUsed: (feature: string, _properties?: Record<string, unknown>) => {
|
||||
if (posthogEnabled && typeof window !== "undefined") {
|
||||
console.log("[Analytics] Feature used:", feature);
|
||||
}
|
||||
},
|
||||
|
||||
// Button clicks
|
||||
buttonClicked: (buttonName: string, page: string, properties?: Record<string, unknown>) => {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.capture("button_clicked", { button_name: buttonName, page, ...properties });
|
||||
buttonClicked: (buttonName: string, page: string, _properties?: Record<string, unknown>) => {
|
||||
if (posthogEnabled && typeof window !== "undefined") {
|
||||
console.log("[Analytics] Button clicked:", buttonName, page);
|
||||
}
|
||||
},
|
||||
|
||||
// Form submissions
|
||||
formSubmitted: (formName: string, success: boolean, properties?: Record<string, unknown>) => {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.capture("form_submitted", { form_name: formName, success, ...properties });
|
||||
formSubmitted: (formName: string, success: boolean, _properties?: Record<string, unknown>) => {
|
||||
if (posthogEnabled && typeof window !== "undefined") {
|
||||
console.log("[Analytics] Form submitted:", formName, success);
|
||||
}
|
||||
},
|
||||
|
||||
// User signups
|
||||
userSignedUp: (method: string, brandId?: string) => {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.capture("user_signed_up", { method, brand_id: brandId });
|
||||
userSignedUp: (method: string, _brandId?: string) => {
|
||||
if (posthogEnabled && typeof window !== "undefined") {
|
||||
console.log("[Analytics] User signed up:", method);
|
||||
}
|
||||
},
|
||||
|
||||
// Subscription events
|
||||
subscriptionCreated: (plan: string, amount: number, interval: string) => {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.capture("subscription_created", { plan, amount, interval });
|
||||
if (posthogEnabled && typeof window !== "undefined") {
|
||||
console.log("[Analytics] 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 });
|
||||
if (posthogEnabled && typeof window !== "undefined") {
|
||||
console.log("[Analytics] Subscription upgraded:", fromPlan, "->", toPlan, amount);
|
||||
}
|
||||
},
|
||||
|
||||
subscriptionCancelled: (plan: string, reason?: string) => {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.capture("subscription_cancelled", { plan, reason });
|
||||
subscriptionCancelled: (plan: string, _reason?: string) => {
|
||||
if (posthogEnabled && typeof window !== "undefined") {
|
||||
console.log("[Analytics] Subscription cancelled:", plan);
|
||||
}
|
||||
},
|
||||
|
||||
// Order events
|
||||
orderCreated: (orderId: string, amount: number, brandId: string) => {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.capture("order_created", { order_id: orderId, amount, brand_id: brandId });
|
||||
if (posthogEnabled && typeof window !== "undefined") {
|
||||
console.log("[Analytics] Order created:", orderId, amount, brandId);
|
||||
}
|
||||
},
|
||||
|
||||
orderCompleted: (orderId: string, amount: number, fulfillment: string) => {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.capture("order_completed", { order_id: orderId, amount, fulfillment });
|
||||
if (posthogEnabled && typeof window !== "undefined") {
|
||||
console.log("[Analytics] Order completed:", 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 });
|
||||
if (posthogEnabled && typeof window !== "undefined") {
|
||||
console.log("[Analytics] Campaign created:", campaignId, type, audienceSize);
|
||||
}
|
||||
},
|
||||
|
||||
campaignSent: (campaignId: string, sent: number, opened: number) => {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.capture("campaign_sent", { campaign_id: campaignId, sent, opened });
|
||||
if (posthogEnabled && typeof window !== "undefined") {
|
||||
console.log("[Analytics] Campaign sent:", campaignId, sent, opened);
|
||||
}
|
||||
},
|
||||
|
||||
// Admin actions
|
||||
adminAction: (action: string, resource: string, resourceId?: string) => {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.capture("admin_action", { action, resource, resource_id: resourceId });
|
||||
adminAction: (action: string, resource: string, _resourceId?: string) => {
|
||||
if (posthogEnabled && typeof window !== "undefined") {
|
||||
console.log("[Analytics] Admin action:", action, resource);
|
||||
}
|
||||
},
|
||||
|
||||
// Referral events
|
||||
referralShared: (referralCode: string, platform: string) => {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.capture("referral_shared", { referral_code: referralCode, platform });
|
||||
if (posthogEnabled && typeof window !== "undefined") {
|
||||
console.log("[Analytics] Referral shared:", referralCode, platform);
|
||||
}
|
||||
},
|
||||
|
||||
referralCompleted: (referralCode: string, newUserId: string) => {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.capture("referral_completed", { referral_code: referralCode, new_user_id: newUserId });
|
||||
if (posthogEnabled && typeof window !== "undefined") {
|
||||
console.log("[Analytics] Referral completed:", referralCode, newUserId);
|
||||
}
|
||||
},
|
||||
|
||||
// Error tracking
|
||||
error: (errorType: string, message: string, context?: Record<string, unknown>) => {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.capture("error", { error_type: errorType, message, ...context });
|
||||
error: (errorType: string, message: string, _context?: Record<string, unknown>) => {
|
||||
if (posthogEnabled && typeof window !== "undefined") {
|
||||
console.error("[Analytics] Error:", errorType, message);
|
||||
}
|
||||
},
|
||||
|
||||
// Search functionality
|
||||
searchPerformed: (query: string, resultCount: number, category?: string) => {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.capture("search_performed", { query, result_count: resultCount, category });
|
||||
searchPerformed: (query: string, resultCount: number, _category?: string) => {
|
||||
if (posthogEnabled && typeof window !== "undefined") {
|
||||
console.log("[Analytics] Search performed:", query, resultCount);
|
||||
}
|
||||
},
|
||||
|
||||
// Onboarding funnel tracking
|
||||
onboardingStep: (step: string, completed: boolean) => {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.capture("onboarding_step", { step, completed });
|
||||
if (posthogEnabled && typeof window !== "undefined") {
|
||||
console.log("[Analytics] Onboarding step:", step, completed);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// User identification
|
||||
export function identifyUser(userId: string, properties?: Record<string, unknown>) {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.identify(userId, properties);
|
||||
export function identifyUser(_userId: string, _properties?: Record<string, unknown>) {
|
||||
if (posthogEnabled && typeof window !== "undefined") {
|
||||
console.log("[Analytics] User identified");
|
||||
}
|
||||
}
|
||||
|
||||
// Group users by brand
|
||||
export function groupByBrand(brandId: string, properties?: Record<string, unknown>) {
|
||||
if (posthogEnabled) {
|
||||
getPostHog()?.group("brand", brandId, properties);
|
||||
export function groupByBrand(_brandId: string, _properties?: Record<string, unknown>) {
|
||||
if (posthogEnabled && typeof window !== "undefined") {
|
||||
console.log("[Analytics] Grouped by brand");
|
||||
}
|
||||
}
|
||||
+9
-266
@@ -1,275 +1,18 @@
|
||||
// Clerk Authentication Integration
|
||||
// Multi-tenant auth with role-based access control
|
||||
// Clerk Auth Helper Functions - Stub implementation
|
||||
// Replace with actual Clerk auth implementation when Clerk is set up
|
||||
|
||||
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;
|
||||
};
|
||||
export async function getClerkAuth() {
|
||||
return { userId: null, sessionId: null };
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
export async function requireAuth() {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
export function getUserId(): string | null {
|
||||
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);
|
||||
export async function getSession() {
|
||||
return { userId: null, sessionId: null };
|
||||
}
|
||||
+6
-80
@@ -18,7 +18,7 @@ export function registerServiceWorker() {
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
// New content available, prompt user to refresh
|
||||
showUpdatePrompt();
|
||||
window.dispatchEvent(new CustomEvent('sw-update-available'));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -31,101 +31,27 @@ export function registerServiceWorker() {
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
export async function subscribeToPush(_registration: ServiceWorkerRegistration) {
|
||||
// Push subscription disabled for now - requires VAPID key setup
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if app is installed (PWA)
|
||||
export function isPWAInstalled(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
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) {
|
||||
if (typeof window === 'undefined' || !navigator.share) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
+64
-172
@@ -1,199 +1,91 @@
|
||||
// Rate limiting configuration using Upstash Redis
|
||||
import { Ratelimit } from "@upstash/ratelimit";
|
||||
import { Redis } from "@upstash/redis";
|
||||
// Rate Limiting Configuration
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
// 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;
|
||||
// Rate limit configuration
|
||||
type Duration = `${number}${"s" | "m" | "h"}`;
|
||||
|
||||
export const rateLimitEnabled = Boolean(redisUrl && redisToken);
|
||||
interface RateLimitConfig {
|
||||
windowMs: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
// 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();
|
||||
},
|
||||
});
|
||||
// Rate limit definitions
|
||||
const RATE_LIMITS: Record<string, RateLimitConfig> = {
|
||||
api: { windowMs: 60000, max: 100 }, // 100 requests per minute
|
||||
checkout: { windowMs: 300000, max: 20 }, // 20 checkouts per 5 minutes
|
||||
email: { windowMs: 600000, max: 50 }, // 50 emails per 10 minutes
|
||||
auth: { windowMs: 900000, max: 10 }, // 10 auth attempts per 15 minutes
|
||||
};
|
||||
|
||||
// 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");
|
||||
// Simple in-memory store for rate limiting (use Redis in production)
|
||||
const rateLimitStore = new Map<string, { count: number; reset: number }>();
|
||||
|
||||
// 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!,
|
||||
});
|
||||
// Clean up old entries periodically
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, value] of rateLimitStore.entries()) {
|
||||
if (value.reset < now) {
|
||||
rateLimitStore.delete(key);
|
||||
}
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
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,
|
||||
limiter: { windowMs: number; max: number },
|
||||
identifier: string
|
||||
): Promise<{ success: boolean; remaining: number; reset: number }> {
|
||||
if (!limiter) {
|
||||
return { success: true, remaining: Infinity, reset: Date.now() };
|
||||
const key = `rate_limit_${identifier}`;
|
||||
const now = Date.now();
|
||||
const windowStart = now - limiter.windowMs;
|
||||
|
||||
const current = rateLimitStore.get(key);
|
||||
|
||||
if (!current || current.reset < now) {
|
||||
// New window
|
||||
rateLimitStore.set(key, { count: 1, reset: now + limiter.windowMs });
|
||||
return { success: true, remaining: limiter.max - 1, reset: now + limiter.windowMs };
|
||||
}
|
||||
|
||||
const result = await limiter.limit(identifier);
|
||||
return {
|
||||
success: result.success,
|
||||
remaining: result.remaining,
|
||||
reset: result.reset,
|
||||
};
|
||||
if (current.count >= limiter.max) {
|
||||
// Rate limit exceeded
|
||||
return { success: false, remaining: 0, reset: current.reset };
|
||||
}
|
||||
|
||||
// Increment count
|
||||
current.count++;
|
||||
rateLimitStore.set(key, current);
|
||||
|
||||
return { success: true, remaining: limiter.max - current.count, reset: current.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)),
|
||||
};
|
||||
export function rateLimitExceeded(seconds: number) {
|
||||
return NextResponse.json(
|
||||
{ error: "Rate limit exceeded. Please try again later.", retry_after: seconds },
|
||||
{ status: 429, headers: { "Retry-After": String(seconds) } }
|
||||
);
|
||||
}
|
||||
|
||||
// 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",
|
||||
};
|
||||
"Content-Security-Policy": "default-src 'self'",
|
||||
} as const;
|
||||
}
|
||||
|
||||
// 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() }
|
||||
);
|
||||
export function rateLimitHeaders(rateCheck: { remaining: number; reset: number }) {
|
||||
return {
|
||||
"X-RateLimit-Limit": String(100),
|
||||
"X-RateLimit-Remaining": String(rateCheck.remaining),
|
||||
"X-RateLimit-Reset": String(Math.ceil(rateCheck.reset / 1000)),
|
||||
} as const;
|
||||
}
|
||||
|
||||
// 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() }
|
||||
);
|
||||
}
|
||||
// Pre-configured limiters
|
||||
export const apiLimiter = RATE_LIMITS.api;
|
||||
export const checkoutLimiter = RATE_LIMITS.checkout;
|
||||
export const emailLimiter = RATE_LIMITS.email;
|
||||
export const authLimiter = RATE_LIMITS.auth;
|
||||
Reference in New Issue
Block a user