diff --git a/frontend/src/app/components/OnboardingModal.tsx b/frontend/src/app/components/OnboardingModal.tsx index ef10ff95..41b389cc 100644 --- a/frontend/src/app/components/OnboardingModal.tsx +++ b/frontend/src/app/components/OnboardingModal.tsx @@ -1,10 +1,49 @@ import React, { useState, useEffect, useRef } from 'react'; -import { Box, Typography, Modal, Button, CircularProgress, TextField } from '@mui/material'; +import { Box, Typography, Modal, Button, CircularProgress, TextField, InputAdornment } from '@mui/material'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import { useAppSelector } from '@/shared/hooks'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { API_BASE } from '@/shared/config'; import { trackEvent } from '@/shared/analytics'; +// Email validation: format check + typo correction for common domains. +// Real ownership verification is intentionally pushed downstream (mailing list / +// CRM system handles the confirm-subscription flow). +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; + +const COMMON_DOMAIN_TYPOS: Record = { + 'gmial.com': 'gmail.com', + 'gmai.com': 'gmail.com', + 'gnail.com': 'gmail.com', + 'gmaill.com': 'gmail.com', + 'gmail.co': 'gmail.com', + 'gmail.cm': 'gmail.com', + 'yahooo.com': 'yahoo.com', + 'yaho.com': 'yahoo.com', + 'yahoo.co': 'yahoo.com', + 'hotmial.com': 'hotmail.com', + 'hotmai.com': 'hotmail.com', + 'hotmail.co': 'hotmail.com', + 'outlok.com': 'outlook.com', + 'outloook.com': 'outlook.com', + 'iclould.com': 'icloud.com', + 'iclud.com': 'icloud.com', + 'protonmial.com': 'protonmail.com', +}; + +function getEmailSuggestion(email: string): string | null { + const at = email.lastIndexOf('@'); + if (at < 0) return null; + const domain = email.slice(at + 1).toLowerCase(); + const correction = COMMON_DOMAIN_TYPOS[domain]; + if (!correction) return null; + return email.slice(0, at + 1) + correction; +} + +function isValidEmail(email: string): boolean { + return EMAIL_REGEX.test(email.trim()); +} + const SUBSCRIPTION_PROVIDERS = [ { id: 'claude', name: 'Claude', desc: 'Sonnet, Opus, Haiku', color: '#E8927A', preview: false }, { id: 'gemini-cli', name: 'Gemini', desc: 'Gemini 2.5 Pro & Flash', color: '#4285F4', preview: true }, @@ -48,6 +87,7 @@ const OnboardingModal: React.FC = () => { const [step, setStep] = useState<'profile' | 'connect'>('profile'); const [userName, setUserName] = useState(''); const [userEmail, setUserEmail] = useState(''); + const [emailBlurred, setEmailBlurred] = useState(false); const [useCases, setUseCases] = useState([]); const [useCaseOther, setUseCaseOther] = useState(''); const [referralSource, setReferralSource] = useState(''); @@ -145,9 +185,8 @@ const OnboardingModal: React.FC = () => { setOpen(false); }; - // Save profile and move to connect step - const handleProfileContinue = async () => { - // Save profile to settings + // Actually persist profile + advance to connect step. + const submitProfile = async () => { try { const r = await fetch(`${API_BASE}/settings`); const currentSettings = await r.json(); @@ -184,6 +223,37 @@ const OnboardingModal: React.FC = () => { trackEvent('onboarding.connect_started', { nine_router_ready: nineRouterReady }); }; + // Whether all required profile fields are filled in. + const isProfileComplete = (() => { + const trimmedName = userName.trim(); + const trimmedEmail = userEmail.trim(); + if (!trimmedName) return false; + if (!trimmedEmail || !isValidEmail(trimmedEmail)) return false; + if (useCases.length === 0) return false; + if (useCases.includes('Other') && !useCaseOther.trim()) return false; + if (!referralSource) return false; + if (referralSource === 'Other' && !referralSourceOther.trim()) return false; + return true; + })(); + + // Continue: gate on full profile completion, then submit. + const handleProfileContinue = async () => { + const trimmed = userEmail.trim(); + // Invalid format with non-empty value — refuse and force error state. + if (trimmed && !isValidEmail(trimmed)) { + setEmailBlurred(true); + trackEvent('onboarding.email_invalid_blocked', { value_length: trimmed.length }); + return; + } + if (!isProfileComplete) return; + submitProfile(); + }; + + const handleApplySuggestion = (suggested: string) => { + setUserEmail(suggested); + trackEvent('onboarding.email_suggestion_applied'); + }; + // Same connect logic as Settings/SubscriptionCards const handleConnect = async (providerId: string) => { // Cancel any previous attempt @@ -341,25 +411,67 @@ const OnboardingModal: React.FC = () => { '& .MuiOutlinedInput-input::placeholder': { color: c.text.ghost, opacity: 1 }, }} /> - setUserEmail(e.target.value)} - size="small" - fullWidth - sx={{ - '& .MuiOutlinedInput-root': { - fontSize: '0.82rem', - color: c.text.primary, - borderRadius: `${c.radius.md}px`, - '& fieldset': { borderColor: c.border.subtle }, - '&:hover fieldset': { borderColor: c.border.medium }, - '&.Mui-focused fieldset': { borderColor: c.accent.primary }, - }, - '& .MuiOutlinedInput-input::placeholder': { color: c.text.ghost, opacity: 1 }, - }} - /> + {(() => { + const trimmed = userEmail.trim(); + const valid = trimmed.length > 0 && isValidEmail(trimmed); + const showError = emailBlurred && trimmed.length > 0 && !valid; + const suggestion = valid ? getEmailSuggestion(trimmed) : null; + return ( + + setUserEmail(e.target.value)} + onBlur={() => setEmailBlurred(true)} + error={showError} + size="small" + fullWidth + InputProps={{ + endAdornment: valid ? ( + + + + ) : undefined, + }} + sx={{ + '& .MuiOutlinedInput-root': { + fontSize: '0.82rem', + color: c.text.primary, + borderRadius: `${c.radius.md}px`, + '& fieldset': { borderColor: showError ? c.status.error : c.border.subtle }, + '&:hover fieldset': { borderColor: showError ? c.status.error : c.border.medium }, + '&.Mui-focused fieldset': { borderColor: showError ? c.status.error : c.accent.primary }, + }, + '& .MuiOutlinedInput-input::placeholder': { color: c.text.ghost, opacity: 1 }, + }} + /> + {showError && ( + + That doesn't look like a valid email address + + )} + {suggestion && ( + + Did you mean{' '} + handleApplySuggestion(suggestion)} + sx={{ + color: c.accent.primary, + fontWeight: 600, + cursor: 'pointer', + '&:hover': { textDecoration: 'underline' }, + }} + > + {suggestion} + + ? + + )} + + ); + })()} What will you use OpenSwarm for? @@ -457,11 +569,13 @@ const OnboardingModal: React.FC = () => {