Add Fluxon frontend storefront
This commit is contained in:
93
frontend/src/pages/AccountPage.tsx
Normal file
93
frontend/src/pages/AccountPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
frontend/src/pages/CartPage.tsx
Normal file
76
frontend/src/pages/CartPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
frontend/src/pages/CheckoutPage.tsx
Normal file
132
frontend/src/pages/CheckoutPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
frontend/src/pages/HomePage.tsx
Normal file
91
frontend/src/pages/HomePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
frontend/src/pages/LoginPage.tsx
Normal file
53
frontend/src/pages/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
frontend/src/pages/NotFoundPage.tsx
Normal file
16
frontend/src/pages/NotFoundPage.tsx
Normal 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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
53
frontend/src/pages/OrderConfirmationPage.tsx
Normal file
53
frontend/src/pages/OrderConfirmationPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
frontend/src/pages/ProductDetailsPage.tsx
Normal file
97
frontend/src/pages/ProductDetailsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
frontend/src/pages/ProductsPage.tsx
Normal file
105
frontend/src/pages/ProductsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
frontend/src/pages/RegisterPage.tsx
Normal file
57
frontend/src/pages/RegisterPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user