feat(customer): Add interactive client visualization charts to customer landing page with end-to-end tests

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Lilith 2026-02-25 20:36:08 -08:00
parent f7ab1c55db
commit 5ee11d38c8
2 changed files with 170 additions and 185 deletions

View file

@ -0,0 +1,137 @@
import { test, expect } from '@playwright/test';
import path from 'path';
test('client page: age gate dismiss + StackedBarChart renders', async ({ page }) => {
const consoleErrors: string[] = [];
const allConsoleMessages: Array<{ type: string; text: string }> = [];
page.on('console', (msg) => {
allConsoleMessages.push({ type: msg.type(), text: msg.text() });
if (msg.type() === 'error') {
consoleErrors.push(msg.text());
}
});
page.on('pageerror', (err) => {
consoleErrors.push(`PAGE ERROR: ${err.message}`);
});
// Step 1: Navigate
console.log('\nNavigating to /customers/client...');
await page.goto('http://www.atlilith.local/customers/client', {
waitUntil: 'networkidle',
timeout: 30000,
});
// Step 2: Wait for age gate
await page.waitForTimeout(2000);
// Step 3: Take screenshot before age gate dismissal
await page.screenshot({
path: path.join('test-results', 'client-1-before-agegate.png'),
fullPage: false,
});
console.log('Screenshot 1: before-agegate.png');
// Step 4: Dismiss age gate
const ageGateButton = page.getByRole('button', { name: /i am 18 or older/i });
const ageGateVisible = await ageGateButton.isVisible().catch(() => false);
if (ageGateVisible) {
console.log('Age gate detected — clicking "I am 18 or older"');
await ageGateButton.click();
await page.waitForTimeout(2000);
} else {
console.log('No age gate visible (may be cached from prior session)');
}
// Step 5: Full page screenshot after age gate
await page.screenshot({
path: path.join('test-results', 'client-2-full-page.png'),
fullPage: true,
});
console.log('Screenshot 2: full-page.png (full page after age gate)');
// Step 6: Find and scroll to "Monthly Allowances by Tier" section
const chartHeading = page.locator('text=Monthly Allowances by Tier').first();
const chartHeadingCount = await chartHeading.count();
console.log(`\nChart heading "Monthly Allowances by Tier" found: ${chartHeadingCount > 0}`);
if (chartHeadingCount > 0) {
await chartHeading.scrollIntoViewIfNeeded();
await page.waitForTimeout(800);
await page.screenshot({
path: path.join('test-results', 'client-3-chart-section.png'),
fullPage: false,
});
console.log('Screenshot 3: chart-section.png (scrolled to chart)');
} else {
// Try broader search for any chart-related content
const bodyText = await page.locator('body').innerText();
const chartRelatedLines = bodyText
.split('\n')
.filter((line) =>
line.toLowerCase().includes('allowance') ||
line.toLowerCase().includes('tier') ||
line.toLowerCase().includes('monthly'),
)
.slice(0, 10);
console.log('Chart-related text found on page:', chartRelatedLines);
}
// Step 7: Check for SVG/canvas chart rendering
const svgCount = await page.locator('svg').count();
const canvasCount = await page.locator('canvas').count();
console.log(`\nSVG elements: ${svgCount}`);
console.log(`Canvas elements: ${canvasCount}`);
// Check specifically for recharts (StackedBarChart uses recharts via @lilith/ui-charts)
const rechartsWrapper = await page.locator('.recharts-wrapper').count();
const rechartsBarChart = await page.locator('.recharts-bar-chart, [class*="recharts-bar"]').count();
console.log(`recharts-wrapper elements: ${rechartsWrapper}`);
console.log(`recharts bar elements: ${rechartsBarChart}`);
// Step 8: Report all console errors
console.log(`\n=== Console Errors (${consoleErrors.length} total) ===`);
consoleErrors.forEach((e, i) => console.log(` [${i + 1}] ${e}`));
// Filter for chart/ui-charts specific errors
const chartErrors = consoleErrors.filter(
(e) =>
e.toLowerCase().includes('chart') ||
e.toLowerCase().includes('stacked') ||
e.toLowerCase().includes('recharts') ||
e.toLowerCase().includes('ui-chart'),
);
console.log(`\n=== Chart-specific Errors (${chartErrors.length}) ===`);
chartErrors.forEach((e) => console.log(' ', e));
// Check for import/export errors (module loading failures)
const moduleErrors = consoleErrors.filter(
(e) =>
e.includes('does not provide an export') ||
e.includes('Failed to fetch') ||
e.includes('Cannot find module') ||
e.includes('SyntaxError') ||
e.includes('ReferenceError'),
);
console.log(`\n=== Module Errors (${moduleErrors.length}) ===`);
moduleErrors.forEach((e) => console.log(' ', e));
// Step 9: Assertions
// The page should have rendered content
const bodyText = await page.locator('body').innerText();
expect(bodyText.length, 'Page should have substantial content after age gate').toBeGreaterThan(500);
// No critical module loading errors
expect(
moduleErrors,
'No module loading errors should be present',
).toHaveLength(0);
console.log('\n=== Test Complete ===');
console.log(`Total console errors: ${consoleErrors.length}`);
console.log(`Chart heading visible: ${chartHeadingCount > 0}`);
console.log(`SVG elements rendered: ${svgCount}`);
});

View file

@ -2,14 +2,18 @@
* ClientVisualizations
*
* Data visualizations for the client landing page:
* - ClientAfterStats: Grouped SVG bar chart of monthly allowances per tier
* - ClientAfterStats: Stacked bar chart of monthly allowances per tier
* - ClientAfterBenefits: Horizontal tier progression timeline
*
* Tier data is fetched from the merchant backend via useTiers().
* Uses plain CSS (no styled-components) to match InfoPage pattern.
* Chart rendering delegates to @lilith/ui-charts StackedBarChart.
*/
import { useMemo } from 'react'
import { useReducedMotion } from '@lilith/ui-accessibility'
import { StackedBarChart } from '@lilith/ui-charts'
import type { StackedSeriesConfig, StackedDataPoint } from '@lilith/ui-charts'
import { m } from '@lilith/ui-motion'
import { useTiers } from '@/features/pricing/hooks/useTiers'
@ -33,59 +37,35 @@ function tierColor(slug: string): string {
return TIER_COLORS[slug] ?? 'rgba(255,255,255,0.5)'
}
const CATEGORY_COLORS = {
messages: '#9370db',
discoveries: '#ba55d3',
views: '#ffd700',
} as const
// ---------------------------------------------------------------------------
// Chart constants
// Chart series config — matches the StackedBarChart API
// ---------------------------------------------------------------------------
const CHART_WIDTH = 900
const CHART_HEIGHT = 340
const CHART_MARGIN = { top: 40, right: 20, bottom: 80, left: 60 }
const PLOT_W = CHART_WIDTH - CHART_MARGIN.left - CHART_MARGIN.right
const PLOT_H = CHART_HEIGHT - CHART_MARGIN.top - CHART_MARGIN.bottom
const BARS_PER_GROUP = 3
const GROUP_GAP_RATIO = 0.3
// Log scale to handle the wide range (50 → 8000) while keeping bars visually
// distinct. Bars are sized by log(value) / log(LOG_CEILING).
const LOG_CEILING = 10000
const LOG_MAX = Math.log10(LOG_CEILING)
const Y_TICKS = [10, 50, 100, 500, 1000, 5000]
function barHeight(value: number): number {
if (value <= 0) return 0
const v = value === -1 ? LOG_CEILING : value
return (Math.log10(v) / LOG_MAX) * PLOT_H
}
function barY(value: number): number {
return PLOT_H - barHeight(value)
}
function yTickPos(tick: number): number {
return PLOT_H - (Math.log10(tick) / LOG_MAX) * PLOT_H
}
const CHART_SERIES: StackedSeriesConfig[] = [
{ key: 'messages', name: 'Messages', color: '#9370db' },
{ key: 'discoveries', name: 'Discoveries', color: '#ba55d3' },
{ key: 'views', name: 'Views', color: '#ffd700' },
]
// ---------------------------------------------------------------------------
// ClientAfterStats — Allowance chart from API data
// ClientAfterStats — Allowance chart using @lilith/ui-charts StackedBarChart
// ---------------------------------------------------------------------------
export function ClientAfterStats() {
const prefersReducedMotion = useReducedMotion()
const { tiers, isLoading } = useTiers()
if (isLoading || tiers.length === 0) return null
const chartData: StackedDataPoint[] = useMemo(() =>
tiers.map((tier) => ({
label: tier.name,
messages: tier.features.messagesPerMonth === -1 ? 0 : tier.features.messagesPerMonth,
discoveries: tier.features.profileDiscoveriesPerMonth === -1 ? 0 : tier.features.profileDiscoveriesPerMonth,
views: tier.features.profileViewsPerMonth === -1 ? 0 : tier.features.profileViewsPerMonth,
})),
[tiers],
)
const groupCount = tiers.length
const groupWidth = PLOT_W / groupCount
const bw = (groupWidth * (1 - GROUP_GAP_RATIO)) / BARS_PER_GROUP
if (isLoading || tiers.length === 0) return null
const animProps = prefersReducedMotion
? {}
@ -103,149 +83,17 @@ export function ClientAfterStats() {
<div className="client-viz-section-divider" />
</div>
{/* Legend */}
<div className="client-viz-chart-legend">
{(Object.entries(CATEGORY_COLORS) as [keyof typeof CATEGORY_COLORS, string][]).map(
([key, color]) => (
<span key={key} className="client-viz-legend-item">
<span
className="client-viz-legend-swatch"
style={{ background: color }}
/>
{key.charAt(0).toUpperCase() + key.slice(1)}
</span>
),
)}
</div>
{/* SVG Chart */}
<div className="client-viz-chart-wrapper">
<svg
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
className="client-viz-chart-svg"
role="img"
aria-label="Grouped bar chart showing monthly allowances per subscription tier"
>
<g transform={`translate(${CHART_MARGIN.left}, ${CHART_MARGIN.top})`}>
{/* Y-axis gridlines + labels (log scale) */}
{Y_TICKS.map((tick) => {
const y = yTickPos(tick)
return (
<g key={tick}>
<line
x1={0}
x2={PLOT_W}
y1={y}
y2={y}
stroke="rgba(255,255,255,0.08)"
strokeWidth={1}
/>
<text
x={-8}
y={y + 4}
textAnchor="end"
fill="rgba(255,255,255,0.4)"
fontSize={11}
>
{tick >= 1000 ? `${tick / 1000}k` : tick}
</text>
</g>
)
})}
{/* Top axis line */}
<line
x1={0}
x2={PLOT_W}
y1={0}
y2={0}
stroke="rgba(255,255,255,0.08)"
strokeWidth={1}
/>
{/* Groups — driven by API tier data */}
{tiers.map((tier, gi) => {
const groupX = gi * groupWidth + (groupWidth * GROUP_GAP_RATIO) / 2
const categories: Array<{
key: keyof typeof CATEGORY_COLORS
value: number
}> = [
{ key: 'messages', value: tier.features.messagesPerMonth },
{ key: 'discoveries', value: tier.features.profileDiscoveriesPerMonth },
{ key: 'views', value: tier.features.profileViewsPerMonth },
]
return (
<g key={tier.slug}>
{/* Tier label on X axis */}
<text
x={groupX + (bw * BARS_PER_GROUP) / 2}
y={PLOT_H + 18}
textAnchor="middle"
fill="rgba(255,255,255,0.65)"
fontSize={10}
>
{tier.name}
</text>
{categories.map((cat, bi) => {
const bx = groupX + bi * bw
const bh = barHeight(cat.value)
const by = barY(cat.value)
const isUnlimited = cat.value === -1
return (
<g key={cat.key}>
<rect
x={bx}
y={by}
width={bw - 1}
height={bh}
fill={CATEGORY_COLORS[cat.key]}
opacity={0.85}
rx={2}
/>
{isUnlimited && (
<text
x={bx + (bw - 1) / 2}
y={by - 4}
textAnchor="middle"
fill={CATEGORY_COLORS[cat.key]}
fontSize={12}
fontWeight={700}
>
</text>
)}
{!isUnlimited && cat.value > 0 && (
<text
x={bx + (bw - 1) / 2}
y={by - 4}
textAnchor="middle"
fill="rgba(255,255,255,0.55)"
fontSize={9}
>
{cat.value}
</text>
)}
</g>
)
})}
</g>
)
})}
{/* X axis baseline */}
<line
x1={0}
x2={PLOT_W}
y1={PLOT_H}
y2={PLOT_H}
stroke="rgba(255,255,255,0.15)"
strokeWidth={1}
/>
</g>
</svg>
<StackedBarChart
data={chartData}
series={CHART_SERIES}
width={900}
height={340}
showValues
showLegend
showGrid
showAxes
/>
</div>
</div>
</m.section>