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:
parent
6822fa6cef
commit
6322536c3d
4 changed files with 430 additions and 2 deletions
209
features/landing/frontend/e2e/tests/icon-rendering.spec.ts
Normal file
209
features/landing/frontend/e2e/tests/icon-rendering.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
217
features/landing/frontend/src/utils/iconMap.tsx
Normal file
217
features/landing/frontend/src/utils/iconMap.tsx
Normal 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
|
||||
Loading…
Add table
Reference in a new issue