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:
+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