diff --git a/packages/frontend/next.config.js b/packages/frontend/next.config.js
new file mode 100644
index 0000000..4047df0
--- /dev/null
+++ b/packages/frontend/next.config.js
@@ -0,0 +1,135 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ output: 'standalone',
+ experimental: {
+ appDir: true,
+ },
+
+ // Environment variables
+ env: {
+ NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001',
+ NEXT_PUBLIC_WS_URL: process.env.NEXT_PUBLIC_WS_URL || 'ws://localhost:3001',
+ NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
+ NEXT_PUBLIC_GOOGLE_CLIENT_ID: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
+ },
+
+ // Image configuration for external sources
+ images: {
+ remotePatterns: [
+ {
+ protocol: 'https',
+ hostname: 'lh3.googleusercontent.com',
+ port: '',
+ pathname: '/a/**',
+ },
+ {
+ protocol: 'http',
+ hostname: 'localhost',
+ port: '3001',
+ pathname: '/api/images/**',
+ },
+ ],
+ dangerouslyAllowSVG: true,
+ contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
+ },
+
+ // Headers for security
+ async headers() {
+ return [
+ {
+ source: '/(.*)',
+ headers: [
+ {
+ key: 'X-Frame-Options',
+ value: 'DENY',
+ },
+ {
+ key: 'X-Content-Type-Options',
+ value: 'nosniff',
+ },
+ {
+ key: 'Referrer-Policy',
+ value: 'strict-origin-when-cross-origin',
+ },
+ {
+ key: 'X-XSS-Protection',
+ value: '1; mode=block',
+ },
+ ],
+ },
+ ];
+ },
+
+ // Rewrites for API proxy in development
+ async rewrites() {
+ if (process.env.NODE_ENV === 'development') {
+ return [
+ {
+ source: '/api/:path*',
+ destination: `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/api/:path*`,
+ },
+ ];
+ }
+ return [];
+ },
+
+ // Webpack configuration
+ webpack: (config, { dev, isServer }) => {
+ // Optimization for production
+ if (!dev && !isServer) {
+ config.optimization.splitChunks.cacheGroups = {
+ ...config.optimization.splitChunks.cacheGroups,
+ vendor: {
+ test: /[\\/]node_modules[\\/]/,
+ name: 'vendors',
+ chunks: 'all',
+ priority: 10,
+ },
+ common: {
+ name: 'common',
+ minChunks: 2,
+ chunks: 'all',
+ priority: 5,
+ reuseExistingChunk: true,
+ },
+ };
+ }
+
+ return config;
+ },
+
+ // TypeScript configuration
+ typescript: {
+ ignoreBuildErrors: false,
+ },
+
+ // ESLint configuration
+ eslint: {
+ ignoreDuringBuilds: false,
+ },
+
+ // Compression and optimization
+ compress: true,
+ poweredByHeader: false,
+ generateEtags: true,
+
+ // Redirects
+ async redirects() {
+ return [
+ {
+ source: '/dashboard',
+ destination: '/',
+ permanent: false,
+ has: [
+ {
+ type: 'cookie',
+ key: 'authenticated',
+ value: undefined,
+ },
+ ],
+ },
+ ];
+ },
+};
+
+module.exports = nextConfig;
\ No newline at end of file
diff --git a/packages/frontend/package.json b/packages/frontend/package.json
new file mode 100644
index 0000000..6572364
--- /dev/null
+++ b/packages/frontend/package.json
@@ -0,0 +1,92 @@
+{
+ "name": "@seo-image-renamer/frontend",
+ "version": "1.0.0",
+ "description": "Next.js frontend for SEO Image Renamer with complete backend integration",
+ "private": true,
+ "scripts": {
+ "dev": "next dev -p 3000",
+ "build": "next build",
+ "start": "next start -p 3000",
+ "lint": "next lint",
+ "type-check": "tsc --noEmit",
+ "test": "jest",
+ "test:watch": "jest --watch",
+ "test:coverage": "jest --coverage",
+ "storybook": "storybook dev -p 6006",
+ "build-storybook": "storybook build"
+ },
+ "dependencies": {
+ "next": "^14.0.4",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "@types/node": "^20.10.5",
+ "@types/react": "^18.2.45",
+ "@types/react-dom": "^18.2.18",
+ "typescript": "^5.3.3",
+ "tailwindcss": "^3.3.6",
+ "autoprefixer": "^10.4.16",
+ "postcss": "^8.4.32",
+ "@tailwindcss/forms": "^0.5.7",
+ "@tailwindcss/typography": "^0.5.10",
+ "@headlessui/react": "^1.7.17",
+ "@heroicons/react": "^2.0.18",
+ "socket.io-client": "^4.7.4",
+ "axios": "^1.6.2",
+ "@stripe/stripe-js": "^2.4.0",
+ "react-dropzone": "^14.2.3",
+ "react-hook-form": "^7.48.2",
+ "react-hot-toast": "^2.4.1",
+ "clsx": "^2.0.0",
+ "class-variance-authority": "^0.7.0",
+ "lucide-react": "^0.298.0",
+ "next-themes": "^0.2.1",
+ "zustand": "^4.4.7",
+ "jszip": "^3.10.1",
+ "file-saver": "^2.0.5",
+ "@hookform/resolvers": "^3.3.2",
+ "zod": "^3.22.4",
+ "react-query": "^3.39.3",
+ "framer-motion": "^10.16.16"
+ },
+ "devDependencies": {
+ "@types/file-saver": "^2.0.7",
+ "@types/jszip": "^3.4.1",
+ "@typescript-eslint/eslint-plugin": "^6.14.0",
+ "@typescript-eslint/parser": "^6.14.0",
+ "eslint": "^8.55.0",
+ "eslint-config-next": "^14.0.4",
+ "eslint-plugin-react": "^7.33.2",
+ "eslint-plugin-react-hooks": "^4.6.0",
+ "@testing-library/react": "^14.1.2",
+ "@testing-library/jest-dom": "^6.1.5",
+ "@testing-library/user-event": "^14.5.1",
+ "jest": "^29.7.0",
+ "jest-environment-jsdom": "^29.7.0",
+ "@storybook/addon-essentials": "^7.6.6",
+ "@storybook/addon-interactions": "^7.6.6",
+ "@storybook/addon-links": "^7.6.6",
+ "@storybook/blocks": "^7.6.6",
+ "@storybook/nextjs": "^7.6.6",
+ "@storybook/react": "^7.6.6",
+ "@storybook/testing-library": "^0.2.2",
+ "storybook": "^7.6.6",
+ "prettier": "^3.1.1",
+ "prettier-plugin-tailwindcss": "^0.5.9"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "browserslist": {
+ "production": [
+ ">0.2%",
+ "not dead",
+ "not op_mini all"
+ ],
+ "development": [
+ "last 1 chrome version",
+ "last 1 firefox version",
+ "last 1 safari version"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/packages/frontend/postcss.config.js b/packages/frontend/postcss.config.js
new file mode 100644
index 0000000..8567b4c
--- /dev/null
+++ b/packages/frontend/postcss.config.js
@@ -0,0 +1,6 @@
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
\ No newline at end of file
diff --git a/packages/frontend/src/app/globals.css b/packages/frontend/src/app/globals.css
new file mode 100644
index 0000000..98a81cf
--- /dev/null
+++ b/packages/frontend/src/app/globals.css
@@ -0,0 +1,344 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap');
+@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@100;200;300;400;500;600;700;800&display=swap');
+
+/* Base styles */
+@layer base {
+ html {
+ @apply scroll-smooth;
+ }
+
+ body {
+ @apply bg-white text-secondary-900 antialiased;
+ font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
+ }
+
+ /* Dark mode */
+ .dark body {
+ @apply bg-secondary-900 text-secondary-100;
+ }
+
+ /* Focus styles */
+ *:focus {
+ @apply outline-none ring-2 ring-primary-500 ring-offset-2;
+ }
+
+ .dark *:focus {
+ @apply ring-offset-secondary-900;
+ }
+
+ /* Selection */
+ ::selection {
+ @apply bg-primary-100 text-primary-900;
+ }
+
+ .dark ::selection {
+ @apply bg-primary-800 text-primary-100;
+ }
+
+ /* Scrollbar */
+ ::-webkit-scrollbar {
+ @apply w-2;
+ }
+
+ ::-webkit-scrollbar-track {
+ @apply bg-secondary-100;
+ }
+
+ ::-webkit-scrollbar-thumb {
+ @apply bg-secondary-300 rounded-full;
+ }
+
+ ::-webkit-scrollbar-thumb:hover {
+ @apply bg-secondary-400;
+ }
+
+ .dark ::-webkit-scrollbar-track {
+ @apply bg-secondary-800;
+ }
+
+ .dark ::-webkit-scrollbar-thumb {
+ @apply bg-secondary-600;
+ }
+
+ .dark ::-webkit-scrollbar-thumb:hover {
+ @apply bg-secondary-500;
+ }
+}
+
+/* Component styles */
+@layer components {
+ /* Button variants */
+ .btn {
+ @apply inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed;
+ }
+
+ .btn-primary {
+ @apply bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500 shadow-sm;
+ }
+
+ .btn-secondary {
+ @apply bg-secondary-100 text-secondary-900 hover:bg-secondary-200 focus:ring-secondary-500 border border-secondary-200;
+ }
+
+ .btn-success {
+ @apply bg-success-600 text-white hover:bg-success-700 focus:ring-success-500 shadow-sm;
+ }
+
+ .btn-danger {
+ @apply bg-error-600 text-white hover:bg-error-700 focus:ring-error-500 shadow-sm;
+ }
+
+ .btn-outline {
+ @apply bg-transparent text-secondary-700 hover:bg-secondary-50 focus:ring-secondary-500 border border-secondary-300;
+ }
+
+ .btn-ghost {
+ @apply bg-transparent text-secondary-600 hover:bg-secondary-100 hover:text-secondary-900 focus:ring-secondary-500;
+ }
+
+ .btn-sm {
+ @apply px-3 py-1.5 text-xs;
+ }
+
+ .btn-lg {
+ @apply px-6 py-3 text-base;
+ }
+
+ .btn-xl {
+ @apply px-8 py-4 text-lg;
+ }
+
+ /* Dark mode button variants */
+ .dark .btn-secondary {
+ @apply bg-secondary-800 text-secondary-100 hover:bg-secondary-700 border-secondary-700;
+ }
+
+ .dark .btn-outline {
+ @apply text-secondary-300 hover:bg-secondary-800 border-secondary-600;
+ }
+
+ .dark .btn-ghost {
+ @apply text-secondary-400 hover:bg-secondary-800 hover:text-secondary-200;
+ }
+
+ /* Input styles */
+ .input {
+ @apply block w-full px-3 py-2 border border-secondary-300 rounded-lg text-secondary-900 placeholder-secondary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-secondary-50 disabled:cursor-not-allowed transition-colors;
+ }
+
+ .dark .input {
+ @apply bg-secondary-800 border-secondary-600 text-secondary-100 placeholder-secondary-400 focus:border-primary-400 disabled:bg-secondary-900;
+ }
+
+ /* Card styles */
+ .card {
+ @apply bg-white border border-secondary-200 rounded-xl shadow-soft;
+ }
+
+ .dark .card {
+ @apply bg-secondary-800 border-secondary-700;
+ }
+
+ /* Modal styles */
+ .modal-backdrop {
+ @apply fixed inset-0 bg-black bg-opacity-50 backdrop-blur-sm z-40;
+ }
+
+ .modal-content {
+ @apply fixed inset-x-4 top-1/2 -translate-y-1/2 max-w-lg mx-auto bg-white rounded-xl shadow-large z-50 max-h-[90vh] overflow-y-auto;
+ }
+
+ .dark .modal-content {
+ @apply bg-secondary-800;
+ }
+
+ /* Loading spinner */
+ .spinner {
+ @apply animate-spin h-5 w-5 border-2 border-secondary-300 border-t-primary-600 rounded-full;
+ }
+
+ /* Shimmer loading effect */
+ .shimmer {
+ @apply relative overflow-hidden bg-secondary-200 rounded;
+ }
+
+ .shimmer::after {
+ @apply absolute top-0 right-0 bottom-0 left-0 bg-gradient-to-r from-transparent via-white to-transparent;
+ content: '';
+ animation: shimmer 2s infinite;
+ }
+
+ .dark .shimmer {
+ @apply bg-secondary-700;
+ }
+
+ .dark .shimmer::after {
+ @apply via-secondary-600;
+ }
+
+ /* Upload area */
+ .upload-area {
+ @apply border-2 border-dashed border-secondary-300 rounded-xl p-8 text-center transition-colors hover:border-primary-400 hover:bg-primary-50;
+ }
+
+ .upload-area.active {
+ @apply border-primary-500 bg-primary-50;
+ }
+
+ .dark .upload-area {
+ @apply border-secondary-600 hover:border-primary-500 hover:bg-primary-900/10;
+ }
+
+ .dark .upload-area.active {
+ @apply border-primary-400 bg-primary-900/20;
+ }
+
+ /* Progress bar */
+ .progress-bar {
+ @apply w-full bg-secondary-200 rounded-full h-2 overflow-hidden;
+ }
+
+ .progress-fill {
+ @apply h-full bg-primary-600 transition-all duration-300 ease-in-out;
+ }
+
+ .dark .progress-bar {
+ @apply bg-secondary-700;
+ }
+
+ /* Toast styles */
+ .toast {
+ @apply flex items-center gap-3 p-4 bg-white border border-secondary-200 rounded-lg shadow-medium max-w-sm;
+ }
+
+ .toast-success {
+ @apply border-success-200 bg-success-50;
+ }
+
+ .toast-error {
+ @apply border-error-200 bg-error-50;
+ }
+
+ .toast-warning {
+ @apply border-warning-200 bg-warning-50;
+ }
+
+ .dark .toast {
+ @apply bg-secondary-800 border-secondary-700;
+ }
+
+ .dark .toast-success {
+ @apply border-success-800 bg-success-900/20;
+ }
+
+ .dark .toast-error {
+ @apply border-error-800 bg-error-900/20;
+ }
+
+ .dark .toast-warning {
+ @apply border-warning-800 bg-warning-900/20;
+ }
+
+ /* Badge styles */
+ .badge {
+ @apply inline-flex items-center gap-1 px-2.5 py-0.5 text-xs font-medium rounded-full;
+ }
+
+ .badge-primary {
+ @apply bg-primary-100 text-primary-800;
+ }
+
+ .badge-success {
+ @apply bg-success-100 text-success-800;
+ }
+
+ .badge-warning {
+ @apply bg-warning-100 text-warning-800;
+ }
+
+ .badge-error {
+ @apply bg-error-100 text-error-800;
+ }
+
+ .dark .badge-primary {
+ @apply bg-primary-900/30 text-primary-300;
+ }
+
+ .dark .badge-success {
+ @apply bg-success-900/30 text-success-300;
+ }
+
+ .dark .badge-warning {
+ @apply bg-warning-900/30 text-warning-300;
+ }
+
+ .dark .badge-error {
+ @apply bg-error-900/30 text-error-300;
+ }
+}
+
+/* Utility classes */
+@layer utilities {
+ .text-balance {
+ text-wrap: balance;
+ }
+
+ .animation-delay-75 {
+ animation-delay: 75ms;
+ }
+
+ .animation-delay-100 {
+ animation-delay: 100ms;
+ }
+
+ .animation-delay-150 {
+ animation-delay: 150ms;
+ }
+
+ .animation-delay-200 {
+ animation-delay: 200ms;
+ }
+
+ .animation-delay-300 {
+ animation-delay: 300ms;
+ }
+
+ .animation-delay-500 {
+ animation-delay: 500ms;
+ }
+
+ .animation-delay-700 {
+ animation-delay: 700ms;
+ }
+
+ .animation-delay-1000 {
+ animation-delay: 1000ms;
+ }
+
+ /* Glass morphism effect */
+ .glass {
+ @apply bg-white/80 backdrop-blur-md border border-white/20;
+ }
+
+ .dark .glass {
+ @apply bg-secondary-900/80 border-secondary-700/50;
+ }
+
+ /* Gradient text */
+ .gradient-text {
+ @apply bg-gradient-to-r from-primary-600 to-primary-400 bg-clip-text text-transparent;
+ }
+
+ /* Safe area padding for mobile */
+ .safe-area-top {
+ padding-top: env(safe-area-inset-top);
+ }
+
+ .safe-area-bottom {
+ padding-bottom: env(safe-area-inset-bottom);
+ }
+}
\ No newline at end of file
diff --git a/packages/frontend/src/app/layout.tsx b/packages/frontend/src/app/layout.tsx
new file mode 100644
index 0000000..c7fe967
--- /dev/null
+++ b/packages/frontend/src/app/layout.tsx
@@ -0,0 +1,97 @@
+import type { Metadata } from 'next';
+import { Inter } from 'next/font/google';
+import './globals.css';
+
+const inter = Inter({ subsets: ['latin'] });
+
+export const metadata: Metadata = {
+ title: 'SEO Image Renamer - AI-Powered Image SEO Tool',
+ description: 'Transform your image SEO workflow with AI that analyzes content and generates perfect filenames automatically. No more manual renaming - just upload, enhance, and download.',
+ keywords: ['SEO', 'image optimization', 'AI', 'filename generator', 'image renaming', 'bulk processing'],
+ authors: [{ name: 'SEO Image Renamer Team' }],
+ creator: 'SEO Image Renamer',
+ publisher: 'SEO Image Renamer',
+ openGraph: {
+ type: 'website',
+ locale: 'en_US',
+ url: 'https://seo-image-renamer.com',
+ title: 'SEO Image Renamer - AI-Powered Image SEO Tool',
+ description: 'Transform your image SEO workflow with AI that analyzes content and generates perfect filenames automatically.',
+ siteName: 'SEO Image Renamer',
+ images: [
+ {
+ url: '/og-image.png',
+ width: 1200,
+ height: 630,
+ alt: 'SEO Image Renamer - AI-Powered Image SEO Tool',
+ },
+ ],
+ },
+ twitter: {
+ card: 'summary_large_image',
+ title: 'SEO Image Renamer - AI-Powered Image SEO Tool',
+ description: 'Transform your image SEO workflow with AI that analyzes content and generates perfect filenames automatically.',
+ images: ['/og-image.png'],
+ },
+ robots: {
+ index: true,
+ follow: true,
+ googleBot: {
+ index: true,
+ follow: true,
+ 'max-video-preview': -1,
+ 'max-image-preview': 'large',
+ 'max-snippet': -1,
+ },
+ },
+ viewport: {
+ width: 'device-width',
+ initialScale: 1,
+ maximumScale: 1,
+ },
+ themeColor: [
+ { media: '(prefers-color-scheme: light)', color: '#ffffff' },
+ { media: '(prefers-color-scheme: dark)', color: '#0f172a' },
+ ],
+};
+
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/packages/frontend/src/app/page.tsx b/packages/frontend/src/app/page.tsx
new file mode 100644
index 0000000..719ecee
--- /dev/null
+++ b/packages/frontend/src/app/page.tsx
@@ -0,0 +1,80 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { useAuth } from '@/hooks/useAuth';
+import { useWebSocket } from '@/hooks/useWebSocket';
+import { Header } from '@/components/Layout/Header';
+import { Footer } from '@/components/Layout/Footer';
+import { HeroSection } from '@/components/Landing/HeroSection';
+import { FeaturesSection } from '@/components/Landing/FeaturesSection';
+import { HowItWorksSection } from '@/components/Landing/HowItWorksSection';
+import { PricingSection } from '@/components/Landing/PricingSection';
+import { Dashboard } from '@/components/Dashboard/Dashboard';
+import { WorkflowSection } from '@/components/Workflow/WorkflowSection';
+import { LoadingSpinner } from '@/components/UI/LoadingSpinner';
+import { ErrorBoundary } from '@/components/UI/ErrorBoundary';
+import { ToastProvider } from '@/components/UI/ToastProvider';
+
+export default function HomePage() {
+ const { user, isAuthenticated, isLoading } = useAuth();
+ const { connect } = useWebSocket();
+ const [showWorkflow, setShowWorkflow] = useState(false);
+
+ // Connect WebSocket when user is authenticated
+ useEffect(() => {
+ if (isAuthenticated && user) {
+ connect(user.id);
+ }
+ }, [isAuthenticated, user, connect]);
+
+ // Handle workflow visibility
+ const handleStartWorkflow = () => {
+ setShowWorkflow(true);
+ };
+
+ const handleWorkflowComplete = () => {
+ setShowWorkflow(false);
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ {isAuthenticated ? (
+ <>
+ {showWorkflow ? (
+ setShowWorkflow(false)}
+ />
+ ) : (
+
+ )}
+ >
+ ) : (
+ <>
+
+
+
+
+ >
+ )}
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/packages/frontend/src/components/Auth/LoginButton.tsx b/packages/frontend/src/components/Auth/LoginButton.tsx
new file mode 100644
index 0000000..f9a2077
--- /dev/null
+++ b/packages/frontend/src/components/Auth/LoginButton.tsx
@@ -0,0 +1,80 @@
+'use client';
+
+import { useState } from 'react';
+import { useAuth } from '@/hooks/useAuth';
+
+interface LoginButtonProps {
+ variant?: 'primary' | 'secondary' | 'outline';
+ size?: 'sm' | 'md' | 'lg';
+ className?: string;
+ children?: React.ReactNode;
+}
+
+export function LoginButton({
+ variant = 'primary',
+ size = 'md',
+ className = '',
+ children
+}: LoginButtonProps) {
+ const { login, isLoading } = useAuth();
+ const [isClicked, setIsClicked] = useState(false);
+
+ const handleLogin = async () => {
+ try {
+ setIsClicked(true);
+ await login();
+ } catch (error) {
+ console.error('Login failed:', error);
+ setIsClicked(false);
+ }
+ };
+
+ const buttonClasses = [
+ 'btn',
+ `btn-${variant}`,
+ `btn-${size}`,
+ 'transition-all duration-200',
+ 'focus:ring-2 focus:ring-offset-2 focus:ring-primary-500',
+ className,
+ ].filter(Boolean).join(' ');
+
+ const isButtonLoading = isLoading || isClicked;
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/packages/frontend/src/components/Layout/Footer.tsx b/packages/frontend/src/components/Layout/Footer.tsx
new file mode 100644
index 0000000..411ed75
--- /dev/null
+++ b/packages/frontend/src/components/Layout/Footer.tsx
@@ -0,0 +1,125 @@
+'use client';
+
+export function Footer() {
+ const currentYear = new Date().getFullYear();
+
+ const footerLinks = {
+ product: [
+ { name: 'Features', href: '#features' },
+ { name: 'How It Works', href: '#how-it-works' },
+ { name: 'Pricing', href: '#pricing' },
+ ],
+ company: [
+ { name: 'About Us', href: '/about' },
+ { name: 'Blog', href: '/blog' },
+ { name: 'Contact', href: '/contact' },
+ ],
+ legal: [
+ { name: 'Privacy Policy', href: '/privacy' },
+ { name: 'Terms of Service', href: '/terms' },
+ { name: 'Cookie Policy', href: '/cookies' },
+ ],
+ };
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/packages/frontend/src/components/Layout/Header.tsx b/packages/frontend/src/components/Layout/Header.tsx
new file mode 100644
index 0000000..f162625
--- /dev/null
+++ b/packages/frontend/src/components/Layout/Header.tsx
@@ -0,0 +1,143 @@
+'use client';
+
+import { useState } from 'react';
+import Image from 'next/image';
+import { useAuth } from '@/hooks/useAuth';
+import { LoginButton } from '@/components/Auth/LoginButton';
+
+export function Header() {
+ const { user, isAuthenticated, logout } = useAuth();
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+
+ const navigation = [
+ { name: 'Features', href: '#features' },
+ { name: 'How It Works', href: '#how-it-works' },
+ { name: 'Pricing', href: '#pricing' },
+ ];
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/packages/frontend/src/components/UI/ErrorBoundary.tsx b/packages/frontend/src/components/UI/ErrorBoundary.tsx
new file mode 100644
index 0000000..66d9abd
--- /dev/null
+++ b/packages/frontend/src/components/UI/ErrorBoundary.tsx
@@ -0,0 +1,114 @@
+'use client';
+
+import React from 'react';
+
+interface ErrorBoundaryState {
+ hasError: boolean;
+ error: Error | null;
+ errorInfo: React.ErrorInfo | null;
+}
+
+interface ErrorBoundaryProps {
+ children: React.ReactNode;
+ fallback?: React.ComponentType<{ error: Error; retry: () => void }>;
+}
+
+export class ErrorBoundary extends React.Component {
+ constructor(props: ErrorBoundaryProps) {
+ super(props);
+ this.state = {
+ hasError: false,
+ error: null,
+ errorInfo: null,
+ };
+ }
+
+ static getDerivedStateFromError(error: Error): Partial {
+ return {
+ hasError: true,
+ error,
+ };
+ }
+
+ componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
+ console.error('ErrorBoundary caught an error:', error, errorInfo);
+ this.setState({
+ error,
+ errorInfo,
+ });
+ }
+
+ handleRetry = () => {
+ this.setState({
+ hasError: false,
+ error: null,
+ errorInfo: null,
+ });
+ };
+
+ render() {
+ if (this.state.hasError && this.state.error) {
+ if (this.props.fallback) {
+ const FallbackComponent = this.props.fallback;
+ return ;
+ }
+
+ return (
+
+
+
+
+
+
+ Something went wrong
+
+
+
+ We encountered an unexpected error. Please try refreshing the page or contact support if the problem persists.
+
+
+ {process.env.NODE_ENV === 'development' && (
+
+
+ Error Details (Development)
+
+
+
Error:
+
{this.state.error.toString()}
+ {this.state.errorInfo && (
+ <>
+
Component Stack:
+
{this.state.errorInfo.componentStack}
+ >
+ )}
+
+
+ )}
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
\ No newline at end of file
diff --git a/packages/frontend/src/components/UI/LoadingSpinner.tsx b/packages/frontend/src/components/UI/LoadingSpinner.tsx
new file mode 100644
index 0000000..3fb6096
--- /dev/null
+++ b/packages/frontend/src/components/UI/LoadingSpinner.tsx
@@ -0,0 +1,42 @@
+'use client';
+
+interface LoadingSpinnerProps {
+ size?: 'sm' | 'md' | 'lg' | 'xl';
+ className?: string;
+ color?: 'primary' | 'secondary' | 'white';
+}
+
+const sizeClasses = {
+ sm: 'w-4 h-4',
+ md: 'w-6 h-6',
+ lg: 'w-8 h-8',
+ xl: 'w-12 h-12',
+};
+
+const colorClasses = {
+ primary: 'border-primary-600',
+ secondary: 'border-secondary-600',
+ white: 'border-white',
+};
+
+export function LoadingSpinner({
+ size = 'md',
+ className = '',
+ color = 'primary'
+}: LoadingSpinnerProps) {
+ return (
+
+ Loading...
+
+ );
+}
\ No newline at end of file
diff --git a/packages/frontend/src/components/UI/ToastProvider.tsx b/packages/frontend/src/components/UI/ToastProvider.tsx
new file mode 100644
index 0000000..5050aaf
--- /dev/null
+++ b/packages/frontend/src/components/UI/ToastProvider.tsx
@@ -0,0 +1,211 @@
+'use client';
+
+import React, { createContext, useContext, useState, useCallback } from 'react';
+import { createPortal } from 'react-dom';
+
+interface Toast {
+ id: string;
+ type: 'success' | 'error' | 'warning' | 'info';
+ title: string;
+ message?: string;
+ duration?: number;
+ action?: {
+ label: string;
+ onClick: () => void;
+ };
+}
+
+interface ToastContextValue {
+ showToast: (toast: Omit) => void;
+ removeToast: (id: string) => void;
+ clearAllToasts: () => void;
+}
+
+const ToastContext = createContext(null);
+
+export function useToast() {
+ const context = useContext(ToastContext);
+ if (!context) {
+ throw new Error('useToast must be used within a ToastProvider');
+ }
+ return context;
+}
+
+interface ToastProviderProps {
+ children: React.ReactNode;
+}
+
+export function ToastProvider({ children }: ToastProviderProps) {
+ const [toasts, setToasts] = useState([]);
+
+ const showToast = useCallback((toast: Omit) => {
+ const id = Math.random().toString(36).substr(2, 9);
+ const newToast: Toast = {
+ ...toast,
+ id,
+ duration: toast.duration || 5000,
+ };
+
+ setToasts(prev => [...prev, newToast]);
+
+ // Auto remove toast after duration
+ if (newToast.duration > 0) {
+ setTimeout(() => {
+ removeToast(id);
+ }, newToast.duration);
+ }
+ }, []);
+
+ const removeToast = useCallback((id: string) => {
+ setToasts(prev => prev.filter(toast => toast.id !== id));
+ }, []);
+
+ const clearAllToasts = useCallback(() => {
+ setToasts([]);
+ }, []);
+
+ const value = {
+ showToast,
+ removeToast,
+ clearAllToasts,
+ };
+
+ return (
+
+ {children}
+
+
+ );
+}
+
+interface ToastPortalProps {
+ toasts: Toast[];
+ onRemove: (id: string) => void;
+}
+
+function ToastPortal({ toasts, onRemove }: ToastPortalProps) {
+ if (typeof window === 'undefined') return null;
+
+ const toastRoot = document.getElementById('toast-root');
+ if (!toastRoot) return null;
+
+ return createPortal(
+
+ {toasts.map(toast => (
+
+ ))}
+
,
+ toastRoot
+ );
+}
+
+interface ToastItemProps {
+ toast: Toast;
+ onRemove: (id: string) => void;
+}
+
+function ToastItem({ toast, onRemove }: ToastItemProps) {
+ const [isVisible, setIsVisible] = useState(false);
+ const [isExiting, setIsExiting] = useState(false);
+
+ React.useEffect(() => {
+ // Trigger enter animation
+ const timer = setTimeout(() => setIsVisible(true), 10);
+ return () => clearTimeout(timer);
+ }, []);
+
+ const handleRemove = () => {
+ setIsExiting(true);
+ setTimeout(() => onRemove(toast.id), 150);
+ };
+
+ const getToastStyles = () => {
+ const baseStyles = 'toast pointer-events-auto transform transition-all duration-300 ease-in-out';
+ const typeStyles = {
+ success: 'toast-success',
+ error: 'toast-error',
+ warning: 'toast-warning',
+ info: '',
+ };
+
+ const animationStyles = isExiting
+ ? 'translate-x-full opacity-0 scale-95'
+ : isVisible
+ ? 'translate-x-0 opacity-100 scale-100'
+ : 'translate-x-full opacity-0 scale-95';
+
+ return `${baseStyles} ${typeStyles[toast.type]} ${animationStyles}`;
+ };
+
+ const getIcon = () => {
+ switch (toast.type) {
+ case 'success':
+ return (
+
+ );
+ case 'error':
+ return (
+
+ );
+ case 'warning':
+ return (
+
+ );
+ default:
+ return (
+
+ );
+ }
+ };
+
+ return (
+
+
+
+ {getIcon()}
+
+
+
+
+ {toast.title}
+
+ {toast.message && (
+
+ {toast.message}
+
+ )}
+ {toast.action && (
+
+
+
+ )}
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/packages/frontend/src/hooks/useAuth.ts b/packages/frontend/src/hooks/useAuth.ts
new file mode 100644
index 0000000..5413ce4
--- /dev/null
+++ b/packages/frontend/src/hooks/useAuth.ts
@@ -0,0 +1,191 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { useRouter } from 'next/navigation';
+import { apiClient } from '@/lib/api-client';
+import type { User, AuthResponse } from '@/types';
+
+interface AuthState {
+ user: User | null;
+ isAuthenticated: boolean;
+ isLoading: boolean;
+ error: string | null;
+}
+
+interface UseAuthReturn extends AuthState {
+ login: () => Promise;
+ logout: () => Promise;
+ handleCallback: (code: string) => Promise;
+ clearError: () => void;
+ refreshUser: () => Promise;
+}
+
+export function useAuth(): UseAuthReturn {
+ const [state, setState] = useState({
+ user: null,
+ isAuthenticated: false,
+ isLoading: true,
+ error: null,
+ });
+
+ const router = useRouter();
+
+ const clearError = useCallback(() => {
+ setState(prev => ({ ...prev, error: null }));
+ }, []);
+
+ const setUser = useCallback((user: User | null) => {
+ setState(prev => ({
+ ...prev,
+ user,
+ isAuthenticated: !!user,
+ isLoading: false,
+ error: null,
+ }));
+ }, []);
+
+ const setError = useCallback((error: string) => {
+ setState(prev => ({
+ ...prev,
+ error,
+ isLoading: false,
+ }));
+ }, []);
+
+ const setLoading = useCallback((isLoading: boolean) => {
+ setState(prev => ({ ...prev, isLoading }));
+ }, []);
+
+ // Initialize auth state
+ useEffect(() => {
+ const initAuth = async () => {
+ const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null;
+
+ if (!token) {
+ setLoading(false);
+ return;
+ }
+
+ try {
+ const user = await apiClient.getProfile();
+ setUser(user);
+ } catch (error) {
+ console.error('Failed to initialize auth:', error);
+ // Clear invalid token
+ localStorage.removeItem('auth_token');
+ setLoading(false);
+ }
+ };
+
+ initAuth();
+ }, [setUser, setLoading]);
+
+ // Listen for auth events
+ useEffect(() => {
+ const handleAuthEvent = (event: CustomEvent) => {
+ switch (event.type) {
+ case 'auth:logout':
+ setUser(null);
+ break;
+ }
+ };
+
+ window.addEventListener('auth:logout', handleAuthEvent as EventListener);
+
+ return () => {
+ window.removeEventListener('auth:logout', handleAuthEvent as EventListener);
+ };
+ }, [setUser]);
+
+ const login = useCallback(async () => {
+ try {
+ setLoading(true);
+ clearError();
+
+ const { url } = await apiClient.getAuthUrl();
+
+ // Store the current URL for redirect after login
+ const currentUrl = window.location.href;
+ localStorage.setItem('auth_redirect', currentUrl);
+
+ // Redirect to Google OAuth
+ window.location.href = url;
+ } catch (error) {
+ console.error('Login failed:', error);
+ setError(error instanceof Error ? error.message : 'Login failed');
+ }
+ }, [setLoading, clearError, setError]);
+
+ const handleCallback = useCallback(async (code: string) => {
+ try {
+ setLoading(true);
+ clearError();
+
+ const response: AuthResponse = await apiClient.handleCallback(code);
+ setUser(response.user);
+
+ // Redirect to original URL or dashboard
+ const redirectUrl = localStorage.getItem('auth_redirect') || '/';
+ localStorage.removeItem('auth_redirect');
+ router.push(redirectUrl);
+ } catch (error) {
+ console.error('Auth callback failed:', error);
+ setError(error instanceof Error ? error.message : 'Authentication failed');
+ router.push('/');
+ }
+ }, [setLoading, clearError, setUser, setError, router]);
+
+ const logout = useCallback(async () => {
+ try {
+ setLoading(true);
+ await apiClient.logout();
+ } catch (error) {
+ console.error('Logout failed:', error);
+ } finally {
+ setUser(null);
+ router.push('/');
+ }
+ }, [setLoading, setUser, router]);
+
+ const refreshUser = useCallback(async () => {
+ if (!state.isAuthenticated) return;
+
+ try {
+ const user = await apiClient.getProfile();
+ setUser(user);
+ } catch (error) {
+ console.error('Failed to refresh user:', error);
+ // Don't set error for refresh failures, just log them
+ }
+ }, [state.isAuthenticated, setUser]);
+
+ return {
+ ...state,
+ login,
+ logout,
+ handleCallback,
+ clearError,
+ refreshUser,
+ };
+}
+
+// Context provider hook for easier usage
+export function useAuthRequired(): UseAuthReturn & { user: User } {
+ const auth = useAuth();
+ const router = useRouter();
+
+ useEffect(() => {
+ if (!auth.isLoading && !auth.isAuthenticated) {
+ router.push('/');
+ }
+ }, [auth.isLoading, auth.isAuthenticated, router]);
+
+ if (!auth.user) {
+ throw new Error('User is required but not authenticated');
+ }
+
+ return {
+ ...auth,
+ user: auth.user,
+ };
+}
\ No newline at end of file
diff --git a/packages/frontend/src/hooks/useUpload.ts b/packages/frontend/src/hooks/useUpload.ts
new file mode 100644
index 0000000..bcffd0b
--- /dev/null
+++ b/packages/frontend/src/hooks/useUpload.ts
@@ -0,0 +1,317 @@
+'use client';
+
+import { useState, useCallback, useRef } from 'react';
+import { apiClient } from '@/lib/api-client';
+import type { Batch, Image, BatchCreateRequest } from '@/types';
+
+interface UploadState {
+ files: File[];
+ selectedFiles: File[];
+ uploadProgress: number;
+ isUploading: boolean;
+ isValidating: boolean;
+ currentBatch: Batch | null;
+ uploadedImages: Image[];
+ error: string | null;
+ dragActive: boolean;
+}
+
+interface ValidationError {
+ file: File;
+ error: string;
+}
+
+interface UseUploadReturn extends UploadState {
+ // File selection
+ addFiles: (files: File[]) => void;
+ removeFile: (index: number) => void;
+ clearFiles: () => void;
+
+ // Drag and drop
+ onDragEnter: (e: React.DragEvent) => void;
+ onDragLeave: (e: React.DragEvent) => void;
+ onDragOver: (e: React.DragEvent) => void;
+ onDrop: (e: React.DragEvent) => void;
+
+ // Upload process
+ startUpload: (keywords: string[], batchName?: string) => Promise;
+ cancelUpload: () => void;
+
+ // Validation
+ validateFiles: (files: File[]) => ValidationError[];
+
+ // State management
+ clearError: () => void;
+ reset: () => void;
+}
+
+const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
+const MAX_FILES = 50;
+const SUPPORTED_TYPES = [
+ 'image/jpeg',
+ 'image/jpg',
+ 'image/png',
+ 'image/webp',
+ 'image/gif'
+];
+
+export function useUpload(): UseUploadReturn {
+ const [state, setState] = useState({
+ files: [],
+ selectedFiles: [],
+ uploadProgress: 0,
+ isUploading: false,
+ isValidating: false,
+ currentBatch: null,
+ uploadedImages: [],
+ error: null,
+ dragActive: false,
+ });
+
+ const dragCounter = useRef(0);
+ const uploadAbortController = useRef(null);
+
+ const clearError = useCallback(() => {
+ setState(prev => ({ ...prev, error: null }));
+ }, []);
+
+ const setError = useCallback((error: string) => {
+ setState(prev => ({ ...prev, error, isUploading: false }));
+ }, []);
+
+ const validateFiles = useCallback((files: File[]): ValidationError[] => {
+ const errors: ValidationError[] = [];
+
+ for (const file of files) {
+ // Check file size
+ if (file.size > MAX_FILE_SIZE) {
+ errors.push({
+ file,
+ error: `File "${file.name}" is too large. Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB.`
+ });
+ continue;
+ }
+
+ // Check file type
+ if (!SUPPORTED_TYPES.includes(file.type)) {
+ errors.push({
+ file,
+ error: `File "${file.name}" has unsupported format. Supported formats: JPG, PNG, WebP, GIF.`
+ });
+ continue;
+ }
+
+ // Check for duplicates
+ const existingFile = state.files.find(f =>
+ f.name === file.name && f.size === file.size && f.lastModified === file.lastModified
+ );
+ if (existingFile) {
+ errors.push({
+ file,
+ error: `File "${file.name}" is already selected.`
+ });
+ continue;
+ }
+ }
+
+ // Check total file count
+ if (state.files.length + files.length - errors.length > MAX_FILES) {
+ const allowedCount = MAX_FILES - state.files.length;
+ errors.push({
+ file: files[0], // Use first file as reference
+ error: `Too many files. You can only upload ${MAX_FILES} files at once. You can add ${allowedCount} more files.`
+ });
+ }
+
+ return errors;
+ }, [state.files]);
+
+ const addFiles = useCallback((newFiles: File[]) => {
+ setState(prev => ({ ...prev, isValidating: true, error: null }));
+
+ const validationErrors = validateFiles(newFiles);
+
+ if (validationErrors.length > 0) {
+ const errorMessage = validationErrors.map(e => e.error).join('\n');
+ setState(prev => ({
+ ...prev,
+ error: errorMessage,
+ isValidating: false
+ }));
+ return;
+ }
+
+ const validFiles = newFiles.filter(file =>
+ !validationErrors.some(error => error.file === file)
+ );
+
+ setState(prev => ({
+ ...prev,
+ files: [...prev.files, ...validFiles],
+ selectedFiles: [...prev.selectedFiles, ...validFiles],
+ isValidating: false,
+ }));
+ }, [validateFiles]);
+
+ const removeFile = useCallback((index: number) => {
+ setState(prev => ({
+ ...prev,
+ files: prev.files.filter((_, i) => i !== index),
+ selectedFiles: prev.selectedFiles.filter((_, i) => i !== index),
+ }));
+ }, []);
+
+ const clearFiles = useCallback(() => {
+ setState(prev => ({
+ ...prev,
+ files: [],
+ selectedFiles: [],
+ uploadedImages: [],
+ currentBatch: null,
+ uploadProgress: 0,
+ error: null,
+ }));
+ }, []);
+
+ // Drag and drop handlers
+ const onDragEnter = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ dragCounter.current++;
+ if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
+ setState(prev => ({ ...prev, dragActive: true }));
+ }
+ }, []);
+
+ const onDragLeave = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ dragCounter.current--;
+ if (dragCounter.current === 0) {
+ setState(prev => ({ ...prev, dragActive: false }));
+ }
+ }, []);
+
+ const onDragOver = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ }, []);
+
+ const onDrop = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ setState(prev => ({ ...prev, dragActive: false }));
+ dragCounter.current = 0;
+
+ if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
+ const files = Array.from(e.dataTransfer.files);
+ addFiles(files);
+ }
+ }, [addFiles]);
+
+ const startUpload = useCallback(async (keywords: string[], batchName?: string) => {
+ if (state.files.length === 0) {
+ setError('No files selected for upload');
+ return;
+ }
+
+ try {
+ setState(prev => ({
+ ...prev,
+ isUploading: true,
+ uploadProgress: 0,
+ error: null
+ }));
+
+ // Create abort controller
+ uploadAbortController.current = new AbortController();
+
+ // Create batch
+ const batchData: BatchCreateRequest = {
+ name: batchName || `Batch ${new Date().toLocaleString()}`,
+ keywords,
+ };
+
+ const batch = await apiClient.createBatch(batchData);
+
+ setState(prev => ({ ...prev, currentBatch: batch }));
+
+ // Upload images with progress tracking
+ const uploadedImages = await apiClient.uploadImages(
+ state.files,
+ batch.id,
+ (progress) => {
+ setState(prev => ({ ...prev, uploadProgress: progress }));
+ }
+ );
+
+ setState(prev => ({
+ ...prev,
+ uploadedImages,
+ uploadProgress: 100,
+ isUploading: false,
+ }));
+
+ } catch (error) {
+ console.error('Upload failed:', error);
+
+ if (error instanceof Error) {
+ if (error.name === 'AbortError') {
+ setState(prev => ({ ...prev, isUploading: false, uploadProgress: 0 }));
+ } else {
+ setError(error.message);
+ }
+ } else {
+ setError('Upload failed. Please try again.');
+ }
+ } finally {
+ uploadAbortController.current = null;
+ }
+ }, [state.files, setError]);
+
+ const cancelUpload = useCallback(() => {
+ if (uploadAbortController.current) {
+ uploadAbortController.current.abort();
+ uploadAbortController.current = null;
+ }
+
+ setState(prev => ({
+ ...prev,
+ isUploading: false,
+ uploadProgress: 0,
+ }));
+ }, []);
+
+ const reset = useCallback(() => {
+ cancelUpload();
+ setState({
+ files: [],
+ selectedFiles: [],
+ uploadProgress: 0,
+ isUploading: false,
+ isValidating: false,
+ currentBatch: null,
+ uploadedImages: [],
+ error: null,
+ dragActive: false,
+ });
+ dragCounter.current = 0;
+ }, [cancelUpload]);
+
+ return {
+ ...state,
+ addFiles,
+ removeFile,
+ clearFiles,
+ onDragEnter,
+ onDragLeave,
+ onDragOver,
+ onDrop,
+ startUpload,
+ cancelUpload,
+ validateFiles,
+ clearError,
+ reset,
+ };
+}
\ No newline at end of file
diff --git a/packages/frontend/src/hooks/useWebSocket.ts b/packages/frontend/src/hooks/useWebSocket.ts
new file mode 100644
index 0000000..ed28d1e
--- /dev/null
+++ b/packages/frontend/src/hooks/useWebSocket.ts
@@ -0,0 +1,297 @@
+'use client';
+
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { Socket } from 'socket.io-client';
+import { apiClient } from '@/lib/api-client';
+import type { ProgressUpdate, Batch, Image, UserQuota, Subscription } from '@/types';
+
+interface WebSocketState {
+ isConnected: boolean;
+ isConnecting: boolean;
+ error: string | null;
+ reconnectAttempts: number;
+}
+
+interface UseWebSocketReturn extends WebSocketState {
+ connect: (userId?: string) => void;
+ disconnect: () => void;
+ subscribeToProgress: (batchId: string, callback: (update: ProgressUpdate) => void) => () => void;
+ subscribeToBatch: (batchId: string, callbacks: BatchCallbacks) => () => void;
+ subscribeToUser: (callbacks: UserCallbacks) => () => void;
+}
+
+interface BatchCallbacks {
+ onBatchUpdated?: (batch: Batch) => void;
+ onBatchCompleted?: (batch: Batch) => void;
+ onBatchFailed?: (batch: Batch) => void;
+ onImageProcessing?: (image: Image) => void;
+ onImageCompleted?: (image: Image) => void;
+ onImageFailed?: (image: Image) => void;
+ onProgress?: (update: ProgressUpdate) => void;
+}
+
+interface UserCallbacks {
+ onQuotaUpdated?: (quota: UserQuota) => void;
+ onSubscriptionUpdated?: (subscription: Subscription) => void;
+}
+
+const MAX_RECONNECT_ATTEMPTS = 5;
+const RECONNECT_INTERVAL = 5000;
+
+export function useWebSocket(): UseWebSocketReturn {
+ const [state, setState] = useState({
+ isConnected: false,
+ isConnecting: false,
+ error: null,
+ reconnectAttempts: 0,
+ });
+
+ const socketRef = useRef(null);
+ const reconnectTimeoutRef = useRef(null);
+ const subscriptionsRef = useRef