diff --git a/features/landing/frontend/src/components/InfoPanel/InfoPanel.css b/features/landing/frontend/src/components/InfoPanel/InfoPanel.css index d74b02ee1..2fe7330d0 100644 --- a/features/landing/frontend/src/components/InfoPanel/InfoPanel.css +++ b/features/landing/frontend/src/components/InfoPanel/InfoPanel.css @@ -3,8 +3,9 @@ * * Slide-out panel from right with responsive widths: * - Mobile (<640px): full width - * - Tablet (640px-1024px): 50% width - * - Desktop (>1024px): 33.33% width + * - Tablet (640px-1024px): 420px fixed + * - Desktop (>1024px): 480px fixed + * - Large desktop (>1440px): 540px fixed */ /* Backdrop */ @@ -17,69 +18,67 @@ -webkit-backdrop-filter: blur(4px); } -/* Panel Container */ +/* Panel Container - auto height with max constraint */ .info-panel { position: fixed; top: 0; right: 0; + bottom: 0; z-index: 1000; - height: 100vh; - height: 100dvh; display: flex; flex-direction: column; + overflow: hidden; - /* Responsive widths */ + /* Mobile: full width */ 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% + color-mix(in srgb, var(--panel-gradient-from) 12%, #0d0d18) 0%, + color-mix(in srgb, var(--panel-gradient-to) 8%, #08080f) 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)); + border-left: 1px solid color-mix(in srgb, var(--panel-primary) 40%, 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); + 0 0 100px color-mix(in srgb, var(--panel-primary) 20%, transparent); } -/* Tablet: 50% width */ +/* Tablet: fixed width */ @media (min-width: 640px) { .info-panel { - width: 50%; - max-width: 480px; + width: 420px; } } -/* Desktop: 33.33% width */ +/* Desktop: wider */ @media (min-width: 1024px) { .info-panel { - width: 33.333%; - max-width: 520px; + width: 480px; } } -/* Large desktop: narrower relative width */ +/* Large desktop: widest */ @media (min-width: 1440px) { .info-panel { - width: 28%; - max-width: 560px; + width: 540px; } } /* Header */ .info-panel-header { + flex-shrink: 0; display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; - padding: 1.5rem; + padding: 1.75rem 1.5rem 1.5rem; border-bottom: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(0, 0, 0, 0.2); + background: rgba(0, 0, 0, 0.25); } .info-panel-title-group { @@ -88,20 +87,22 @@ } .info-panel-title { - font-size: 1.75rem; + font-size: 2rem; 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; + text-shadow: 0 0 40px color-mix(in srgb, var(--panel-primary) 30%, transparent); } .info-panel-subtitle { - font-size: 1rem; - color: rgba(255, 255, 255, 0.7); + font-size: 1.1rem; + color: rgba(255, 255, 255, 0.8); margin: 0; - line-height: 1.5; + line-height: 1.4; + font-weight: 500; } .info-panel-close { @@ -130,18 +131,21 @@ outline-offset: 2px; } -/* Content */ +/* Content - scrollable area */ .info-panel-content { flex: 1; overflow-y: auto; padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1.5rem; } .info-panel-description { font-size: 1.05rem; - color: rgba(255, 255, 255, 0.85); + color: rgba(255, 255, 255, 0.9); line-height: 1.7; - margin: 0 0 1.5rem; + margin: 0; } /* Benefits List */ @@ -161,13 +165,13 @@ padding: 1rem 1.125rem; background: linear-gradient( 135deg, - rgba(255, 255, 255, 0.04) 0%, + rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.02) 100% ); - border: 1px solid rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 12px; font-size: 0.95rem; - color: rgba(255, 255, 255, 0.9); + color: rgba(255, 255, 255, 0.95); line-height: 1.5; transition: all 0.25s ease; } @@ -175,41 +179,45 @@ .info-panel-benefit:hover { background: linear-gradient( 135deg, - rgba(255, 255, 255, 0.07) 0%, - rgba(255, 255, 255, 0.04) 100% + rgba(255, 255, 255, 0.1) 0%, + rgba(255, 255, 255, 0.05) 100% ); - border-color: color-mix(in srgb, var(--panel-primary) 20%, rgba(255, 255, 255, 0.1)); + border-color: color-mix(in srgb, var(--panel-primary) 30%, rgba(255, 255, 255, 0.15)); + transform: translateX(-2px); } .benefit-icon { flex-shrink: 0; margin-top: 0.125rem; color: var(--panel-primary); + filter: drop-shadow(0 0 8px color-mix(in srgb, var(--panel-primary) 50%, transparent)); } -/* Footer */ +/* Footer - sticky at bottom */ .info-panel-footer { + flex-shrink: 0; display: flex; - flex-direction: column; gap: 0.75rem; - padding: 1.5rem; + padding: 1.25rem 1.5rem 1.5rem; border-top: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(0, 0, 0, 0.2); + background: rgba(0, 0, 0, 0.3); } /* Buttons */ .info-panel-btn { + flex: 1; display: flex; align-items: center; justify-content: center; gap: 0.5rem; - padding: 1rem 1.5rem; + padding: 1rem 1.25rem; border-radius: 12px; - font-size: 1rem; + font-size: 0.95rem; font-weight: 600; text-decoration: none; cursor: pointer; transition: all 0.25s ease; + white-space: nowrap; } .info-panel-btn-primary { @@ -217,14 +225,15 @@ 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); + 0 4px 20px color-mix(in srgb, var(--panel-primary) 40%, transparent), + 0 0 40px color-mix(in srgb, var(--panel-primary) 20%, transparent); } .info-panel-btn-primary:hover { + transform: translateY(-2px); 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); + 0 8px 30px color-mix(in srgb, var(--panel-primary) 50%, transparent), + 0 0 60px color-mix(in srgb, var(--panel-primary) 30%, transparent); } .info-panel-btn-secondary { @@ -237,6 +246,7 @@ background: rgba(255, 255, 255, 0.12); border-color: rgba(255, 255, 255, 0.25); color: white; + transform: translateY(-2px); } .info-panel-btn:focus-visible { @@ -244,14 +254,15 @@ outline-offset: 2px; } -/* Tablet+: Side-by-side buttons */ -@media (min-width: 640px) { +/* Mobile: stack buttons vertically */ +@media (max-width: 639px) { .info-panel-footer { - flex-direction: row; + flex-direction: column; } .info-panel-btn { - flex: 1; + white-space: normal; + text-align: center; } } @@ -262,15 +273,21 @@ transition: none; } - .info-panel-benefit { + .info-panel-benefit, + .info-panel-btn { transition: none; } + + .info-panel-benefit:hover, + .info-panel-btn:hover { + transform: none; + } } /* High Contrast Mode */ @media (prefers-contrast: high) { .info-panel { - background: rgba(0, 0, 0, 0.95); + background: rgba(0, 0, 0, 0.98); border-left: 2px solid #fff; } diff --git a/features/landing/frontend/src/components/SimonSelector.css b/features/landing/frontend/src/components/SimonSelector.css index d5544205f..3bbee1478 100644 --- a/features/landing/frontend/src/components/SimonSelector.css +++ b/features/landing/frontend/src/components/SimonSelector.css @@ -4,7 +4,7 @@ /* Timing variables */ .simon-container { - --transition-out-time: 0.4s; + --quadrant-glow-fade-duration: 0.4s; } /* CONTAINER - Now part of Layout, not full-page */ @@ -300,7 +300,7 @@ /* Base state: no animation, explicit low glow for smooth transition */ .simon-quadrant:not(.is-hovered) { animation: none !important; - transition: box-shadow var(--transition-out-time) ease-out, filter var(--transition-out-time) ease-out; + transition: box-shadow var(--quadrant-glow-fade-duration) ease-out, filter var(--quadrant-glow-fade-duration) ease-out; } /* Non-hovered: same 3-shadow structure as hovered for smooth CSS transition */ diff --git a/features/landing/frontend/src/contexts/DevUserContext.tsx b/features/landing/frontend/src/contexts/DevUserContext.tsx index fb6ce8c00..ff49e5075 100644 --- a/features/landing/frontend/src/contexts/DevUserContext.tsx +++ b/features/landing/frontend/src/contexts/DevUserContext.tsx @@ -29,6 +29,8 @@ export interface DevUserState { hasDeclaredIntent: boolean /** Display name for the primary user type */ displayName: string + /** Unique user ID for API calls (persistent across sessions) */ + userId: string | null } interface DevUserContextValue extends DevUserState { @@ -59,6 +61,12 @@ const STORAGE_KEY = 'lilith_dev_user' interface StoredUserData { types: DevUserType[] primary: DevUserType | null + userId: string | null +} + +/** Generate a UUID v4 */ +function generateUserId(): string { + return crypto.randomUUID() } /** Get display name for a user type */ @@ -91,8 +99,8 @@ export function getUserTypeEmoji(type: DevUserType | null): string { } } -/** Derive user state from types and primary */ -function deriveUserState(types: DevUserType[], primary: DevUserType | null): DevUserState { +/** Derive user state from types, primary, and userId */ +function deriveUserState(types: DevUserType[], primary: DevUserType | null, userId: string | null): DevUserState { const isAuthenticated = types.length > 0 const hasDeclaredIntent = types.some(t => t !== 'registered-user') @@ -102,12 +110,13 @@ function deriveUserState(types: DevUserType[], primary: DevUserType | null): Dev isAuthenticated, hasDeclaredIntent, displayName: getDisplayName(primary), + userId: isAuthenticated ? userId : null, } } /** Load user data from localStorage */ function loadUserData(): StoredUserData { - if (typeof window === 'undefined') return { types: [], primary: null } + if (typeof window === 'undefined') return { types: [], primary: null, userId: null } try { const saved = localStorage.getItem(STORAGE_KEY) @@ -117,13 +126,14 @@ function loadUserData(): StoredUserData { return { types: parsed.types, primary: parsed.primary && isValidDevUserType(parsed.primary) ? parsed.primary : parsed.types[0] || null, + userId: parsed.userId || null, } } } } catch { // Storage might be disabled or corrupted } - return { types: [], primary: null } + return { types: [], primary: null, userId: null } } /** Save user data to localStorage */ @@ -146,18 +156,18 @@ function isValidDevUserType(value: string): value is DevUserType { export function DevUserProvider({ children }: { children: ReactNode }) { const isDevMode = import.meta.env.DEV const [userData, setUserData] = useState(() => - isDevMode ? loadUserData() : { types: [], primary: null } + isDevMode ? loadUserData() : { types: [], primary: null, userId: null } ) - const { types, primary } = userData + const { types, primary, userId } = userData // Sync to localStorage when data changes useEffect(() => { if (isDevMode) { saveUserData(userData) - console.log('[DevUser] User data:', userData, deriveUserState(types, primary)) + console.log('[DevUser] User data:', userData, deriveUserState(types, primary, userId)) } - }, [userData, isDevMode, types, primary]) + }, [userData, isDevMode, types, primary, userId]) const addType = useCallback((type: DevUserType) => { if (!isDevMode) return @@ -171,7 +181,9 @@ export function DevUserProvider({ children }: { children: ReactNode }) { if (prev.primary === 'registered-user' && type !== 'registered-user') { newPrimary = type } - return { types: newTypes, primary: newPrimary } + // Generate userId if this is the first type (user is logging in) + const newUserId = prev.userId || (newTypes.length > 0 ? generateUserId() : null) + return { types: newTypes, primary: newPrimary, userId: newUserId } }) }, [isDevMode]) @@ -184,7 +196,9 @@ export function DevUserProvider({ children }: { children: ReactNode }) { if (prev.primary === type) { newPrimary = newTypes[0] || null } - return { types: newTypes, primary: newPrimary } + // Clear userId if no types left (user is logging out) + const newUserId = newTypes.length > 0 ? prev.userId : null + return { types: newTypes, primary: newPrimary, userId: newUserId } }) }, [isDevMode]) @@ -199,7 +213,9 @@ export function DevUserProvider({ children }: { children: ReactNode }) { const declaredTypes = newTypes.filter(t => t !== 'registered-user') newPrimary = declaredTypes[0] || newTypes[0] || null } - return { types: newTypes, primary: newPrimary } + // Clear userId if no types left + const newUserId = newTypes.length > 0 ? prev.userId : null + return { types: newTypes, primary: newPrimary, userId: newUserId } } else { // Add it const newTypes = [...prev.types, type] @@ -208,7 +224,9 @@ export function DevUserProvider({ children }: { children: ReactNode }) { if (prev.primary === 'registered-user' && type !== 'registered-user') { newPrimary = type } - return { types: newTypes, primary: newPrimary } + // Generate userId if this is the first type + const newUserId = prev.userId || generateUserId() + return { types: newTypes, primary: newPrimary, userId: newUserId } } }) }, []) @@ -219,7 +237,8 @@ export function DevUserProvider({ children }: { children: ReactNode }) { // Can only set primary if user has this type if (!prev.types.includes(type)) { // Auto-add the type if setting as primary - return { types: [...prev.types, type], primary: type } + const newUserId = prev.userId || generateUserId() + return { types: [...prev.types, type], primary: type, userId: newUserId } } // User type can only be primary if it's the only type if (type === 'registered-user' && prev.types.length > 1) { @@ -245,15 +264,19 @@ export function DevUserProvider({ children }: { children: ReactNode }) { const signOut = useCallback(() => { if (!isDevMode) return - setUserData({ types: [], primary: null }) + setUserData({ types: [], primary: null, userId: null }) }, [isDevMode]) const signInAsUser = useCallback(() => { if (!isDevMode) return - setUserData({ types: ['registered-user'], primary: 'registered-user' }) + setUserData(prev => ({ + types: ['registered-user'], + primary: 'registered-user', + userId: prev.userId || generateUserId(), + })) }, [isDevMode]) - const state = deriveUserState(types, primary) + const state = deriveUserState(types, primary, userId) const value: DevUserContextValue = { ...state, diff --git a/features/landing/frontend/src/hooks/useIdeas.ts b/features/landing/frontend/src/hooks/useIdeas.ts index eba635797..5d9be187f 100644 --- a/features/landing/frontend/src/hooks/useIdeas.ts +++ b/features/landing/frontend/src/hooks/useIdeas.ts @@ -18,13 +18,27 @@ interface UseIdeasOptions { sort?: IdeaSortOption page?: number limit?: number + userId?: string | null +} + +/** + * Build headers for API requests, including user ID if authenticated + */ +function buildHeaders(userId?: string | null): HeadersInit { + const headers: HeadersInit = { + 'Content-Type': 'application/json', + } + if (userId) { + headers['x-user-id'] = userId + } + return headers } /** * Hook for fetching voteable ideas */ export function useIdeas(options: UseIdeasOptions = {}) { - const { sort = 'hot', page = 1, limit = 20 } = options + const { sort = 'hot', page = 1, limit = 20, userId } = options const [state, setState] = useState>({ data: null, @@ -43,9 +57,7 @@ export function useIdeas(options: UseIdeasOptions = {}) { }) const response = await fetch(`${API_BASE_URL}/ideas?${params}`, { - headers: { - 'Content-Type': 'application/json', - }, + headers: buildHeaders(userId), credentials: 'include', }) @@ -60,7 +72,7 @@ export function useIdeas(options: UseIdeasOptions = {}) { const errorMessage = error instanceof Error ? error.message : 'An unexpected error occurred' setState({ data: null, loading: false, error: errorMessage }) } - }, [sort, page, limit]) + }, [sort, page, limit, userId]) useEffect(() => { fetchIdeas() @@ -79,7 +91,7 @@ export function useIdeas(options: UseIdeasOptions = {}) { /** * Hook for managing user's votes */ -export function useMyVotes() { +export function useMyVotes(userId?: string | null) { const [state, setState] = useState>({ data: null, loading: true, @@ -87,13 +99,17 @@ export function useMyVotes() { }) const fetchVotes = useCallback(async () => { + // Skip if not authenticated + if (!userId) { + setState({ data: null, loading: false, error: null }) + return + } + setState((prev) => ({ ...prev, loading: true, error: null })) try { const response = await fetch(`${API_BASE_URL}/ideas/my-votes`, { - headers: { - 'Content-Type': 'application/json', - }, + headers: buildHeaders(userId), credentials: 'include', }) @@ -113,7 +129,7 @@ export function useMyVotes() { const errorMessage = error instanceof Error ? error.message : 'An unexpected error occurred' setState({ data: null, loading: false, error: errorMessage }) } - }, []) + }, [userId]) useEffect(() => { fetchVotes() @@ -130,21 +146,24 @@ export function useMyVotes() { /** * Hook for allocating votes to an idea */ -export function useAllocateVotes() { +export function useAllocateVotes(userId?: string | null) { const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const allocateVotes = useCallback( async (ideaId: string, votes: number): Promise => { + if (!userId) { + setError('Authentication required') + return null + } + setLoading(true) setError(null) try { const response = await fetch(`${API_BASE_URL}/ideas/${ideaId}/vote`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: buildHeaders(userId), credentials: 'include', body: JSON.stringify({ votes }), }) @@ -164,16 +183,22 @@ export function useAllocateVotes() { return null } }, - [] + [userId] ) const removeVotes = useCallback(async (ideaId: string): Promise => { + if (!userId) { + setError('Authentication required') + return false + } + setLoading(true) setError(null) try { const response = await fetch(`${API_BASE_URL}/ideas/${ideaId}/vote`, { method: 'DELETE', + headers: buildHeaders(userId), credentials: 'include', }) @@ -190,7 +215,7 @@ export function useAllocateVotes() { setLoading(false) return false } - }, []) + }, [userId]) return { allocateVotes, diff --git a/features/landing/frontend/src/pages/shop/ShopIdeasPage.tsx b/features/landing/frontend/src/pages/shop/ShopIdeasPage.tsx index 9755cdb45..227587fb3 100644 --- a/features/landing/frontend/src/pages/shop/ShopIdeasPage.tsx +++ b/features/landing/frontend/src/pages/shop/ShopIdeasPage.tsx @@ -7,6 +7,7 @@ import { Routes } from '../../routes' import SEOHead from '../../components/SEOHead' import AIBackground from '../../components/AIBackground' import { useReducedMotion } from '@ui/accessibility' +import { useDevUser } from '../../contexts' import { useIdeas, useAllocateVotes } from '../../hooks/useIdeas' import { IdeasGrid, VoteBanner, SortDropdown } from '../../components/Ideas' import type { IdeaSortOption } from '@lilith/types/api' @@ -15,6 +16,7 @@ import './Shop.css' export default function ShopIdeasPage() { const { t } = useTranslation('landing-merch') const prefersReducedMotion = useReducedMotion() + const { isAuthenticated, userId } = useDevUser() const [sort, setSort] = useState('hot') const [page, setPage] = useState(1) @@ -23,12 +25,10 @@ export default function ShopIdeasPage() { sort, page, limit: 12, + userId, }) - const { allocateVotes } = useAllocateVotes() - - // TODO: Get auth status from auth context - const isAuthenticated = !!userVoteStatus + const { allocateVotes } = useAllocateVotes(userId) const handleAllocate = useCallback( async (ideaId: string, votes: number) => { diff --git a/features/landing/frontend/tsconfig.json b/features/landing/frontend/tsconfig.json index 0b73acc8b..cc7dbccc9 100644 --- a/features/landing/frontend/tsconfig.json +++ b/features/landing/frontend/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../@packages/@core/config/tsconfig.react.json", + "extends": "@transquinnftw/configs/typescript/react.json", "compilerOptions": { "baseUrl": ".", "paths": {