Add Fluxon frontend storefront
This commit is contained in:
2
frontend/.env.example
Normal file
2
frontend/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_API_BASE_URL=http://localhost:5276
|
||||
VITE_USE_MOCK_API=true
|
||||
39
frontend/INTEGRATION_CHECKLIST.md
Normal file
39
frontend/INTEGRATION_CHECKLIST.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Fluxon Frontend Integration Checklist
|
||||
|
||||
Use this with the backend teammate before switching any feature from mock mode to real API.
|
||||
|
||||
## Product endpoints
|
||||
- Route: `GET /api/product`
|
||||
- Route: `GET /api/product/{id}`
|
||||
- Response fields: `id`, `name`, `description`, `price`, `stock`, `categoryId`
|
||||
- Nice to have: nested `category`
|
||||
|
||||
## Category endpoint
|
||||
- Route: `GET /api/category`
|
||||
- Response fields: `id`, `name`
|
||||
|
||||
## Auth endpoints
|
||||
- Route: `POST /api/auth/login`
|
||||
- Route: `POST /api/auth/register`
|
||||
- Request body:
|
||||
- Login: `email`, `password`
|
||||
- Register: `name`, `email`, `password`
|
||||
- Response: token plus customer identity fields
|
||||
|
||||
## Order endpoint
|
||||
- Route: `GET /api/order`
|
||||
- Route: `POST /api/order`
|
||||
- Request body should accept:
|
||||
- customer/shipping data
|
||||
- `items[]` with `productId`, `quantity`, `unitPrice`
|
||||
- Response should include:
|
||||
- order id
|
||||
- created date
|
||||
- total
|
||||
- status
|
||||
- payment status
|
||||
|
||||
## Frontend env switch
|
||||
- Mock mode default: `VITE_USE_MOCK_API=true`
|
||||
- Real API mode: set `VITE_USE_MOCK_API=false`
|
||||
- API base URL: `VITE_API_BASE_URL=http://localhost:5276`
|
||||
28
frontend/README.md
Normal file
28
frontend/README.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Fluxon Frontend
|
||||
|
||||
React + Vite storefront for the Fluxon project.
|
||||
|
||||
## What is included
|
||||
- Home page, product listing, product details, cart, login, register, checkout, confirmation, and account pages
|
||||
- Cart and auth state stored in `localStorage`
|
||||
- Hybrid API layer that can use real backend endpoints or fall back to mock data
|
||||
- Responsive visual design meant for demos and presentations
|
||||
|
||||
## Run locally
|
||||
1. Install Node.js if it is not already available on your machine.
|
||||
2. Open `C:\Users\bib\Documents\LEA2\WebShop_Fluxon\frontend`
|
||||
3. Copy `.env.example` to `.env`
|
||||
4. Run `npm install`
|
||||
5. Run `npm run dev`
|
||||
|
||||
## API mode
|
||||
- Default is mock mode: `VITE_USE_MOCK_API=true`
|
||||
- To use the backend, set `VITE_USE_MOCK_API=false`
|
||||
- Backend base URL is controlled by `VITE_API_BASE_URL`
|
||||
|
||||
## Important files
|
||||
- `src/services/api.ts`: real API calls plus fallback behavior
|
||||
- `src/services/mockApi.ts`: mock implementations used for demos
|
||||
- `src/state/AuthContext.tsx`: login/register session state
|
||||
- `src/state/CartContext.tsx`: cart state and totals
|
||||
- `INTEGRATION_CHECKLIST.md`: quick contract checklist for backend coordination
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Fluxon Shop</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1771
frontend/package-lock.json
generated
Normal file
1771
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
frontend/package.json
Normal file
23
frontend/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "fluxon-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.30.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
54
frontend/src/App.tsx
Normal file
54
frontend/src/App.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { Layout } from "./ui/Layout";
|
||||
import { HomePage } from "./pages/HomePage";
|
||||
import { ProductsPage } from "./pages/ProductsPage";
|
||||
import { ProductDetailsPage } from "./pages/ProductDetailsPage";
|
||||
import { CartPage } from "./pages/CartPage";
|
||||
import { LoginPage } from "./pages/LoginPage";
|
||||
import { RegisterPage } from "./pages/RegisterPage";
|
||||
import { CheckoutPage } from "./pages/CheckoutPage";
|
||||
import { OrderConfirmationPage } from "./pages/OrderConfirmationPage";
|
||||
import { AccountPage } from "./pages/AccountPage";
|
||||
import { NotFoundPage } from "./pages/NotFoundPage";
|
||||
import { RequireAuth } from "./ui/RequireAuth";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/products" element={<ProductsPage />} />
|
||||
<Route path="/products/:productId" element={<ProductDetailsPage />} />
|
||||
<Route path="/cart" element={<CartPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route
|
||||
path="/checkout"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<CheckoutPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/account"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<AccountPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/order-confirmation/:orderId"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<OrderConfirmationPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route path="/home" element={<Navigate to="/" replace />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
6
frontend/src/config.ts
Normal file
6
frontend/src/config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
const env = import.meta.env;
|
||||
|
||||
export const config = {
|
||||
apiBaseUrl: env.VITE_API_BASE_URL ?? "http://localhost:5276",
|
||||
useMockApi: env.VITE_USE_MOCK_API !== "false"
|
||||
};
|
||||
110
frontend/src/data/mockData.ts
Normal file
110
frontend/src/data/mockData.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { AuthUser, Category, Order, Product } from "../types";
|
||||
|
||||
export const mockCategories: Category[] = [
|
||||
{ id: 1, name: "Tech Essentials" },
|
||||
{ id: 2, name: "Studio Setup" },
|
||||
{ id: 3, name: "Smart Living" },
|
||||
{ id: 4, name: "Travel Picks" }
|
||||
];
|
||||
|
||||
export const mockProducts: Product[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Flux One Headphones",
|
||||
description:
|
||||
"Wireless over-ear headphones tuned for deep focus sessions, clear calls, and long battery life.",
|
||||
price: 179.9,
|
||||
stock: 14,
|
||||
categoryId: 1,
|
||||
image: "linear-gradient(135deg, #ff8a5b 0%, #ffd166 100%)",
|
||||
featured: true
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Nova Desk Lamp",
|
||||
description:
|
||||
"A sculptural LED lamp with warm-to-cool temperature control for late-night work and clean desk aesthetics.",
|
||||
price: 69,
|
||||
stock: 22,
|
||||
categoryId: 2,
|
||||
image: "linear-gradient(135deg, #0f4c81 0%, #9bd1e5 100%)",
|
||||
featured: true
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Orbit Speaker Mini",
|
||||
description:
|
||||
"Portable speaker with punchy sound, matte finish, and enough battery to last through weekend trips.",
|
||||
price: 95.5,
|
||||
stock: 8,
|
||||
categoryId: 4,
|
||||
image: "linear-gradient(135deg, #23395d 0%, #b6c9f0 100%)",
|
||||
featured: true
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Aero Bottle",
|
||||
description:
|
||||
"Insulated stainless steel bottle designed for city commutes, travel, and everyday carry.",
|
||||
price: 32,
|
||||
stock: 40,
|
||||
categoryId: 4,
|
||||
image: "linear-gradient(135deg, #2a6f97 0%, #61c0bf 100%)"
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "Canvas Keyboard",
|
||||
description:
|
||||
"Compact mechanical keyboard with low-profile switches and a clean, minimalist layout.",
|
||||
price: 124,
|
||||
stock: 16,
|
||||
categoryId: 1,
|
||||
image: "linear-gradient(135deg, #5f0f40 0%, #fb8b24 100%)"
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "Pulse Air Purifier",
|
||||
description:
|
||||
"Smart purifier with quiet mode, room-quality indicator, and app-ready control concept.",
|
||||
price: 210,
|
||||
stock: 11,
|
||||
categoryId: 3,
|
||||
image: "linear-gradient(135deg, #355070 0%, #b56576 100%)"
|
||||
}
|
||||
];
|
||||
|
||||
export const mockDemoUser: AuthUser = {
|
||||
name: "Demo Customer",
|
||||
email: "demo@fluxon.shop",
|
||||
token: "mock-jwt-token"
|
||||
};
|
||||
|
||||
export const mockOrders: Order[] = [
|
||||
{
|
||||
id: "FX-2026-1001",
|
||||
createdAt: "2026-03-16T10:30:00.000Z",
|
||||
status: "Confirmed",
|
||||
total: 248.9,
|
||||
items: [
|
||||
{
|
||||
productId: 1,
|
||||
productName: "Flux One Headphones",
|
||||
quantity: 1,
|
||||
unitPrice: 179.9
|
||||
},
|
||||
{
|
||||
productId: 4,
|
||||
productName: "Aero Bottle",
|
||||
quantity: 2,
|
||||
unitPrice: 32
|
||||
}
|
||||
],
|
||||
payment: {
|
||||
method: "PayPal",
|
||||
amount: 248.9,
|
||||
status: "Paid"
|
||||
},
|
||||
customerEmail: "demo@fluxon.shop",
|
||||
shippingAddress: "18 Vision Street, Berlin"
|
||||
}
|
||||
];
|
||||
44
frontend/src/hooks/useCatalog.ts
Normal file
44
frontend/src/hooks/useCatalog.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../services/api";
|
||||
import type { Category, Product } from "../types";
|
||||
|
||||
export function useCatalog() {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [nextProducts, nextCategories] = await Promise.all([api.getProducts(), api.getCategories()]);
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProducts(nextProducts);
|
||||
setCategories(nextCategories);
|
||||
} catch (loadError) {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(loadError instanceof Error ? loadError.message : "Unable to load catalog");
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { products, categories, loading, error };
|
||||
}
|
||||
19
frontend/src/main.tsx
Normal file
19
frontend/src/main.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import { AuthProvider } from "./state/AuthContext";
|
||||
import { CartProvider } from "./state/CartContext";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<CartProvider>
|
||||
<App />
|
||||
</CartProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
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>
|
||||
);
|
||||
}
|
||||
109
frontend/src/services/api.ts
Normal file
109
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { config } from "../config";
|
||||
import { mockApi } from "./mockApi";
|
||||
import type {
|
||||
AuthUser,
|
||||
Category,
|
||||
CheckoutForm,
|
||||
LoginInput,
|
||||
Order,
|
||||
Product,
|
||||
RegisterInput
|
||||
} from "../types";
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${config.apiBaseUrl}${path}`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {})
|
||||
},
|
||||
...init
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
async function withFallback<T>(primary: () => Promise<T>, fallback: () => Promise<T>): Promise<T> {
|
||||
if (config.useMockApi) {
|
||||
return fallback();
|
||||
}
|
||||
|
||||
try {
|
||||
return await primary();
|
||||
} catch {
|
||||
return fallback();
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getProducts(): Promise<Product[]> {
|
||||
return withFallback(
|
||||
() => request<Product[]>("/api/product"),
|
||||
() => mockApi.getProducts()
|
||||
);
|
||||
},
|
||||
|
||||
getProductById(productId: number): Promise<Product> {
|
||||
return withFallback(
|
||||
() => request<Product>(`/api/product/${productId}`),
|
||||
() => mockApi.getProductById(productId)
|
||||
);
|
||||
},
|
||||
|
||||
getCategories(): Promise<Category[]> {
|
||||
return withFallback(
|
||||
() => request<Category[]>("/api/category"),
|
||||
() => mockApi.getCategories()
|
||||
);
|
||||
},
|
||||
|
||||
login(input: LoginInput): Promise<AuthUser> {
|
||||
return withFallback(
|
||||
() =>
|
||||
request<AuthUser>("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input)
|
||||
}),
|
||||
() => mockApi.login(input)
|
||||
);
|
||||
},
|
||||
|
||||
register(input: RegisterInput): Promise<AuthUser> {
|
||||
return withFallback(
|
||||
() =>
|
||||
request<AuthUser>("/api/auth/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input)
|
||||
}),
|
||||
() => mockApi.register(input)
|
||||
);
|
||||
},
|
||||
|
||||
getOrders(): Promise<Order[]> {
|
||||
return withFallback(
|
||||
() => request<Order[]>("/api/order"),
|
||||
() => mockApi.getOrders()
|
||||
);
|
||||
},
|
||||
|
||||
createOrder(input: CheckoutForm, cart: { product: Product; quantity: number; unitPrice: number }[]): Promise<Order> {
|
||||
return withFallback(
|
||||
() =>
|
||||
request<Order>("/api/order", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
...input,
|
||||
items: cart.map((item) => ({
|
||||
productId: item.product.id,
|
||||
quantity: item.quantity,
|
||||
unitPrice: item.unitPrice
|
||||
}))
|
||||
})
|
||||
}),
|
||||
() => mockApi.createOrder(input, cart)
|
||||
);
|
||||
}
|
||||
};
|
||||
99
frontend/src/services/mockApi.ts
Normal file
99
frontend/src/services/mockApi.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { mockCategories, mockDemoUser, mockOrders, mockProducts } from "../data/mockData";
|
||||
import type {
|
||||
AuthUser,
|
||||
CheckoutForm,
|
||||
LoginInput,
|
||||
Order,
|
||||
Product,
|
||||
RegisterInput
|
||||
} from "../types";
|
||||
|
||||
const wait = (ms = 300) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
let orderStore = [...mockOrders];
|
||||
|
||||
export const mockApi = {
|
||||
async getProducts(): Promise<Product[]> {
|
||||
await wait();
|
||||
return mockProducts.map((product) => ({
|
||||
...product,
|
||||
category: mockCategories.find((category) => category.id === product.categoryId)
|
||||
}));
|
||||
},
|
||||
|
||||
async getProductById(productId: number): Promise<Product> {
|
||||
await wait();
|
||||
const product = mockProducts.find((item) => item.id === productId);
|
||||
if (!product) {
|
||||
throw new Error("Product not found");
|
||||
}
|
||||
|
||||
return {
|
||||
...product,
|
||||
category: mockCategories.find((category) => category.id === product.categoryId)
|
||||
};
|
||||
},
|
||||
|
||||
async getCategories() {
|
||||
await wait();
|
||||
return mockCategories;
|
||||
},
|
||||
|
||||
async login(input: LoginInput): Promise<AuthUser> {
|
||||
await wait();
|
||||
if (!input.email || !input.password) {
|
||||
throw new Error("Email and password are required");
|
||||
}
|
||||
|
||||
return {
|
||||
...mockDemoUser,
|
||||
email: input.email
|
||||
};
|
||||
},
|
||||
|
||||
async register(input: RegisterInput): Promise<AuthUser> {
|
||||
await wait();
|
||||
if (!input.name || !input.email || !input.password) {
|
||||
throw new Error("All fields are required");
|
||||
}
|
||||
|
||||
return {
|
||||
name: input.name,
|
||||
email: input.email,
|
||||
token: "mock-registered-token"
|
||||
};
|
||||
},
|
||||
|
||||
async getOrders(): Promise<Order[]> {
|
||||
await wait();
|
||||
return orderStore;
|
||||
},
|
||||
|
||||
async createOrder(input: CheckoutForm, cart: { product: Product; quantity: number; unitPrice: number }[]): Promise<Order> {
|
||||
await wait();
|
||||
|
||||
const total = cart.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0);
|
||||
const order: Order = {
|
||||
id: `FX-2026-${1000 + orderStore.length + 1}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
status: "Confirmed",
|
||||
total,
|
||||
items: cart.map((item) => ({
|
||||
productId: item.product.id,
|
||||
productName: item.product.name,
|
||||
quantity: item.quantity,
|
||||
unitPrice: item.unitPrice
|
||||
})),
|
||||
payment: {
|
||||
method: input.paymentMethod,
|
||||
amount: total,
|
||||
status: "Paid"
|
||||
},
|
||||
customerEmail: input.email,
|
||||
shippingAddress: `${input.address}, ${input.city}, ${input.postalCode}`
|
||||
};
|
||||
|
||||
orderStore = [order, ...orderStore];
|
||||
return order;
|
||||
}
|
||||
};
|
||||
53
frontend/src/state/AuthContext.tsx
Normal file
53
frontend/src/state/AuthContext.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { api } from "../services/api";
|
||||
import type { AuthUser, LoginInput, RegisterInput } from "../types";
|
||||
|
||||
type AuthContextValue = {
|
||||
user: AuthUser | null;
|
||||
login: (input: LoginInput) => Promise<void>;
|
||||
register: (input: RegisterInput) => Promise<void>;
|
||||
logout: () => void;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||
const storageKey = "fluxon.auth";
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = window.localStorage.getItem(storageKey);
|
||||
if (saved) {
|
||||
setUser(JSON.parse(saved) as AuthUser);
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function login(input: LoginInput) {
|
||||
const nextUser = await api.login(input);
|
||||
setUser(nextUser);
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(nextUser));
|
||||
}
|
||||
|
||||
async function register(input: RegisterInput) {
|
||||
const nextUser = await api.register(input);
|
||||
setUser(nextUser);
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(nextUser));
|
||||
}
|
||||
|
||||
function logout() {
|
||||
setUser(null);
|
||||
window.localStorage.removeItem(storageKey);
|
||||
}
|
||||
|
||||
return <AuthContext.Provider value={{ user, login, register, logout }}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error("useAuth must be used inside AuthProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
88
frontend/src/state/CartContext.tsx
Normal file
88
frontend/src/state/CartContext.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||
import type { CartItem, Product } from "../types";
|
||||
|
||||
type CartContextValue = {
|
||||
items: CartItem[];
|
||||
addItem: (product: Product, quantity?: number) => void;
|
||||
removeItem: (productId: number) => void;
|
||||
updateQuantity: (productId: number, quantity: number) => void;
|
||||
clearCart: () => void;
|
||||
itemCount: number;
|
||||
subtotal: number;
|
||||
};
|
||||
|
||||
const CartContext = createContext<CartContextValue | undefined>(undefined);
|
||||
const storageKey = "fluxon.cart";
|
||||
|
||||
export function CartProvider({ children }: { children: ReactNode }) {
|
||||
const [items, setItems] = useState<CartItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = window.localStorage.getItem(storageKey);
|
||||
if (saved) {
|
||||
setItems(JSON.parse(saved) as CartItem[]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(items));
|
||||
}, [items]);
|
||||
|
||||
function addItem(product: Product, quantity = 1) {
|
||||
setItems((current) => {
|
||||
const existing = current.find((item) => item.product.id === product.id);
|
||||
if (existing) {
|
||||
return current.map((item) =>
|
||||
item.product.id === product.id
|
||||
? { ...item, quantity: Math.min(item.quantity + quantity, product.stock) }
|
||||
: item
|
||||
);
|
||||
}
|
||||
|
||||
return [...current, { product, quantity: Math.min(quantity, product.stock), unitPrice: product.price }];
|
||||
});
|
||||
}
|
||||
|
||||
function removeItem(productId: number) {
|
||||
setItems((current) => current.filter((item) => item.product.id !== productId));
|
||||
}
|
||||
|
||||
function updateQuantity(productId: number, quantity: number) {
|
||||
setItems((current) =>
|
||||
current.map((item) =>
|
||||
item.product.id === productId
|
||||
? { ...item, quantity: Math.max(1, Math.min(quantity, item.product.stock)) }
|
||||
: item
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function clearCart() {
|
||||
setItems([]);
|
||||
}
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
items,
|
||||
addItem,
|
||||
removeItem,
|
||||
updateQuantity,
|
||||
clearCart,
|
||||
itemCount: items.reduce((sum, item) => sum + item.quantity, 0),
|
||||
subtotal: items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0)
|
||||
}),
|
||||
[items]
|
||||
);
|
||||
|
||||
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
|
||||
}
|
||||
|
||||
export function useCart() {
|
||||
const context = useContext(CartContext);
|
||||
if (!context) {
|
||||
throw new Error("useCart must be used inside CartProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
587
frontend/src/styles.css
Normal file
587
frontend/src/styles.css
Normal file
@@ -0,0 +1,587 @@
|
||||
:root {
|
||||
font-family: "Trebuchet MS", "Segoe UI", sans-serif;
|
||||
color: #132238;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 186, 117, 0.45), transparent 28%),
|
||||
radial-gradient(circle at top right, rgba(81, 169, 187, 0.28), transparent 24%),
|
||||
linear-gradient(180deg, #fef6ee 0%, #f7fbfc 52%, #eef4f7 100%);
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
color-scheme: light;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
--surface: rgba(255, 255, 255, 0.82);
|
||||
--surface-strong: rgba(255, 255, 255, 0.94);
|
||||
--border: rgba(19, 34, 56, 0.1);
|
||||
--accent: #ff7a45;
|
||||
--accent-deep: #db5b25;
|
||||
--ink-soft: #5b6f84;
|
||||
--shadow: 0 16px 50px rgba(35, 57, 93, 0.12);
|
||||
--radius-lg: 28px;
|
||||
--radius-md: 18px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
.cta-button,
|
||||
.ghost-button {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 0.9rem 1.3rem;
|
||||
cursor: pointer;
|
||||
transition: transform 180ms ease, box-shadow 180ms ease, background 180ms ease;
|
||||
}
|
||||
|
||||
button:hover,
|
||||
.cta-button:hover,
|
||||
.ghost-button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
button,
|
||||
.cta-button {
|
||||
color: white;
|
||||
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-deep) 100%);
|
||||
box-shadow: 0 12px 30px rgba(219, 91, 37, 0.24);
|
||||
}
|
||||
|
||||
.ghost-button,
|
||||
.nav-action {
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
color: #132238;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.nav-action {
|
||||
padding: 0.65rem 1rem;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 0.95rem 1rem;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(19, 34, 56, 0.12);
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
font-weight: 600;
|
||||
color: #20364f;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
width: min(1180px, calc(100vw - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 24px 0 48px;
|
||||
}
|
||||
|
||||
.site-header,
|
||||
.site-footer,
|
||||
.panel,
|
||||
.hero-panel,
|
||||
.product-card,
|
||||
.category-card,
|
||||
.order-card,
|
||||
.status-view {
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.site-header {
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 28px;
|
||||
padding: 1rem 1.25rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.68);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.brand-mark small {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
color: var(--ink-soft);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.brand-badge {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #132238 0%, #2a6f97 100%);
|
||||
}
|
||||
|
||||
.main-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.main-nav a {
|
||||
padding: 0.65rem 1rem;
|
||||
border-radius: 999px;
|
||||
color: var(--ink-soft);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.main-nav a.active,
|
||||
.main-nav a:hover {
|
||||
color: #132238;
|
||||
background: rgba(255, 255, 255, 0.74);
|
||||
}
|
||||
|
||||
.cart-link span {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
margin-left: 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: #132238;
|
||||
color: white;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.menu-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page-stack {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.hero-panel,
|
||||
.panel,
|
||||
.status-view {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
gap: 24px;
|
||||
padding: 34px;
|
||||
}
|
||||
|
||||
.hero-copy h1,
|
||||
.panel h1,
|
||||
.panel h2,
|
||||
.status-view h2 {
|
||||
margin: 0;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.hero-copy h1 {
|
||||
font-size: clamp(2.3rem, 4vw, 4.6rem);
|
||||
}
|
||||
|
||||
.hero-copy p,
|
||||
.panel p,
|
||||
.status-view p {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.hero-card-grid,
|
||||
.category-grid,
|
||||
.product-grid,
|
||||
.footer-grid,
|
||||
.order-list {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.hero-card-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.hero-stat,
|
||||
.category-card,
|
||||
.order-card {
|
||||
padding: 1.1rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-strong);
|
||||
}
|
||||
|
||||
.hero-stat strong {
|
||||
display: block;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-stat.accent {
|
||||
grid-column: 1 / -1;
|
||||
background: linear-gradient(135deg, rgba(255, 122, 69, 0.18) 0%, rgba(19, 34, 56, 0.08) 100%);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: inline-block;
|
||||
margin-bottom: 0.55rem;
|
||||
color: var(--accent-deep);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.section-heading,
|
||||
.toolbar,
|
||||
.summary-row,
|
||||
.product-actions,
|
||||
.meta-row,
|
||||
.inline-actions,
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.panel.compact {
|
||||
padding: 22px 28px;
|
||||
}
|
||||
|
||||
.category-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
}
|
||||
|
||||
.category-card h3,
|
||||
.product-card h3 {
|
||||
margin: 0 0 0.35rem;
|
||||
}
|
||||
|
||||
.product-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
}
|
||||
|
||||
.product-card {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 26px;
|
||||
background: var(--surface-strong);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.product-visual,
|
||||
.detail-visual,
|
||||
.cart-visual {
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.product-copy {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.chip,
|
||||
.stock,
|
||||
.category-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.45rem 0.8rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.chip,
|
||||
.category-pill {
|
||||
background: rgba(255, 122, 69, 0.12);
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.stock.available {
|
||||
background: rgba(97, 192, 191, 0.15);
|
||||
color: #17655d;
|
||||
}
|
||||
|
||||
.stock.sold-out {
|
||||
background: rgba(170, 31, 57, 0.12);
|
||||
color: #8b1e33;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1 1 320px;
|
||||
}
|
||||
|
||||
.select-input {
|
||||
width: min(240px, 100%);
|
||||
}
|
||||
|
||||
.pill-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.category-pill {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.category-pill.active {
|
||||
background: #132238;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.product-detail-layout,
|
||||
.checkout-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 0.9fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.detail-visual {
|
||||
border-radius: var(--radius-lg);
|
||||
min-height: 520px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.lead {
|
||||
font-size: 1.08rem;
|
||||
}
|
||||
|
||||
.quantity-control {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.35rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(19, 34, 56, 0.06);
|
||||
}
|
||||
|
||||
.quantity-control button {
|
||||
min-width: 42px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.cart-list,
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.cart-row {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1.4fr auto auto auto;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid rgba(19, 34, 56, 0.08);
|
||||
}
|
||||
|
||||
.cart-visual {
|
||||
min-height: 88px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.cart-copy h3,
|
||||
.confirmation-card h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.order-summary {
|
||||
align-self: start;
|
||||
position: sticky;
|
||||
top: 112px;
|
||||
}
|
||||
|
||||
.summary-row.total {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid rgba(19, 34, 56, 0.08);
|
||||
}
|
||||
|
||||
.auth-shell {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 70vh;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: min(520px, 100%);
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #aa1f39;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.muted {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.confirmation-card,
|
||||
.status-view {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.status-view {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 36px;
|
||||
}
|
||||
|
||||
.text-button {
|
||||
background: transparent;
|
||||
color: #8b1e33;
|
||||
box-shadow: none;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 2rem;
|
||||
margin-top: 34px;
|
||||
padding: 24px 28px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.footer-grid {
|
||||
grid-template-columns: repeat(3, auto);
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero-panel,
|
||||
.product-detail-layout,
|
||||
.checkout-layout,
|
||||
.site-footer {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cart-row {
|
||||
grid-template-columns: 1fr;
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.order-summary {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
border-radius: 28px;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.menu-toggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.main-nav {
|
||||
display: none;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.main-nav.open {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app-shell {
|
||||
width: min(100vw - 20px, 1180px);
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.hero-panel,
|
||||
.panel,
|
||||
.status-view {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.hero-card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.section-heading,
|
||||
.toolbar,
|
||||
.product-actions,
|
||||
.meta-row,
|
||||
.inline-actions,
|
||||
.detail-meta,
|
||||
.site-footer {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.footer-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
72
frontend/src/types.ts
Normal file
72
frontend/src/types.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
export type Category = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type Product = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
stock: number;
|
||||
categoryId: number;
|
||||
category?: Category;
|
||||
image: string;
|
||||
featured?: boolean;
|
||||
};
|
||||
|
||||
export type CartItem = {
|
||||
product: Product;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
};
|
||||
|
||||
export type LoginInput = {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export type RegisterInput = {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export type AuthUser = {
|
||||
name: string;
|
||||
email: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export type CheckoutForm = {
|
||||
fullName: string;
|
||||
email: string;
|
||||
address: string;
|
||||
city: string;
|
||||
postalCode: string;
|
||||
paymentMethod: "CreditCard" | "PayPal" | "CashOnDelivery";
|
||||
};
|
||||
|
||||
export type OrderItem = {
|
||||
productId: number;
|
||||
productName: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
};
|
||||
|
||||
export type Payment = {
|
||||
method: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type Order = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
status: string;
|
||||
total: number;
|
||||
items: OrderItem[];
|
||||
payment: Payment;
|
||||
customerEmail: string;
|
||||
shippingAddress: string;
|
||||
};
|
||||
15
frontend/src/ui/CategoryPill.tsx
Normal file
15
frontend/src/ui/CategoryPill.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Category } from "../types";
|
||||
|
||||
type CategoryPillProps = {
|
||||
category: Category | { id: number; name: string };
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export function CategoryPill({ category, active, onClick }: CategoryPillProps) {
|
||||
return (
|
||||
<button className={`category-pill ${active ? "active" : ""}`} onClick={onClick}>
|
||||
{category.name}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
67
frontend/src/ui/Layout.tsx
Normal file
67
frontend/src/ui/Layout.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { Link, NavLink } from "react-router-dom";
|
||||
import { useCart } from "../state/CartContext";
|
||||
import { useAuth } from "../state/AuthContext";
|
||||
|
||||
export function Layout({ children }: { children: ReactNode }) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const { itemCount } = useCart();
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="site-header">
|
||||
<Link to="/" className="brand-mark">
|
||||
<span className="brand-badge">F</span>
|
||||
<span>
|
||||
Fluxon
|
||||
<small>Visual commerce demo</small>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<button type="button" className="menu-toggle" onClick={() => setMenuOpen((value) => !value)} aria-label="Toggle menu">
|
||||
Menu
|
||||
</button>
|
||||
|
||||
<nav className={`main-nav ${menuOpen ? "open" : ""}`}>
|
||||
<NavLink to="/" onClick={() => setMenuOpen(false)}>
|
||||
Home
|
||||
</NavLink>
|
||||
<NavLink to="/products" onClick={() => setMenuOpen(false)}>
|
||||
Shop
|
||||
</NavLink>
|
||||
<NavLink to="/account" onClick={() => setMenuOpen(false)}>
|
||||
Account
|
||||
</NavLink>
|
||||
{user ? (
|
||||
<button type="button" className="nav-action" onClick={logout}>
|
||||
Logout
|
||||
</button>
|
||||
) : (
|
||||
<NavLink to="/login" onClick={() => setMenuOpen(false)}>
|
||||
Login
|
||||
</NavLink>
|
||||
)}
|
||||
<NavLink to="/cart" className="cart-link" onClick={() => setMenuOpen(false)}>
|
||||
Cart <span>{itemCount}</span>
|
||||
</NavLink>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>{children}</main>
|
||||
|
||||
<footer className="site-footer">
|
||||
<div>
|
||||
<strong>Fluxon</strong>
|
||||
<p>Frontend demo built to stay presentable even while the API is still evolving.</p>
|
||||
</div>
|
||||
<div className="footer-grid">
|
||||
<span>Hybrid API mode</span>
|
||||
<span>Responsive storefront</span>
|
||||
<span>Checkout-ready flow</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
frontend/src/ui/ProductCard.tsx
Normal file
36
frontend/src/ui/ProductCard.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import type { Product } from "../types";
|
||||
|
||||
type ProductCardProps = {
|
||||
product: Product;
|
||||
onAdd: (product: Product) => void;
|
||||
};
|
||||
|
||||
export function ProductCard({ product, onAdd }: ProductCardProps) {
|
||||
return (
|
||||
<article className="product-card">
|
||||
<div className="product-visual" style={{ background: product.image }} />
|
||||
<div className="product-copy">
|
||||
<div className="meta-row">
|
||||
<span className="chip">{product.category?.name ?? "Category"}</span>
|
||||
<span className={product.stock > 0 ? "stock available" : "stock sold-out"}>
|
||||
{product.stock > 0 ? `${product.stock} in stock` : "Sold out"}
|
||||
</span>
|
||||
</div>
|
||||
<h3>{product.name}</h3>
|
||||
<p>{product.description}</p>
|
||||
<div className="product-actions">
|
||||
<strong>EUR {product.price.toFixed(2)}</strong>
|
||||
<div className="inline-actions">
|
||||
<Link to={`/products/${product.id}`} className="ghost-button">
|
||||
Details
|
||||
</Link>
|
||||
<button onClick={() => onAdd(product)} disabled={product.stock === 0}>
|
||||
Add to cart
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
23
frontend/src/ui/QuantityControl.tsx
Normal file
23
frontend/src/ui/QuantityControl.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
type QuantityControlProps = {
|
||||
value: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
onChange: (value: number) => void;
|
||||
};
|
||||
|
||||
export function QuantityControl({ value, min = 1, max = 99, onChange }: QuantityControlProps) {
|
||||
const nextDown = Math.max(min, value - 1);
|
||||
const nextUp = Math.min(max, value + 1);
|
||||
|
||||
return (
|
||||
<div className="quantity-control">
|
||||
<button onClick={() => onChange(nextDown)} disabled={value <= min}>
|
||||
-
|
||||
</button>
|
||||
<span>{value}</span>
|
||||
<button onClick={() => onChange(nextUp)} disabled={value >= max}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
frontend/src/ui/RequireAuth.tsx
Normal file
14
frontend/src/ui/RequireAuth.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "../state/AuthContext";
|
||||
|
||||
export function RequireAuth({ children }: { children: ReactNode }) {
|
||||
const { user } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
19
frontend/src/ui/StatusView.tsx
Normal file
19
frontend/src/ui/StatusView.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function StatusView({
|
||||
title,
|
||||
message,
|
||||
action
|
||||
}: {
|
||||
title: string;
|
||||
message: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="status-view">
|
||||
<h2>{title}</h2>
|
||||
<p>{message}</p>
|
||||
{action}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
21
frontend/tsconfig.app.json
Normal file
21
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
|
||||
}
|
||||
6
frontend/tsconfig.json
Normal file
6
frontend/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" }
|
||||
]
|
||||
}
|
||||
9
frontend/vite.config.ts
Normal file
9
frontend/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user