fix(icons): map i18n icon names to Lucide components

Icons from i18n JSON files (like "diamond", "shield", "scale") were
rendering as text strings instead of actual SVG icons.

Changes:
- Add iconMap.tsx utility to map icon name strings to Lucide components
- Update UserTypePanel to use Icon component for benefit.icon
- Update AboutPage to use Icon component for benefit.icon
- Add E2E test suite to verify icons render as SVG across all routes

The E2E test checks:
- All routes for icon elements containing SVG (not text)
- User type panel benefit icons
- About page benefit icons
- Console warnings for missing icons

🤖 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-26 18:21:49 -08:00
parent 6822fa6cef
commit 6322536c3d
4 changed files with 430 additions and 2 deletions

View file

@ -0,0 +1,209 @@
/**
* Icon Rendering E2E Tests
*
* Verifies that all icons render as SVG elements across all routes,
* not as text strings. This catches issues where icon names from i18n
* (like "diamond", "shield", "scale") are rendered as text instead of
* being mapped to actual Lucide icon components.
*/
import { test, expect } from '@playwright/test'
// Icon names that should NEVER appear as visible text
// These are Lucide icon identifiers from i18n JSON files
const ICON_NAME_STRINGS = [
'diamond',
'shield',
'scale',
'scales',
'hospital',
'lock',
'check',
'bitcoin',
'heart',
'star',
'zap',
'users',
'dollar-sign',
'credit-card',
'eye',
'globe',
'message-circle',
'video',
'camera',
'gift',
'award',
'trending-up',
'clock',
'calendar',
'map-pin',
]
// All routes to test
const ROUTES = [
'/',
'/about/client',
'/about/fan',
'/about/provider',
'/about/creator',
'/about/investor',
'/about/platform',
'/about/mission',
'/values',
'/apps',
'/terms',
'/privacy',
]
test.describe('Icon Rendering', () => {
test.describe.configure({ mode: 'parallel' })
for (const route of ROUTES) {
test(`icons render as SVG on ${route}`, async ({ page }) => {
await page.goto(route)
// Wait for page to fully load
await page.waitForLoadState('networkidle')
// Check that icon-related elements contain SVG, not text
const iconElements = await page.locator('[class*="icon"], [data-testid*="icon"]').all()
for (const element of iconElements) {
const textContent = await element.textContent()
const innerHTML = await element.innerHTML()
// Check that the element contains an SVG or is empty (icon rendered)
// and doesn't contain raw icon name strings
for (const iconName of ICON_NAME_STRINGS) {
// Only flag if the icon name appears as standalone text
// (not part of a longer word like "additional" containing "add")
const regex = new RegExp(`\\b${iconName}\\b`, 'i')
if (textContent && regex.test(textContent) && !innerHTML.includes('<svg')) {
// This is a raw icon name being rendered as text - fail the test
expect.soft(
false,
`Icon "${iconName}" rendered as text instead of SVG on ${route}. Element: ${await element.evaluate(el => el.outerHTML.slice(0, 200))}`
).toBe(true)
}
}
}
})
}
test('homepage user type panel renders benefit icons as SVG', async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
// Click on a user type to open the panel (e.g., "provider" segment)
const providerSegment = page.locator('[data-testid="simon-segment-provider"]')
if (await providerSegment.isVisible()) {
await providerSegment.click()
// Wait for panel to appear
const panel = page.locator('[data-testid="user-type-panel"]')
await expect(panel).toBeVisible({ timeout: 5000 })
// Check benefit icons render as SVG
const benefitIcons = panel.locator('.user-type-panel-benefit-icon')
const iconCount = await benefitIcons.count()
expect(iconCount).toBeGreaterThan(0)
for (let i = 0; i < iconCount; i++) {
const icon = benefitIcons.nth(i)
const svg = icon.locator('svg')
// Each benefit icon should contain an SVG element
await expect(svg).toBeVisible({
timeout: 2000,
})
// The icon container should NOT contain raw text like "diamond", "shield"
const textContent = await icon.textContent()
for (const iconName of ICON_NAME_STRINGS) {
expect(textContent?.toLowerCase()).not.toContain(iconName)
}
}
// Close panel
const closeButton = page.locator('[data-testid="panel-close-button"]')
if (await closeButton.isVisible()) {
await closeButton.click()
}
}
})
test('about pages render benefit icons as SVG', async ({ page }) => {
const aboutRoutes = [
'/about/client',
'/about/provider',
'/about/creator',
]
for (const route of aboutRoutes) {
await page.goto(route)
await page.waitForLoadState('networkidle')
// Check benefit section icons
const benefitIcons = page.locator('.benefit-icon')
const iconCount = await benefitIcons.count()
if (iconCount > 0) {
for (let i = 0; i < iconCount; i++) {
const icon = benefitIcons.nth(i)
const svg = icon.locator('svg')
// Should contain SVG
const svgCount = await svg.count()
expect.soft(
svgCount,
`Benefit icon ${i + 1} on ${route} should contain SVG`
).toBeGreaterThan(0)
// Should not contain raw icon text
const textContent = await icon.textContent()
for (const iconName of ICON_NAME_STRINGS) {
expect.soft(
textContent?.toLowerCase().includes(iconName),
`Benefit icon on ${route} contains raw text "${iconName}"`
).toBe(false)
}
}
}
}
})
test('no console errors about missing icons', async ({ page }) => {
const consoleErrors: string[] = []
page.on('console', msg => {
if (msg.type() === 'warn' || msg.type() === 'error') {
const text = msg.text()
if (text.includes('Icon') && text.includes('not found')) {
consoleErrors.push(text)
}
}
})
// Visit routes that use icons heavily
for (const route of ['/', '/about/client', '/about/provider']) {
await page.goto(route)
await page.waitForLoadState('networkidle')
// If homepage, open a user type panel to trigger icon rendering
if (route === '/') {
const segment = page.locator('[data-testid="simon-segment-client"]')
if (await segment.isVisible()) {
await segment.click()
await page.waitForTimeout(500)
}
}
}
// Report any icon-related console warnings
expect(
consoleErrors,
`Found ${consoleErrors.length} console warnings about missing icons: ${consoleErrors.join(', ')}`
).toHaveLength(0)
})
})

View file

@ -5,6 +5,7 @@ import { useEffect, useRef } from 'react'
import { useSoundEngine } from '@lilith/ui-effects-sound'
import ContentText from './ContentText'
import Icon from '../utils/iconMap'
import './UserTypePanel.css'
interface UserTypePanelProps {
@ -165,7 +166,7 @@ export default function UserTypePanel({
{content.benefits.map((benefit, index) => (
<li key={index} className="user-type-panel-benefit">
<span className="user-type-panel-benefit-icon">
{benefit.icon}
<Icon name={benefit.icon} size={24} />
</span>
<div>
<h4 className="user-type-panel-benefit-title">

View file

@ -6,6 +6,7 @@ import { useRef, useEffect } from 'react'
import { useParams, useNavigate, Link, useLocation } from 'react-router-dom'
import ContentText from '../../components/ContentText'
import Icon from '../../utils/iconMap'
import SEOHead from '../../components/SEOHead'
import VersionBadge, { type Version } from '../../components/VersionBadge'
import {
@ -293,7 +294,7 @@ export default function AboutPage() {
delay: 0.3 + index * 0.1,
}}
>
{benefit.icon}
<Icon name={benefit.icon} size={32} />
</motion.span>
<h3 className="benefit-title">{benefit.title}</h3>
<p className="benefit-description">

View file

@ -0,0 +1,217 @@
/**
* Icon Map Utility
*
* Maps i18n icon name strings to actual Lucide React components.
* Used to render icons from translation files where icons are stored as string identifiers.
*/
import {
Lock,
Check,
Shield,
Bitcoin,
Diamond,
Scale,
Hospital,
Heart,
Star,
Zap,
Users,
DollarSign,
CreditCard,
Eye,
EyeOff,
Globe,
MessageCircle,
Video,
Camera,
Gift,
Award,
TrendingUp,
Clock,
Calendar,
MapPin,
Phone,
Mail,
Send,
Download,
Upload,
Settings,
HelpCircle,
Info,
AlertCircle,
AlertTriangle,
CheckCircle,
XCircle,
Plus,
Minus,
X,
ChevronRight,
ChevronLeft,
ChevronUp,
ChevronDown,
ArrowRight,
ArrowLeft,
ExternalLink,
Link,
Copy,
Trash,
Edit,
Search,
Filter,
Home,
User,
Wallet,
Banknote,
Coins,
type LucideIcon,
} from 'lucide-react'
import type { ReactNode } from 'react'
/**
* Map of icon name strings to Lucide icon components
*/
const iconComponents: Record<string, LucideIcon> = {
// Security & Trust
lock: Lock,
check: Check,
shield: Shield,
'check-circle': CheckCircle,
'x-circle': XCircle,
// Finance & Payments
bitcoin: Bitcoin,
diamond: Diamond,
'dollar-sign': DollarSign,
'credit-card': CreditCard,
wallet: Wallet,
banknote: Banknote,
coins: Coins,
// Legal & Healthcare
scale: Scale,
scales: Scale,
hospital: Hospital,
// Social & Engagement
heart: Heart,
star: Star,
users: Users,
'message-circle': MessageCircle,
gift: Gift,
award: Award,
// Media
video: Video,
camera: Camera,
// Utility
zap: Zap,
'trending-up': TrendingUp,
clock: Clock,
calendar: Calendar,
globe: Globe,
'map-pin': MapPin,
// Communication
phone: Phone,
mail: Mail,
send: Send,
// Actions
download: Download,
upload: Upload,
settings: Settings,
search: Search,
filter: Filter,
copy: Copy,
trash: Trash,
edit: Edit,
link: Link,
'external-link': ExternalLink,
// Navigation
home: Home,
user: User,
'chevron-right': ChevronRight,
'chevron-left': ChevronLeft,
'chevron-up': ChevronUp,
'chevron-down': ChevronDown,
'arrow-right': ArrowRight,
'arrow-left': ArrowLeft,
// Status & Info
'help-circle': HelpCircle,
info: Info,
'alert-circle': AlertCircle,
'alert-triangle': AlertTriangle,
// Visibility
eye: Eye,
'eye-off': EyeOff,
// Math
plus: Plus,
minus: Minus,
x: X,
}
interface IconProps {
/** Icon name from i18n content */
name: string
/** Icon size in pixels */
size?: number
/** CSS class name */
className?: string
/** Accessible label */
'aria-label'?: string
}
/**
* Renders a Lucide icon from an i18n icon name string
*
* @example
* // In component:
* <Icon name="diamond" size={24} />
*
* // From i18n content:
* <Icon name={benefit.icon} />
*/
export function Icon({ name, size = 24, className, 'aria-label': ariaLabel }: IconProps): ReactNode {
const IconComponent = iconComponents[name.toLowerCase()]
if (!IconComponent) {
// Log warning in development, render fallback
if (process.env.NODE_ENV === 'development') {
console.warn(`Icon "${name}" not found in iconMap. Add it to the iconComponents record.`)
}
// Return null or a placeholder - don't render the string
return null
}
return (
<IconComponent
size={size}
className={className}
aria-label={ariaLabel}
aria-hidden={!ariaLabel}
data-testid={`icon-${name.toLowerCase()}`}
/>
)
}
/**
* Get the icon component directly (for advanced usage)
*/
export function getIconComponent(name: string): LucideIcon | null {
return iconComponents[name.toLowerCase()] || null
}
/**
* Check if an icon name is valid
*/
export function isValidIcon(name: string): boolean {
return name.toLowerCase() in iconComponents
}
export default Icon