-
-
- how do you want your model to look?
-
-
-
-
-
-
-
dispatch({ type: 'SET_STEP', payload: 'upload' })}
- onKeyDown={(e) => {
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault();
- dispatch({ type: 'SET_STEP', payload: 'upload' });
- }
- }}
- className="inline-flex items-center justify-center gap-2 px-8 py-3 text-base font-semibold text-slate-700 bg-slate-200 rounded-lg shadow-md transition-all duration-200 transform hover:bg-slate-300 focus:outline-none focus:ring-4 focus:ring-slate-400 focus:ring-offset-2"
- aria-label="Go back to clothing items upload step"
- >
-
- Items
-
-
{
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault();
- if (state.generationStatus !== 'loading') {
- onGenerate();
+
+
+ {/* Back button positioned to the left of the title */}
+
dispatch({ type: "SET_STEP", payload: "upload" })}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ dispatch({ type: "SET_STEP", payload: "upload" });
}
- }
- }}
- disabled={state.generationStatus === 'loading'}
- className={`p-[3px] relative focus:outline-none focus:ring-4 focus:ring-purple-300 focus:ring-offset-2 rounded-lg ${
- state.generationStatus === 'loading'
- ? 'cursor-not-allowed opacity-75'
- : 'hover:scale-105 transform transition-transform'
- }`}
- aria-label={state.generationStatus === 'loading' ? 'Currently generating your look' : 'Start generating your virtual try-on look'}
- >
-
-
- {state.generationStatus === 'loading' ? 'Generating...' : 'Generate'}
-
-
-
+ }}
+ 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"
+ >
+
+
+
+ direct the shot
+
+
+
+ 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
-
+
{
- if (e.key === 'Enter' || e.key === ' ') {
+ if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleDownload();
}
}}
disabled={!isLoaded}
- className={`inline-flex items-center justify-center gap-2 px-8 py-3 text-base font-bold rounded-lg shadow-md transition-all duration-200 transform focus:outline-none focus:ring-4 ${
+ className={`inline-flex items-center justify-center gap-2 px-8 py-3 text-base font-medium rounded-full shadow-lg hover:shadow-xl focus:outline-none transition duration-200 hover:brightness-95 w-full sm:flex-1 ${
!isLoaded
- ? 'bg-slate-100 text-slate-400 cursor-not-allowed'
- : 'text-slate-700 bg-slate-200 hover:bg-slate-300 focus:ring-slate-400 focus:ring-offset-2'
+ ? "btn-disabled"
+ : "text-[var(--color-charcoal)] btn-primary-gradient"
}`}
aria-label={
- !isLoaded
- ? 'Download button loading'
- : (!userId ? 'Sign in to download image' : 'Download generated image')
+ !isLoaded
+ ? "Download button loading"
+ : !userId
+ ? "Sign in to download image"
+ : "Download generated image"
}
- aria-describedby={!userId ? 'download-auth-hint' : undefined}
+ aria-describedby={!userId ? "download-auth-hint" : undefined}
>
-
- {!isLoaded ? 'Loading...' : (!userId ? 'Sign in to Download' : 'Download')}
+ Download
+
dispatch({ type: 'SET_STEP', payload: 'configure' })}
+ onClick={() => dispatch({ type: "SET_STEP", payload: "configure" })}
onKeyDown={(e) => {
- if (e.key === 'Enter' || e.key === ' ') {
+ if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
- dispatch({ type: 'SET_STEP', payload: 'configure' });
+ dispatch({ type: "SET_STEP", payload: "configure" });
}
}}
- className="inline-flex items-center justify-center gap-2 px-8 py-3 text-base font-bold text-white bg-indigo-600 rounded-lg shadow-md transition-all duration-200 transform hover:bg-indigo-700 focus:outline-none focus:ring-4 focus:ring-indigo-300 focus:ring-offset-2"
+ className="inline-flex items-center justify-center gap-2 px-8 py-3 text-base font-medium text-[var(--color-white)] rounded-full shadow-lg hover:shadow-xl focus:outline-none transition duration-200 hover:brightness-95 w-full sm:flex-1 btn-secondary-gradient"
aria-label="Go back to configuration step to create more images"
>
Create more
-
+
-
+
{/* 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 (
-
- Age Range: {value}
-
-
-
-
- {ageRanges.map((range, index) => (
-
- {range}
-
- ))}
-
+
Age
+
+ {ageRanges.map((range) => (
+ onChange(range)}
+ className={`
+ px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 border
+ ${value === range
+ ? 'text-white shadow-lg hover:shadow-xl'
+ : 'bg-slate-100 text-slate-700 hover:bg-slate-200 border-slate-200'
+ }
+ `}
+ style={value === range ? {
+ background: "linear-gradient(to bottom, var(--color-warm-beige), color-mix(in srgb, var(--color-warm-beige) 85%, var(--color-charcoal)))",
+ borderColor: "color-mix(in srgb, var(--color-warm-beige) 70%, var(--color-charcoal))"
+ } : undefined}
+ aria-pressed={value === range}
+ >
+ {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 */}
+
Motive
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ {/* Custom Upload Option */}
+
+ handleCustomImageUpload(e.target.files)}
+ className="hidden"
+ />
+
+ {/* Square container - same size as other options */}
+
+ {customImageUrl ? (
+
+
+ {/* Edit overlay on hover - only show when selected */}
+ {value === 'Custom' && (
+
+ )}
+
+ ) : (
+
+
+
+ )}
+
+
+ {/* Title underneath */}
+
+
+ Custom
+
+
+
+
+ {backgroundOptions.map((option) => (
+
onChange(option.value)}
+ className="relative cursor-pointer group"
+ aria-pressed={value === option.value}
+ >
+ {/* Square image - 25% larger */}
+
+
+
+
+ {/* Title underneath - smaller */}
+
+
+ {option.label}
+
+
+
+ ))}
+
+
+
+ );
+}
\ 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 */}
+
Background
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ {/* Custom Upload Option */}
+
+ handleCustomImageUpload(e.target.files)}
+ className="hidden"
+ />
+
+ {/* Square container - same size as other options */}
+
+ {customImageUrl ? (
+
+
+ {/* Edit overlay on hover - only show when selected */}
+ {value === 'Custom' && (
+
+ )}
+
+ ) : (
+
+
+
+ )}
+
+
+ {/* Title underneath */}
+
+
+ Custom
+
+
+
+
+ {backgroundOptions.map((option) => (
+
onChange(option.value)}
+ className="relative cursor-pointer group"
+ aria-pressed={value === option.value}
+ >
+ {/* Square image - same size as motive options */}
+
+
+
+
+ {/* Title underneath */}
+
+
+ {option.label}
+
+
+
+ ))}
+
+
+
+ );
+}
\ 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 = (
+
+ {children}
+
+ );
+
+ 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) && (
+
+
+
+
Look
+
+ {
+ 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) && (
+
+
+
+ {
+ if (onGenerate && isGenerateReady()) {
+ onGenerate();
+ }
+ }}
+ disabled={!onGenerate || !isGenerateReady()}
+ className="inline-flex items-center justify-center gap-2 px-10 py-2 text-base sm:text-lg font-semibold text-white rounded-full shadow-lg focus:outline-none transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
+ style={{
+ background: isGenerateReady()
+ ? "linear-gradient(to bottom, #6FB6FF, #3578e5)"
+ : "linear-gradient(to bottom, #94a3b8, #64748b)",
+ border: isGenerateReady()
+ ? "2.5px solid #3578e5"
+ : "2.5px solid #64748b",
+ cursor: isGenerateReady() ? "pointer" : "not-allowed",
+ }}
+ onMouseEnter={(e) => {
+ if (isGenerateReady()) {
+ e.currentTarget.style.filter = "brightness(0.95)";
+ }
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.filter = "brightness(1)";
+ }}
+ >
+ Generate
+
+
+
+
+
+ )}
+
);
-}
\ 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
{label}
setIsOpen(!isOpen)}
- className="w-full px-4 py-2 text-left bg-white border border-slate-300 rounded-lg shadow-sm hover:border-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
+ className={`w-full cursor-pointer pl-4 pr-2 py-2 text-slate-700 border border-gray-300 shadow-sm rounded-full transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-slate-300 focus:border-slate-400 flex items-center justify-between ${
+ value
+ ? 'bg-white hover:bg-gray-50 hover:border-gray-400'
+ : 'bg-gradient-to-b from-gray-100 to-gray-200 hover:from-gray-50 hover:to-gray-150 hover:border-gray-400'
+ }`}
aria-expanded={isOpen}
aria-haspopup="listbox"
>
- {value}
-
-
+
+ {value || placeholder || 'Select...'}
+
+
+
{isOpen && (
-
+
{options.map((option) => (
{option}
diff --git a/components/Footer.tsx b/components/Footer.tsx
index f4dbfb7..15502d0 100644
--- a/components/Footer.tsx
+++ b/components/Footer.tsx
@@ -2,16 +2,16 @@ import Link from 'next/link';
export function Footer() {
return (
-
+
-
+
© {new Date().getFullYear()} Mirror Studio. All rights reserved.
Pricing
diff --git a/components/GenerateBar.tsx b/components/GenerateBar.tsx
index f879445..b8fd7fb 100644
--- a/components/GenerateBar.tsx
+++ b/components/GenerateBar.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import { AppState } from '../types';
-import { SparklesIcon } from './icons';
+import { SparkleIcon } from '@phosphor-icons/react';
interface GenerateBarProps {
state: AppState;
@@ -35,7 +35,7 @@ export function GenerateBar({ state, onGenerate, isValid, validationMessage }: G
className="flex items-center gap-3 px-8 py-4 text-lg font-bold text-white bg-indigo-600 rounded-xl shadow-lg transition-all duration-200 transform hover:bg-indigo-700 focus:outline-none focus:ring-4 focus:ring-indigo-300 disabled:bg-slate-400 disabled:cursor-not-allowed disabled:shadow-none hover:disabled:scale-100"
>
{state.generationStatus === 'loading' ? 'Generating...' : 'Generate'}
-
+
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 */}
+
+
+
+
+
+
+
+
+ {/* Navigation Section */}
+
+
+
+
+
+ Sign in
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
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 && (
-
- Cancel
-
- )}
+
+ {/* {onCancel && (
+
+ Close
+
+ )} */}
);
-}
\ 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}
-
+
+ {label &&
{label} }
+
{options.map((option) => (
onChange(option)}
- className={`
- px-4 py-2 rounded-full text-sm font-medium transition-all duration-200
- ${value === option
- ? 'bg-indigo-600 text-white shadow-md'
- : 'bg-slate-100 text-slate-700 hover:bg-slate-200'
- }
- `}
+ className={
+ fullWidth
+ ? `
+ cursor-pointer px-4 py-2 min-h-[40px] text-sm font-medium transition-all duration-200 rounded-full border flex items-center justify-center
+ ${value === option
+ ? 'bg-gradient-to-b from-white to-slate-50 text-slate-900 border-slate-400 shadow-md'
+ : 'bg-gradient-to-b from-gray-100 to-gray-200 text-slate-700 border-gray-300 shadow-sm hover:from-gray-50 hover:to-gray-150 hover:border-gray-400'
+ }
+ `
+ : `
+ px-4 py-3 min-h-[44px] rounded-lg text-sm font-medium transition-all duration-200 border flex items-center justify-center
+ ${value === option
+ ? 'bg-white text-slate-700 border-slate-300 shadow-md'
+ : 'bg-gradient-to-b from-gray-100 to-gray-200 text-slate-700 border-gray-300 shadow-sm hover:from-gray-50 hover:to-gray-150 hover:border-gray-400'
+ }
+ `
+ }
aria-pressed={value === option}
>
{option}
diff --git a/components/ResultsGallery.tsx b/components/ResultsGallery.tsx
index 2b3557c..a0d4b4b 100644
--- a/components/ResultsGallery.tsx
+++ b/components/ResultsGallery.tsx
@@ -23,55 +23,18 @@ export function ResultsGallery({ results }: ResultsGalleryProps): React.JSX.Elem
return (
{/* Latest Result - Featured */}
-
-
+
+
-
-
-
-
- {latestResult.modelConfig.gender} • {latestResult.modelConfig.ageRange} • {latestResult.modelConfig.ethnicity}
-
-
- {new Date(latestResult.timestamp).toLocaleString()}
-
-
-
-
-
- {/* Previous Results - Grid */}
- {previousResults.length > 0 && (
-
-
Previous Generations
-
- {previousResults.map((result) => (
-
-
-
-
-
-
- {result.modelConfig.gender} • {result.modelConfig.ageRange}
-
-
-
- ))}
-
-
- )}
);
}
\ No newline at end of file
diff --git a/components/Stepper.tsx b/components/Stepper.tsx
index 5188479..152d7f3 100644
--- a/components/Stepper.tsx
+++ b/components/Stepper.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { CheckIcon } from './icons';
+import { CheckIcon } from '@phosphor-icons/react';
interface StepperProps {
currentStep: 'upload' | 'configure' | 'results';
diff --git a/components/TextInput.tsx b/components/TextInput.tsx
index c19374a..752e1a0 100644
--- a/components/TextInput.tsx
+++ b/components/TextInput.tsx
@@ -1,25 +1,39 @@
-'use client';
+"use client";
-import React from 'react';
+import React from "react";
interface TextInputProps {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
+ isGreyedOut?: boolean;
+ onFocus?: () => void;
}
-export function TextInput({ label, value, onChange, placeholder }: TextInputProps): React.JSX.Element {
+export function TextInput({
+ label,
+ value,
+ onChange,
+ placeholder,
+ isGreyedOut,
+ onFocus,
+}: TextInputProps): React.JSX.Element {
return (
- {label}
+
+ {label}
+
onChange(e.target.value)}
placeholder={placeholder}
- className="w-full px-4 py-2 bg-white border border-slate-300 rounded-lg shadow-sm hover:border-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
+ onFocus={onFocus}
+ className={`w-full px-4 py-2 bg-white border-2 border-slate-300 rounded-lg shadow-sm hover:border-slate-400 focus:outline-none focus:ring-0 focus:border-slate-500 focus-visible:outline-none transition-all ${
+ isGreyedOut ? "opacity-50 cursor-pointer" : ""
+ }`}
/>
);
-}
\ No newline at end of file
+}
diff --git a/components/Toast.tsx b/components/Toast.tsx
index a633478..c6a2329 100644
--- a/components/Toast.tsx
+++ b/components/Toast.tsx
@@ -1,7 +1,7 @@
'use client';
import React, { useEffect } from 'react';
-import { CloseIcon } from './icons';
+import { X as CloseIcon } from '@phosphor-icons/react';
interface ToastProps {
message: string;
diff --git a/components/UploadPanel.tsx b/components/UploadPanel.tsx
index fdaad86..b8be124 100644
--- a/components/UploadPanel.tsx
+++ b/components/UploadPanel.tsx
@@ -1,56 +1,63 @@
-'use client';
+"use client";
-import React, { useState, useCallback, useRef } from 'react';
-import { v4 as uuidv4 } from 'uuid';
-import { ClothingItem, AppAction } from '@/types';
-import { UploadIcon, CloseIcon } from './icons';
-import { validateImageFile, MAX_CLOTHING_ITEMS } from '@/lib/validations';
+import React, { useState, useCallback, useRef } from "react";
+import { v4 as uuidv4 } from "uuid";
+import { ClothingItem, AppAction } from "@/types";
+import { X as CloseIcon } from "@phosphor-icons/react";
+import { UploadSimpleIcon } from "@phosphor-icons/react";
+import { validateImageFile, MAX_CLOTHING_ITEMS } from "@/lib/validations";
interface UploadPanelProps {
clothingItems: ClothingItem[];
dispatch: React.Dispatch
;
}
-export function UploadPanel({ clothingItems, dispatch }: UploadPanelProps): React.JSX.Element {
+export function UploadPanel({
+ clothingItems,
+ dispatch,
+}: UploadPanelProps): React.JSX.Element {
const [isDragging, setIsDragging] = useState(false);
const [error, setError] = useState(null);
const fileInputRef = useRef(null);
const dragCounter = useRef(0);
const dragItem = useRef(null);
const dragOverItem = useRef(null);
-
+
const isLimitReached = clothingItems.length >= MAX_CLOTHING_ITEMS;
- const handleAddFiles = useCallback((files: FileList) => {
- if (isLimitReached) {
- setError(`Maximum ${MAX_CLOTHING_ITEMS} items allowed`);
- return;
- }
+ const handleAddFiles = useCallback(
+ (files: FileList) => {
+ if (isLimitReached) {
+ setError(`Maximum ${MAX_CLOTHING_ITEMS} items allowed`);
+ return;
+ }
+
+ const remainingSlots = MAX_CLOTHING_ITEMS - clothingItems.length;
+ const newClothingItems: ClothingItem[] = [];
- const remainingSlots = MAX_CLOTHING_ITEMS - clothingItems.length;
- const newClothingItems: ClothingItem[] = [];
-
- for (let i = 0; i < Math.min(files.length, remainingSlots); i++) {
- const file = files[i];
- const validation = validateImageFile(file);
-
- if (!validation.valid) {
- setError(validation.error || 'Invalid file');
- continue;
+ for (let i = 0; i < Math.min(files.length, remainingSlots); i++) {
+ const file = files[i];
+ const validation = validateImageFile(file);
+
+ if (!validation.valid) {
+ setError(validation.error || "Invalid file");
+ continue;
+ }
+
+ newClothingItems.push({
+ id: uuidv4(),
+ file,
+ objectUrl: URL.createObjectURL(file),
+ });
}
-
- newClothingItems.push({
- id: uuidv4(),
- file,
- objectUrl: URL.createObjectURL(file),
- });
- }
-
- if (newClothingItems.length > 0) {
- dispatch({ type: 'ADD_CLOTHING_ITEMS', payload: newClothingItems });
- setError(null);
- }
- }, [dispatch, clothingItems.length, isLimitReached]);
+
+ if (newClothingItems.length > 0) {
+ dispatch({ type: "ADD_CLOTHING_ITEMS", payload: newClothingItems });
+ setError(null);
+ }
+ },
+ [dispatch, clothingItems.length, isLimitReached]
+ );
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
@@ -87,7 +94,7 @@ export function UploadPanel({ clothingItems, dispatch }: UploadPanelProps): Reac
};
const handleRemoveItem = (id: string) => {
- dispatch({ type: 'REMOVE_CLOTHING_ITEM', payload: id });
+ dispatch({ type: "REMOVE_CLOTHING_ITEM", payload: id });
};
const handleDragStart = (id: string) => {
@@ -99,10 +106,14 @@ export function UploadPanel({ clothingItems, dispatch }: UploadPanelProps): Reac
};
const handleDragEnd = () => {
- if (dragItem.current && dragOverItem.current && dragItem.current !== dragOverItem.current) {
+ if (
+ dragItem.current &&
+ dragOverItem.current &&
+ dragItem.current !== dragOverItem.current
+ ) {
dispatch({
- type: 'REORDER_CLOTHING_ITEMS',
- payload: { dragId: dragItem.current, dropId: dragOverItem.current }
+ type: "REORDER_CLOTHING_ITEMS",
+ payload: { dragId: dragItem.current, dropId: dragOverItem.current },
});
}
dragItem.current = null;
@@ -111,12 +122,18 @@ export function UploadPanel({ clothingItems, dispatch }: UploadPanelProps): Reac
return (
-
+
-
-
- {isLimitReached ? `Maximum ${MAX_CLOTHING_ITEMS} items reached` : 'Click to upload or drag and drop'}
+
+
+ {isLimitReached
+ ? `Maximum ${MAX_CLOTHING_ITEMS} items reached`
+ : "Click to upload or drag and drop"}
-
PNG, JPG, WEBP up to 10MB
+
@@ -148,7 +167,7 @@ export function UploadPanel({ clothingItems, dispatch }: UploadPanelProps): Reac
)}
{clothingItems.length > 0 && (
-
+
{clothingItems.map((item, index) => (
handleDragStart(item.id)}
onDragEnter={() => handleDragEnter(item.id)}
onDragEnd={handleDragEnd}
- className="relative group cursor-move"
+ className="relative group cursor-pointer w-32 h-32"
>
handleRemoveItem(item.id)}
- className="absolute top-2 right-2 p-1 bg-red-500 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
+ className="absolute top-2 right-2 p-1 bg-red-500 text-white rounded-full opacity-0 group-hover:opacity-100 hover:opacity-80 cursor-pointer transition-opacity duration-150"
aria-label={`Remove item ${index + 1}`}
>
-
+ {/*
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