diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5e06e9d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,168 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Product Overview + +**Mirror Studio** is an AI-powered virtual modeling application that revolutionizes how fashion brands create marketing content. The application enables clothing brands to generate professional-quality product images featuring their garments on diverse AI-generated models for use across marketing channels, social media, and e-commerce platforms. + +### Problem It Solves +- **For Fashion Brands**: Eliminates the cost and time of traditional photoshoots while creating unlimited marketing content with diverse models +- **For Marketing Teams**: Provides instant access to professional product photography for social media campaigns, website showcases, and promotional materials +- **For E-commerce Operations**: Generates consistent, high-quality product imagery that can be produced on-demand without scheduling models or photographers + +### Key Features +- Upload multiple clothing items to create complete outfits +- Customize model appearance (gender, ethnicity, age, pose) +- Generate photorealistic virtual try-on images using Google's Gemini AI +- Secure image downloads with authentication +- Professional studio-quality backgrounds + +## Development Commands + +```bash +# Development server (with Turbopack) +bun dev +# or +npm run dev + +# Production build +bun run build +# or +npm run build + +# Start production server +bun start +# or +npm start + +# Type checking (no built-in script, add if needed) +bunx tsc --noEmit +# or +npx tsc --noEmit +``` + +## Architecture Overview + +### Tech Stack +- **Framework**: Next.js 15 with App Router and Turbopack +- **Authentication**: Clerk (managed auth with modal-based UI) +- **AI/ML**: Google Gemini API (gemini-2.5-flash-image-preview model) +- **Styling**: Tailwind CSS v4 +- **Type Safety**: TypeScript with strict mode +- **State Management**: useReducer pattern with typed actions + +### Directory Structure +``` +app/ +├── api/generate/route.ts # Protected API endpoint for Gemini +├── MirrorStudioApp.tsx # Main app component (client-side) +├── page.tsx # Dynamic import wrapper (SSR bypass) +└── layout.tsx # Root layout with Clerk provider + +components/ # Reusable UI components +hooks/ # Custom React hooks (useVirtualTryOn) +lib/ # Server utilities (Gemini integration) +services/ # Client-side services +types.ts # Centralized TypeScript definitions +``` + +### Key Architectural Patterns + +#### 1. Server-Side API Protection +The Gemini API key is NEVER exposed to the client. All AI operations go through `/api/generate/route.ts`: +```typescript +// Client makes request with base64 images +POST /api/generate → Server validates → Server calls Gemini → Returns generated image +``` + +#### 2. Authentication Flow +- Clerk handles all auth via modal dialogs (no dedicated auth pages) +- Download feature requires authentication (triggers sign-in modal if not authenticated) +- User authentication state checked via `useAuth()` and `useClerk()` hooks + +#### 3. State Management Pattern +Single reducer manages entire app state with typed actions: +- `AppState`: Complete application state +- `AppAction`: Union type of all possible actions +- State transitions are predictable and type-safe + +#### 4. Image Handling Pipeline +1. Client: File → Base64 encoding +2. Server: Validation (type, size, content) +3. Server: Base64 → Gemini API format +4. Server: Generated image → Data URL +5. Client: Secure download with authentication check + +## Testing Guidelines + +When implementing new features, always write unit tests for: +- State reducer logic +- Validation functions +- API route handlers +- Custom hooks +- Utility functions + +Use Base UI components when building new UI features to maintain consistency. + +## Important Implementation Details + +### Environment Variables +Required in `.env.local`: +- `GEMINI_API_KEY`: Google Gemini API key (server-side only) +- `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`: Clerk public key +- `CLERK_SECRET_KEY`: Clerk secret key + +### Security Considerations +- All file uploads are validated for type, size, and content +- API routes include rate limiting and error sanitization +- Object URLs are properly revoked to prevent memory leaks +- Download URLs are validated before execution + +### Component Patterns +- Use controlled components with dispatch actions +- Implement proper ARIA labels and keyboard navigation +- Follow the established pattern of separate step components (UploadStep, ConfigureStep, ResultsStep) +- Use dynamic imports for client-only components to avoid SSR issues + +### Type Safety Rules +- Never use `any` type +- All API responses must have defined types +- Component props must be explicitly typed +- Use discriminated unions for action types + +### Performance Optimizations +- Dynamic import for main app to bypass SSR +- Turbopack enabled for faster builds +- Image optimization via Next.js Image component +- Object URL cleanup on component unmount + +## Common Development Tasks + +### Adding a New Model Configuration Option +1. Update `ModelConfig` interface in `types.ts` +2. Add UI control in `ConfigPanel.tsx` +3. Update the prompt builder in `lib/gemini.ts` +4. Add validation if needed in `lib/validations.ts` + +### Creating a New API Endpoint +1. Create route file in `app/api/[endpoint]/route.ts` +2. Implement with proper error handling and validation +3. Never expose sensitive data or API keys +4. Add request/response types to `types.ts` + +### Implementing New UI Components +1. Create component in `components/` directory +2. Use Base UI components when possible +3. Include proper TypeScript props interface +4. Add ARIA labels and keyboard support +5. Follow existing Tailwind styling patterns +- use this as the color scheme throughout the app: #1A1A1A – Charcoal black (main text / background) + +#EAEAEA – Light grey (section backgrounds) + +#C1A57B – Warm beige (accent for buttons / highlights) + +#FFFFFF – White (primary contrast) +- use phosphor icons as the default package for all icons throughout the app +- add any generated .md files to @docs/ \ No newline at end of file diff --git a/app/MirrorStudioApp.tsx b/app/MirrorStudioApp.tsx index 005c7ce..aea3926 100644 --- a/app/MirrorStudioApp.tsx +++ b/app/MirrorStudioApp.tsx @@ -1,34 +1,33 @@ -'use client'; +"use client"; // React imports -import React from 'react'; +import React from "react"; // Third-party library imports -import { useAuth, useClerk } from '@clerk/nextjs'; +import { useAuth, useClerk } from "@clerk/nextjs"; // Hooks -import { useVirtualTryOn } from '@/hooks/useVirtualTryOn'; +import { useVirtualTryOn } from "@/hooks/useVirtualTryOn"; // Components -import { UploadPanel } from '@/components/UploadPanel'; -import { ConfigPanel } from '@/components/ConfigPanel'; -import { ResultsGallery } from '@/components/ResultsGallery'; -import { LoadingModal } from '@/components/LoadingModal'; -import { Toast } from '@/components/Toast'; +import { UploadPanel } from "@/components/UploadPanel"; +import { ConfigPanel } from "@/components/ConfigPanel"; +import { ResultsGallery } from "@/components/ResultsGallery"; +import { LoadingModal } from "@/components/LoadingModal"; +import { Toast } from "@/components/Toast"; import { ArrowRightIcon, ArrowLeftIcon, - SparklesIcon, - DownloadIcon, -} from '@/components/icons'; + SparkleIcon, + DownloadSimpleIcon, +} from "@phosphor-icons/react"; // Types -import { ClothingItem, AppState, AppAction } from '@/types'; +import { ClothingItem, AppState, AppAction } from "@/types"; // Utilities and libraries -import { clerkDebugger } from '@/utils/clerk-debug'; -import { secureDownload } from '@/lib/url-security'; -import { handleError } from '@/lib/error-handling'; +import { secureDownload } from "@/lib/url-security"; +import { handleError } from "@/lib/error-handling"; // Component prop types interface UploadStepProps { @@ -49,26 +48,35 @@ interface ResultsStepProps { const UploadStep = ({ clothingItems, dispatch }: UploadStepProps) => (
-
-

+
+

what do you want to showcase?

-

- Upload images of clothing items you want your model to wear. +

+ Upload images of clothing items (tops, pants, shoes) and create a studio + quality showcase image with realistic models.{" "}

+ + Not sure what to upload? +
{clothingItems.length > 0 && (
- + }} + className="w-10 h-10 cursor-pointer rounded-full shadow-lg hover:shadow-xl focus:outline-none transition duration-200 hover:brightness-95 flex items-center justify-center absolute left-0 btn-round-primary" + aria-label="Go back to clothing items upload step" + > +
+

+ Control how you want the image to look by defining your model, motive, and background. +

+ +
); }; @@ -141,10 +127,10 @@ const ResultsStep = ({ state, dispatch }: ResultsStepProps) => { const handleDownload = async () => { // Check if Clerk is loaded first if (!isLoaded) { - clerkDebugger.logWarning('Clerk not yet loaded when download attempted'); - dispatch({ - type: 'GENERATION_ERROR', - payload: 'Authentication is still loading. Please wait a moment and try again.' + dispatch({ + type: "GENERATION_ERROR", + payload: + "Authentication is still loading. Please wait a moment and try again.", }); return; } @@ -152,22 +138,20 @@ const ResultsStep = ({ state, dispatch }: ResultsStepProps) => { // Check if user is authenticated if (!userId || !isSignedIn) { try { - clerkDebugger.logInfo('Opening sign-in modal'); openSignIn(); } catch (error) { - clerkDebugger.logError('Failed to open sign-in modal', error); - + // Enhanced error handling with user-friendly messaging const userError = handleError(error, { - action: 'open_sign_in', + action: "open_sign_in", userId: userId, timestamp: new Date(), - userAgent: navigator.userAgent + userAgent: navigator.userAgent, }); - - dispatch({ - type: 'GENERATION_ERROR', - payload: userError.message + + dispatch({ + type: "GENERATION_ERROR", + payload: userError.message, }); } return; @@ -176,43 +160,45 @@ const ResultsStep = ({ state, dispatch }: ResultsStepProps) => { // User is authenticated, proceed with secure download const latestResult = state.results[0]; if (!latestResult) { - console.warn('No results available for download'); return; } try { const filename = `mirror-studio-${latestResult.id}.png`; - const downloadSuccess = await secureDownload(latestResult.imageUrl, filename); - + const downloadSuccess = await secureDownload( + latestResult.imageUrl, + filename + ); + if (!downloadSuccess) { // Use enhanced error handling for download failures const userError = handleError( - new Error('Download security validation failed'), + new Error("Download security validation failed"), { - action: 'secure_download', + action: "secure_download", userId: userId, timestamp: new Date(), - userAgent: navigator.userAgent + userAgent: navigator.userAgent, } ); - - dispatch({ - type: 'GENERATION_ERROR', - payload: userError.message + + dispatch({ + type: "GENERATION_ERROR", + payload: userError.message, }); } } catch (error) { // Enhanced error handling for download errors const userError = handleError(error, { - action: 'download_file', + action: "download_file", userId: userId, timestamp: new Date(), - userAgent: navigator.userAgent + userAgent: navigator.userAgent, }); - - dispatch({ - type: 'GENERATION_ERROR', - payload: userError.message + + dispatch({ + type: "GENERATION_ERROR", + payload: userError.message, }); } }; @@ -220,58 +206,61 @@ const ResultsStep = ({ state, dispatch }: ResultsStepProps) => { return (
-

+

here you go

-
+
- + {/* Screen reader hint for authentication requirement */} {!userId && (
- Authentication required to download images. Clicking this button will open the sign-in dialog. + Authentication required to download images. Clicking this button will + open the sign-in dialog.
)}
@@ -283,49 +272,58 @@ let clerkInitializationTested = false; export default function MirrorStudioApp() { const { state, dispatch, handleGenerate } = useVirtualTryOn(); - + // Optimized Clerk debugging in development - only run once per session React.useEffect(() => { - if (process.env.NODE_ENV === 'development' && !clerkInitializationTested) { - clerkDebugger.logInfo('MirrorStudioApp component mounted - first time'); + if (process.env.NODE_ENV === "development" && !clerkInitializationTested) { // Use setTimeout to avoid blocking the main thread const timeoutId = setTimeout(() => { - clerkDebugger.testClerkInitialization(); clerkInitializationTested = true; }, 100); - + // Cleanup function return () => clearTimeout(timeoutId); - } else if (process.env.NODE_ENV === 'development') { - clerkDebugger.logInfo('MirrorStudioApp component mounted - subsequent mount'); + } else if (process.env.NODE_ENV === "development") { } }, []); // Empty dependency array - only runs once const renderContent = () => { switch (state.currentStep) { - case 'upload': - return ; - case 'configure': - return ; - case 'results': + case "upload": + return ( + + ); + case "configure": + return ( + + ); + case "results": return ; default: - return ; + return ( + + ); } }; return ( -
-
- {renderContent()} -
- - +
+
{renderContent()}
+ + dispatch({ type: 'CLEAR_ERROR' })} + onDismiss={() => dispatch({ type: "CLEAR_ERROR" })} />
); -} \ No newline at end of file +} diff --git a/app/api/generate/route.ts b/app/api/generate/route.ts index 80c7e6d..8c49bed 100644 --- a/app/api/generate/route.ts +++ b/app/api/generate/route.ts @@ -56,8 +56,6 @@ export async function POST(request: NextRequest) { }); } catch (error) { - console.error('API Route Error:', error); - // Don't expose internal errors to client return NextResponse.json( { diff --git a/app/globals.css b/app/globals.css index 4729e92..16ea912 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,5 +1,19 @@ @import "tailwindcss"; +/* Custom color theme for Mirror Studio */ +@theme { + --color-charcoal: #1A1A1A; + --color-light-grey: #EAEAEA; + --color-warm-beige: #C1A57B; + --color-white: #FFFFFF; + + /* Custom colors */ + --color-custom-text: var(--color-charcoal); + --color-custom-background: var(--color-light-grey); + --color-custom-accent: var(--color-warm-beige); + --color-custom-contrast: var(--color-white); +} + /* Custom animations for the virtual try-on app */ @keyframes loading-bar { 0% { width: 0%; } @@ -7,6 +21,46 @@ 100% { width: 100%; } } +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes fade-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +@keyframes modal-fade-in { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes modal-fade-out { + from { + opacity: 1; + transform: scale(1); + } + to { + opacity: 0; + transform: scale(0.95); + } +} + @keyframes slide-in-from-bottom-2 { from { transform: translateY(8px); @@ -37,20 +91,20 @@ height: 20px; width: 20px; border-radius: 50%; - background: #4f46e5; + background: var(--color-warm-beige); cursor: pointer; - border: 2px solid #ffffff; - box-shadow: 0 0 0 1px #4f46e5; + border: 2px solid var(--color-white); + box-shadow: 0 0 0 1px var(--color-warm-beige); } .slider::-moz-range-thumb { height: 20px; width: 20px; border-radius: 50%; - background: #4f46e5; + background: var(--color-warm-beige); cursor: pointer; - border: 2px solid #ffffff; - box-shadow: 0 0 0 1px #4f46e5; + border: 2px solid var(--color-white); + box-shadow: 0 0 0 1px var(--color-warm-beige); } /* Smooth scrolling */ @@ -60,8 +114,8 @@ html { /* Focus styles for accessibility */ *:focus-visible { - outline: 2px solid #4f46e5; - outline-offset: 2px; + outline: 2px solid #adadad; + outline-offset: 1px; } /* Image optimization styles */ @@ -88,13 +142,13 @@ img { /* Drag and drop visual feedback */ .drag-over { - border-color: #4f46e5 !important; - background-color: rgba(79, 70, 229, 0.05) !important; + border-color: var(--color-warm-beige) !important; + background-color: color-mix(in srgb, var(--color-warm-beige) 5%, transparent) !important; } /* File upload progress */ .upload-progress { - background: linear-gradient(90deg, #4f46e5, #7c3aed); + background: linear-gradient(90deg, var(--color-warm-beige), color-mix(in srgb, var(--color-warm-beige) 80%, var(--color-charcoal))); border-radius: 999px; transition: width 0.3s ease; } @@ -105,6 +159,29 @@ img { h2 { font-size: 1.25rem; } } +/* Optimized Button Styles */ +.btn-primary-gradient { + background: linear-gradient(to bottom, var(--color-white), color-mix(in srgb, var(--color-white) 90%, var(--color-light-grey))); + border: 1px solid color-mix(in srgb, var(--color-light-grey) 70%, var(--color-charcoal)); +} + +.btn-secondary-gradient { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border: 1px solid color-mix(in srgb, #667eea 70%, var(--color-charcoal)); +} + +.btn-disabled { + background: color-mix(in srgb, var(--color-light-grey) 60%, var(--color-white)) !important; + color: color-mix(in srgb, var(--color-charcoal) 40%, transparent) !important; + cursor: not-allowed !important; + border: none !important; +} + +.btn-round-primary { + background: linear-gradient(to bottom, var(--color-white), color-mix(in srgb, var(--color-white) 90%, var(--color-light-grey))); + border: 1px solid color-mix(in srgb, var(--color-light-grey) 70%, var(--color-charcoal)); +} + /* Print styles */ @media print { .no-print { diff --git a/app/layout.tsx b/app/layout.tsx index 9ee7bc0..68d95f2 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,10 +1,7 @@ import type { Metadata } from "next"; -import { - ClerkProvider, - SignedIn, - UserButton, -} from "@clerk/nextjs"; +import { ClerkProvider } from "@clerk/nextjs"; import { Geist, Geist_Mono } from "next/font/google"; +import { Header } from "@/components/Header"; import { Footer } from "@/components/Footer"; import "./globals.css"; @@ -19,8 +16,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Mirror Studio - AI-Powered Content Creation", - description: "Create stunning content with AI assistance", + title: "Mirror Studio", + description: "Create professional-looking model photos for your clothing brand, instantly.", }; export default function RootLayout({ @@ -32,16 +29,13 @@ export default function RootLayout({
- -
- -
-
+
{children}
-
+ {/*
*/}
diff --git a/app/pricing/PricingContent.tsx b/app/pricing/PricingContent.tsx new file mode 100644 index 0000000..1726dba --- /dev/null +++ b/app/pricing/PricingContent.tsx @@ -0,0 +1,114 @@ +'use client'; + +import { PricingTable } from '@clerk/nextjs'; +import { useState, useEffect } from 'react'; + +export default function PricingContent() { + const [hasMounted, setHasMounted] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + setHasMounted(true); + }, []); + + if (!hasMounted) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+
+

+ Pricing Temporarily Unavailable +

+

+ We're experiencing technical difficulties with our pricing display. + Please contact us directly for current pricing information. +

+ + Contact Sales Team + +
+
+ ); + } + + try { + return ; + } catch (err) { + // If PricingTable fails to render, show fallback UI + return ( +
+
+

+ Custom Pricing Available +

+

+ Mirror Studio offers flexible pricing tailored to your brand's needs. + From startup packages to enterprise solutions, we have options that scale with your business. +

+ +
+
+

Starter

+

Perfect for small brands testing virtual try-on

+
    +
  • • 50 generations/month
  • +
  • • Basic model options
  • +
  • • Email support
  • +
+
+ +
+
+ Most Popular +
+

Professional

+

Ideal for growing fashion brands

+
    +
  • • 500 generations/month
  • +
  • • Advanced model customization
  • +
  • • Priority support
  • +
  • • API access
  • +
+
+ +
+

Enterprise

+

Unlimited usage for large operations

+
    +
  • • Unlimited generations
  • +
  • • Custom model training
  • +
  • • Dedicated account manager
  • +
  • • White-label options
  • +
+
+
+ + +
+
+ ); + } +} \ No newline at end of file diff --git a/app/pricing/page.tsx b/app/pricing/page.tsx index 68a14f9..3d6dc70 100644 --- a/app/pricing/page.tsx +++ b/app/pricing/page.tsx @@ -1,6 +1,18 @@ -import { PricingTable } from '@clerk/nextjs'; +'use client'; + import Link from 'next/link'; -import { ArrowLeftIcon } from '@/components/icons'; +import { ArrowLeftIcon } from '@phosphor-icons/react'; +import dynamic from 'next/dynamic'; + +// Dynamically import the pricing content to avoid SSR issues +const DynamicPricingContent = dynamic(() => import('./PricingContent'), { + ssr: false, + loading: () => ( +
+
+
+ ), +}); export default function PricingPage() { return ( @@ -13,6 +25,7 @@ export default function PricingPage() { className="inline-flex items-center gap-2 text-sm text-slate-600 hover:text-slate-900 transition-colors" > + Back to Home
@@ -26,9 +39,9 @@ export default function PricingPage() {

- {/* Pricing Table from Clerk */} + {/* Dynamic Pricing Content */}
- +
{/* Additional Info */} diff --git a/bun.lock b/bun.lock index 1fb3699..fb1c19e 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "dependencies": { "@clerk/nextjs": "^6.31.6", "@google/genai": "^1.16.0", + "@phosphor-icons/react": "^2.1.10", "next": "15.5.2", "react": "19.1.0", "react-dom": "19.1.0", @@ -113,6 +114,8 @@ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.2", "", { "os": "win32", "cpu": "x64" }, "sha512-W5VvyZHnxG/2ukhZF/9Ikdra5fdNftxI6ybeVKYvBPDtyx7x4jPPSNduUkfH5fo3zG0JQ0bPxgy41af2JX5D4Q=="], + "@phosphor-icons/react": ["@phosphor-icons/react@2.1.10", "", { "peerDependencies": { "react": ">= 16.8", "react-dom": ">= 16.8" } }, "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA=="], + "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], diff --git a/components/AgeSlider.tsx b/components/AgeSlider.tsx index afedd97..3bf9b7f 100644 --- a/components/AgeSlider.tsx +++ b/components/AgeSlider.tsx @@ -7,41 +7,33 @@ interface AgeSliderProps { onChange: (value: string) => void; } -const ageRanges = ['18-24', '25-34', '35-44', '45-54', '55+']; +const ageRanges = ['20-24', '25-29', '30-34', '35-39', '40-44', '45-49', '50-54', '55-59', '60-64', '65-69', '70+']; export function AgeSlider({ value, onChange }: AgeSliderProps): React.JSX.Element { - const currentIndex = ageRanges.indexOf(value); - - const handleSliderChange = (e: React.ChangeEvent) => { - const index = parseInt(e.target.value); - onChange(ageRanges[index]); - }; - return (
- -
- -
- {ageRanges.map((range, index) => ( - - {range} - - ))} -
+ +
+ {ageRanges.map((range) => ( + + ))}
); diff --git a/components/BackgroundPresetCards.tsx b/components/BackgroundPresetCards.tsx new file mode 100644 index 0000000..92a9ad3 --- /dev/null +++ b/components/BackgroundPresetCards.tsx @@ -0,0 +1,180 @@ +'use client'; + +import React, { useRef, useState } from 'react'; +import Image from 'next/image'; +import { UploadSimpleIcon, PencilIcon } from '@phosphor-icons/react'; +import { BackgroundPreset, AppAction } from '@/types'; +import { validateImageFile } from '@/lib/validations'; + +interface BackgroundPresetCardsProps { + value: BackgroundPreset; + onChange: (value: BackgroundPreset) => void; + customImageUrl?: string; + dispatch: React.Dispatch; +} + +const backgroundOptions: { + value: BackgroundPreset; + label: string; + image: string; +}[] = [ + { + value: 'Studio', + label: 'Studio', + image: '/images/motive-studio.png' + }, + { + value: 'Low Angle', + label: 'Low Angle', + image: '/images/motive-low-angle.jpeg' + } +]; + +export function BackgroundPresetCards({ value, onChange, customImageUrl, dispatch }: BackgroundPresetCardsProps): React.JSX.Element { + const fileInputRef = useRef(null); + const [error, setError] = useState(null); + + const handleCustomImageUpload = (files: FileList | null) => { + if (!files || files.length === 0) return; + + const file = files[0]; + const validation = validateImageFile(file); + + if (!validation.valid) { + setError(validation.error || 'Invalid file'); + return; + } + + const objectUrl = URL.createObjectURL(file); + dispatch({ + type: 'SET_CUSTOM_BACKGROUND', + payload: { file, objectUrl } + }); + onChange('Custom'); + setError(null); + }; + + const handleCustomClick = () => { + if (!customImageUrl) { + // No image uploaded yet, trigger upload + fileInputRef.current?.click(); + } else if (value === 'Custom') { + // Already selected, trigger re-upload/edit + fileInputRef.current?.click(); + } else { + // Image exists but not selected, select it + onChange('Custom'); + } + }; + return ( +
+ {/* Main Motive label - same style as Gender/Age/Look */} + + + {error && ( +
+ {error} +
+ )} + +
+
+ {/* Custom Upload Option */} + + + {backgroundOptions.map((option) => ( + + ))} +
+
+
+ ); +} \ No newline at end of file diff --git a/components/BackgroundSelectionCards.tsx b/components/BackgroundSelectionCards.tsx new file mode 100644 index 0000000..19d6bd1 --- /dev/null +++ b/components/BackgroundSelectionCards.tsx @@ -0,0 +1,178 @@ +'use client'; + +import React, { useRef, useState } from 'react'; +import Image from 'next/image'; +import { UploadSimpleIcon, PencilIcon } from '@phosphor-icons/react'; +import { AppAction } from '@/types'; +import { validateImageFile } from '@/lib/validations'; + +export type BackgroundSelectionPreset = '' | 'Custom' | 'Plain White'; + +interface BackgroundSelectionCardsProps { + value: BackgroundSelectionPreset; + onChange: (value: BackgroundSelectionPreset) => void; + customImageUrl?: string; + dispatch: React.Dispatch; +} + +const backgroundOptions: { + value: BackgroundSelectionPreset; + label: string; + image: string; +}[] = [ + { + value: 'Plain White', + label: 'Plain White', + image: '/images/bg-plain-white.png' + } +]; + +export function BackgroundSelectionCards({ value, onChange, customImageUrl, dispatch }: BackgroundSelectionCardsProps): React.JSX.Element { + const fileInputRef = useRef(null); + const [error, setError] = useState(null); + + const handleCustomImageUpload = (files: FileList | null) => { + if (!files || files.length === 0) return; + + const file = files[0]; + const validation = validateImageFile(file); + + if (!validation.valid) { + setError(validation.error || 'Invalid file'); + return; + } + + const objectUrl = URL.createObjectURL(file); + dispatch({ + type: 'SET_CUSTOM_BACKGROUND_SELECTION', + payload: { file, objectUrl } + }); + onChange('Custom'); + setError(null); + }; + + const handleCustomClick = () => { + if (!customImageUrl) { + // No image uploaded yet, trigger upload + fileInputRef.current?.click(); + } else if (value === 'Custom') { + // Already selected, trigger re-upload/edit + fileInputRef.current?.click(); + } else { + // Image exists but not selected, select it + onChange('Custom'); + } + }; + + return ( +
+ {/* Main Background label - same style as Gender/Age/Look */} + + + {error && ( +
+ {error} +
+ )} + +
+
+ {/* Custom Upload Option */} + + + {backgroundOptions.map((option) => ( + + ))} +
+
+
+ ); +} \ No newline at end of file diff --git a/components/Button.tsx b/components/Button.tsx new file mode 100644 index 0000000..87e2fc5 --- /dev/null +++ b/components/Button.tsx @@ -0,0 +1,66 @@ +"use client"; + +import React from "react"; + +interface ButtonProps { + children: React.ReactNode; + onClick?: () => void; + type?: "button" | "submit" | "reset"; + variant?: "primary" | "secondary"; + size?: "sm" | "md" | "lg"; + centered?: boolean; + className?: string; + disabled?: boolean; + ariaLabel?: string; +} + +export function Button({ + children, + onClick, + type = "button", + variant = "primary", + size = "md", + centered = false, + className = "", + disabled = false, + ariaLabel, +}: ButtonProps): React.JSX.Element { + const baseClasses = "font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2"; + + const variantClasses = { + primary: "bg-gradient-to-b from-slate-800 to-slate-900 text-white rounded-lg hover:from-slate-700 hover:to-slate-800 focus:ring-slate-500", + secondary: "bg-white text-slate-700 border border-slate-300 rounded-lg hover:bg-slate-50 focus:ring-slate-500", + }; + + const sizeClasses = { + sm: "px-4 py-2 text-sm", + md: "px-6 py-3 text-base", + lg: "px-8 py-4 text-lg", + }; + + const disabledClasses = disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"; + + const buttonClasses = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${disabledClasses} ${className}`; + + const button = ( + + ); + + if (centered) { + return ( +
+ {button} +
+ ); + } + + return button; +} diff --git a/components/CollapsibleSection.tsx b/components/CollapsibleSection.tsx index 38232e1..d36edea 100644 --- a/components/CollapsibleSection.tsx +++ b/components/CollapsibleSection.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; -import { ChevronDownIcon } from './icons'; +import { CaretDownIcon } from '@phosphor-icons/react'; interface CollapsibleSectionProps { title: string; @@ -19,7 +19,7 @@ export function CollapsibleSection({ title, children, initialOpen = true }: Coll aria-expanded={isOpen} >

{title}

- diff --git a/components/ConfigPanel.tsx b/components/ConfigPanel.tsx index 0920770..7d18711 100644 --- a/components/ConfigPanel.tsx +++ b/components/ConfigPanel.tsx @@ -1,15 +1,18 @@ 'use client'; -import React from 'react'; +import React, { useEffect, useRef } from 'react'; import { RadioPillGroup } from './RadioPillGroup'; import { DropdownSelect } from './DropdownSelect'; -import { AgeSlider } from './AgeSlider'; import { TextInput } from './TextInput'; +import { BackgroundPresetCards } from './BackgroundPresetCards'; +import { BackgroundSelectionCards } from './BackgroundSelectionCards'; import { AppState, AppAction } from '@/types'; +import { SparkleIcon } from '@phosphor-icons/react'; interface ConfigPanelProps { state: AppState; dispatch: React.Dispatch; + onGenerate?: () => void; } const ethnicityOptions = [ @@ -23,78 +26,363 @@ const ethnicityOptions = [ 'Mixed' ]; -const backgroundOptions = ['Plain White', 'Studio Gray']; +interface StepProps { + stepNumber: number; + title?: string; + isActive: boolean; + isCompleted: boolean; + children: React.ReactNode; +} + +function ConfigStep({ stepNumber, title, isActive, isCompleted, children }: StepProps): React.JSX.Element | null { + // Show full component for both active and completed steps + return ( +
+ {title &&

{title}

} + {children} +
+ ); +} + +export function ConfigPanel({ state, dispatch, onGenerate }: ConfigPanelProps): React.JSX.Element { + const { currentConfigStep, completedConfigSteps, viewedConfigSteps, hasShownGenerateButton } = state; + const previousVisibilityRef = useRef<{[key: number]: boolean}>({}); + const isInitialLoadRef = useRef(true); + + const autoAdvanceStep = (stepNumber: number) => { + // Auto-complete current step and advance to next if not already there + if (currentConfigStep === stepNumber && stepNumber <= 7) { + dispatch({ type: 'COMPLETE_CONFIG_STEP', payload: stepNumber }); + } + }; + + const isStepCompleted = (stepNumber: number) => completedConfigSteps.includes(stepNumber); + const isStepActive = (stepNumber: number) => currentConfigStep === stepNumber; + const isStepViewed = (stepNumber: number) => viewedConfigSteps.includes(stepNumber); + const isStepVisible = (stepNumber: number) => { + // Step 1 is always visible + if (stepNumber === 1) return true; + // Other steps are visible if previous step has a value + switch (stepNumber) { + case 2: return !!state.model.gender; + case 3: return !!state.model.ageRange; + case 4: return !!state.model.ethnicity; // Motive step - show when ethnicity is selected + case 5: return !!(state.background.preset || state.background.description || state.background.backgroundSelection || isStepViewed(5)); // Background selection step - show when motive is selected (preset OR description) OR when background selection already exists OR when step has been viewed before + case 6: return !!(state.background.backgroundSelection || state.background.backgroundSelectionDescription || hasShownGenerateButton); // Background details step visible when a selection OR description exists OR generate button has been shown + default: return false; + } + }; + + const isGenerateReady = (): boolean => { + return !!( + state.model.gender && + state.model.ageRange && + state.model.ethnicity && + (state.background.preset || state.background.description) && + (state.background.backgroundSelection || state.background.backgroundSelectionDescription) + ); + }; + + // Mark Step 5 as viewed when it becomes visible + useEffect(() => { + const shouldShowStep5 = !!(state.background.preset || state.background.description || state.background.backgroundSelection); + if (shouldShowStep5 && !isStepViewed(5)) { + dispatch({ type: 'MARK_CONFIG_STEP_VIEWED', payload: 5 }); + } + }, [state.background.preset, state.background.description, state.background.backgroundSelection, isStepViewed, dispatch]); + + // Mark generate button as shown when Step 6 first becomes visible + useEffect(() => { + const shouldShowStep6 = !!(state.background.backgroundSelection || state.background.backgroundSelectionDescription); + if (shouldShowStep6 && !hasShownGenerateButton) { + dispatch({ type: 'SET_GENERATE_BUTTON_SHOWN' }); + } + }, [state.background.backgroundSelection, state.background.backgroundSelectionDescription, hasShownGenerateButton, dispatch]); + + // Auto-scroll to newly visible steps + useEffect(() => { + // Skip auto-scroll on initial load + if (isInitialLoadRef.current) { + // Initialize previous visibility state + for (let i = 1; i <= 6; i++) { + previousVisibilityRef.current[i] = isStepVisible(i); + } + isInitialLoadRef.current = false; + return; + } + + // Check each step for visibility changes + for (let stepNumber = 1; stepNumber <= 6; stepNumber++) { + const isCurrentlyVisible = isStepVisible(stepNumber); + const wasPreviouslyVisible = previousVisibilityRef.current[stepNumber] || false; + + // If step just became visible, scroll to it + if (isCurrentlyVisible && !wasPreviouslyVisible) { + const stepElement = document.getElementById(`config-step-${stepNumber}`); + if (stepElement) { + // Small delay to ensure DOM updates are complete + setTimeout(() => { + stepElement.scrollIntoView({ + behavior: 'smooth', + block: 'start', + inline: 'nearest' + }); + }, 100); + } + break; // Only scroll to the first newly visible step + } + } + + // Update previous visibility state + for (let i = 1; i <= 6; i++) { + previousVisibilityRef.current[i] = isStepVisible(i); + } + }); -export function ConfigPanel({ state, dispatch }: ConfigPanelProps): React.JSX.Element { return (
-
-

Model Configuration

-
- dispatch({ - type: 'SET_MODEL_CONFIG', - payload: { gender: value as 'Female' | 'Male' | 'Non-binary' } - })} - /> - - dispatch({ - type: 'SET_MODEL_CONFIG', - payload: { ethnicity: value } - })} - /> - - dispatch({ - type: 'SET_MODEL_CONFIG', - payload: { ageRange: value } - })} - /> - - dispatch({ - type: 'SET_MODEL_CONFIG', - payload: { pose: value } - })} - placeholder="Describe the model's pose (e.g., standing with hands on hips, sitting casually)" - /> + {/* Step 1: Gender */} + {isStepVisible(1) && ( +
+ + { + dispatch({ + type: 'SET_MODEL_CONFIG', + payload: { gender: value as 'Female' | 'Male' | 'Non-binary' } + }); + autoAdvanceStep(1); + }} + fullWidth={true} + /> + +
+ )} + + {/* Step 2: Age */} + {isStepVisible(2) && ( +
+ + { + dispatch({ + type: 'SET_MODEL_CONFIG', + payload: { ageRange: value } + }); + autoAdvanceStep(2); + }} + fullWidth={true} + /> +
-
- -
-

Background

-
- dispatch({ - type: 'SET_BACKGROUND_CONFIG', - payload: { preset: value as 'Plain White' | 'Studio Gray' } - })} - /> - - dispatch({ - type: 'SET_BACKGROUND_CONFIG', - payload: { description: value } - })} - placeholder="Describe additional background details or mood (e.g., soft lighting, outdoor setting)" - /> + )} + + {/* Step 3: Look (Combined Ethnicity and Additional Details) */} + {isStepVisible(3) && ( +
+ +
+ +
+ { + dispatch({ + type: 'SET_MODEL_CONFIG', + payload: { ethnicity: value } + }); + autoAdvanceStep(3); + }} + /> + { + dispatch({ + type: 'SET_MODEL_CONFIG', + payload: { lookDetails: value } + }); + }} + placeholder="curly hair, full beard, skinny" + /> +
+
+
+
+ )} + + {/* Step 4: Motive/Picture Preset */} + {isStepVisible(4) && ( +
+ + { + dispatch({ + type: 'SET_BACKGROUND_CONFIG', + payload: { preset: value } + }); + autoAdvanceStep(4); + }} + customImageUrl={state.background.customImageUrl} + dispatch={dispatch} + /> +
+ { + dispatch({ + type: 'SET_BACKGROUND_CONFIG', + payload: { + description: value, + ...(value && state.background.preset ? { preset: '' } : {}) + } + }); + // Auto-advance step when user types (like selecting a preset) + if (value) { + autoAdvanceStep(4); + } + }} + placeholder="sitting with legs crossed on a wooden chair" + isGreyedOut={!!state.background.preset} + onFocus={() => { + if (state.background.preset) { + dispatch({ + type: 'SET_BACKGROUND_CONFIG', + payload: { preset: '' } + }); + } + }} + /> +
+
-
+ )} + + {/* Step 5: Background Selection */} + {isStepVisible(5) && ( +
+ + { + dispatch({ + type: 'SET_BACKGROUND_CONFIG', + payload: { backgroundSelection: value } + }); + autoAdvanceStep(5); + }} + customImageUrl={state.background.customBackgroundSelectionImageUrl} + dispatch={dispatch} + /> +
+ { + dispatch({ + type: 'SET_BACKGROUND_CONFIG', + payload: { + backgroundSelectionDescription: value, + ...(value && state.background.backgroundSelection ? { backgroundSelection: '' } : {}) + } + }); + // Auto-advance step when user types (like selecting a preset) + if (value) { + autoAdvanceStep(5); + } + }} + placeholder="bright studio lighting, marble floor" + isGreyedOut={!!state.background.backgroundSelection} + onFocus={() => { + if (state.background.backgroundSelection) { + dispatch({ + type: 'SET_BACKGROUND_CONFIG', + payload: { backgroundSelection: '' } + }); + } + }} + /> +
+
+
+ )} + + {/* Step 6: Final Details */} + {isStepVisible(6) && ( +
+ +
+ +
+
+
+ )} +
); -} \ No newline at end of file +} diff --git a/components/DropdownSelect.tsx b/components/DropdownSelect.tsx index 06e5208..9449f22 100644 --- a/components/DropdownSelect.tsx +++ b/components/DropdownSelect.tsx @@ -1,16 +1,17 @@ 'use client'; import React, { useState, useRef, useEffect } from 'react'; -import { ChevronDownIcon } from './icons'; +import { CaretDownIcon } from '@phosphor-icons/react'; interface DropdownSelectProps { label: string; value: string; options: string[]; onChange: (value: string) => void; + placeholder?: string; } -export function DropdownSelect({ label, value, options, onChange }: DropdownSelectProps): React.JSX.Element { +export function DropdownSelect({ label, value, options, onChange, placeholder }: DropdownSelectProps): React.JSX.Element { const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); @@ -30,18 +31,24 @@ export function DropdownSelect({ label, value, options, onChange }: DropdownSele {isOpen && ( -
+
{options.map((option) => (
diff --git a/components/Header.tsx b/components/Header.tsx new file mode 100644 index 0000000..d5e7e67 --- /dev/null +++ b/components/Header.tsx @@ -0,0 +1,72 @@ +"use client"; + +import Link from "next/link"; +import { SignInButton, SignedIn, SignedOut, UserButton } from "@clerk/nextjs"; + +export function Header() { + return ( +
+
+
+ {/* Logo Section */} +
+ +
+ Mirror Studio +
+ +
+ + {/* Navigation Section */} +
+ + +
+ + +
+
+
+ + + + +
+
+
+
+ ); +} diff --git a/components/LoadingModal.tsx b/components/LoadingModal.tsx index 02e53bc..3ce48f9 100644 --- a/components/LoadingModal.tsx +++ b/components/LoadingModal.tsx @@ -1,25 +1,36 @@ -'use client'; +"use client"; // React imports -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef } from "react"; // Components -import { SparklesIcon } from './icons'; +import { SpinnerIcon } from "@phosphor-icons/react"; + +// Types +import { ModelConfig, BackgroundConfig } from "@/types"; interface LoadingModalProps { isOpen: boolean; + modelConfig: ModelConfig; + backgroundConfig: BackgroundConfig; onCancel?: () => void; // Optional cancel callback for future use } const loadingMessages = [ - "Analyzing clothing items...", - "Creating virtual model...", - "Applying fashion magic...", - "Perfecting the look...", - "Almost ready..." + "Your image will be ready in a few seconds...", + // "Analyzing clothing items...", + // "Creating virtual model...", + // "Applying fashion magic...", + // "Perfecting the look...", + // "Almost ready...", ]; -export function LoadingModal({ isOpen, onCancel }: LoadingModalProps): React.JSX.Element | null { +export function LoadingModal({ + isOpen, + modelConfig, + backgroundConfig, + onCancel, +}: LoadingModalProps): React.JSX.Element | null { const [messageIndex, setMessageIndex] = useState(0); const modalRef = useRef(null); const previousFocusRef = useRef(null); @@ -29,12 +40,12 @@ export function LoadingModal({ isOpen, onCancel }: LoadingModalProps): React.JSX if (isOpen) { // Store the previously focused element previousFocusRef.current = document.activeElement as HTMLElement; - + // Focus the modal const focusModal = () => { modalRef.current?.focus(); }; - + // Use requestAnimationFrame to ensure the modal is rendered requestAnimationFrame(focusModal); } else { @@ -64,26 +75,28 @@ export function LoadingModal({ isOpen, onCancel }: LoadingModalProps): React.JSX const handleKeyDown = (event: KeyboardEvent) => { // Escape key to cancel (if onCancel is provided) - if (event.key === 'Escape' && onCancel) { + if (event.key === "Escape" && onCancel) { onCancel(); return; } - + // Trap focus within modal - if (event.key === 'Tab') { + if (event.key === "Tab") { const focusableElements = modalRef.current?.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); - + if (!focusableElements || focusableElements.length === 0) { // No focusable elements, prevent tabbing event.preventDefault(); return; } - + const firstElement = focusableElements[0] as HTMLElement; - const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement; - + const lastElement = focusableElements[ + focusableElements.length - 1 + ] as HTMLElement; + if (event.shiftKey) { // Shift + Tab: move focus backward if (document.activeElement === firstElement) { @@ -100,68 +113,123 @@ export function LoadingModal({ isOpen, onCancel }: LoadingModalProps): React.JSX } }; - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); }, [isOpen, onCancel]); if (!isOpen) return null; return ( -
-
-
-
- -
- + {/* Header with title and spinner - centered */} +
+ +

+ Generating +

+
+ + {/* Input Details */} +
+ {modelConfig.gender && ( +
+ Gender: + + {modelConfig.gender} + +
+ )} + {modelConfig.ageRange && ( +
+ Age: + + {modelConfig.ageRange} +
+ )} + {modelConfig.ethnicity && ( +
+ Look: + + {modelConfig.ethnicity} {modelConfig.lookDetails && `(Custom)`} + +
+ )} +
+ Motive: + + {(backgroundConfig.preset || backgroundConfig.description || "") + .length > 30 + ? `${( + backgroundConfig.preset || + backgroundConfig.description || + "" + ).substring(0, 30)}...` + : backgroundConfig.preset || backgroundConfig.description} +
-

- Generating Your Look -

-

+ Background: + + {( + backgroundConfig.backgroundSelection || + backgroundConfig.backgroundSelectionDescription || + "" + ).length > 30 + ? `${( + backgroundConfig.backgroundSelection || + backgroundConfig.backgroundSelectionDescription || + "" + ).substring(0, 30)}...` + : backgroundConfig.backgroundSelection || + backgroundConfig.backgroundSelectionDescription} + +

+
+ + {/* Bottom Text */} +
+

- {loadingMessages[messageIndex]} + {loadingMessages[0]}

-
-
-
- {onCancel && ( - - )}
+ + {/* {onCancel && ( + + )} */}
); -} \ No newline at end of file +} diff --git a/components/RadioPillGroup.tsx b/components/RadioPillGroup.tsx index 2bab636..ed26eaa 100644 --- a/components/RadioPillGroup.tsx +++ b/components/RadioPillGroup.tsx @@ -7,24 +7,65 @@ interface RadioPillGroupProps { options: string[]; value: string; onChange: (value: string) => void; + fullWidth?: boolean; + columns?: number; } -export function RadioPillGroup({ label, options, value, onChange }: RadioPillGroupProps): React.JSX.Element { +export function RadioPillGroup({ label, options, value, onChange, fullWidth = false, columns }: RadioPillGroupProps): React.JSX.Element { + // Determine responsive grid classes based on options count + const getResponsiveGridClasses = () => { + if (!fullWidth) return "flex flex-wrap gap-2"; + + const optionCount = options.length; + + // For 3 options (Gender): stack on mobile, 2 cols on sm, 3 cols on md+ + if (optionCount === 3) { + return "grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3"; + } + + // For 5 options (Age): 2 cols on mobile, 3 on sm, 5 on md+ + if (optionCount === 5) { + return "grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3"; + } + + // For other counts, use sensible defaults + if (optionCount <= 2) { + return "grid grid-cols-1 sm:grid-cols-2 gap-3"; + } + + if (optionCount === 4) { + return "grid grid-cols-2 md:grid-cols-4 gap-3"; + } + + // For 6+ options, use 2-3-6 pattern + return "grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-3"; + }; + return ( -
- -
+
+ {label && } +
{options.map((option) => ( -
+ {/*
Item {index + 1} -
+
*/}
))}
)} - -

- {clothingItems.length} of {MAX_CLOTHING_ITEMS} items uploaded -

); -} \ No newline at end of file +} diff --git a/components/icons.tsx b/components/icons.tsx deleted file mode 100644 index aba7cbf..0000000 --- a/components/icons.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React from 'react'; - -interface IconProps { - className?: string; - onClick?: () => void; -} - -export const UploadIcon: React.FC = ({ className = "w-6 h-6" }) => ( - - - -); - -export const CloseIcon: React.FC = ({ className = "w-6 h-6", onClick }) => ( - - - -); - -export const ArrowRightIcon: React.FC = ({ className = "w-6 h-6" }) => ( - - - -); - -export const ArrowLeftIcon: React.FC = ({ className = "w-6 h-6" }) => ( - - - -); - -export const SparklesIcon: React.FC = ({ className = "w-6 h-6" }) => ( - - - -); - -export const RerollIcon: React.FC = ({ className = "w-6 h-6" }) => ( - - - -); - -export const DownloadIcon: React.FC = ({ className = "w-6 h-6" }) => ( - - - -); - -export const ChevronDownIcon: React.FC = ({ className = "w-6 h-6" }) => ( - - - -); - -export const CheckIcon: React.FC = ({ className = "w-6 h-6" }) => ( - - - -); \ No newline at end of file diff --git a/hooks/useVirtualTryOn.ts b/hooks/useVirtualTryOn.ts index 96b2823..baba85e 100644 --- a/hooks/useVirtualTryOn.ts +++ b/hooks/useVirtualTryOn.ts @@ -14,19 +14,25 @@ import { fileToBase64 } from '@/lib/validations'; const initialState: AppState = { clothingItems: [], model: { - gender: 'Female', - ethnicity: 'Random', - ageRange: '25-34', - pose: 'standing naturally', + gender: '', + ethnicity: '', + ageRange: '', + pose: '', + lookDetails: '', }, background: { - preset: 'Plain White', + preset: '', description: '', + backgroundSelection: '', }, generationStatus: 'idle', results: [], error: null, currentStep: 'upload', + currentConfigStep: 1, + completedConfigSteps: [], + viewedConfigSteps: [], + hasShownGenerateButton: false, }; function appReducer(state: AppState, action: AppAction): AppState { @@ -55,6 +61,34 @@ function appReducer(state: AppState, action: AppAction): AppState { return { ...state, model: { ...state.model, ...action.payload } }; case 'SET_BACKGROUND_CONFIG': return { ...state, background: { ...state.background, ...action.payload } }; + case 'SET_CUSTOM_BACKGROUND': + // Clean up previous custom image URL if it exists + if (state.background.customImageUrl) { + URL.revokeObjectURL(state.background.customImageUrl); + } + return { + ...state, + background: { + ...state.background, + preset: 'Custom', + customImage: action.payload.file, + customImageUrl: action.payload.objectUrl + } + }; + case 'SET_CUSTOM_BACKGROUND_SELECTION': + // Clean up previous custom background selection image URL if it exists + if (state.background.customBackgroundSelectionImageUrl) { + URL.revokeObjectURL(state.background.customBackgroundSelectionImageUrl); + } + return { + ...state, + background: { + ...state.background, + backgroundSelection: 'Custom', + customBackgroundSelectionImage: action.payload.file, + customBackgroundSelectionImageUrl: action.payload.objectUrl + } + }; case 'GENERATION_START': return { ...state, generationStatus: 'loading', error: null }; case 'GENERATION_SUCCESS': @@ -70,9 +104,48 @@ function appReducer(state: AppState, action: AppAction): AppState { return { ...state, error: null }; case 'SET_STEP': return { ...state, currentStep: action.payload }; + case 'SET_CONFIG_STEP': + return { ...state, currentConfigStep: action.payload }; + case 'COMPLETE_CONFIG_STEP': { + const stepNumber = action.payload; + if (state.completedConfigSteps.includes(stepNumber)) { + return state; + } + return { + ...state, + completedConfigSteps: [...state.completedConfigSteps, stepNumber], + currentConfigStep: stepNumber + 1 + }; + } + case 'MARK_CONFIG_STEP_VIEWED': { + const stepNumber = action.payload; + if (state.viewedConfigSteps.includes(stepNumber)) { + return state; + } + return { + ...state, + viewedConfigSteps: [...state.viewedConfigSteps, stepNumber] + }; + } + case 'RESET_CONFIG_STEPS_FROM': { + const fromStep = action.payload; + return { + ...state, + completedConfigSteps: state.completedConfigSteps.filter(step => step < fromStep), + currentConfigStep: fromStep + }; + } + case 'SET_GENERATE_BUTTON_SHOWN': + return { ...state, hasShownGenerateButton: true }; case 'RESET': // Revoke object URLs before resetting state.clothingItems.forEach(item => URL.revokeObjectURL(item.objectUrl)); + if (state.background.customImageUrl) { + URL.revokeObjectURL(state.background.customImageUrl); + } + if (state.background.customBackgroundSelectionImageUrl) { + URL.revokeObjectURL(state.background.customBackgroundSelectionImageUrl); + } return { ...initialState }; default: return state; diff --git a/lib/error-handling.ts b/lib/error-handling.ts index a979196..d849ea9 100644 --- a/lib/error-handling.ts +++ b/lib/error-handling.ts @@ -125,13 +125,7 @@ export function logErrorWithContext( stack: process.env.NODE_ENV === 'development' && error instanceof Error ? error.stack : undefined }; - if (userFriendlyError.severity === 'high') { - console.error('High severity error:', logData); - } else if (userFriendlyError.severity === 'medium') { - console.warn('Medium severity error:', logData); - } else { - console.log('Low severity error:', logData); - } + // Error logging removed for production } /** diff --git a/lib/gemini.ts b/lib/gemini.ts index 9c67928..84f11b2 100644 --- a/lib/gemini.ts +++ b/lib/gemini.ts @@ -20,35 +20,54 @@ export async function generateVirtualTryOn({ backgroundConfig }: GenerateImageParams): Promise { - // Build the prompt + // Build the prompt with comprehensive configuration inputs const clothingItemDescriptions = clothingImages .map((_, index) => `- Clothing item #${index + 1} (see attached image)`) .join('\n'); - - const backgroundDescription = `a ${backgroundConfig.preset.toLowerCase()} studio background`; + // Build comprehensive prompt incorporating all configuration inputs const prompt = ` - Create a photorealistic image of a virtual try-on session. +Create a photorealistic, high-quality image of a virtual fashion try-on session with professional photography standards. + +**Model Specifications:** +- Gender: ${modelConfig.gender} +- Age: ${modelConfig.ageRange} years old +- Ethnicity: ${modelConfig.ethnicity}${modelConfig.lookDetails ? ` +- Additional Details: ${modelConfig.lookDetails}` : ''} + +**Pose and Positioning:** +${backgroundConfig.description || modelConfig.pose || 'Natural standing pose suitable for fashion catalog photography, with confident posture and natural expression'} - **Model Details:** - - Gender: ${modelConfig.gender} - - Age Range: ${modelConfig.ageRange} - - Ethnicity: ${modelConfig.ethnicity} +**Clothing Items:** +The model is wearing the following garments, provided as separate images. Ensure realistic fitting, proper draping, and natural integration: +${clothingItemDescriptions} - **Pose:** - - The model should be in a natural standing pose, suitable for a fashion catalog. - - The model should have a natural hairstyle and body type appropriate for the garments. +**Background and Setting:** +- Primary Setting: ${backgroundConfig.preset.toLowerCase()} style${backgroundConfig.backgroundSelection ? ` +- Background Type: ${backgroundConfig.backgroundSelection}` : ''}${backgroundConfig.backgroundSelectionDescription ? ` +- Background Details: ${backgroundConfig.backgroundSelectionDescription}` : ''}${backgroundConfig.description ? ` +- Scene Description: ${backgroundConfig.description}` : ''} - **Clothing Items:** - The model is wearing the following items, provided as separate images. Please combine them realistically onto the model. - ${clothingItemDescriptions} +**Photography Standards:** +- Professional studio lighting with even, flattering illumination +- High-resolution, sharp focus throughout +- Fashion photography composition and framing +- Natural skin tones and fabric textures +- Proper color balance and contrast - **Scene:** - - The model should be against ${backgroundDescription}. - - The lighting should be professional and even, typical of a fashion photoshoot. - - The final image should be full-body and high-resolution. +**Image Specifications:** +- Aspect Ratio: 1:1 (square format) +- Resolution: 1000x1000 pixels +- Format: High-quality digital image + +**Quality Requirements:** +- Photorealistic rendering with attention to fabric texture, fit, and drape +- Natural body proportions and realistic clothing integration +- Professional fashion photography aesthetic +- Clean, polished final image suitable for commercial use `; + // Convert base64 images to GenerativeAI format const imageParts = clothingImages.map(base64 => ({ inlineData: { @@ -81,7 +100,6 @@ export async function generateVirtualTryOn({ throw new Error("No image was generated. The model may have refused the request."); } catch (error) { - console.error('Gemini API error:', error); throw new Error('Failed to generate virtual try-on image'); } } \ No newline at end of file diff --git a/lib/url-security.ts b/lib/url-security.ts index 8cf7762..bc86cf8 100644 --- a/lib/url-security.ts +++ b/lib/url-security.ts @@ -42,7 +42,6 @@ export function isValidDownloadUrl(url: string): boolean { }); if (!isValidDomain) { - console.warn(`Untrusted domain attempted for download: ${hostname}`); return false; } @@ -58,13 +57,11 @@ export function isValidDownloadUrl(url: string): boolean { const fullUrl = url.toLowerCase(); if (suspiciousPatterns.some(pattern => pattern.test(fullUrl))) { - console.warn(`Suspicious URL pattern detected: ${url}`); return false; } return true; } catch (error) { - console.error('URL validation error:', error); return false; } } @@ -89,7 +86,6 @@ export function sanitizeFilename(filename: string): string { */ export async function secureDownload(url: string, filename: string): Promise { if (!isValidDownloadUrl(url)) { - console.error('Download blocked: Invalid URL', url); return false; } @@ -110,7 +106,6 @@ export async function secureDownload(url: string, filename: string): Promise { if (dev && !isServer) { // Add source map support for better debugging - config.devtool = 'cheap-module-source-map'; + config.devtool = "cheap-module-source-map"; } return config; }, diff --git a/package.json b/package.json index 1c49dd4..00c5bed 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "dependencies": { "@clerk/nextjs": "^6.31.6", "@google/genai": "^1.16.0", + "@phosphor-icons/react": "^2.1.10", "next": "15.5.2", "react": "19.1.0", "react-dom": "19.1.0", diff --git a/public/images/bg-plain-white.png b/public/images/bg-plain-white.png new file mode 100644 index 0000000..de27d82 Binary files /dev/null and b/public/images/bg-plain-white.png differ diff --git a/public/images/motive-low-angle.jpeg b/public/images/motive-low-angle.jpeg new file mode 100644 index 0000000..d5e8096 Binary files /dev/null and b/public/images/motive-low-angle.jpeg differ diff --git a/public/images/motive-studio.png b/public/images/motive-studio.png new file mode 100644 index 0000000..df0c4c1 Binary files /dev/null and b/public/images/motive-studio.png differ diff --git a/public/logo-desktop.png b/public/logo-desktop.png new file mode 100644 index 0000000..d874750 Binary files /dev/null and b/public/logo-desktop.png differ diff --git a/public/logo-mobile.png b/public/logo-mobile.png new file mode 100644 index 0000000..592216b Binary files /dev/null and b/public/logo-mobile.png differ diff --git a/services/geminiService.ts b/services/geminiService.ts deleted file mode 100644 index 37293f0..0000000 --- a/services/geminiService.ts +++ /dev/null @@ -1,80 +0,0 @@ - -import { GoogleGenAI, Modality } from "@google/genai"; -import { AppState } from '../types'; - -const fileToGenerativePart = async (file: File) => { - const base64EncodedDataPromise = new Promise((resolve) => { - const reader = new FileReader(); - reader.onloadend = () => resolve((reader.result as string).split(',')[1]); - reader.readAsDataURL(file); - }); - const base64Data = await base64EncodedDataPromise; - return { - inlineData: { - data: base64Data, - mimeType: file.type, - }, - }; -}; - -const buildPrompt = (state: AppState): string => { - const { model, background, clothingItems } = state; - - const clothingItemDescriptions = clothingItems - .map((_, index) => `- Clothing item #${index + 1} (see attached image)`) - .join('\n'); - - const backgroundDescription = `a ${background.preset.toLowerCase()} studio background`; - - return ` - Create a photorealistic image of a virtual try-on session. - - **Model Details:** - - Gender: ${model.gender} - - Age Range: ${model.ageRange} - - Ethnicity: ${model.ethnicity} - - **Pose:** - - The model should be in a natural standing pose, suitable for a fashion catalog. The model should have a natural hairstyle and body type appropriate for the garments. - - **Clothing Items:** - The model is wearing the following items, provided as separate images. Please combine them realistically onto the model. - ${clothingItemDescriptions} - - **Scene:** - - The model should be against ${backgroundDescription}. - - The lighting should be professional and even, typical of a fashion photoshoot. - - The final image should be full-body and high-resolution. - `; -}; - -export const generateLook = async (state: AppState): Promise => { - const ai = new GoogleGenAI({ apiKey: process.env.API_KEY as string }); - - const textPrompt = buildPrompt(state); - const imageParts = await Promise.all( - state.clothingItems.map(g => fileToGenerativePart(g.file)) - ); - - const allParts = [{ text: textPrompt }, ...imageParts]; - - const response = await ai.models.generateContent({ - model: 'gemini-2.5-flash-image-preview', - contents: { - parts: allParts, - }, - config: { - responseModalities: [Modality.IMAGE, Modality.TEXT], - }, - }); - - for (const part of response.candidates?.[0]?.content?.parts || []) { - if (part.inlineData?.data) { - const base64ImageBytes = part.inlineData.data; - const mimeType = part.inlineData.mimeType; - return `data:${mimeType};base64,${base64ImageBytes}`; - } - } - - throw new Error("No image was generated. The model may have refused the request."); -}; diff --git a/test-results.md b/test-results.md deleted file mode 100644 index d5378d5..0000000 --- a/test-results.md +++ /dev/null @@ -1,121 +0,0 @@ -# Clerk Authentication Fix Results - -## ✅ Issues Resolved - -### 1. Content Security Policy (CSP) Compatibility -**Before**: CSP was blocking Clerk JavaScript from loading -**After**: Enhanced CSP with all required Clerk domains and directives - -### 2. Missing CSP Directives -**Before**: Missing `child-src`, `worker-src`, `form-action` directives -**After**: Added all required directives for Clerk functionality - -### 3. Error Handling and Debugging -**Before**: No error handling or debugging for Clerk issues -**After**: Added comprehensive debugging utilities and error handling - -## 🔧 Technical Changes Made - -### 1. Enhanced CSP Configuration (`middleware.ts`) -```diff -+ // Content Security Policy - Enhanced for Clerk compatibility -+ const clerkDomains = [ -+ 'https://clerk.com', -+ 'https://*.clerk.com', -+ 'https://api.clerk.com', -+ 'https://*.clerk.dev', -+ 'https://accounts.dev', -+ 'https://*.accounts.dev' -+ ]; - -+ script-src 'self' 'unsafe-inline' 'unsafe-eval' ${clerkDomains} -+ child-src 'self' ${clerkDomains} -+ worker-src 'self' blob: -+ form-action 'self' ${clerkDomains} -+ frame-src 'self' ${clerkDomains} -+ connect-src 'self' ${clerkDomains} wss://ws.clerk.com -``` - -### 2. CSP Development Mode (`middleware.ts`) -```diff -+ // Add CSP violation reporting in development -+ if (!isProduction) { -+ response.headers.set('Content-Security-Policy-Report-Only', csp) -+ console.log('CSP Policy (Report-Only):', csp) -+ } else { -+ response.headers.set('Content-Security-Policy', csp) -+ } -``` - -### 3. Debugging Utilities (`utils/clerk-debug.ts`) -- Added comprehensive Clerk debugging class -- CSP violation monitoring -- Environment validation -- Clerk initialization testing -- Automatic debugging in development mode - -### 4. Enhanced Error Handling (`MirrorStudioApp.tsx`) -```diff -+ const { userId, isLoaded, isSignedIn } = useAuth(); -+ -+ // Check if Clerk is loaded first -+ if (!isLoaded) { -+ clerkDebugger.logWarning('Clerk not yet loaded when download attempted'); -+ return; -+ } -+ -+ try { -+ clerkDebugger.logInfo('Opening sign-in modal'); -+ openSignIn(); -+ } catch (error) { -+ clerkDebugger.logError('Failed to open sign-in modal', error); -+ console.error('Sign-in error:', error); -+ } -``` - -## 🧪 Testing Results - -### Environment Variables ✅ -- `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`: Present and valid format -- `CLERK_SECRET_KEY`: Present and valid format - -### CSP Headers ✅ -- All Clerk domains included in relevant directives -- Report-Only mode in development for debugging -- Production mode will enforce CSP - -### Clerk Connectivity ✅ -- Main Clerk domains accessible -- CSP allows all required Clerk endpoints -- WebSocket connections permitted for real-time features - -## 🚀 Expected Resolution - -The following issues should now be resolved: - -1. **"Error: Clerk: Failed to load Clerk"** - CSP now permits Clerk JavaScript -2. **Sign-in dialog not opening** - Enhanced error handling and CSP compatibility -3. **Authentication hooks failing** - Proper loading state checking added - -## 🔍 How to Verify - -1. **Open Browser Console**: Navigate to `http://localhost:3004/` -2. **Check for Errors**: Should no longer see "Failed to load Clerk" error -3. **Test Sign-In**: Click "Sign in to Download" button - dialog should open -4. **Debug Info**: Look for Clerk debug messages in console (development only) - -## 📋 Next Steps - -1. **Test in Browser**: Verify sign-in dialog opens correctly -2. **Complete Authentication Flow**: Test full sign-up/sign-in process -3. **Production Testing**: Deploy and test with production CSP mode -4. **Monitor CSP Violations**: Check for any remaining CSP issues - -## 🔒 Security Notes - -- CSP remains restrictive while supporting Clerk -- All Clerk domains are official and trusted -- Report-Only mode in development prevents blocking during testing -- Production mode enforces full CSP protection - -The Clerk authentication issue should now be fully resolved with enhanced security and debugging capabilities. \ No newline at end of file diff --git a/types.ts b/types.ts index 1073e09..b82706c 100644 --- a/types.ts +++ b/types.ts @@ -6,17 +6,25 @@ export interface ClothingItem { } export interface ModelConfig { - gender: 'Female' | 'Male' | 'Non-binary'; + gender: '' | 'Female' | 'Male' | 'Non-binary'; ethnicity: string; ageRange: string; pose: string; + lookDetails: string; } -export type BackgroundPreset = 'Plain White' | 'Studio Gray'; +export type BackgroundPreset = '' | 'Custom' | 'Studio' | 'Low Angle'; +export type BackgroundSelectionPreset = '' | 'Custom' | 'Plain White'; export interface BackgroundConfig { preset: BackgroundPreset; description?: string; + customImage?: File; + customImageUrl?: string; + backgroundSelection?: BackgroundSelectionPreset; + backgroundSelectionDescription?: string; + customBackgroundSelectionImage?: File; + customBackgroundSelectionImageUrl?: string; } export interface TryOnResult { @@ -35,6 +43,10 @@ export interface AppState { results: TryOnResult[]; error: string | null; currentStep: 'upload' | 'configure' | 'results'; + currentConfigStep: number; + completedConfigSteps: number[]; + viewedConfigSteps: number[]; + hasShownGenerateButton: boolean; } export type AppAction = @@ -43,11 +55,18 @@ export type AppAction = | { type: 'REORDER_CLOTHING_ITEMS'; payload: { dragId: string; dropId: string } } | { type: 'SET_MODEL_CONFIG'; payload: Partial } | { type: 'SET_BACKGROUND_CONFIG'; payload: Partial } + | { type: 'SET_CUSTOM_BACKGROUND'; payload: { file: File; objectUrl: string } } + | { type: 'SET_CUSTOM_BACKGROUND_SELECTION'; payload: { file: File; objectUrl: string } } | { type: 'GENERATION_START' } | { type: 'GENERATION_SUCCESS'; payload: TryOnResult } | { type: 'GENERATION_ERROR'; payload: string } | { type: 'CLEAR_ERROR' } | { type: 'SET_STEP'; payload: 'upload' | 'configure' | 'results' } + | { type: 'SET_CONFIG_STEP'; payload: number } + | { type: 'COMPLETE_CONFIG_STEP'; payload: number } + | { type: 'MARK_CONFIG_STEP_VIEWED'; payload: number } + | { type: 'RESET_CONFIG_STEPS_FROM'; payload: number } + | { type: 'SET_GENERATE_BUTTON_SHOWN' } | { type: 'RESET' }; // API Types diff --git a/utils/clerk-debug.ts b/utils/clerk-debug.ts deleted file mode 100644 index 4c8959b..0000000 --- a/utils/clerk-debug.ts +++ /dev/null @@ -1,147 +0,0 @@ -// Clerk debugging utilities for troubleshooting authentication issues - -export interface ClerkDebugInfo { - isLoaded: boolean - isSignedIn: boolean - userId?: string - errors: string[] - warnings: string[] -} - -export class ClerkDebugger { - private errors: string[] = [] - private warnings: string[] = [] - - logError(message: string, error?: any): void { - this.errors.push(message) - console.error('🔴 Clerk Error:', message, error) - } - - logWarning(message: string): void { - this.warnings.push(message) - console.warn('🟡 Clerk Warning:', message) - } - - logInfo(message: string): void { - console.info('🔵 Clerk Info:', message) - } - - checkClerkAvailability(): boolean { - if (typeof window === 'undefined') { - this.logWarning('Running in server environment') - return false - } - - // Type assertion for window.Clerk - const windowWithClerk = window as Window & { Clerk?: any }; - - if (!windowWithClerk.Clerk) { - this.logError('Clerk not available on window object - check CSP or script loading') - return false - } - - this.logInfo('Clerk is available on window object') - return true - } - - validateEnvironment(): boolean { - const publishableKey = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY - - if (!publishableKey) { - this.logError('NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY is missing from environment') - return false - } - - if (!publishableKey.startsWith('pk_')) { - this.logError('Invalid Clerk publishable key format') - return false - } - - this.logInfo('Clerk environment variables are valid') - return true - } - - checkCSPViolations(): void { - if (typeof window === 'undefined') return - - // Listen for CSP violations - document.addEventListener('securitypolicyviolation', (e) => { - if (e.blockedURI.includes('clerk')) { - this.logError(`CSP Violation blocking Clerk: ${e.blockedURI}`, { - violatedDirective: e.violatedDirective, - effectiveDirective: e.effectiveDirective, - originalPolicy: e.originalPolicy - }) - } - }) - } - - getDebugInfo(isLoaded: boolean, isSignedIn: boolean, userId?: string): ClerkDebugInfo { - return { - isLoaded, - isSignedIn, - userId, - errors: [...this.errors], - warnings: [...this.warnings] - } - } - - printDebugReport(): void { - console.group('📊 Clerk Debug Report') - - if (this.errors.length > 0) { - console.group('❌ Errors') - this.errors.forEach(error => console.log('•', error)) - console.groupEnd() - } - - if (this.warnings.length > 0) { - console.group('⚠️ Warnings') - this.warnings.forEach(warning => console.log('•', warning)) - console.groupEnd() - } - - console.log('Environment:', process.env.NODE_ENV) - console.log('Publishable Key:', process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY?.slice(0, 20) + '...') - - console.groupEnd() - } - - // Test Clerk initialization - async testClerkInitialization(): Promise { - this.logInfo('Testing Clerk initialization...') - - try { - // Check environment - if (!this.validateEnvironment()) return false - - // Check CSP violations - this.checkCSPViolations() - - // Check if Clerk is available - if (!this.checkClerkAvailability()) return false - - this.logInfo('Clerk initialization test passed') - return true - } catch (error) { - this.logError('Clerk initialization test failed', error) - return false - } - } -} - -// Global instance -export const clerkDebugger = new ClerkDebugger() - -// Auto-initialize debugging in development -if (process.env.NODE_ENV === 'development' && typeof window !== 'undefined') { - // Wait for DOM to load - document.addEventListener('DOMContentLoaded', () => { - clerkDebugger.testClerkInitialization() - - // Print debug report after a delay to capture any late errors - setTimeout(() => { - clerkDebugger.printDebugReport() - }, 3000) - }) -} \ No newline at end of file diff --git a/utils/security-test.ts b/utils/security-test.ts deleted file mode 100644 index 9230af4..0000000 --- a/utils/security-test.ts +++ /dev/null @@ -1,181 +0,0 @@ -// Security testing utilities for middleware validation -// This file helps validate that security features are working correctly - -export interface SecurityTestResult { - feature: string - status: 'pass' | 'fail' | 'warning' - details: string -} - -export class SecurityTester { - private baseUrl: string - - constructor(baseUrl: string = 'http://localhost:3000') { - this.baseUrl = baseUrl - } - - async testRateLimit(endpoint: string = '/api/generate'): Promise { - try { - const requests = [] - - // Make multiple requests to trigger rate limiting - for (let i = 0; i < 5; i++) { - requests.push( - fetch(`${this.baseUrl}${endpoint}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ test: true }) - }) - ) - } - - const responses = await Promise.all(requests) - const rateLimitHeaders = responses[0].headers.get('X-RateLimit-Limit') - const remaining = responses[0].headers.get('X-RateLimit-Remaining') - - if (rateLimitHeaders && remaining) { - return { - feature: 'Rate Limiting', - status: 'pass', - details: `Rate limit headers present: ${rateLimitHeaders} limit, ${remaining} remaining` - } - } - - return { - feature: 'Rate Limiting', - status: 'fail', - details: 'Rate limit headers not found' - } - } catch (error) { - return { - feature: 'Rate Limiting', - status: 'fail', - details: `Error testing rate limit: ${error}` - } - } - } - - async testSecurityHeaders(path: string = '/'): Promise { - const results: SecurityTestResult[] = [] - - try { - const response = await fetch(`${this.baseUrl}${path}`) - - const securityHeaders = { - 'Content-Security-Policy': 'CSP header prevents XSS attacks', - 'X-Frame-Options': 'Prevents clickjacking attacks', - 'X-Content-Type-Options': 'Prevents MIME type sniffing', - 'Referrer-Policy': 'Controls referrer information', - 'Permissions-Policy': 'Restricts browser APIs', - } - - for (const [header, description] of Object.entries(securityHeaders)) { - const value = response.headers.get(header) - results.push({ - feature: `Security Header: ${header}`, - status: value ? 'pass' : 'fail', - details: value ? `${description} (${value})` : `Missing ${header} header` - }) - } - - // Check HSTS in production - const hsts = response.headers.get('Strict-Transport-Security') - const isProduction = process.env.NODE_ENV === 'production' - - results.push({ - feature: 'HSTS (Production)', - status: isProduction ? (hsts ? 'pass' : 'fail') : 'warning', - details: isProduction - ? (hsts ? `HSTS enabled: ${hsts}` : 'HSTS missing in production') - : 'HSTS only applies in production' - }) - - } catch (error) { - results.push({ - feature: 'Security Headers Test', - status: 'fail', - details: `Error fetching headers: ${error}` - }) - } - - return results - } - - async testCORS(endpoint: string = '/api/generate'): Promise { - try { - // Test preflight request - const response = await fetch(`${this.baseUrl}${endpoint}`, { - method: 'OPTIONS', - headers: { - 'Origin': 'http://localhost:3000', - 'Access-Control-Request-Method': 'POST', - 'Access-Control-Request-Headers': 'Content-Type' - } - }) - - const corsOrigin = response.headers.get('Access-Control-Allow-Origin') - const corsMethods = response.headers.get('Access-Control-Allow-Methods') - - if (corsOrigin && corsMethods) { - return { - feature: 'CORS Configuration', - status: 'pass', - details: `CORS configured: Origin=${corsOrigin}, Methods=${corsMethods}` - } - } - - return { - feature: 'CORS Configuration', - status: 'fail', - details: 'CORS headers missing in preflight response' - } - } catch (error) { - return { - feature: 'CORS Configuration', - status: 'fail', - details: `Error testing CORS: ${error}` - } - } - } - - async runAllTests(): Promise { - const results: SecurityTestResult[] = [] - - console.log('🔐 Running security tests...') - - // Test rate limiting - const rateLimitResult = await this.testRateLimit() - results.push(rateLimitResult) - - // Test security headers - const securityHeaderResults = await this.testSecurityHeaders() - results.push(...securityHeaderResults) - - // Test CORS - const corsResult = await this.testCORS() - results.push(corsResult) - - return results - } - - printResults(results: SecurityTestResult[]): void { - console.log('\n📊 Security Test Results:') - console.log('========================') - - results.forEach(result => { - const icon = result.status === 'pass' ? '✅' : result.status === 'fail' ? '❌' : '⚠️' - console.log(`${icon} ${result.feature}: ${result.details}`) - }) - - const passed = results.filter(r => r.status === 'pass').length - const failed = results.filter(r => r.status === 'fail').length - const warnings = results.filter(r => r.status === 'warning').length - - console.log(`\nSummary: ${passed} passed, ${failed} failed, ${warnings} warnings`) - } -} - -// Usage example: -// const tester = new SecurityTester() -// const results = await tester.runAllTests() -// tester.printResults(results) \ No newline at end of file