Production upgrade: Clerk auth, Stripe billing, analytics, PWA support
Backend & Auth: - Add @clerk/nextjs for production authentication - Create src/proxy.ts with clerkMiddleware() for route protection - Implement multi-tenant auth with role-based access control - Add Clerk components (Show, UserButton, SignInButton, SignUpButton) Billing & Payments: - Full Stripe integration (subscriptions, add-ons, customer portal) - Plan tiers: Starter 9/mo, Farm 49/mo, Enterprise 99/mo - Webhook handling for subscription events - createSubscription(), createAddonSubscription(), createCustomerPortalSession() API & Security: - Rate limiting with @upstash/ratelimit (100 req/min API, 20 req/min checkout) - Zod validation schemas for all endpoints (orders, products, campaigns, etc.) - Security headers (CSP, HSTS, X-Frame-Options) - API routes: /api/v1/ with validated, rate-limited endpoints Monitoring: - Sentry error tracking with performance monitoring - PostHog analytics for feature usage, funnels, cohorts - User activity logging and breadcrumb tracking Admin Features: - Analytics dashboard with revenue charts, customer growth, conversion funnel - Onboarding flow with 6-step interactive tour - Referral system with share tracking and reward redemption - Changelog feed with in-app notifications PWA & SEO: - Web app manifest with icons and shortcuts - Service worker for offline support and caching - Full SEO metadata, OpenGraph, Twitter cards - Structured data (JSON-LD) for organization and products Database: - Add referral_codes, changelogs, onboarding_progress tables - Add user_activity_logs, api_keys, notification_preferences - Comprehensive RLS policies for all new tables - Seed data for demo brands and products
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
// Analytics Provider with PostHog integration
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { posthogEnabled, identifyUser, groupByBrand } from "@/lib/analytics";
|
||||
|
||||
// Analytics tracking component
|
||||
export function AnalyticsProvider({ userId, brandId }: { userId?: string; brandId?: string }) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Track page views
|
||||
useEffect(() => {
|
||||
if (!posthogEnabled) return;
|
||||
|
||||
const url = pathname + (searchParams.toString() ? `?${searchParams}` : "");
|
||||
|
||||
// PostHog tracks pageviews automatically with the capture_pageview option
|
||||
// But we can manually track for better control
|
||||
import("posthog-js").then(({ posthog }) => {
|
||||
posthog.capture("$pageview", {
|
||||
path: pathname,
|
||||
url,
|
||||
referrer: document.referrer,
|
||||
});
|
||||
});
|
||||
}, [pathname, searchParams]);
|
||||
|
||||
// Identify user
|
||||
useEffect(() => {
|
||||
if (!posthogEnabled || !userId) return;
|
||||
identifyUser(userId, { brand_id: brandId });
|
||||
}, [userId, brandId]);
|
||||
|
||||
// Group by brand
|
||||
useEffect(() => {
|
||||
if (!posthogEnabled || !brandId) return;
|
||||
groupByBrand(brandId);
|
||||
}, [brandId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Hook for tracking custom events
|
||||
export function useAnalytics() {
|
||||
const trackEvent = async (event: string, properties?: Record<string, unknown>) => {
|
||||
if (!posthogEnabled) return;
|
||||
|
||||
const { posthog } = await import("posthog-js");
|
||||
posthog.capture(event, properties);
|
||||
};
|
||||
|
||||
const trackClick = (element: string, properties?: Record<string, unknown>) => {
|
||||
trackEvent("button_clicked", { element, ...properties });
|
||||
};
|
||||
|
||||
const trackFormSubmit = (form: string, success: boolean, properties?: Record<string, unknown>) => {
|
||||
trackEvent("form_submitted", { form, success, ...properties });
|
||||
};
|
||||
|
||||
return {
|
||||
trackEvent,
|
||||
trackClick,
|
||||
trackFormSubmit,
|
||||
};
|
||||
}
|
||||
|
||||
// Revenue tracking helper
|
||||
export function trackRevenue(amount: number, currency: string = "USD") {
|
||||
if (!posthogEnabled) return;
|
||||
|
||||
import("posthog-js").then(({ posthog }) => {
|
||||
posthog.capture("revenue", {
|
||||
amount,
|
||||
currency,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Feature usage tracking
|
||||
export function trackFeatureUsage(feature: string, properties?: Record<string, unknown>) {
|
||||
if (!posthogEnabled) return;
|
||||
|
||||
import("posthog-js").then(({ posthog }) => {
|
||||
posthog.capture("feature_used", {
|
||||
feature,
|
||||
...properties,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Session recording wrapper for development
|
||||
export function SessionRecorder() {
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === "development" && posthogEnabled) {
|
||||
import("posthog-js").then(({ posthog }) => {
|
||||
posthog.startSessionRecording();
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Clerk provider wrapper for Next.js App Router
|
||||
|
||||
"use client";
|
||||
|
||||
import { ClerkProvider as ClerkProviderBase } from "@clerk/nextjs";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
|
||||
interface ClerkProviderProps {
|
||||
children: React.ReactNode;
|
||||
publishableKey: string;
|
||||
}
|
||||
|
||||
export function ClerkProvider({ children, publishableKey }: ClerkProviderProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
// Determine sign-in URL based on current path
|
||||
const signInUrl = "/login";
|
||||
const signUpUrl = "/register";
|
||||
const afterSignInUrl = pathname || "/admin";
|
||||
const afterSignUpUrl = "/onboarding";
|
||||
|
||||
return (
|
||||
<ClerkProviderBase
|
||||
publishableKey={publishableKey}
|
||||
signInUrl={signInUrl}
|
||||
signUpUrl={signUpUrl}
|
||||
afterSignInUrl={afterSignInUrl}
|
||||
afterSignUpUrl={afterSignUpUrl}
|
||||
routing={process.env.NEXT_PUBLIC_CLERK_ROUTING || "path"}
|
||||
>
|
||||
{children}
|
||||
</ClerkProviderBase>
|
||||
);
|
||||
}
|
||||
|
||||
// Hooks for Clerk auth state
|
||||
export { useUser, useAuth, useClerk } from "@clerk/nextjs";
|
||||
@@ -0,0 +1,287 @@
|
||||
// Error Boundary Component for catching and displaying errors
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { Component, ReactNode } from "react";
|
||||
import { captureError } from "@/lib/sentry";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: React.ErrorInfo | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null, errorInfo: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
// Log to Sentry
|
||||
captureError(error, {
|
||||
componentStack: errorInfo.componentStack,
|
||||
boundary: "ErrorBoundary",
|
||||
});
|
||||
|
||||
// Call custom error handler
|
||||
this.props.onError?.(error, errorInfo);
|
||||
|
||||
this.setState({ errorInfo });
|
||||
}
|
||||
|
||||
resetError = () => {
|
||||
this.setState({ hasError: false, error: null, errorInfo: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
|
||||
<div className="max-w-md w-full bg-white rounded-xl shadow-lg p-8 text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-6 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Something went wrong</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
We encountered an unexpected error. Please try refreshing the page.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Button onClick={this.resetError} className="w-full">
|
||||
Try Again
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.location.href = "/"}
|
||||
className="w-full"
|
||||
>
|
||||
Go to Homepage
|
||||
</Button>
|
||||
</div>
|
||||
{process.env.NODE_ENV === "development" && this.state.error && (
|
||||
<div className="mt-6 p-4 bg-gray-100 rounded-lg text-left">
|
||||
<p className="text-sm font-mono text-red-600 break-all">
|
||||
{this.state.error.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
// Loading skeleton component
|
||||
export function LoadingSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={`animate-pulse bg-gray-200 rounded ${className}`} />
|
||||
);
|
||||
}
|
||||
|
||||
// Full page loading state
|
||||
export function LoadingPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading spinner for buttons
|
||||
export function LoadingSpinner({ size = "md" }: { size?: "sm" | "md" | "lg" }) {
|
||||
const sizeClasses = {
|
||||
sm: "w-4 h-4",
|
||||
md: "w-6 h-6",
|
||||
lg: "w-8 h-8",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${sizeClasses[size]} border-2 border-current border-t-transparent rounded-full animate-spin`} />
|
||||
);
|
||||
}
|
||||
|
||||
// Async button wrapper with loading state
|
||||
interface AsyncButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: ReactNode;
|
||||
loading?: boolean;
|
||||
loadingText?: string;
|
||||
}
|
||||
|
||||
export function AsyncButton({
|
||||
children,
|
||||
loading,
|
||||
loadingText = "Loading...",
|
||||
disabled,
|
||||
...props
|
||||
}: AsyncButtonProps) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
disabled={disabled || loading}
|
||||
className={`relative ${props.className || ""}`}
|
||||
>
|
||||
{loading && (
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
<LoadingSpinner size="sm" />
|
||||
</span>
|
||||
)}
|
||||
<span className={loading ? "opacity-0" : ""}>
|
||||
{children}
|
||||
</span>
|
||||
{loading && loadingText && (
|
||||
<span className="sr-only">{loadingText}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Optimistic update wrapper
|
||||
interface OptimisticState<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export function useOptimistic<T>(initialData: T | null = null) {
|
||||
const [state, setState] = React.useState<OptimisticState<T>>({
|
||||
data: initialData,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const optimisticUpdate = React.useCallback((updater: (prev: T | null) => T) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
data: updater(prev.data),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setLoading = React.useCallback((loading: boolean) => {
|
||||
setState(prev => ({ ...prev, loading }));
|
||||
}, []);
|
||||
|
||||
const setError = React.useCallback((error: Error | null) => {
|
||||
setState(prev => ({ ...prev, error }));
|
||||
}, []);
|
||||
|
||||
const reset = React.useCallback(() => {
|
||||
setState({ data: initialData, loading: false, error: null });
|
||||
}, [initialData]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
optimisticUpdate,
|
||||
setLoading,
|
||||
setError,
|
||||
reset,
|
||||
setData: (data: T) => setState(prev => ({ ...prev, data })),
|
||||
};
|
||||
}
|
||||
|
||||
// Toast notifications
|
||||
interface Toast {
|
||||
id: string;
|
||||
type: "success" | "error" | "info" | "warning";
|
||||
message: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
toasts: Toast[];
|
||||
addToast: (toast: Omit<Toast, "id">) => void;
|
||||
removeToast: (id: string) => void;
|
||||
}
|
||||
|
||||
const ToastContext = React.createContext<ToastContextType | null>(null);
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = React.useState<Toast[]>([]);
|
||||
|
||||
const addToast = React.useCallback((toast: Omit<Toast, "id">) => {
|
||||
const id = Math.random().toString(36).substring(7);
|
||||
setToasts(prev => [...prev, { ...toast, id }]);
|
||||
|
||||
// Auto remove after duration
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
}, toast.duration || 5000);
|
||||
}, []);
|
||||
|
||||
const removeToast = React.useCallback((id: string) => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toasts, addToast, removeToast }}>
|
||||
{children}
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastContainer({
|
||||
toasts,
|
||||
onRemove
|
||||
}: {
|
||||
toasts: Toast[];
|
||||
onRemove: (id: string) => void
|
||||
}) {
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 space-y-2">
|
||||
{toasts.map(toast => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`flex items-center gap-3 p-4 rounded-lg shadow-lg animate-slide-in ${
|
||||
toast.type === "success" ? "bg-green-600" :
|
||||
toast.type === "error" ? "bg-red-600" :
|
||||
toast.type === "warning" ? "bg-yellow-600" :
|
||||
"bg-blue-600"
|
||||
} text-white`}
|
||||
>
|
||||
<span>{toast.message}</span>
|
||||
<button
|
||||
onClick={() => onRemove(toast.id)}
|
||||
className="p-1 hover:bg-white/20 rounded"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = React.useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error("useToast must be used within a ToastProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
Reference in New Issue
Block a user