Add Fluxon frontend storefront

This commit is contained in:
2026-03-17 15:04:55 +01:00
parent 91ee7a14bf
commit 1d2f7c0732
89 changed files with 4580 additions and 29 deletions

View File

@@ -0,0 +1,93 @@
import { useEffect, useState } from "react";
import { api } from "../services/api";
import { useAuth } from "../state/AuthContext";
import type { Order } from "../types";
import { StatusView } from "../ui/StatusView";
export function AccountPage() {
const { user } = useAuth();
const [orders, setOrders] = useState<Order[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let active = true;
async function loadOrders() {
try {
const nextOrders = await api.getOrders();
if (active) {
setOrders(nextOrders);
}
} catch (loadError) {
if (active) {
setError(loadError instanceof Error ? loadError.message : "Unable to load orders");
}
} finally {
if (active) {
setLoading(false);
}
}
}
void loadOrders();
return () => {
active = false;
};
}, []);
if (loading) {
return <StatusView title="Loading account" message="Fetching customer profile and recent orders." />;
}
if (error) {
return <StatusView title="Unable to load account" message={error} />;
}
return (
<div className="page-stack">
<section className="panel compact">
<span className="eyebrow">Account</span>
<h1>{user?.name}</h1>
<p>{user?.email}</p>
</section>
<section className="panel">
<div className="section-heading">
<div>
<span className="eyebrow">Orders</span>
<h2>Recent order history</h2>
</div>
</div>
{orders.length === 0 ? (
<StatusView title="No orders yet" message="Orders created during checkout will appear here." />
) : (
<div className="order-list">
{orders.map((order) => (
<article key={order.id} className="order-card">
<div>
<strong>{order.id}</strong>
<p>{new Date(order.createdAt).toLocaleDateString()}</p>
</div>
<div>
<span>Status</span>
<strong>{order.status}</strong>
</div>
<div>
<span>Payment</span>
<strong>{order.payment.status}</strong>
</div>
<div>
<span>Total</span>
<strong>EUR {order.total.toFixed(2)}</strong>
</div>
</article>
))}
</div>
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { Link } from "react-router-dom";
import { useCart } from "../state/CartContext";
import { QuantityControl } from "../ui/QuantityControl";
import { StatusView } from "../ui/StatusView";
export function CartPage() {
const { items, removeItem, updateQuantity, subtotal } = useCart();
if (items.length === 0) {
return (
<StatusView
title="Your cart is empty"
message="Add a few products to continue the shopping flow."
action={
<Link to="/products" className="cta-button">
Browse products
</Link>
}
/>
);
}
return (
<div className="checkout-layout">
<section className="panel">
<div className="section-heading">
<div>
<span className="eyebrow">Cart</span>
<h1>Review selected items</h1>
</div>
</div>
<div className="cart-list">
{items.map((item) => (
<article key={item.product.id} className="cart-row">
<div className="cart-visual" style={{ background: item.product.image }} />
<div className="cart-copy">
<h3>{item.product.name}</h3>
<p>{item.product.description}</p>
</div>
<QuantityControl
value={item.quantity}
max={item.product.stock}
onChange={(quantity) => updateQuantity(item.product.id, quantity)}
/>
<strong>EUR {(item.unitPrice * item.quantity).toFixed(2)}</strong>
<button className="text-button" onClick={() => removeItem(item.product.id)}>
Remove
</button>
</article>
))}
</div>
</section>
<aside className="panel order-summary">
<span className="eyebrow">Summary</span>
<h2>Checkout snapshot</h2>
<div className="summary-row">
<span>Subtotal</span>
<strong>EUR {subtotal.toFixed(2)}</strong>
</div>
<div className="summary-row">
<span>Shipping</span>
<strong>Free</strong>
</div>
<div className="summary-row total">
<span>Total</span>
<strong>EUR {subtotal.toFixed(2)}</strong>
</div>
<Link to="/checkout" className="cta-button">
Continue to checkout
</Link>
</aside>
</div>
);
}

View File

@@ -0,0 +1,132 @@
import { FormEvent, useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../services/api";
import { useAuth } from "../state/AuthContext";
import { useCart } from "../state/CartContext";
import type { CheckoutForm } from "../types";
import { StatusView } from "../ui/StatusView";
export function CheckoutPage() {
const { user } = useAuth();
const { items, subtotal, clearCart } = useCart();
const navigate = useNavigate();
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [form, setForm] = useState<CheckoutForm>({
fullName: user?.name ?? "",
email: user?.email ?? "",
address: "",
city: "",
postalCode: "",
paymentMethod: "CreditCard"
});
if (items.length === 0) {
return <StatusView title="Nothing to checkout" message="Your cart is empty. Add products before opening checkout." />;
}
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setSubmitting(true);
setError(null);
try {
const order = await api.createOrder(form, items);
clearCart();
navigate(`/order-confirmation/${order.id}`, { state: { order } });
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "Unable to create order");
} finally {
setSubmitting(false);
}
}
return (
<div className="checkout-layout">
<form className="panel" onSubmit={handleSubmit}>
<span className="eyebrow">Checkout</span>
<h1>Collect shipping and payment details</h1>
<div className="form-grid">
<label>
Full name
<input
value={form.fullName}
onChange={(event) => setForm((current) => ({ ...current, fullName: event.target.value }))}
required
/>
</label>
<label>
Email
<input
type="email"
value={form.email}
onChange={(event) => setForm((current) => ({ ...current, email: event.target.value }))}
required
/>
</label>
<label>
Address
<input
value={form.address}
onChange={(event) => setForm((current) => ({ ...current, address: event.target.value }))}
required
/>
</label>
<label>
City
<input
value={form.city}
onChange={(event) => setForm((current) => ({ ...current, city: event.target.value }))}
required
/>
</label>
<label>
Postal code
<input
value={form.postalCode}
onChange={(event) => setForm((current) => ({ ...current, postalCode: event.target.value }))}
required
/>
</label>
<label>
Payment method
<select
value={form.paymentMethod}
onChange={(event) =>
setForm((current) => ({
...current,
paymentMethod: event.target.value as CheckoutForm["paymentMethod"]
}))
}
>
<option value="CreditCard">Credit card</option>
<option value="PayPal">PayPal</option>
<option value="CashOnDelivery">Cash on delivery</option>
</select>
</label>
</div>
{error ? <p className="error-text">{error}</p> : null}
<button type="submit" disabled={submitting}>
{submitting ? "Placing order..." : "Place order"}
</button>
</form>
<aside className="panel order-summary">
<span className="eyebrow">Order summary</span>
<h2>Ready for API handoff</h2>
{items.map((item) => (
<div className="summary-row" key={item.product.id}>
<span>
{item.product.name} x {item.quantity}
</span>
<strong>EUR {(item.quantity * item.unitPrice).toFixed(2)}</strong>
</div>
))}
<div className="summary-row total">
<span>Total</span>
<strong>EUR {subtotal.toFixed(2)}</strong>
</div>
</aside>
</div>
);
}

View File

@@ -0,0 +1,91 @@
import { Link } from "react-router-dom";
import { useCatalog } from "../hooks/useCatalog";
import { ProductCard } from "../ui/ProductCard";
import { StatusView } from "../ui/StatusView";
import { useCart } from "../state/CartContext";
export function HomePage() {
const { products, categories, loading, error } = useCatalog();
const { addItem } = useCart();
if (loading) {
return <StatusView title="Loading storefront" message="Preparing products, categories, and hero content." />;
}
if (error) {
return <StatusView title="Catalog unavailable" message={error} />;
}
const featuredProducts = products.filter((product) => product.featured).slice(0, 3);
return (
<div className="page-stack">
<section className="hero-panel">
<div className="hero-copy">
<span className="eyebrow">Fluxon storefront</span>
<h1>Build a demo-ready shop now, then swap in real APIs as the backend catches up.</h1>
<p>
This frontend is structured for hybrid delivery: polished enough for presentation, flexible enough for
incomplete endpoints.
</p>
<div className="inline-actions">
<Link to="/products" className="cta-button">
Explore Products
</Link>
<Link to="/register" className="ghost-button">
Create Demo Account
</Link>
</div>
</div>
<div className="hero-card-grid">
<div className="hero-stat">
<strong>{products.length}</strong>
<span>Curated products</span>
</div>
<div className="hero-stat">
<strong>{categories.length}</strong>
<span>Shop categories</span>
</div>
<div className="hero-stat accent">
<strong>Mock + API</strong>
<span>Safe for evolving backend</span>
</div>
</div>
</section>
<section className="panel">
<div className="section-heading">
<div>
<span className="eyebrow">Categories</span>
<h2>Shape the browsing experience around the backend data model</h2>
</div>
</div>
<div className="category-grid">
{categories.map((category) => (
<article key={category.id} className="category-card">
<h3>{category.name}</h3>
<p>Use this category as a filter, navigation cue, and featured-content block.</p>
</article>
))}
</div>
</section>
<section className="panel">
<div className="section-heading">
<div>
<span className="eyebrow">Featured</span>
<h2>Presentation-friendly products for your MVP demo flow</h2>
</div>
<Link to="/products" className="ghost-button">
View All
</Link>
</div>
<div className="product-grid">
{featuredProducts.map((product) => (
<ProductCard key={product.id} product={product} onAdd={addItem} />
))}
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { FormEvent, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../state/AuthContext";
export function LoginPage() {
const [email, setEmail] = useState("demo@fluxon.shop");
const [password, setPassword] = useState("demo123");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setSubmitting(true);
setError(null);
try {
await login({ email, password });
navigate((location.state as { from?: string } | null)?.from ?? "/account");
} catch (loginError) {
setError(loginError instanceof Error ? loginError.message : "Unable to login");
} finally {
setSubmitting(false);
}
}
return (
<section className="auth-shell">
<form className="panel auth-card" onSubmit={handleSubmit}>
<span className="eyebrow">Login</span>
<h1>Sign in to continue checkout</h1>
<p>Use the demo credentials or any email/password while mock auth is enabled.</p>
<label>
Email
<input type="email" value={email} onChange={(event) => setEmail(event.target.value)} required />
</label>
<label>
Password
<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} required />
</label>
{error ? <p className="error-text">{error}</p> : null}
<button type="submit" disabled={submitting}>
{submitting ? "Signing in..." : "Login"}
</button>
<p className="muted">
No account yet? <Link to="/register">Create one</Link>
</p>
</form>
</section>
);
}

View File

@@ -0,0 +1,16 @@
import { Link } from "react-router-dom";
import { StatusView } from "../ui/StatusView";
export function NotFoundPage() {
return (
<StatusView
title="Page not found"
message="This route is not part of the current storefront flow."
action={
<Link to="/" className="cta-button">
Return home
</Link>
}
/>
);
}

View File

@@ -0,0 +1,53 @@
import { Link, useLocation, useParams } from "react-router-dom";
import type { Order } from "../types";
import { StatusView } from "../ui/StatusView";
export function OrderConfirmationPage() {
const { orderId } = useParams();
const location = useLocation();
const order = (location.state as { order?: Order } | null)?.order;
if (!order) {
return (
<StatusView
title="Order recorded"
message={`Your order ${orderId ?? ""} was created, but the confirmation data is not available in memory anymore.`}
action={
<Link to="/account" className="cta-button">
View account
</Link>
}
/>
);
}
return (
<section className="panel confirmation-card">
<span className="eyebrow">Order confirmed</span>
<h1>{order.id}</h1>
<p>Your order has been submitted successfully and the frontend flow is ready for demo use.</p>
<div className="summary-row">
<span>Status</span>
<strong>{order.status}</strong>
</div>
<div className="summary-row">
<span>Payment</span>
<strong>
{order.payment.method} / {order.payment.status}
</strong>
</div>
<div className="summary-row total">
<span>Total</span>
<strong>EUR {order.total.toFixed(2)}</strong>
</div>
<div className="inline-actions">
<Link to="/products" className="ghost-button">
Continue shopping
</Link>
<Link to="/account" className="cta-button">
View account
</Link>
</div>
</section>
);
}

View File

@@ -0,0 +1,97 @@
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { api } from "../services/api";
import type { Product } from "../types";
import { StatusView } from "../ui/StatusView";
import { QuantityControl } from "../ui/QuantityControl";
import { useCart } from "../state/CartContext";
export function ProductDetailsPage() {
const { productId } = useParams();
const { addItem } = useCart();
const [product, setProduct] = useState<Product | null>(null);
const [quantity, setQuantity] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let active = true;
async function loadProduct() {
if (!productId) {
setError("Missing product id");
setLoading(false);
return;
}
try {
const nextProduct = await api.getProductById(Number(productId));
if (active) {
setProduct(nextProduct);
}
} catch (loadError) {
if (active) {
setError(loadError instanceof Error ? loadError.message : "Unable to load product");
}
} finally {
if (active) {
setLoading(false);
}
}
}
void loadProduct();
return () => {
active = false;
};
}, [productId]);
if (loading) {
return <StatusView title="Loading product" message="Fetching product details and stock information." />;
}
if (error || !product) {
return (
<StatusView
title="Product unavailable"
message={error ?? "The requested product could not be found."}
action={
<Link to="/products" className="ghost-button">
Back to shop
</Link>
}
/>
);
}
return (
<section className="product-detail-layout">
<div className="detail-visual" style={{ background: product.image }} />
<div className="detail-copy panel">
<span className="eyebrow">{product.category?.name ?? "Product details"}</span>
<h1>{product.name}</h1>
<p className="lead">{product.description}</p>
<div className="detail-meta">
<div>
<span>Price</span>
<strong>EUR {product.price.toFixed(2)}</strong>
</div>
<div>
<span>Availability</span>
<strong>{product.stock > 0 ? `${product.stock} ready to ship` : "Sold out"}</strong>
</div>
</div>
<QuantityControl value={quantity} max={product.stock} onChange={setQuantity} />
<div className="inline-actions">
<button onClick={() => addItem(product, quantity)} disabled={product.stock === 0}>
Add {quantity} to cart
</button>
<Link to="/cart" className="ghost-button">
Go to cart
</Link>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,105 @@
import { useMemo, useState } from "react";
import { useCatalog } from "../hooks/useCatalog";
import { ProductCard } from "../ui/ProductCard";
import { CategoryPill } from "../ui/CategoryPill";
import { StatusView } from "../ui/StatusView";
import { useCart } from "../state/CartContext";
export function ProductsPage() {
const { products, categories, loading, error } = useCatalog();
const { addItem } = useCart();
const [query, setQuery] = useState("");
const [categoryId, setCategoryId] = useState<number | "all">("all");
const [sort, setSort] = useState("featured");
const filteredProducts = useMemo(() => {
let next = [...products];
if (categoryId !== "all") {
next = next.filter((product) => product.categoryId === categoryId);
}
if (query.trim()) {
const lowered = query.toLowerCase();
next = next.filter(
(product) =>
product.name.toLowerCase().includes(lowered) || product.description.toLowerCase().includes(lowered)
);
}
switch (sort) {
case "price-asc":
next.sort((a, b) => a.price - b.price);
break;
case "price-desc":
next.sort((a, b) => b.price - a.price);
break;
case "stock":
next.sort((a, b) => b.stock - a.stock);
break;
default:
next.sort((a, b) => Number(b.featured) - Number(a.featured));
break;
}
return next;
}, [categoryId, products, query, sort]);
if (loading) {
return <StatusView title="Loading products" message="Fetching product cards and category filters." />;
}
if (error) {
return <StatusView title="Unable to show products" message={error} />;
}
return (
<div className="page-stack">
<section className="panel compact">
<div className="section-heading">
<div>
<span className="eyebrow">Shop</span>
<h1>Browse, filter, and sort products</h1>
</div>
</div>
<div className="toolbar">
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
className="search-input"
placeholder="Search products"
/>
<select value={sort} onChange={(event) => setSort(event.target.value)} className="select-input">
<option value="featured">Featured first</option>
<option value="price-asc">Price: low to high</option>
<option value="price-desc">Price: high to low</option>
<option value="stock">Most in stock</option>
</select>
</div>
<div className="pill-row">
<CategoryPill category={{ id: 0, name: "All" }} active={categoryId === "all"} onClick={() => setCategoryId("all")} />
{categories.map((category) => (
<CategoryPill
key={category.id}
category={category}
active={category.id === categoryId}
onClick={() => setCategoryId(category.id)}
/>
))}
</div>
</section>
{filteredProducts.length === 0 ? (
<StatusView title="No matching products" message="Try a different search term or category filter." />
) : (
<section className="product-grid">
{filteredProducts.map((product) => (
<ProductCard key={product.id} product={product} onAdd={addItem} />
))}
</section>
)}
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { FormEvent, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../state/AuthContext";
export function RegisterPage() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const { register } = useAuth();
const navigate = useNavigate();
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setSubmitting(true);
setError(null);
try {
await register({ name, email, password });
navigate("/account");
} catch (registerError) {
setError(registerError instanceof Error ? registerError.message : "Unable to register");
} finally {
setSubmitting(false);
}
}
return (
<section className="auth-shell">
<form className="panel auth-card" onSubmit={handleSubmit}>
<span className="eyebrow">Register</span>
<h1>Create a customer account</h1>
<p>This screen already matches the backend registration DTO and can run on mock or real auth.</p>
<label>
Name
<input type="text" value={name} onChange={(event) => setName(event.target.value)} required />
</label>
<label>
Email
<input type="email" value={email} onChange={(event) => setEmail(event.target.value)} required />
</label>
<label>
Password
<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} required minLength={6} />
</label>
{error ? <p className="error-text">{error}</p> : null}
<button type="submit" disabled={submitting}>
{submitting ? "Creating account..." : "Register"}
</button>
<p className="muted">
Already registered? <Link to="/login">Login</Link>
</p>
</form>
</section>
);
}