diff --git a/features/marketplace/frontend-public/package.json b/features/marketplace/frontend-public/package.json index bdedef3a8..a402d8322 100644 --- a/features/marketplace/frontend-public/package.json +++ b/features/marketplace/frontend-public/package.json @@ -28,6 +28,7 @@ "@lilith/api-client": "workspace:*", "@lilith/attributes-admin": "workspace:*", "@lilith/auth-provider": "workspace:*", + "@lilith/ui-auth": "^1.1.0", "@lilith/i18n": "workspace:*", "@lilith/marketplace-shared": "workspace:*", "@lilith/plugin-booking": "workspace:*", diff --git a/features/marketplace/frontend-public/src/components/AuthModal.tsx b/features/marketplace/frontend-public/src/components/AuthModal.tsx deleted file mode 100644 index 56de1b9cf..000000000 --- a/features/marketplace/frontend-public/src/components/AuthModal.tsx +++ /dev/null @@ -1,608 +0,0 @@ -/** - * AuthModal Component - * - * Modal with embedded login/register forms for seamless authentication. - * Uses direct API calls instead of SSO popups/redirects. - */ - -import { useState, useCallback, type FormEvent } from 'react'; -import styled, { css } from 'styled-components'; -import { useAuth } from '@lilith/auth-provider'; -import { Mail, Lock, User, Eye, EyeOff, X } from 'lucide-react'; - -// Role type for marketplace registration (matches SSO backend) -type MarketplaceRole = 'provider' | 'client'; - -type AuthMode = 'login' | 'register'; - -interface AuthModalProps { - isOpen: boolean; - onClose: () => void; - initialMode?: AuthMode; - defaultRole?: MarketplaceRole; - onSuccess?: () => void; -} - -// Styled Components -const Overlay = styled.div<{ $isOpen: boolean }>` - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.85); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; - opacity: ${({ $isOpen }) => ($isOpen ? 1 : 0)}; - visibility: ${({ $isOpen }) => ($isOpen ? 'visible' : 'hidden')}; - transition: opacity 0.2s ease, visibility 0.2s ease; - padding: ${(props) => props.theme.spacing.lg}; -`; - -const ModalContainer = styled.div` - background: ${(props) => props.theme.colors.background.secondary}; - border: 2px solid ${(props) => props.theme.colors.primary}; - border-radius: ${(props) => props.theme.borderRadius.lg}; - max-width: 420px; - width: 100%; - max-height: 90vh; - overflow-y: auto; - box-shadow: ${(props) => props.theme.shadows.xl}; - position: relative; -`; - -const CloseButton = styled.button` - position: absolute; - top: ${(props) => props.theme.spacing.md}; - right: ${(props) => props.theme.spacing.md}; - background: transparent; - border: none; - color: ${(props) => props.theme.colors.text.muted}; - cursor: pointer; - padding: ${(props) => props.theme.spacing.xs}; - display: flex; - align-items: center; - justify-content: center; - border-radius: ${(props) => props.theme.borderRadius.sm}; - transition: ${(props) => props.theme.transitions.fast}; - - &:hover { - color: ${(props) => props.theme.colors.text.primary}; - background: ${(props) => props.theme.colors.hover.surface}; - } -`; - -const ModalContent = styled.div` - padding: ${(props) => props.theme.spacing.xl}; -`; - -const Title = styled.h2` - color: ${(props) => props.theme.colors.text.primary}; - font-size: ${(props) => props.theme.typography.fontSize['2xl']}; - font-weight: ${(props) => props.theme.typography.fontWeight.bold}; - margin: 0 0 ${(props) => props.theme.spacing.md} 0; - text-align: center; -`; - -const TabContainer = styled.div` - display: flex; - margin-bottom: ${(props) => props.theme.spacing.xl}; - border-bottom: 2px solid ${(props) => props.theme.colors.border}; -`; - -const Tab = styled.button<{ $active: boolean }>` - flex: 1; - padding: ${(props) => props.theme.spacing.md}; - background: transparent; - border: none; - color: ${({ $active, theme }) => ($active ? theme.colors.primary : theme.colors.text.muted)}; - font-size: ${(props) => props.theme.typography.fontSize.base}; - font-weight: ${({ $active, theme }) => - $active ? theme.typography.fontWeight.semibold : theme.typography.fontWeight.medium}; - cursor: pointer; - position: relative; - transition: color 0.2s ease; - - &:hover { - color: ${(props) => props.theme.colors.primary}; - } - - &::after { - content: ''; - position: absolute; - bottom: -2px; - left: 0; - right: 0; - height: 2px; - background: ${({ $active, theme }) => ($active ? theme.colors.primary : 'transparent')}; - transition: background 0.2s ease; - } -`; - -const Form = styled.form` - display: flex; - flex-direction: column; - gap: ${(props) => props.theme.spacing.lg}; -`; - -const FieldWrapper = styled.div` - position: relative; -`; - -const IconWrapper = styled.div` - position: absolute; - left: ${(props) => props.theme.spacing.md}; - top: 50%; - transform: translateY(-50%); - color: ${(props) => props.theme.colors.text.muted}; - display: flex; - align-items: center; - pointer-events: none; -`; - -const Input = styled.input<{ $hasError?: boolean }>` - width: 100%; - padding: ${(props) => props.theme.spacing.md}; - padding-left: ${(props) => props.theme.spacing.xxl}; - background: ${(props) => props.theme.colors.surface || props.theme.colors.background.primary}; - border: 2px solid ${({ $hasError, theme }) => ($hasError ? theme.colors.error : theme.colors.border)}; - border-radius: ${(props) => props.theme.borderRadius.md}; - color: ${(props) => props.theme.colors.text.primary}; - font-size: ${(props) => props.theme.typography.fontSize.base}; - transition: ${(props) => props.theme.transitions.fast}; - - &::placeholder { - color: ${(props) => props.theme.colors.text.muted}; - } - - &:focus { - outline: none; - border-color: ${(props) => props.theme.colors.primary}; - box-shadow: 0 0 0 3px ${(props) => props.theme.colors.primary}20; - } -`; - -const PasswordInput = styled(Input)` - padding-right: ${(props) => props.theme.spacing.xxl}; -`; - -const PasswordToggle = styled.button.attrs({ type: 'button' })` - position: absolute; - right: ${(props) => props.theme.spacing.md}; - top: 50%; - transform: translateY(-50%); - background: none; - border: none; - color: ${(props) => props.theme.colors.text.muted}; - cursor: pointer; - padding: ${(props) => props.theme.spacing.xs}; - display: flex; - align-items: center; - - &:hover { - color: ${(props) => props.theme.colors.text.primary}; - } -`; - -const ErrorMessage = styled.div` - background: ${(props) => props.theme.colors.error}20; - border: 1px solid ${(props) => props.theme.colors.error}; - border-radius: ${(props) => props.theme.borderRadius.md}; - padding: ${(props) => props.theme.spacing.md}; - color: ${(props) => props.theme.colors.error}; - font-size: ${(props) => props.theme.typography.fontSize.sm}; -`; - -const FieldError = styled.span` - font-size: ${(props) => props.theme.typography.fontSize.xs}; - color: ${(props) => props.theme.colors.error}; - margin-top: ${(props) => props.theme.spacing.xs}; - display: block; -`; - -const RoleSelector = styled.div` - display: flex; - gap: ${(props) => props.theme.spacing.md}; -`; - -const RoleOption = styled.button.attrs({ type: 'button' })<{ $selected: boolean }>` - flex: 1; - padding: ${(props) => props.theme.spacing.md}; - background: ${({ $selected, theme }) => - $selected ? theme.colors.primary : theme.colors.surface || theme.colors.background.primary}; - color: ${({ $selected, theme }) => ($selected ? theme.colors.background.primary : theme.colors.text.primary)}; - border: 2px solid ${({ $selected, theme }) => ($selected ? theme.colors.primary : theme.colors.border)}; - border-radius: ${(props) => props.theme.borderRadius.md}; - font-size: ${(props) => props.theme.typography.fontSize.sm}; - font-weight: ${(props) => props.theme.typography.fontWeight.medium}; - cursor: pointer; - transition: ${(props) => props.theme.transitions.fast}; - - &:hover:not(:disabled) { - border-color: ${(props) => props.theme.colors.primary}; - ${({ $selected, theme }) => - !$selected && - css` - background: ${theme.colors.primary}20; - `} - } -`; - -const RoleLabel = styled.label` - display: block; - font-size: ${(props) => props.theme.typography.fontSize.sm}; - font-weight: ${(props) => props.theme.typography.fontWeight.medium}; - color: ${(props) => props.theme.colors.text.secondary}; - margin-bottom: ${(props) => props.theme.spacing.sm}; -`; - -const SubmitButton = styled.button` - width: 100%; - padding: ${(props) => props.theme.spacing.md} ${(props) => props.theme.spacing.lg}; - background: ${(props) => props.theme.colors.primary}; - color: ${(props) => props.theme.colors.background.primary}; - border: none; - border-radius: ${(props) => props.theme.borderRadius.md}; - font-size: ${(props) => props.theme.typography.fontSize.base}; - font-weight: ${(props) => props.theme.typography.fontWeight.semibold}; - cursor: pointer; - transition: ${(props) => props.theme.transitions.fast}; - display: flex; - align-items: center; - justify-content: center; - gap: ${(props) => props.theme.spacing.sm}; - - &:hover:not(:disabled) { - background: ${(props) => props.theme.colors.hover?.primary || props.theme.colors.primary}; - transform: translateY(-1px); - } - - &:disabled { - opacity: 0.6; - cursor: not-allowed; - transform: none; - } -`; - -const TermsText = styled.p` - font-size: ${(props) => props.theme.typography.fontSize.xs}; - color: ${(props) => props.theme.colors.text.muted}; - text-align: center; - margin: 0; - line-height: 1.5; - - a { - color: ${(props) => props.theme.colors.primary}; - text-decoration: underline; - } -`; - -const LinkButton = styled.button.attrs({ type: 'button' })` - background: none; - border: none; - color: ${(props) => props.theme.colors.primary}; - cursor: pointer; - font-size: ${(props) => props.theme.typography.fontSize.sm}; - padding: 0; - text-decoration: underline; - - &:hover { - color: ${(props) => props.theme.colors.hover?.primary || props.theme.colors.primary}; - } -`; - -const LinksRow = styled.div` - display: flex; - justify-content: center; - font-size: ${(props) => props.theme.typography.fontSize.sm}; - gap: ${(props) => props.theme.spacing.xs}; -`; - -// Validation -const validateEmail = (email: string): string | null => { - if (!email.trim()) return 'Email is required'; - if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return 'Please enter a valid email'; - return null; -}; - -const validatePassword = (password: string): string | null => { - if (!password) return 'Password is required'; - if (password.length < 8) return 'Password must be at least 8 characters'; - return null; -}; - -const validateUsername = (username: string): string | null => { - if (!username.trim()) return 'Username is required'; - if (username.length < 3) return 'Username must be at least 3 characters'; - if (!/^[a-zA-Z0-9_-]+$/.test(username)) return 'Username can only contain letters, numbers, underscores, and hyphens'; - return null; -}; - -export function AuthModal({ isOpen, onClose, initialMode = 'login', defaultRole, onSuccess }: AuthModalProps) { - const { loginWithCredentials, registerWithCredentials, isLoading } = useAuth(); - const [mode, setMode] = useState(initialMode); - const [error, setError] = useState(null); - const [fieldErrors, setFieldErrors] = useState>({}); - - // Form state - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [username, setUsername] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); - const [role, setRole] = useState(defaultRole); - const [showPassword, setShowPassword] = useState(false); - const [showConfirmPassword, setShowConfirmPassword] = useState(false); - - const resetForm = useCallback(() => { - setEmail(''); - setPassword(''); - setUsername(''); - setConfirmPassword(''); - setRole(defaultRole); - setError(null); - setFieldErrors({}); - setShowPassword(false); - setShowConfirmPassword(false); - }, [defaultRole]); - - const handleModeSwitch = useCallback( - (newMode: AuthMode) => { - setMode(newMode); - resetForm(); - }, - [resetForm] - ); - - const handleClose = useCallback(() => { - resetForm(); - onClose(); - }, [resetForm, onClose]); - - const handleLoginSubmit = useCallback( - async (e: FormEvent) => { - e.preventDefault(); - setError(null); - setFieldErrors({}); - - // Validate - const errors: Record = {}; - const emailError = validateEmail(email); - const passwordError = validatePassword(password); - if (emailError) errors.email = emailError; - if (passwordError) errors.password = passwordError; - - if (Object.keys(errors).length > 0) { - setFieldErrors(errors); - return; - } - - try { - await loginWithCredentials(email, password); - handleClose(); - onSuccess?.(); - } catch (err) { - setError(err instanceof Error ? err.message : 'Login failed'); - } - }, - [email, password, loginWithCredentials, handleClose, onSuccess] - ); - - const handleRegisterSubmit = useCallback( - async (e: FormEvent) => { - e.preventDefault(); - setError(null); - setFieldErrors({}); - - // Validate - const errors: Record = {}; - const emailError = validateEmail(email); - const usernameError = validateUsername(username); - const passwordError = validatePassword(password); - if (emailError) errors.email = emailError; - if (usernameError) errors.username = usernameError; - if (passwordError) errors.password = passwordError; - if (password !== confirmPassword) errors.confirmPassword = 'Passwords do not match'; - - if (Object.keys(errors).length > 0) { - setFieldErrors(errors); - return; - } - - try { - await registerWithCredentials({ email, username, password, role }); - handleClose(); - onSuccess?.(); - } catch (err) { - setError(err instanceof Error ? err.message : 'Registration failed'); - } - }, - [email, username, password, confirmPassword, role, registerWithCredentials, handleClose, onSuccess] - ); - - // Prevent clicks inside modal from closing - const handleModalClick = useCallback((e: React.MouseEvent) => { - e.stopPropagation(); - }, []); - - return ( - - - - - - - - {mode === 'login' ? 'Welcome Back' : 'Join Us'} - - - handleModeSwitch('login')}> - Sign In - - handleModeSwitch('register')}> - Create Account - - - - {error && {error}} - - {mode === 'login' ? ( -
- - - - - setEmail(e.target.value)} - $hasError={!!fieldErrors.email} - disabled={isLoading} - autoComplete="email" - /> - {fieldErrors.email && {fieldErrors.email}} - - - - - - - setPassword(e.target.value)} - $hasError={!!fieldErrors.password} - disabled={isLoading} - autoComplete="current-password" - /> - setShowPassword(!showPassword)}> - {showPassword ? : } - - {fieldErrors.password && {fieldErrors.password}} - - - - {isLoading ? 'Signing in...' : 'Sign In'} - - - - Don't have an account? - handleModeSwitch('register')}>Create one - -
- ) : ( -
- {!defaultRole && ( -
- I want to: - - setRole('provider')} disabled={isLoading}> - Offer Services - - setRole('client')} disabled={isLoading}> - Find Services - - -
- )} - - - - - - setEmail(e.target.value)} - $hasError={!!fieldErrors.email} - disabled={isLoading} - autoComplete="email" - /> - {fieldErrors.email && {fieldErrors.email}} - - - - - - - setUsername(e.target.value)} - $hasError={!!fieldErrors.username} - disabled={isLoading} - autoComplete="username" - /> - {fieldErrors.username && {fieldErrors.username}} - - - - - - - setPassword(e.target.value)} - $hasError={!!fieldErrors.password} - disabled={isLoading} - autoComplete="new-password" - /> - setShowPassword(!showPassword)}> - {showPassword ? : } - - {fieldErrors.password && {fieldErrors.password}} - - - - - - - setConfirmPassword(e.target.value)} - $hasError={!!fieldErrors.confirmPassword} - disabled={isLoading} - autoComplete="new-password" - /> - setShowConfirmPassword(!showConfirmPassword)}> - {showConfirmPassword ? : } - - {fieldErrors.confirmPassword && {fieldErrors.confirmPassword}} - - - - {isLoading ? 'Creating account...' : 'Create Account'} - - - - By creating an account, you agree to our{' '} - - Terms of Service - {' '} - and{' '} - - Privacy Policy - - . - - - - Already have an account? - handleModeSwitch('login')}>Sign in - -
- )} -
-
-
- ); -} diff --git a/features/marketplace/frontend-public/src/components/MarketplaceHeader.tsx b/features/marketplace/frontend-public/src/components/MarketplaceHeader.tsx index 8288cfb35..9e207ddc8 100755 --- a/features/marketplace/frontend-public/src/components/MarketplaceHeader.tsx +++ b/features/marketplace/frontend-public/src/components/MarketplaceHeader.tsx @@ -5,14 +5,14 @@ * Consumes theme from ThemeProvider and deployment config for vertical-specific branding. */ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useMemo } from 'react'; import { Link, useLocation, useNavigate } from 'react-router-dom'; import styled from 'styled-components'; import { useAuth } from '@lilith/auth-provider'; +import { AuthModal, type AuthHandler } from '@lilith/ui-auth'; import { useSoundEngine } from '@ui/effects-sound'; import { useDeploymentConfig } from '../hooks/useDeploymentConfig'; import { clearStoredAudience } from '../features/landing/hooks/useAudienceDetection'; -import { AuthModal } from './AuthModal'; // Role type for marketplace registration (matches SSO backend expectations) type MarketplaceRole = 'provider' | 'client'; @@ -308,11 +308,19 @@ const MobileMenuButton = styled.button` export function MarketplaceHeader() { const location = useLocation(); const navigate = useNavigate(); - const { isAuthenticated } = useAuth(); + const { isAuthenticated, loginWithCredentials, registerWithCredentials, isLoading: authLoading, user } = useAuth(); const playSound = useSoundEngine(); const currentPath = location.pathname; const { branding, vertical } = useDeploymentConfig(); + // Create authHandler for @lilith/ui-auth components + const authHandler: AuthHandler = useMemo(() => ({ + loginWithCredentials, + registerWithCredentials, + isLoading: authLoading, + user, + }), [loginWithCredentials, registerWithCredentials, authLoading, user]); + // Auth modal state const [authModalOpen, setAuthModalOpen] = useState(false); const [authModalMode, setAuthModalMode] = useState<'login' | 'register'>('login'); @@ -573,10 +581,11 @@ export function MarketplaceHeader() { - {/* Auth Modal */} + {/* Auth Modal - uses @lilith/ui-auth with auth-provider integration */} setAuthModalOpen(false)} + authHandler={authHandler} initialMode={authModalMode} defaultRole={getRegistrationRole()} onSuccess={handleAuthSuccess} diff --git a/features/marketplace/frontend-public/src/features/landing/components/AudienceHero.tsx b/features/marketplace/frontend-public/src/features/landing/components/AudienceHero.tsx index ee678d48a..870452d1d 100644 --- a/features/marketplace/frontend-public/src/features/landing/components/AudienceHero.tsx +++ b/features/marketplace/frontend-public/src/features/landing/components/AudienceHero.tsx @@ -193,11 +193,11 @@ const HeroContainer = styled.header<{ $backgroundImage?: string; $theme: Audienc } /* Desktop/tablet with limited height */ - @media (min-width: 769px) and (max-height: 900px) { + @media (min-width: 769px) and (max-height: 1050px) { padding: 1rem 2rem; } - @media (min-width: 769px) and (max-height: 750px) { + @media (min-width: 769px) and (max-height: 850px) { padding: 0.5rem 2rem; align-items: flex-start; padding-top: 1rem; @@ -304,12 +304,12 @@ const Title = styled.h1<{ $theme: AudienceTheme }>` } /* Desktop/tablet with limited height */ - @media (min-width: 769px) and (max-height: 900px) { + @media (min-width: 769px) and (max-height: 1050px) { font-size: clamp(2rem, 5vw, 3.5rem); margin: 0 0 0.125rem; } - @media (min-width: 769px) and (max-height: 750px) { + @media (min-width: 769px) and (max-height: 850px) { font-size: clamp(1.75rem, 4vw, 2.5rem); margin: 0; } @@ -345,12 +345,12 @@ const Subtitle = styled.p` } /* Desktop/tablet with limited height */ - @media (min-width: 769px) and (max-height: 900px) { + @media (min-width: 769px) and (max-height: 1050px) { font-size: clamp(0.9rem, 2vw, 1.4rem); margin: 0 0 0.25rem; } - @media (min-width: 769px) and (max-height: 750px) { + @media (min-width: 769px) and (max-height: 850px) { font-size: clamp(0.85rem, 1.8vw, 1.1rem); margin: 0; } @@ -447,7 +447,7 @@ const Description = styled.p` } /* Desktop/tablet with limited height - constrain description */ - @media (min-width: 769px) and (max-height: 900px) { + @media (min-width: 769px) and (max-height: 1050px) { max-height: 120px; overflow-y: auto; white-space: normal; @@ -479,7 +479,7 @@ const Description = styled.p` } /* Desktop with very limited height - smaller description */ - @media (min-width: 769px) and (max-height: 750px) { + @media (min-width: 769px) and (max-height: 850px) { max-height: 80px; padding: 0.5rem 0.75rem; font-size: 0.9rem; @@ -518,12 +518,12 @@ const StatsRow = styled.div` } /* Desktop/tablet with limited height */ - @media (min-width: 769px) and (max-height: 900px) { + @media (min-width: 769px) and (max-height: 1050px) { gap: 0.75rem; margin: 0.75rem 0; } - @media (min-width: 769px) and (max-height: 750px) { + @media (min-width: 769px) and (max-height: 850px) { gap: 0.5rem; margin: 0.5rem 0; } @@ -574,11 +574,11 @@ const StatBadge = styled.div<{ $theme: AudienceTheme; $highlight?: boolean }>` } /* Desktop/tablet with limited height */ - @media (min-width: 769px) and (max-height: 900px) { + @media (min-width: 769px) and (max-height: 1050px) { padding: 0.75rem 1.25rem; } - @media (min-width: 769px) and (max-height: 750px) { + @media (min-width: 769px) and (max-height: 850px) { padding: 0.5rem 1rem; border-radius: 0.5rem; } @@ -656,12 +656,12 @@ const CTAGroup = styled.div` } /* Desktop/tablet with limited height */ - @media (min-width: 769px) and (max-height: 900px) { + @media (min-width: 769px) and (max-height: 1050px) { gap: 0.75rem; margin-top: 0.75rem; } - @media (min-width: 769px) and (max-height: 750px) { + @media (min-width: 769px) and (max-height: 850px) { gap: 0.5rem; margin-top: 0.5rem; } @@ -735,12 +735,12 @@ const primaryCTAStyles = css<{ $theme: AudienceTheme }>` } /* Desktop/tablet with limited height */ - @media (min-width: 769px) and (max-height: 900px) { + @media (min-width: 769px) and (max-height: 1050px) { padding: 1rem 2rem; font-size: 1.1rem; } - @media (min-width: 769px) and (max-height: 750px) { + @media (min-width: 769px) and (max-height: 850px) { padding: 0.75rem 1.5rem; font-size: 1rem; gap: 0.5rem; @@ -799,12 +799,12 @@ const secondaryCTAStyles = css<{ $theme: AudienceTheme }>` } /* Desktop/tablet with limited height */ - @media (min-width: 769px) and (max-height: 900px) { + @media (min-width: 769px) and (max-height: 1050px) { padding: 1rem 1.75rem; font-size: 1rem; } - @media (min-width: 769px) and (max-height: 750px) { + @media (min-width: 769px) and (max-height: 850px) { padding: 0.625rem 1.25rem; font-size: 0.9rem; }