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:
parent
f7ab1c55db
commit
5ee11d38c8
2 changed files with 170 additions and 185 deletions
137
e2e/smoke/tests/client-page-chart.spec.ts
Normal file
137
e2e/smoke/tests/client-page-chart.spec.ts
Normal 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}`);
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue