feat: group analytics
* wip * wip * wip * wip * wip * add buffer * wip * wip * fixes * fix * wip * group validation * fix group issues * docs: add groups
This commit is contained in:
committed by
GitHub
parent
88a2d876ce
commit
11e9ecac1a
63
apps/testbed/src/pages/Cart.tsx
Normal file
63
apps/testbed/src/pages/Cart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
apps/testbed/src/pages/Checkout.tsx
Normal file
43
apps/testbed/src/pages/Checkout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
186
apps/testbed/src/pages/Login.tsx
Normal file
186
apps/testbed/src/pages/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
apps/testbed/src/pages/Product.tsx
Normal file
61
apps/testbed/src/pages/Product.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
apps/testbed/src/pages/Shop.tsx
Normal file
36
apps/testbed/src/pages/Shop.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user