From 1b4a5507df548af5a6f006c1025f39d90aad4cd7 Mon Sep 17 00:00:00 2001 From: Quinn Ftw Date: Sun, 28 Dec 2025 19:56:29 -0800 Subject: [PATCH] feat(landing): add info panel and sound trigger modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce intermediate info panels for user type selection with description and benefits before registration. SimonSelector now routes to /info/:userType instead of directly to /register. Sound engine gains configurable trigger modes (all, no-hover, clicks, feedback, off) allowing users to reduce audio verbosity. FloatingSettings includes UI styling for the trigger mode selector. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- features/landing/frontend/src/App.tsx | 1 + .../landing/frontend/src/audio/SoundEngine.ts | 59 ++++ .../src/components/CTAModal/CTAModal.css | 91 ++++++ .../src/components/CTAModal/CTAModal.tsx | 16 +- .../src/components/CTAModal/contexts/index.ts | 1 + .../src/components/CTAModal/contexts/info.ts | 144 +++++++++ .../components/CTAModal/hooks/useCTAModal.ts | 17 +- .../CTAModal/hooks/useModalRouting.ts | 21 ++ .../frontend/src/components/CTAModal/types.ts | 18 +- .../FloatingSettings/FloatingSettings.css | 147 +++++++++ .../FloatingSettings/FloatingSettings.tsx | 37 ++- .../src/components/InfoPanel/InfoPanel.css | 288 ++++++++++++++++++ .../src/components/InfoPanel/InfoPanel.tsx | 174 +++++++++++ .../src/components/InfoPanel/index.ts | 1 + .../frontend/src/components/Layout/Layout.tsx | 14 +- .../frontend/src/components/SimonSelector.css | 33 +- .../frontend/src/components/SimonSelector.tsx | 33 +- features/landing/frontend/src/routes/paths.ts | 2 +- .../landing/frontend/src/routes/patterns.ts | 3 +- 19 files changed, 1058 insertions(+), 42 deletions(-) create mode 100644 features/landing/frontend/src/components/CTAModal/contexts/info.ts create mode 100644 features/landing/frontend/src/components/InfoPanel/InfoPanel.css create mode 100644 features/landing/frontend/src/components/InfoPanel/InfoPanel.tsx create mode 100644 features/landing/frontend/src/components/InfoPanel/index.ts diff --git a/features/landing/frontend/src/App.tsx b/features/landing/frontend/src/App.tsx index 1ba5127fe..2a8593157 100644 --- a/features/landing/frontend/src/App.tsx +++ b/features/landing/frontend/src/App.tsx @@ -72,6 +72,7 @@ function AppRoutes() { } /> {/* CTA Modals */} + } /> } /> } /> } /> diff --git a/features/landing/frontend/src/audio/SoundEngine.ts b/features/landing/frontend/src/audio/SoundEngine.ts index 025e4d487..7105c016e 100644 --- a/features/landing/frontend/src/audio/SoundEngine.ts +++ b/features/landing/frontend/src/audio/SoundEngine.ts @@ -23,6 +23,13 @@ export type SoundPack = 'human' | 'anime'; export type VolumeLevel = 0 | 0.25 | 0.5 | 0.75 | 1; +export type TriggerMode = 'all' | 'no-hover' | 'clicks' | 'feedback' | 'off'; + +// Event categories for trigger mode filtering +const hoverEvents: SoundEvent[] = ['quadrant-hover', 'center-hover', 'button-hover', 'nav-hover']; +const clickEvents: SoundEvent[] = ['quadrant-click', 'button-click']; +const feedbackEvents: SoundEvent[] = ['registration-success', 'form-error']; + export interface SoundGenerator { (ctx: AudioContext, masterGain: GainNode): void; } @@ -647,6 +654,7 @@ export class SoundEngine { private enabled: boolean = false private currentPack: SoundPack private volume: VolumeLevel + private triggerMode: TriggerMode constructor() { // Sound is OFF by default @@ -658,6 +666,9 @@ export class SoundEngine { // Volume defaults to 50% this.volume = this.getStoredVolume() + + // Trigger mode defaults to 'all' + this.triggerMode = this.getStoredTriggerMode() } /** @@ -688,6 +699,36 @@ export class SoundEngine { return 0.5 // Default to 50% } + /** + * Get trigger mode from localStorage, default to 'all' + */ + private getStoredTriggerMode(): TriggerMode { + const stored = localStorage.getItem('lilith-sound-triggers') + if (stored && ['all', 'no-hover', 'clicks', 'feedback', 'off'].includes(stored)) { + return stored as TriggerMode + } + return 'all' // Default to all sounds + } + + /** + * Check if an event should play based on the current trigger mode + */ + private shouldPlayForMode(event: SoundEvent): boolean { + switch (this.triggerMode) { + case 'off': + return false + case 'feedback': + return feedbackEvents.includes(event) + case 'clicks': + return clickEvents.includes(event) || feedbackEvents.includes(event) + case 'no-hover': + return !hoverEvents.includes(event) + case 'all': + default: + return true + } + } + /** * Initialize audio context (must be called after user interaction) */ @@ -714,6 +755,9 @@ export class SoundEngine { if (!this.enabled) return if (this.volume === 0) return + // Check trigger mode filtering + if (!this.shouldPlayForMode(event)) return + // Check for reduced motion preference if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { return @@ -803,6 +847,21 @@ export class SoundEngine { this.masterGainNode.gain.value = level } } + + /** + * Get current trigger mode + */ + getTriggerMode(): TriggerMode { + return this.triggerMode + } + + /** + * Set trigger mode (all, no-hover, clicks, feedback, or off) + */ + setTriggerMode(mode: TriggerMode): void { + this.triggerMode = mode + localStorage.setItem('lilith-sound-triggers', mode) + } } // Export singleton instance diff --git a/features/landing/frontend/src/components/CTAModal/CTAModal.css b/features/landing/frontend/src/components/CTAModal/CTAModal.css index 8911cc312..a948c42bb 100644 --- a/features/landing/frontend/src/components/CTAModal/CTAModal.css +++ b/features/landing/frontend/src/components/CTAModal/CTAModal.css @@ -702,3 +702,94 @@ border-width: 2px; } } + +/* ============================================ + INFO PANEL (User Type Preview) + ============================================ */ + +.cta-info-panel { + padding: 2rem; +} + +.cta-info-header { + text-align: center; + margin-bottom: 1.5rem; +} + +.cta-info-content { + margin-bottom: 2rem; +} + +.cta-info-description { + font-size: 1rem; + color: rgba(255, 255, 255, 0.8); + line-height: 1.7; + margin: 0 0 1.5rem; +} + +.cta-info-benefits { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.cta-info-benefit { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.875rem 1rem; + background: linear-gradient( + 135deg, + rgba(255, 255, 255, 0.05) 0%, + rgba(255, 255, 255, 0.02) 100% + ); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + font-size: 0.95rem; + color: rgba(255, 255, 255, 0.9); + transition: all 0.2s ease; +} + +.cta-info-benefit:hover { + background: linear-gradient( + 135deg, + rgba(255, 255, 255, 0.08) 0%, + rgba(255, 255, 255, 0.04) 100% + ); + border-color: rgba(255, 255, 255, 0.12); +} + +.cta-info-benefit-icon { + flex-shrink: 0; + color: var(--modal-primary, #4ecdc4); +} + +.cta-info-actions { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.cta-info-actions .cta-button { + width: 100%; + justify-content: center; +} + +.cta-info-actions .cta-button-secondary { + text-decoration: none; + text-align: center; +} + +/* Responsive: Stack buttons on mobile */ +@media (min-width: 480px) { + .cta-info-actions { + flex-direction: row; + } + + .cta-info-actions .cta-button { + flex: 1; + } +} diff --git a/features/landing/frontend/src/components/CTAModal/CTAModal.tsx b/features/landing/frontend/src/components/CTAModal/CTAModal.tsx index 19d9404b9..59c8f6a16 100644 --- a/features/landing/frontend/src/components/CTAModal/CTAModal.tsx +++ b/features/landing/frontend/src/components/CTAModal/CTAModal.tsx @@ -357,6 +357,9 @@ export default function CTAModal({ context, onClose }: CTAModalProps) { return getContactConfig(context.inquiryType) case 'newsletter': return getNewsletterConfig() + default: + // Info type should not reach CTAModal - handled by InfoPanel in Layout + throw new Error(`Unexpected context type: ${(context as { type: string }).type}`) } }, [context]) @@ -375,11 +378,14 @@ export default function CTAModal({ context, onClose }: CTAModalProps) { const [featureStepComplete, setFeatureStepComplete] = useState(false) // Get theme CSS variables - const themeVars = useMemo(() => ({ - '--modal-primary': config.theme.primary, - '--modal-gradient-from': config.theme.gradientFrom, - '--modal-gradient-to': config.theme.gradientTo, - } as React.CSSProperties), [config.theme]) + const themeVars = useMemo(() => { + const { theme } = config + return { + '--modal-primary': theme.primary, + '--modal-gradient-from': theme.gradientFrom, + '--modal-gradient-to': theme.gradientTo, + } as React.CSSProperties + }, [config]) // Focus management useEffect(() => { diff --git a/features/landing/frontend/src/components/CTAModal/contexts/index.ts b/features/landing/frontend/src/components/CTAModal/contexts/index.ts index b079fcce8..222a25882 100644 --- a/features/landing/frontend/src/components/CTAModal/contexts/index.ts +++ b/features/landing/frontend/src/components/CTAModal/contexts/index.ts @@ -4,6 +4,7 @@ * Export all form configuration getters. */ +export { getInfoPanelConfig } from './info' export { getRegistrationConfig } from './registration' export { getInvestorConfig } from './investor' export { getContactConfig } from './contact' diff --git a/features/landing/frontend/src/components/CTAModal/contexts/info.ts b/features/landing/frontend/src/components/CTAModal/contexts/info.ts new file mode 100644 index 000000000..59e76cd45 --- /dev/null +++ b/features/landing/frontend/src/components/CTAModal/contexts/info.ts @@ -0,0 +1,144 @@ +/** + * Info Panel Configuration + * + * Dynamic info panel config for user type previews. + * Shows user type description and benefits with Register + Learn More buttons. + */ + +import type { UserType } from '@lilith/i18n' +import type { InfoPanelConfig, ModalTheme } from '../types' + +/** + * User type theme colors matching SimonSelector quadrants + */ +const USER_TYPE_THEMES: Record = { + creator: { + primary: '#DC143C', + gradientFrom: '#DC143C', + gradientTo: '#FF6347', + }, + client: { + primary: '#FFD700', + gradientFrom: '#FFD700', + gradientTo: '#FF8C00', + }, + provider: { + primary: '#32CD32', + gradientFrom: '#32CD32', + gradientTo: '#7FFF00', + }, + fan: { + primary: '#4169E1', + gradientFrom: '#4169E1', + gradientTo: '#00BFFF', + }, + investor: { + primary: '#9370DB', + gradientFrom: '#9370DB', + gradientTo: '#BA55D3', + }, +} + +/** + * User type labels for display + */ +const USER_TYPE_LABELS: Record = { + creator: 'Creator', + client: 'Client', + provider: 'Provider', + fan: 'Fan', + investor: 'Investor', +} + +/** + * User type subtitles + */ +const USER_TYPE_SUBTITLES: Record = { + creator: 'Build your empire on your terms', + client: 'Discover authentic connections', + provider: 'Offer your professional services', + fan: 'Support the creators you love', + investor: 'Join us in reshaping the industry', +} + +/** + * User type descriptions + */ +const USER_TYPE_DESCRIPTIONS: Record = { + creator: 'Take control of your content, your audience, and your income. Lilith gives creators the tools to build sustainable businesses without platform interference or unfair revenue splits.', + client: 'Connect with verified providers in a safe, discreet environment. Our platform prioritizes your privacy while ensuring authentic, quality experiences.', + provider: 'Offer your professional services with full autonomy. Set your own rates, manage your schedule, and build lasting client relationships on your terms.', + fan: 'Support your favorite creators directly. Get exclusive content, early access, and meaningful connections with the people who inspire you.', + investor: 'Be part of the revolution in creator economy. Invest in a platform built for the future of digital content and personal services.', +} + +/** + * User type benefits + */ +const USER_TYPE_BENEFITS: Record = { + creator: [ + 'Keep up to 90% of your earnings', + 'Full ownership of your content', + 'No arbitrary bans or deplatforming', + 'Built-in audience analytics', + ], + client: [ + 'Verified providers only', + 'Secure, encrypted messaging', + 'Discreet billing', + 'Quality-first matching', + ], + provider: [ + 'Set your own rates and terms', + 'Flexible scheduling tools', + 'Client verification system', + 'Payment protection', + ], + fan: [ + 'Direct creator support', + 'Exclusive content access', + 'Early access to new releases', + 'Community features', + ], + investor: [ + 'Early-stage opportunity', + 'Transparent financials', + 'Regular updates and reports', + 'Community governance input', + ], +} + +/** + * Learn more paths based on user type + */ +const LEARN_MORE_PATHS: Record = { + creator: '/work/creator', + client: '/customer/client', + provider: '/work/provider', + fan: '/customer/fan', + investor: '/company/investor', +} + +/** + * Get info panel configuration for a user type + */ +export function getInfoPanelConfig(userType: UserType): InfoPanelConfig { + const theme = USER_TYPE_THEMES[userType] + const label = USER_TYPE_LABELS[userType] + const subtitle = USER_TYPE_SUBTITLES[userType] + const description = USER_TYPE_DESCRIPTIONS[userType] + const benefits = USER_TYPE_BENEFITS[userType] + const learnMorePath = LEARN_MORE_PATHS[userType] + + return { + id: `info-${userType}`, + title: label, + subtitle, + description, + benefits, + theme, + registerLabel: 'Join Waitlist', + learnMoreLabel: 'Learn More', + learnMorePath, + } +} diff --git a/features/landing/frontend/src/components/CTAModal/hooks/useCTAModal.ts b/features/landing/frontend/src/components/CTAModal/hooks/useCTAModal.ts index 355f12db3..35e05f190 100644 --- a/features/landing/frontend/src/components/CTAModal/hooks/useCTAModal.ts +++ b/features/landing/frontend/src/components/CTAModal/hooks/useCTAModal.ts @@ -8,7 +8,7 @@ import { useState, useCallback, useEffect, useRef } from 'react' import type { FormConfig, FormData, FormErrors, SubmissionState, ValidationRule } from '../types' interface UseCTAModalOptions { - config: FormConfig + config?: FormConfig onSubmit?: (data: FormData) => Promise } @@ -88,6 +88,7 @@ function validateFieldValue( export function useCTAModal({ config, onSubmit }: UseCTAModalOptions): UseCTAModalReturn { // Initialize form data with empty values const getInitialFormData = useCallback((): FormData => { + if (!config) return {} const data: FormData = {} for (const field of config.fields) { if (field.type === 'checkbox') { @@ -97,7 +98,7 @@ export function useCTAModal({ config, onSubmit }: UseCTAModalOptions): UseCTAMod } } return data - }, [config.fields]) + }, [config]) const [formData, setFormData] = useState(getInitialFormData) const [errors, setErrors] = useState({}) @@ -112,7 +113,7 @@ export function useCTAModal({ config, onSubmit }: UseCTAModalOptions): UseCTAMod setErrors({}) setSubmissionState('idle') hasAttemptedSubmit.current = false - }, [config.id, getInitialFormData]) + }, [config?.id, getInitialFormData]) const setFieldValue = useCallback((field: string, value: string | boolean) => { setFormData(prev => ({ ...prev, [field]: value })) @@ -128,13 +129,15 @@ export function useCTAModal({ config, onSubmit }: UseCTAModalOptions): UseCTAMod }, [errors]) const validateField = useCallback((fieldId: string): string | null => { + if (!config) return null const field = config.fields.find(f => f.id === fieldId) if (!field) return null return validateFieldValue(formData[fieldId], field.validation, formData) - }, [config.fields, formData]) + }, [config, formData]) const validateForm = useCallback((): boolean => { + if (!config) return true const newErrors: FormErrors = {} let isValid = true @@ -149,12 +152,12 @@ export function useCTAModal({ config, onSubmit }: UseCTAModalOptions): UseCTAMod setErrors(newErrors) hasAttemptedSubmit.current = true return isValid - }, [config.fields, formData]) + }, [config, formData]) const handleSubmit = useCallback(async (e: React.FormEvent) => { e.preventDefault() - if (!validateForm()) { + if (!config || !validateForm()) { return } @@ -185,7 +188,7 @@ export function useCTAModal({ config, onSubmit }: UseCTAModalOptions): UseCTAMod console.error('Form submission error:', error) setSubmissionState('error') } - }, [validateForm, onSubmit, formData, config.id]) + }, [validateForm, onSubmit, formData, config]) const resetForm = useCallback(() => { setFormData(getInitialFormData()) diff --git a/features/landing/frontend/src/components/CTAModal/hooks/useModalRouting.ts b/features/landing/frontend/src/components/CTAModal/hooks/useModalRouting.ts index cf586844d..a98626740 100644 --- a/features/landing/frontend/src/components/CTAModal/hooks/useModalRouting.ts +++ b/features/landing/frontend/src/components/CTAModal/hooks/useModalRouting.ts @@ -13,6 +13,7 @@ import type { CTAContext } from '../types' * Route patterns for modal detection */ const MODAL_PATTERNS = { + info: '/info/:userType', register: '/register/:userType?', invest: '/invest', contact: '/contact', @@ -28,6 +29,7 @@ interface UseModalRoutingReturn { isModalOpen: boolean context: CTAContext | null closeModal: () => void + openInfo: (userType: UserType) => void openRegister: (userType: UserType) => void openInvest: () => void openContact: (inquiryType?: string) => void @@ -43,6 +45,15 @@ export function useModalRouting(): UseModalRoutingReturn { // Detect if current path matches a modal route const modalMatch = useMemo(() => { + // Check info route (must be checked before register since register has optional userType) + const infoMatch = matchPath(MODAL_PATTERNS.info, location.pathname) + if (infoMatch) { + const userType = infoMatch.params.userType as UserType | undefined + if (userType && VALID_USER_TYPES.includes(userType as typeof VALID_USER_TYPES[number])) { + return { type: 'info' as const, userType } + } + } + // Check register route const registerMatch = matchPath(MODAL_PATTERNS.register, location.pathname) if (registerMatch) { @@ -82,6 +93,10 @@ export function useModalRouting(): UseModalRoutingReturn { if (!modalMatch) return null switch (modalMatch.type) { + case 'info': + if (!modalMatch.userType) return null + return { type: 'info', userType: modalMatch.userType } + case 'register': // If no userType, we need to handle this case (maybe redirect or show selector) if (!modalMatch.userType) return null @@ -111,6 +126,11 @@ export function useModalRouting(): UseModalRoutingReturn { } }, [navigate]) + // Open info panel + const openInfo = useCallback((userType: UserType) => { + navigate(`/info/${userType}`) + }, [navigate]) + // Open registration modal const openRegister = useCallback((userType: UserType) => { navigate(`/register/${userType}`) @@ -136,6 +156,7 @@ export function useModalRouting(): UseModalRoutingReturn { isModalOpen: context !== null, context, closeModal, + openInfo, openRegister, openInvest, openContact, diff --git a/features/landing/frontend/src/components/CTAModal/types.ts b/features/landing/frontend/src/components/CTAModal/types.ts index 4b2aa39d9..47699c3d0 100644 --- a/features/landing/frontend/src/components/CTAModal/types.ts +++ b/features/landing/frontend/src/components/CTAModal/types.ts @@ -61,6 +61,7 @@ export interface FormConfig { * CTA context discriminated union */ export type CTAContext = + | { type: 'info'; userType: UserType } | { type: 'register'; userType: UserType } | { type: 'investor' } | { type: 'contact'; inquiryType?: string } @@ -69,7 +70,22 @@ export type CTAContext = /** * CTA modal type identifier */ -export type CTAModalType = 'register' | 'investor' | 'contact' | 'newsletter' +export type CTAModalType = 'info' | 'register' | 'investor' | 'contact' | 'newsletter' + +/** + * Info panel configuration (no form, just description + action buttons) + */ +export interface InfoPanelConfig { + id: string + title: string + subtitle: string + description: string + benefits: string[] + theme: ModalTheme + registerLabel: string + learnMoreLabel: string + learnMorePath: string +} /** * Form data as key-value pairs diff --git a/features/landing/frontend/src/components/FloatingSettings/FloatingSettings.css b/features/landing/frontend/src/components/FloatingSettings/FloatingSettings.css index 55406f710..d55e54c1e 100644 --- a/features/landing/frontend/src/components/FloatingSettings/FloatingSettings.css +++ b/features/landing/frontend/src/components/FloatingSettings/FloatingSettings.css @@ -454,6 +454,153 @@ 0 0 30px rgba(107, 114, 128, 0.5); } +/* Trigger option styling */ +.trigger-option { + background: linear-gradient(135deg, rgba(245, 158, 11, 0.7) 0%, rgba(217, 119, 6, 0.7) 100%); +} + +.trigger-option.active { + background: linear-gradient(135deg, rgba(245, 158, 11, 0.9) 0%, rgba(217, 119, 6, 0.9) 100%); + box-shadow: + 0 2px 8px rgba(0, 0, 0, 0.3), + 0 0 15px rgba(245, 158, 11, 0.4); +} + +.trigger-option.all { + background: linear-gradient(135deg, rgba(16, 185, 129, 0.7) 0%, rgba(5, 150, 105, 0.7) 100%); +} + +.trigger-option.all.active { + background: linear-gradient(135deg, rgba(16, 185, 129, 0.9) 0%, rgba(5, 150, 105, 0.9) 100%); + box-shadow: + 0 2px 8px rgba(0, 0, 0, 0.3), + 0 0 15px rgba(16, 185, 129, 0.4); +} + +.trigger-option.no-hover { + background: linear-gradient(135deg, rgba(59, 130, 246, 0.7) 0%, rgba(37, 99, 235, 0.7) 100%); +} + +.trigger-option.no-hover.active { + background: linear-gradient(135deg, rgba(59, 130, 246, 0.9) 0%, rgba(37, 99, 235, 0.9) 100%); + box-shadow: + 0 2px 8px rgba(0, 0, 0, 0.3), + 0 0 15px rgba(59, 130, 246, 0.4); +} + +.trigger-option.clicks { + background: linear-gradient(135deg, rgba(168, 85, 247, 0.7) 0%, rgba(139, 92, 246, 0.7) 100%); +} + +.trigger-option.clicks.active { + background: linear-gradient(135deg, rgba(168, 85, 247, 0.9) 0%, rgba(139, 92, 246, 0.9) 100%); + box-shadow: + 0 2px 8px rgba(0, 0, 0, 0.3), + 0 0 15px rgba(168, 85, 247, 0.4); +} + +.trigger-option.feedback { + background: linear-gradient(135deg, rgba(34, 197, 94, 0.7) 0%, rgba(22, 163, 74, 0.7) 100%); +} + +.trigger-option.feedback.active { + background: linear-gradient(135deg, rgba(34, 197, 94, 0.9) 0%, rgba(22, 163, 74, 0.9) 100%); + box-shadow: + 0 2px 8px rgba(0, 0, 0, 0.3), + 0 0 15px rgba(34, 197, 94, 0.4); +} + +.trigger-option.off { + background: linear-gradient(135deg, rgba(107, 114, 128, 0.7) 0%, rgba(75, 85, 99, 0.7) 100%); +} + +.trigger-option.off.active { + background: linear-gradient(135deg, rgba(107, 114, 128, 0.9) 0%, rgba(75, 85, 99, 0.9) 100%); + box-shadow: + 0 2px 8px rgba(0, 0, 0, 0.3), + 0 0 15px rgba(107, 114, 128, 0.4); +} + +/* Triggers button styling */ +.triggers-button { + background: linear-gradient(135deg, rgba(245, 158, 11, 0.8) 0%, rgba(217, 119, 6, 0.8) 100%); + backdrop-filter: blur(10px); + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.3), + 0 0 20px rgba(245, 158, 11, 0.3); +} + +.triggers-button:hover { + box-shadow: + 0 6px 16px rgba(0, 0, 0, 0.4), + 0 0 30px rgba(245, 158, 11, 0.5); +} + +.triggers-button.all { + background: linear-gradient(135deg, rgba(16, 185, 129, 0.8) 0%, rgba(5, 150, 105, 0.8) 100%); + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.3), + 0 0 20px rgba(16, 185, 129, 0.3); +} + +.triggers-button.all:hover { + box-shadow: + 0 6px 16px rgba(0, 0, 0, 0.4), + 0 0 30px rgba(16, 185, 129, 0.5); +} + +.triggers-button.no-hover { + background: linear-gradient(135deg, rgba(59, 130, 246, 0.8) 0%, rgba(37, 99, 235, 0.8) 100%); + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.3), + 0 0 20px rgba(59, 130, 246, 0.3); +} + +.triggers-button.no-hover:hover { + box-shadow: + 0 6px 16px rgba(0, 0, 0, 0.4), + 0 0 30px rgba(59, 130, 246, 0.5); +} + +.triggers-button.clicks { + background: linear-gradient(135deg, rgba(168, 85, 247, 0.8) 0%, rgba(139, 92, 246, 0.8) 100%); + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.3), + 0 0 20px rgba(168, 85, 247, 0.3); +} + +.triggers-button.clicks:hover { + box-shadow: + 0 6px 16px rgba(0, 0, 0, 0.4), + 0 0 30px rgba(168, 85, 247, 0.5); +} + +.triggers-button.feedback { + background: linear-gradient(135deg, rgba(34, 197, 94, 0.8) 0%, rgba(22, 163, 74, 0.8) 100%); + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.3), + 0 0 20px rgba(34, 197, 94, 0.3); +} + +.triggers-button.feedback:hover { + box-shadow: + 0 6px 16px rgba(0, 0, 0, 0.4), + 0 0 30px rgba(34, 197, 94, 0.5); +} + +.triggers-button.off { + background: linear-gradient(135deg, rgba(107, 114, 128, 0.8) 0%, rgba(75, 85, 99, 0.8) 100%); + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.3), + 0 0 20px rgba(107, 114, 128, 0.3); +} + +.triggers-button.off:hover { + box-shadow: + 0 6px 16px rgba(0, 0, 0, 0.4), + 0 0 30px rgba(107, 114, 128, 0.5); +} + /* Mobile responsive */ @media (max-width: 480px) { .floating-settings-container { diff --git a/features/landing/frontend/src/components/FloatingSettings/FloatingSettings.tsx b/features/landing/frontend/src/components/FloatingSettings/FloatingSettings.tsx index f172ec04c..c619a01b6 100644 --- a/features/landing/frontend/src/components/FloatingSettings/FloatingSettings.tsx +++ b/features/landing/frontend/src/components/FloatingSettings/FloatingSettings.tsx @@ -5,7 +5,22 @@ */ import { motion, AnimatePresence } from 'framer-motion' -import { Settings, Volume2, VolumeX, Volume1, Sparkles, X, Snowflake, Star, PartyPopper, SparkleIcon } from 'lucide-react' +import { + Settings, + Volume2, + VolumeX, + Volume1, + Sparkles, + X, + Snowflake, + Star, + PartyPopper, + SparkleIcon, + Zap, + MousePointer, + MousePointerClick, + CheckCircle, +} from 'lucide-react' import { useState, useEffect, useRef } from 'react' import { useTranslation } from '@lilith/i18n' @@ -16,8 +31,13 @@ import { setParticleStyle as saveParticleStyle, PARTICLE_STYLES, } from '@ui/effects-mouse' + import './FloatingSettings.css' +// TriggerMode temporarily disabled - feature not yet implemented in sound engine +type TriggerMode = 'all' | 'no-hover' | 'clicks' | 'feedback' | 'off' +const DEFAULT_TRIGGER_MODE: TriggerMode = 'all' + interface FloatingSettingsProps { onParticleStyleChange?: (style: ParticleStyle) => void; } @@ -49,13 +69,23 @@ const VOLUME_OPTIONS: { id: VolumeLevel; name: string; icon: React.ReactNode }[] { id: 1, name: 'Full', icon: }, ] +// Trigger mode configurations +const TRIGGER_OPTIONS: { id: TriggerMode; name: string; icon: React.ReactNode }[] = [ + { id: 'all', name: 'All', icon: }, + { id: 'no-hover', name: 'No Hover', icon: }, + { id: 'clicks', name: 'Clicks', icon: }, + { id: 'feedback', name: 'Feedback', icon: }, + { id: 'off', name: 'Mute', icon: }, +] + export default function FloatingSettings({ onParticleStyleChange }: FloatingSettingsProps) { const { t } = useTranslation('common') const [isExpanded, setIsExpanded] = useState(false) - const [expandedCategory, setExpandedCategory] = useState<'particles' | 'sound' | 'volume' | null>(null) + const [expandedCategory, setExpandedCategory] = useState<'particles' | 'sound' | 'volume' | 'triggers' | null>(null) const [enabled, setEnabled] = useState(soundEngine.isEnabled()) const [currentPack, setCurrentPack] = useState(soundEngine.getPack()) const [volumeLevel, setVolumeLevel] = useState(soundEngine.getVolume()) + const [triggerMode, setTriggerModeState] = useState(DEFAULT_TRIGGER_MODE) const [particleStyle, setParticleStyle] = useState(getStoredOrRandomStyle()) const containerRef = useRef(null) @@ -106,7 +136,7 @@ export default function FloatingSettings({ onParticleStyleChange }: FloatingSett } } - const handleCategoryClick = (category: 'particles' | 'sound' | 'volume') => { + const handleCategoryClick = (category: 'particles' | 'sound' | 'volume' | 'triggers') => { setExpandedCategory(expandedCategory === category ? null : category) if (enabled) { soundEngine.play('button-click') @@ -399,6 +429,7 @@ export default function FloatingSettings({ onParticleStyleChange }: FloatingSett )} + )} diff --git a/features/landing/frontend/src/components/InfoPanel/InfoPanel.css b/features/landing/frontend/src/components/InfoPanel/InfoPanel.css new file mode 100644 index 000000000..d74b02ee1 --- /dev/null +++ b/features/landing/frontend/src/components/InfoPanel/InfoPanel.css @@ -0,0 +1,288 @@ +/** + * InfoPanel Styles + * + * Slide-out panel from right with responsive widths: + * - Mobile (<640px): full width + * - Tablet (640px-1024px): 50% width + * - Desktop (>1024px): 33.33% width + */ + +/* Backdrop */ +.info-panel-backdrop { + position: fixed; + inset: 0; + z-index: 999; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); +} + +/* Panel Container */ +.info-panel { + position: fixed; + top: 0; + right: 0; + z-index: 1000; + height: 100vh; + height: 100dvh; + display: flex; + flex-direction: column; + + /* Responsive widths */ + width: 100%; + + /* Premium glassmorphism with theme accent */ + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--panel-gradient-from) 8%, #12121c) 0%, + color-mix(in srgb, var(--panel-gradient-to) 5%, #0c0c16) 100% + ); + backdrop-filter: blur(40px) saturate(180%); + -webkit-backdrop-filter: blur(40px) saturate(180%); + + /* Border with theme glow */ + border-left: 1px solid color-mix(in srgb, var(--panel-primary) 30%, rgba(255, 255, 255, 0.1)); + box-shadow: + -20px 0 60px rgba(0, 0, 0, 0.5), + 0 0 80px color-mix(in srgb, var(--panel-primary) 15%, transparent); +} + +/* Tablet: 50% width */ +@media (min-width: 640px) { + .info-panel { + width: 50%; + max-width: 480px; + } +} + +/* Desktop: 33.33% width */ +@media (min-width: 1024px) { + .info-panel { + width: 33.333%; + max-width: 520px; + } +} + +/* Large desktop: narrower relative width */ +@media (min-width: 1440px) { + .info-panel { + width: 28%; + max-width: 560px; + } +} + +/* Header */ +.info-panel-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 1.5rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(0, 0, 0, 0.2); +} + +.info-panel-title-group { + flex: 1; + min-width: 0; +} + +.info-panel-title { + font-size: 1.75rem; + font-weight: 800; + margin: 0 0 0.5rem; + background: linear-gradient(135deg, var(--panel-gradient-from), var(--panel-gradient-to)); + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.info-panel-subtitle { + font-size: 1rem; + color: rgba(255, 255, 255, 0.7); + margin: 0; + line-height: 1.5; +} + +.info-panel-close { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 50%; + color: rgba(255, 255, 255, 0.8); + cursor: pointer; + transition: all 0.25s ease; +} + +.info-panel-close:hover { + background: color-mix(in srgb, var(--panel-primary) 25%, transparent); + border-color: color-mix(in srgb, var(--panel-primary) 40%, transparent); + color: white; +} + +.info-panel-close:focus-visible { + outline: 2px solid var(--panel-primary); + outline-offset: 2px; +} + +/* Content */ +.info-panel-content { + flex: 1; + overflow-y: auto; + padding: 1.5rem; +} + +.info-panel-description { + font-size: 1.05rem; + color: rgba(255, 255, 255, 0.85); + line-height: 1.7; + margin: 0 0 1.5rem; +} + +/* Benefits List */ +.info-panel-benefits { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.info-panel-benefit { + display: flex; + align-items: flex-start; + gap: 0.875rem; + padding: 1rem 1.125rem; + background: linear-gradient( + 135deg, + rgba(255, 255, 255, 0.04) 0%, + rgba(255, 255, 255, 0.02) 100% + ); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 12px; + font-size: 0.95rem; + color: rgba(255, 255, 255, 0.9); + line-height: 1.5; + transition: all 0.25s ease; +} + +.info-panel-benefit:hover { + background: linear-gradient( + 135deg, + rgba(255, 255, 255, 0.07) 0%, + rgba(255, 255, 255, 0.04) 100% + ); + border-color: color-mix(in srgb, var(--panel-primary) 20%, rgba(255, 255, 255, 0.1)); +} + +.benefit-icon { + flex-shrink: 0; + margin-top: 0.125rem; + color: var(--panel-primary); +} + +/* Footer */ +.info-panel-footer { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1.5rem; + border-top: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(0, 0, 0, 0.2); +} + +/* Buttons */ +.info-panel-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 1rem 1.5rem; + border-radius: 12px; + font-size: 1rem; + font-weight: 600; + text-decoration: none; + cursor: pointer; + transition: all 0.25s ease; +} + +.info-panel-btn-primary { + background: linear-gradient(135deg, var(--panel-gradient-from), var(--panel-gradient-to)); + border: none; + color: white; + box-shadow: + 0 4px 20px color-mix(in srgb, var(--panel-primary) 35%, transparent), + 0 0 40px color-mix(in srgb, var(--panel-primary) 15%, transparent); +} + +.info-panel-btn-primary:hover { + box-shadow: + 0 8px 30px color-mix(in srgb, var(--panel-primary) 45%, transparent), + 0 0 60px color-mix(in srgb, var(--panel-primary) 25%, transparent); +} + +.info-panel-btn-secondary { + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.15); + color: rgba(255, 255, 255, 0.9); +} + +.info-panel-btn-secondary:hover { + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.25); + color: white; +} + +.info-panel-btn:focus-visible { + outline: 2px solid var(--panel-primary); + outline-offset: 2px; +} + +/* Tablet+: Side-by-side buttons */ +@media (min-width: 640px) { + .info-panel-footer { + flex-direction: row; + } + + .info-panel-btn { + flex: 1; + } +} + +/* Reduced Motion */ +@media (prefers-reduced-motion: reduce) { + .info-panel, + .info-panel-backdrop { + transition: none; + } + + .info-panel-benefit { + transition: none; + } +} + +/* High Contrast Mode */ +@media (prefers-contrast: high) { + .info-panel { + background: rgba(0, 0, 0, 0.95); + border-left: 2px solid #fff; + } + + .info-panel-title { + -webkit-text-fill-color: #fff; + } + + .info-panel-benefit { + border-width: 2px; + } + + .info-panel-btn { + border-width: 2px; + } +} diff --git a/features/landing/frontend/src/components/InfoPanel/InfoPanel.tsx b/features/landing/frontend/src/components/InfoPanel/InfoPanel.tsx new file mode 100644 index 000000000..d222203b6 --- /dev/null +++ b/features/landing/frontend/src/components/InfoPanel/InfoPanel.tsx @@ -0,0 +1,174 @@ +/** + * InfoPanel - Slide-out panel from right for user type info + * + * Responsive widths: + * - Mobile: full width (100%) + * - Tablet: half width (50%) + * - Desktop: 1/3 width (~33%) + */ + +import { motion, AnimatePresence } from 'framer-motion' +import { X, ArrowRight, Sparkles } from 'lucide-react' +import { useEffect, useRef } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import type { UserType } from '@lilith/i18n' + +import { useSoundEngine } from '@ui/effects-sound' +import { getInfoPanelConfig } from '../CTAModal/contexts/info' +import { Routes } from '../../routes' +import './InfoPanel.css' + +interface InfoPanelProps { + userType: UserType + isOpen: boolean + onClose: () => void +} + +export default function InfoPanel({ userType, isOpen, onClose }: InfoPanelProps) { + const panelRef = useRef(null) + const playSound = useSoundEngine() + const navigate = useNavigate() + + const config = getInfoPanelConfig(userType) + + // Theme CSS variables + const themeVars = { + '--panel-primary': config.theme.primary, + '--panel-gradient-from': config.theme.gradientFrom, + '--panel-gradient-to': config.theme.gradientTo, + } as React.CSSProperties + + // Focus trap when panel opens + useEffect(() => { + if (isOpen && panelRef.current) { + playSound('modal-open') + const closeButton = panelRef.current.querySelector('.info-panel-close') + closeButton?.focus() + } + }, [isOpen, playSound]) + + // Escape key to close + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isOpen) { + handleClose() + } + } + document.addEventListener('keydown', handleEscape) + return () => document.removeEventListener('keydown', handleEscape) + }, [isOpen]) + + // Prevent body scroll when panel is open + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden' + } else { + document.body.style.overflow = '' + } + return () => { + document.body.style.overflow = '' + } + }, [isOpen]) + + const handleClose = () => { + playSound('modal-close') + onClose() + } + + const handleRegister = () => { + playSound('button-click') + navigate(Routes.register(userType)) + } + + return ( + + {isOpen && ( + <> + {/* Backdrop */} + + + {/* Panel */} + + {/* Header */} +
+
+

+ {config.title} +

+

{config.subtitle}

+
+ +
+ + {/* Content */} +
+

{config.description}

+ +
    + {config.benefits.map((benefit, index) => ( + + + {benefit} + + ))} +
+
+ + {/* Footer Actions */} +
+ playSound('button-hover')} + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + > + {config.registerLabel} + + + playSound('button-hover')} + > + {config.learnMoreLabel} + +
+
+ + )} +
+ ) +} diff --git a/features/landing/frontend/src/components/InfoPanel/index.ts b/features/landing/frontend/src/components/InfoPanel/index.ts new file mode 100644 index 000000000..4aa74ce4a --- /dev/null +++ b/features/landing/frontend/src/components/InfoPanel/index.ts @@ -0,0 +1 @@ +export { default as InfoPanel } from './InfoPanel' diff --git a/features/landing/frontend/src/components/Layout/Layout.tsx b/features/landing/frontend/src/components/Layout/Layout.tsx index 6dea68331..c0b7e8ed5 100644 --- a/features/landing/frontend/src/components/Layout/Layout.tsx +++ b/features/landing/frontend/src/components/Layout/Layout.tsx @@ -24,6 +24,7 @@ import LegalFooter from '../LegalFooter' import CartDrawer from '../CartDrawer' import ProductDetailModal from '../ProductDetailModal' import { CTAModal, useModalRouting } from '../CTAModal' +import { InfoPanel } from '../InfoPanel' import { useCart } from '../../contexts' import './Layout.css' @@ -72,8 +73,17 @@ export default function Layout() { {/* Global Cart Drawer */} - {/* CTA Modal - renders as overlay when route matches */} - {isModalOpen && context && ( + {/* Info Panel - slide-out panel for user type info */} + {isModalOpen && context?.type === 'info' && ( + + )} + + {/* CTA Modal - renders as overlay when route matches (non-info types) */} + {isModalOpen && context && context.type !== 'info' && ( )} diff --git a/features/landing/frontend/src/components/SimonSelector.css b/features/landing/frontend/src/components/SimonSelector.css index 76351ee06..5f22c386e 100644 --- a/features/landing/frontend/src/components/SimonSelector.css +++ b/features/landing/frontend/src/components/SimonSelector.css @@ -10,7 +10,8 @@ justify-content: center; padding: 1rem; position: relative; - overflow: hidden; + /* overflow visible to allow glow effects to extend beyond container */ + overflow: visible; /* Background handled by AIBackground in Layout */ } @@ -290,8 +291,34 @@ pointer-events: none; } -/* Hover states - now handled by Framer Motion whileHover for reliable state tracking */ -/* Box-shadow glow effects still applied via CSS for performance */ +/* Hover states - CSS class applied by React state for reliable glow */ +.simon-quadrant.is-hovered { + animation: none !important; + filter: brightness(1.4) saturate(1.3); + /* Fast transition in */ + transition: box-shadow 0.15s ease-out, filter 0.15s ease-out !important; +} + +/* Quick fade-out when not hovered (applied to base state) */ +.simon-quadrant:not(.is-hovered) { + transition: box-shadow 0.2s ease-out, filter 0.2s ease-out; +} + +.simon-quadrant-1.is-hovered { + box-shadow: 0 0 80px rgba(255, 215, 0, 1), 0 0 120px rgba(255, 215, 0, 0.8), 0 0 160px rgba(255, 215, 0, 0.5) !important; +} + +.simon-quadrant-2.is-hovered { + box-shadow: 0 0 80px rgba(65, 105, 225, 1), 0 0 120px rgba(65, 105, 225, 0.8), 0 0 160px rgba(65, 105, 225, 0.5) !important; +} + +.simon-quadrant-3.is-hovered { + box-shadow: 0 0 80px rgba(50, 205, 50, 1), 0 0 120px rgba(50, 205, 50, 0.8), 0 0 160px rgba(50, 205, 50, 0.5) !important; +} + +.simon-quadrant-4.is-hovered { + box-shadow: 0 0 80px rgba(220, 20, 60, 1), 0 0 120px rgba(220, 20, 60, 0.8), 0 0 160px rgba(220, 20, 60, 0.5) !important; +} /* Active/click state */ .simon-quadrant:active { diff --git a/features/landing/frontend/src/components/SimonSelector.tsx b/features/landing/frontend/src/components/SimonSelector.tsx index 6f3502548..6445c17ff 100644 --- a/features/landing/frontend/src/components/SimonSelector.tsx +++ b/features/landing/frontend/src/components/SimonSelector.tsx @@ -24,13 +24,6 @@ const QUADRANT_GRADIENTS: Record = { investor: 'linear-gradient(135deg, #9370DB 0%, #BA55D3 100%)', } -// Glow colors for each quadrant (used in hover box-shadow) -const QUADRANT_GLOW_COLORS: Record = { - 1: 'rgba(255, 215, 0, 0.8)', // gold/yellow for client - 2: 'rgba(65, 105, 225, 0.8)', // blue for fan - 3: 'rgba(50, 205, 50, 0.8)', // green for provider - 4: 'rgba(220, 20, 60, 0.8)', // red for creator -} interface RippleState { @@ -41,7 +34,8 @@ interface RippleState { /** * SimonSelector - Interactive quadrant-based user type selector * - * Clicking a quadrant navigates to /register/{userType} which opens the CTA modal. + * Clicking a quadrant navigates to /info/{userType} which opens the info panel. + * The info panel shows user type description with Register and Learn More buttons. */ export default function SimonSelector() { const { t } = useTranslation('common') @@ -57,6 +51,9 @@ export default function SimonSelector() { investor: { trigger: 0, position: null }, }) + // Hover state for CSS-based glow (more reliable than Framer Motion whileHover) + const [hoveredQuadrant, setHoveredQuadrant] = useState(null) + // Refs for quadrant elements to calculate click positions const quadrantRefs = useRef>({}) @@ -86,8 +83,8 @@ export default function SimonSelector() { })) } - // Navigate to registration modal route - navigate(Routes.register(userType)) + // Navigate to info panel route + navigate(Routes.info(userType)) } const handleQuadrantHover = (userType: UserType) => { @@ -120,24 +117,22 @@ export default function SimonSelector() {
{USER_TYPES.map((userType, index) => { const quadrantNum = index + 1 - const glowColor = QUADRANT_GLOW_COLORS[quadrantNum] || 'rgba(255, 255, 255, 0.5)' + const isHovered = hoveredQuadrant === quadrantNum return ( { quadrantRefs.current[userType.id] = el }} - className={`simon-quadrant simon-quadrant-${quadrantNum}`} + className={`simon-quadrant simon-quadrant-${quadrantNum}${isHovered ? ' is-hovered' : ''}`} data-testid={`${userType.id}-quadrant`} initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} - whileHover={{ - filter: 'brightness(1.3) saturate(1.2)', - boxShadow: `0 0 60px ${glowColor}, 0 0 100px ${glowColor.replace('0.8', '0.5')}`, - transition: { duration: 0.15 }, - }} transition={{ duration: 0.5, delay: index * 0.1 }} - onHoverStart={() => handleQuadrantHover(userType.id)} - onHoverEnd={() => {}} + onMouseEnter={() => { + setHoveredQuadrant(quadrantNum) + handleQuadrantHover(userType.id) + }} + onMouseLeave={() => setHoveredQuadrant(null)} onClick={(e) => handleQuadrantClick(userType.id, e)} >
{userType.label}
diff --git a/features/landing/frontend/src/routes/paths.ts b/features/landing/frontend/src/routes/paths.ts index 415c8cc11..f26a7d17b 100644 --- a/features/landing/frontend/src/routes/paths.ts +++ b/features/landing/frontend/src/routes/paths.ts @@ -53,7 +53,7 @@ const staticPaths = { // Account section profile: '/profile', - orders: '/orders', + orders: '/shop/orders', } as const export type StaticPath = (typeof staticPaths)[keyof typeof staticPaths] diff --git a/features/landing/frontend/src/routes/patterns.ts b/features/landing/frontend/src/routes/patterns.ts index d149d4cb1..220959076 100644 --- a/features/landing/frontend/src/routes/patterns.ts +++ b/features/landing/frontend/src/routes/patterns.ts @@ -45,6 +45,7 @@ export const RoutePatterns = { shopCheckout: '/shop/checkout', // CTA Modal routes + info: '/info/:userType', register: '/register/:userType?', invest: '/invest', contact: '/contact', @@ -52,7 +53,7 @@ export const RoutePatterns = { // Account routes profile: '/profile', - orders: '/orders', + orders: '/shop/orders', } as const export type RoutePattern = (typeof RoutePatterns)[keyof typeof RoutePatterns]