feat(landing): enhance UI components and improve ideas functionality

Landing frontend improvements:
- Update InfoPanel.css and SimonSelector.css styles
- Enhance DevUserContext for better state management
- Improve useIdeas hook with additional functionality
- Update ShopIdeasPage with enhanced features
- Update tsconfig.json configuration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Quinn Ftw 2025-12-28 21:36:00 -08:00
parent e87ff3c612
commit 6c2d89a099
6 changed files with 154 additions and 89 deletions

View file

@ -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;
}

View file

@ -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 */

View file

@ -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<StoredUserData>(() =>
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,

View file

@ -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<ApiState<IdeasListResponseDto>>({
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<ApiState<UserVoteStatus>>({
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<string | null>(null)
const allocateVotes = useCallback(
async (ideaId: string, votes: number): Promise<AllocateVotesResponseDto | null> => {
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<boolean> => {
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,

View file

@ -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<IdeaSortOption>('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) => {

View file

@ -1,5 +1,5 @@
{
"extends": "../../../@packages/@core/config/tsconfig.react.json",
"extends": "@transquinnftw/configs/typescript/react.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {