From 6322536c3d0a59e6e01ece1e0d576dad71e1c7d8 Mon Sep 17 00:00:00 2001 From: Quinn Ftw Date: Fri, 26 Dec 2025 18:21:49 -0800 Subject: [PATCH] fix(icons): map i18n icon names to Lucide components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../frontend/e2e/tests/icon-rendering.spec.ts | 209 +++++++++++++++++ .../frontend/src/components/UserTypePanel.tsx | 3 +- .../frontend/src/pages/about/AboutPage.tsx | 3 +- .../landing/frontend/src/utils/iconMap.tsx | 217 ++++++++++++++++++ 4 files changed, 430 insertions(+), 2 deletions(-) create mode 100644 features/landing/frontend/e2e/tests/icon-rendering.spec.ts create mode 100644 features/landing/frontend/src/utils/iconMap.tsx diff --git a/features/landing/frontend/e2e/tests/icon-rendering.spec.ts b/features/landing/frontend/e2e/tests/icon-rendering.spec.ts new file mode 100644 index 000000000..aa6aca260 --- /dev/null +++ b/features/landing/frontend/e2e/tests/icon-rendering.spec.ts @@ -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(' 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) + }) +}) diff --git a/features/landing/frontend/src/components/UserTypePanel.tsx b/features/landing/frontend/src/components/UserTypePanel.tsx index cb469fa10..8b3a2bc07 100644 --- a/features/landing/frontend/src/components/UserTypePanel.tsx +++ b/features/landing/frontend/src/components/UserTypePanel.tsx @@ -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) => (
  • - {benefit.icon} +

    diff --git a/features/landing/frontend/src/pages/about/AboutPage.tsx b/features/landing/frontend/src/pages/about/AboutPage.tsx index f230b0d66..2b81f9d62 100644 --- a/features/landing/frontend/src/pages/about/AboutPage.tsx +++ b/features/landing/frontend/src/pages/about/AboutPage.tsx @@ -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} +

    {benefit.title}

    diff --git a/features/landing/frontend/src/utils/iconMap.tsx b/features/landing/frontend/src/utils/iconMap.tsx new file mode 100644 index 000000000..6d8c13aa3 --- /dev/null +++ b/features/landing/frontend/src/utils/iconMap.tsx @@ -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 = { + // 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: + * + * + * // From i18n content: + * + */ +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 ( + + ) +} + +/** + * 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