This commit is contained in:
Carl-Gerhard Lindesvärd
2026-03-06 09:00:10 +01:00
parent 765e4aa107
commit 90881e5ffb
68 changed files with 4092 additions and 1694 deletions

218
apps/testbed/src/App.tsx Normal file
View File

@@ -0,0 +1,218 @@
import { useEffect, useState } from 'react';
import { Link, Route, Routes, useNavigate } from 'react-router-dom';
import { op } from './analytics';
import { CartPage } from './pages/Cart';
import { CheckoutPage } from './pages/Checkout';
import { LoginPage, PRESET_GROUPS } from './pages/Login';
import { ProductPage } from './pages/Product';
import { ShopPage } from './pages/Shop';
import type { CartItem, Product, User } from './types';
const PRODUCTS: Product[] = [
{ id: 'p1', name: 'Classic T-Shirt', price: 25, category: 'clothing' },
{ id: 'p2', name: 'Coffee Mug', price: 15, category: 'accessories' },
{ id: 'p3', name: 'Hoodie', price: 60, category: 'clothing' },
{ id: 'p4', name: 'Sticker Pack', price: 10, category: 'accessories' },
{ id: 'p5', name: 'Cap', price: 35, category: 'clothing' },
];
export default function App() {
const navigate = useNavigate();
const [cart, setCart] = useState<CartItem[]>([]);
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
const stored = localStorage.getItem('op_testbed_user');
if (stored) {
const u = JSON.parse(stored) as User;
setUser(u);
op.identify({
profileId: u.id,
firstName: u.firstName,
lastName: u.lastName,
email: u.email,
});
applyGroups(u);
}
op.ready();
}, []);
function applyGroups(u: User) {
op.setGroups(u.groupIds);
for (const id of u.groupIds) {
const meta = PRESET_GROUPS.find((g) => g.id === id);
console.log('meta', meta);
if (meta) {
op.setGroup(id, meta);
}
}
}
function login(u: User) {
localStorage.setItem('op_testbed_user', JSON.stringify(u));
setUser(u);
op.identify({
profileId: u.id,
firstName: u.firstName,
lastName: u.lastName,
email: u.email,
});
applyGroups(u);
op.track('user_login', { method: 'form', group_count: u.groupIds.length });
navigate('/');
}
function logout() {
localStorage.removeItem('op_testbed_user');
op.clear();
setUser(null);
}
function addToCart(product: Product) {
setCart((prev) => {
const existing = prev.find((i) => i.id === product.id);
if (existing) {
return prev.map((i) =>
i.id === product.id ? { ...i, qty: i.qty + 1 } : i
);
}
return [...prev, { ...product, qty: 1 }];
});
op.track('add_to_cart', {
product_id: product.id,
product_name: product.name,
price: product.price,
category: product.category,
});
}
function removeFromCart(id: string) {
const item = cart.find((i) => i.id === id);
if (item) {
op.track('remove_from_cart', {
product_id: item.id,
product_name: item.name,
});
}
setCart((prev) => prev.filter((i) => i.id !== id));
}
function startCheckout() {
const total = cart.reduce((sum, i) => sum + i.price * i.qty, 0);
op.track('checkout_started', {
total,
item_count: cart.reduce((sum, i) => sum + i.qty, 0),
items: cart.map((i) => i.id),
});
navigate('/checkout');
}
function pay(succeed: boolean) {
const total = cart.reduce((sum, i) => sum + i.price * i.qty, 0);
op.track('payment_attempted', { total, success: succeed });
if (succeed) {
op.revenue(total, {
items: cart.map((i) => i.id),
item_count: cart.reduce((sum, i) => sum + i.qty, 0),
});
op.track('purchase_completed', { total });
setCart([]);
navigate('/success');
} else {
op.track('purchase_failed', { total, reason: 'declined' });
navigate('/error');
}
}
const cartCount = cart.reduce((sum, i) => sum + i.qty, 0);
return (
<div className="app">
<nav className="nav">
<Link className="nav-brand" to="/">
TESTSTORE
</Link>
<div className="nav-links">
<Link to="/">Shop</Link>
<Link to="/cart">Cart ({cartCount})</Link>
{user ? (
<>
<span className="nav-user">{user.firstName}</span>
<button onClick={logout} type="button">
Logout
</button>
</>
) : (
<Link to="/login">Login</Link>
)}
</div>
</nav>
<main className="main">
<Routes>
<Route
element={<ShopPage onAddToCart={addToCart} products={PRODUCTS} />}
path="/"
/>
<Route
element={
<ProductPage onAddToCart={addToCart} products={PRODUCTS} />
}
path="/product/:id"
/>
<Route element={<LoginPage onLogin={login} />} path="/login" />
<Route
element={
<CartPage
cart={cart}
onCheckout={startCheckout}
onRemove={removeFromCart}
/>
}
path="/cart"
/>
<Route
element={<CheckoutPage cart={cart} onPay={pay} />}
path="/checkout"
/>
<Route
element={
<div className="result-page">
<div className="result-icon">[OK]</div>
<div className="result-title">Payment successful</div>
<p>Your order has been placed. Thanks for testing!</p>
<div className="result-actions">
<Link to="/">
<button className="primary" type="button">
Continue shopping
</button>
</Link>
</div>
</div>
}
path="/success"
/>
<Route
element={
<div className="result-page">
<div className="result-icon">[ERR]</div>
<div className="result-title">Payment failed</div>
<p>Card declined. Try again or go back to cart.</p>
<div className="result-actions">
<Link to="/checkout">
<button type="button">Retry</button>
</Link>
<Link to="/cart">
<button type="button">Back to cart</button>
</Link>
</div>
</div>
}
path="/error"
/>
</Routes>
</main>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { OpenPanel } from '@openpanel/web';
export const op = new OpenPanel({
clientId: import.meta.env.VITE_OPENPANEL_CLIENT_ID ?? 'testbed-client',
apiUrl: import.meta.env.VITE_OPENPANEL_API_URL ?? 'http://localhost:3333',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
disabled: true,
});

10
apps/testbed/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './styles.css';
createRoot(document.getElementById('root')!).render(
<BrowserRouter>
<App />
</BrowserRouter>
);

View File

@@ -0,0 +1,63 @@
import { Link } from 'react-router-dom';
import type { CartItem } from '../types';
type Props = {
cart: CartItem[];
onRemove: (id: string) => void;
onCheckout: () => void;
};
export function CartPage({ cart, onRemove, onCheckout }: Props) {
const total = cart.reduce((sum, i) => sum + i.price * i.qty, 0);
if (cart.length === 0) {
return (
<div>
<div className="page-title">Cart</div>
<div className="cart-empty">Your cart is empty.</div>
<Link to="/"><button type="button"> Back to shop</button></Link>
</div>
);
}
return (
<div>
<div className="page-title">Cart</div>
<table className="cart-table">
<thead>
<tr>
<th>Product</th>
<th>Price</th>
<th>Qty</th>
<th>Subtotal</th>
<th></th>
</tr>
</thead>
<tbody>
{cart.map((item) => (
<tr key={item.id}>
<td>{item.name}</td>
<td>${item.price}</td>
<td>{item.qty}</td>
<td>${item.price * item.qty}</td>
<td>
<button type="button" className="danger" onClick={() => onRemove(item.id)}>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
<div className="cart-summary">
<div className="cart-total">Total: ${total}</div>
<div className="cart-actions">
<Link to="/"><button type="button"> Shop</button></Link>
<button type="button" className="primary" onClick={onCheckout}>
Checkout
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { Link } from 'react-router-dom';
import type { CartItem } from '../types';
type Props = {
cart: CartItem[];
onPay: (succeed: boolean) => void;
};
export function CheckoutPage({ cart, onPay }: Props) {
const total = cart.reduce((sum, i) => sum + i.price * i.qty, 0);
return (
<div>
<div className="page-title">Checkout</div>
<div className="checkout-form">
<div className="form-group">
<label className="form-label" htmlFor="card">Card number</label>
<input id="card" defaultValue="4242 4242 4242 4242" readOnly />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<div className="form-group">
<label className="form-label" htmlFor="expiry">Expiry</label>
<input id="expiry" defaultValue="12/28" readOnly />
</div>
<div className="form-group">
<label className="form-label" htmlFor="cvc">CVC</label>
<input id="cvc" defaultValue="123" readOnly />
</div>
</div>
<div className="checkout-total">Total: ${total}</div>
<div className="checkout-pay-buttons">
<Link to="/cart"><button type="button"> Back</button></Link>
<button type="button" className="primary" onClick={() => onPay(true)}>
Pay ${total} (success)
</button>
<button type="button" className="danger" onClick={() => onPay(false)}>
Pay ${total} (fail)
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,186 @@
import { useState } from 'react';
import type { Group, User } from '../types';
export const PRESET_GROUPS: Group[] = [
{
type: 'company',
id: 'grp_acme',
name: 'Acme Corp',
properties: { plan: 'enterprise' },
},
{
type: 'company',
id: 'grp_globex',
name: 'Globex',
properties: { plan: 'pro' },
},
{
type: 'company',
id: 'grp_initech',
name: 'Initech',
properties: { plan: 'pro' },
},
{
type: 'company',
id: 'grp_umbrella',
name: 'Umbrella Ltd',
properties: { plan: 'enterprise' },
},
{
type: 'company',
id: 'grp_stark',
name: 'Stark Industries',
properties: { plan: 'enterprise' },
},
{
type: 'company',
id: 'grp_wayne',
name: 'Wayne Enterprises',
properties: { plan: 'pro' },
},
{
type: 'company',
id: 'grp_dunder',
name: 'Dunder Mifflin',
properties: { plan: 'free' },
},
{
type: 'company',
id: 'grp_pied',
name: 'Pied Piper',
properties: { plan: 'free' },
},
{
type: 'company',
id: 'grp_hooli',
name: 'Hooli',
properties: { plan: 'pro' },
},
{
type: 'company',
id: 'grp_vandelay',
name: 'Vandelay Industries',
properties: { plan: 'free' },
},
];
const FIRST_NAMES = ['Alice', 'Bob', 'Carol', 'Dave', 'Eve', 'Frank', 'Grace', 'Hank', 'Iris', 'Jack'];
const LAST_NAMES = ['Smith', 'Jones', 'Brown', 'Taylor', 'Wilson', 'Davis', 'Clark', 'Hall', 'Lewis', 'Young'];
function randomMock(): User {
const first = FIRST_NAMES[Math.floor(Math.random() * FIRST_NAMES.length)];
const last = LAST_NAMES[Math.floor(Math.random() * LAST_NAMES.length)];
const id = Math.random().toString(36).slice(2, 8);
return {
id: `usr_${id}`,
firstName: first,
lastName: last,
email: `${first.toLowerCase()}.${last.toLowerCase()}@example.com`,
groupIds: [],
};
}
type Props = {
onLogin: (user: User) => void;
};
export function LoginPage({ onLogin }: Props) {
const [form, setForm] = useState<User>(randomMock);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
onLogin(form);
}
function set(field: keyof User, value: string) {
setForm((prev) => ({ ...prev, [field]: value }));
}
function toggleGroup(id: string) {
setForm((prev) => ({
...prev,
groupIds: prev.groupIds.includes(id)
? prev.groupIds.filter((g) => g !== id)
: [...prev.groupIds, id],
}));
}
return (
<div>
<div className="page-title">Login</div>
<form className="login-form" onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label" htmlFor="id">
User ID
</label>
<input
id="id"
onChange={(e) => set('id', e.target.value)}
required
value={form.id}
/>
</div>
<div className="form-group">
<label className="form-label" htmlFor="firstName">
First name
</label>
<input
id="firstName"
onChange={(e) => set('firstName', e.target.value)}
required
value={form.firstName}
/>
</div>
<div className="form-group">
<label className="form-label" htmlFor="lastName">
Last name
</label>
<input
id="lastName"
onChange={(e) => set('lastName', e.target.value)}
required
value={form.lastName}
/>
</div>
<div className="form-group">
<label className="form-label" htmlFor="email">
Email
</label>
<input
id="email"
onChange={(e) => set('email', e.target.value)}
required
type="email"
value={form.email}
/>
</div>
<div className="form-group">
<div className="form-label" style={{ marginBottom: 8 }}>
Groups (optional)
</div>
<div className="group-picker">
{PRESET_GROUPS.map((group) => {
const selected = form.groupIds.includes(group.id);
return (
<button
className={selected ? 'primary' : ''}
key={group.id}
onClick={() => toggleGroup(group.id)}
type="button"
>
{group.name}
<span className="group-plan">{group.plan}</span>
</button>
);
})}
</div>
</div>
<button className="primary" style={{ width: '100%' }} type="submit">
Login
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import { useEffect } from 'react';
import { Link, useParams } from 'react-router-dom';
import { op } from '../analytics';
import type { Product } from '../types';
type Props = {
products: Product[];
onAddToCart: (product: Product) => void;
};
export function ProductPage({ products, onAddToCart }: Props) {
const { id } = useParams<{ id: string }>();
const product = products.find((p) => p.id === id);
useEffect(() => {
if (product) {
op.track('product_viewed', {
product_id: product.id,
product_name: product.name,
price: product.price,
category: product.category,
});
}
}, [product]);
if (!product) {
return (
<div>
<div className="page-title">Product not found</div>
<Link to="/"><button type="button"> Back to shop</button></Link>
</div>
);
}
return (
<div>
<div style={{ marginBottom: 16 }}>
<Link to="/"> Back to shop</Link>
</div>
<div className="product-detail">
<div className="product-detail-img">[img]</div>
<div className="product-detail-info">
<div className="product-card-category">{product.category}</div>
<div className="product-detail-name">{product.name}</div>
<div className="product-detail-price">${product.price}</div>
<p className="product-detail-desc">
A high quality {product.name.toLowerCase()} for testing purposes.
Lorem ipsum dolor sit amet consectetur adipiscing elit.
</p>
<button
type="button"
className="primary"
onClick={() => onAddToCart(product)}
>
Add to cart
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { Link } from 'react-router-dom';
import type { Product } from '../types';
type Props = {
products: Product[];
onAddToCart: (product: Product) => void;
};
export function ShopPage({ products, onAddToCart }: Props) {
return (
<div>
<div className="page-title">Products</div>
<div className="product-grid">
{products.map((product) => (
<div key={product.id} className="product-card">
<div className="product-card-category">{product.category}</div>
<Link to={`/product/${product.id}`} className="product-card-name">
{product.name}
</Link>
<div className="product-card-price">${product.price}</div>
<div className="product-card-actions">
<button
type="button"
className="primary"
style={{ width: '100%' }}
onClick={() => onAddToCart(product)}
>
Add to cart
</button>
</div>
</div>
))}
</div>
</div>
);
}

358
apps/testbed/src/styles.css Normal file
View File

@@ -0,0 +1,358 @@
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--border: 1px solid #999;
--bg: #f5f5f5;
--surface: #fff;
--text: #111;
--muted: #666;
--accent: #1a1a1a;
--gap: 16px;
}
body {
font-family: monospace;
font-size: 14px;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
button, input, select {
font-family: monospace;
font-size: 14px;
}
button {
cursor: pointer;
border: var(--border);
background: var(--surface);
padding: 6px 14px;
}
button:hover {
background: var(--accent);
color: #fff;
}
button.primary {
background: var(--accent);
color: #fff;
}
button.primary:hover {
opacity: 0.85;
}
button.danger {
border-color: #c00;
color: #c00;
}
button.danger:hover {
background: #c00;
color: #fff;
}
input {
border: var(--border);
background: var(--surface);
padding: 6px 10px;
width: 100%;
}
input:focus {
outline: 2px solid var(--accent);
}
/* Layout */
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.nav {
border-bottom: var(--border);
padding: 12px var(--gap);
background: var(--surface);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--gap);
}
.nav-brand {
font-weight: bold;
font-size: 16px;
cursor: pointer;
letter-spacing: 1px;
text-decoration: none;
color: inherit;
}
.nav-links a {
color: inherit;
text-decoration: underline;
}
.nav-links a:hover {
color: var(--muted);
}
.nav-links {
display: flex;
align-items: center;
gap: 16px;
}
.nav-links span {
cursor: default;
}
.nav-user {
text-decoration: none !important;
cursor: default !important;
color: var(--muted);
}
.main {
flex: 1;
padding: var(--gap);
max-width: 900px;
margin: 0 auto;
width: 100%;
}
/* Page common */
.page-title {
font-size: 20px;
font-weight: bold;
margin-bottom: 20px;
padding-bottom: 8px;
border-bottom: var(--border);
}
/* Shop */
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--gap);
}
.product-card {
border: var(--border);
background: var(--surface);
padding: var(--gap);
display: flex;
flex-direction: column;
gap: 8px;
}
.product-card-name {
font-weight: bold;
}
.product-card-category {
color: var(--muted);
font-size: 12px;
}
.product-card-price {
font-size: 16px;
}
.product-card-actions {
margin-top: auto;
}
/* Cart */
.cart-empty {
color: var(--muted);
padding: 40px 0;
text-align: center;
}
.cart-table {
width: 100%;
border-collapse: collapse;
margin-bottom: var(--gap);
}
.cart-table th,
.cart-table td {
border: var(--border);
padding: 8px 12px;
text-align: left;
}
.cart-table th {
background: var(--bg);
}
.cart-summary {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-top: var(--border);
margin-top: 8px;
}
.cart-total {
font-size: 16px;
font-weight: bold;
}
.cart-actions {
display: flex;
gap: 8px;
}
/* Checkout */
.checkout-form {
border: var(--border);
background: var(--surface);
padding: var(--gap);
max-width: 400px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
}
.form-label {
font-size: 12px;
color: var(--muted);
text-transform: uppercase;
}
.checkout-total {
margin: 16px 0;
padding: 12px;
border: var(--border);
background: var(--bg);
font-weight: bold;
}
.checkout-pay-buttons {
display: flex;
gap: 8px;
margin-top: 16px;
}
/* Login */
.login-form {
border: var(--border);
background: var(--surface);
padding: var(--gap);
max-width: 360px;
}
/* Product detail */
.product-detail {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32px;
max-width: 700px;
}
.product-detail-img {
border: var(--border);
background: var(--surface);
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
color: var(--muted);
}
.product-detail-info {
display: flex;
flex-direction: column;
gap: 12px;
}
.product-detail-name {
font-size: 22px;
font-weight: bold;
}
.product-detail-price {
font-size: 20px;
}
.product-detail-desc {
color: var(--muted);
line-height: 1.6;
}
.product-card-name {
font-weight: bold;
color: inherit;
}
/* Group picker */
.group-picker {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.group-picker button {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
font-size: 13px;
}
.group-plan {
font-size: 11px;
opacity: 0.6;
border-left: 1px solid currentColor;
padding-left: 6px;
}
/* Result pages */
.result-page {
text-align: center;
padding: 60px 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.result-icon {
font-size: 48px;
line-height: 1;
}
.result-title {
font-size: 22px;
font-weight: bold;
}
.result-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}

23
apps/testbed/src/types.ts Normal file
View File

@@ -0,0 +1,23 @@
export type Product = {
id: string;
name: string;
price: number;
category: string;
};
export type CartItem = Product & { qty: number };
export type User = {
id: string;
firstName: string;
lastName: string;
email: string;
groupIds: string[];
};
export type Group = {
id: string;
name: string;
type: string;
properties: Record<string, string>;
};