This commit is contained in:
2026-06-14 13:19:39 +03:00
parent 4a3db42971
commit 19fff3a92f
152 changed files with 10908 additions and 8276 deletions
@@ -1,128 +1,133 @@
import { LoginForm } from "#/components/login-form.tsx"
import { SignupForm } from "#/components/signup-form.tsx"
import { Dialog, DialogContent } from "#/components/ui/dialog.tsx"
import { useEffect, useState } from "react"
import { useEffect, useState } from "react";
import { LoginForm } from "#/components/login-form.tsx";
import { SignupForm } from "#/components/signup-form.tsx";
import { Dialog, DialogContent } from "#/components/ui/dialog.tsx";
type AuthDialogProps = {
open: boolean
initialMode: "login" | "register"
onOpenChange: (open: boolean) => void
onLogin: (request: { username: string; password: string }) => Promise<unknown>
onRegister: (request: {
username: string
password: string
displayName: string
}) => Promise<unknown>
}
open: boolean;
initialMode: "login" | "register";
onOpenChange: (open: boolean) => void;
onLogin: (request: {
username: string;
password: string;
}) => Promise<unknown>;
onRegister: (request: {
username: string;
password: string;
displayName: string;
}) => Promise<unknown>;
};
export function AuthDialog({
open,
initialMode,
onOpenChange,
onLogin,
onRegister,
open,
initialMode,
onOpenChange,
onLogin,
onRegister,
}: AuthDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md border-0 bg-transparent p-0 shadow-none">
<AuthForm
initialMode={initialMode}
onLogin={onLogin}
onRegister={onRegister}
/>
</DialogContent>
</Dialog>
)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md border-0 bg-transparent p-0 shadow-none">
<AuthForm
initialMode={initialMode}
onLogin={onLogin}
onRegister={onRegister}
/>
</DialogContent>
</Dialog>
);
}
export function AuthForm({
initialMode,
onLogin,
onRegister,
initialMode,
onLogin,
onRegister,
}: Omit<AuthDialogProps, "open" | "onOpenChange">) {
const [mode, setMode] = useState<"login" | "register">(initialMode)
const [username, setUsername] = useState("")
const [displayName, setDisplayName] = useState("")
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [error, setError] = useState<string | null>(null)
const [pending, setPending] = useState(false)
const [mode, setMode] = useState<"login" | "register">(initialMode);
const [username, setUsername] = useState("");
const [displayName, setDisplayName] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
useEffect(() => {
setMode(initialMode)
setError(null)
}, [initialMode])
useEffect(() => {
setMode(initialMode);
setError(null);
}, [initialMode]);
async function handleLogin(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setError(null)
setPending(true)
async function handleLogin(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
setPending(true);
try {
await onLogin({ username, password })
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Sign in failed")
} finally {
setPending(false)
}
}
try {
await onLogin({ username, password });
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Sign in failed");
} finally {
setPending(false);
}
}
async function handleSignup(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setError(null)
async function handleSignup(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (password !== confirmPassword) {
setError("Passwords do not match")
return
}
if (password !== confirmPassword) {
setError("Passwords do not match");
return;
}
setPending(true)
setPending(true);
try {
await onRegister({ username, displayName, password })
await onLogin({ username, password })
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Account creation failed")
} finally {
setPending(false)
}
}
try {
await onRegister({ username, displayName, password });
await onLogin({ username, password });
} catch (caught) {
setError(
caught instanceof Error ? caught.message : "Account creation failed",
);
} finally {
setPending(false);
}
}
if (mode === "register") {
return (
<SignupForm
displayName={displayName}
username={username}
password={password}
confirmPassword={confirmPassword}
error={error}
pending={pending}
onDisplayNameChange={setDisplayName}
onUsernameChange={setUsername}
onPasswordChange={setPassword}
onConfirmPasswordChange={setConfirmPassword}
onSubmit={handleSignup}
onLoginClick={() => {
setMode("login")
setError(null)
}}
/>
)
}
if (mode === "register") {
return (
<SignupForm
displayName={displayName}
username={username}
password={password}
confirmPassword={confirmPassword}
error={error}
pending={pending}
onDisplayNameChange={setDisplayName}
onUsernameChange={setUsername}
onPasswordChange={setPassword}
onConfirmPasswordChange={setConfirmPassword}
onSubmit={handleSignup}
onLoginClick={() => {
setMode("login");
setError(null);
}}
/>
);
}
return (
<LoginForm
username={username}
password={password}
error={error}
pending={pending}
onUsernameChange={setUsername}
onPasswordChange={setPassword}
onSubmit={handleLogin}
onSignupClick={() => {
setMode("register")
setError(null)
}}
/>
)
return (
<LoginForm
username={username}
password={password}
error={error}
pending={pending}
onUsernameChange={setUsername}
onPasswordChange={setPassword}
onSubmit={handleLogin}
onSignupClick={() => {
setMode("register");
setError(null);
}}
/>
);
}
@@ -1,69 +1,82 @@
import {QuantityStepper} from "#/components/store/QuantityStepper";
import {Button} from "#/components/ui/button";
import {Card, CardContent, CardFooter, CardHeader, CardTitle} from "#/components/ui/card";
import {StatusBadge} from "#/components/ui/status-badge";
import {formatCurrency} from "#/hooks/formatCurrency";
import type {Product} from "#/types/product";
import {Link} from "@tanstack/react-router";
import {ShoppingCart} from "lucide-react";
import {useState} from "react";
import {toast} from "sonner";
import { Link } from "@tanstack/react-router";
import { ShoppingCart } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { QuantityStepper } from "#/components/store/QuantityStepper";
import { Button } from "#/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "#/components/ui/card";
import { StatusBadge } from "#/components/ui/status-badge";
import { formatCurrency } from "#/hooks/formatCurrency";
import type { Product } from "#/types/product";
type ProductCardProps = {
product: Product;
onAddToCart?: (product: Product, quantity: number) => void;
product: Product;
onAddToCart?: (product: Product, quantity: number) => void;
};
export function ProductCard({product, onAddToCart}: ProductCardProps) {
const [quantity, setQuantity] = useState(1);
const inStock = product.availableQuantity > 0;
export function ProductCard({ product, onAddToCart }: ProductCardProps) {
const [quantity, setQuantity] = useState(1);
const inStock = product.availableQuantity > 0;
return (
<Card className="gap-4">
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>{product.name}</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">{product.sku}</p>
</div>
<StatusBadge status={inStock ? "IN_STOCK" : "OUT_OF_STOCK"} label={inStock ? "In stock" : "Out"}/>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-2xl font-semibold">{formatCurrency(product.price, product.currency)}</p>
<p className="text-sm text-muted-foreground">{product.availableQuantity} available</p>
{onAddToCart ? (
<div className="flex flex-wrap items-center justify-between gap-3">
<QuantityStepper
value={Math.min(quantity, Math.max(product.availableQuantity, 1))}
min={1}
max={Math.max(product.availableQuantity, 1)}
disabled={!inStock}
onChange={setQuantity}
/>
<Button
type="button"
disabled={!inStock}
onClick={() => {
onAddToCart(product, quantity);
toast.success("Added to cart", {
description: `${quantity} x ${product.name}`,
});
}}
>
<ShoppingCart/>
Add
</Button>
</div>
) : null}
</CardContent>
<CardFooter>
<Button asChild variant="outline" className="w-full">
<Link to="/products/$productId" params={{productId: product.id}}>
View details
</Link>
</Button>
</CardFooter>
</Card>
);
return (
<Card className="gap-4">
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>{product.name}</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">{product.sku}</p>
</div>
<StatusBadge
status={inStock ? "IN_STOCK" : "OUT_OF_STOCK"}
label={inStock ? "In stock" : "Out"}
/>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-2xl font-semibold">
{formatCurrency(product.price, product.currency)}
</p>
<p className="text-sm text-muted-foreground">
{product.availableQuantity} available
</p>
{onAddToCart ? (
<div className="flex flex-wrap items-center justify-between gap-3">
<QuantityStepper
value={Math.min(quantity, Math.max(product.availableQuantity, 1))}
min={1}
max={Math.max(product.availableQuantity, 1)}
disabled={!inStock}
onChange={setQuantity}
/>
<Button
type="button"
disabled={!inStock}
onClick={() => {
onAddToCart(product, quantity);
toast.success("Added to cart", {
description: `${quantity} x ${product.name}`,
});
}}
>
<ShoppingCart />
Add
</Button>
</div>
) : null}
</CardContent>
<CardFooter>
<Button asChild variant="outline" className="w-full">
<Link to="/products/$productId" params={{ productId: product.id }}>
View details
</Link>
</Button>
</CardFooter>
</Card>
);
}
@@ -1,31 +1,39 @@
import {ProductCard} from "#/components/store/ProductCard";
import {LoadingState} from "#/components/ui/LoadingState";
import type {Product} from "#/types/product";
import { ProductCard } from "#/components/store/ProductCard";
import { LoadingState } from "#/components/ui/LoadingState";
import type { Product } from "#/types/product";
type ProductGridProps = {
products: Product[];
isLoading: boolean;
onAddToCart?: (product: Product, quantity: number) => void;
products: Product[];
isLoading: boolean;
onAddToCart?: (product: Product, quantity: number) => void;
};
export function ProductGrid({products, isLoading, onAddToCart}: ProductGridProps) {
if (isLoading) {
return <LoadingState title="Loading products"/>;
}
export function ProductGrid({
products,
isLoading,
onAddToCart,
}: ProductGridProps) {
if (isLoading) {
return <LoadingState title="Loading products" />;
}
if (products.length === 0) {
return (
<div className="rounded-lg border border-dashed border-border bg-card p-8 text-center text-sm text-muted-foreground">
No products available.
</div>
);
}
if (products.length === 0) {
return (
<div className="rounded-lg border border-dashed border-border bg-card p-8 text-center text-sm text-muted-foreground">
No products available.
</div>
);
}
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{products.map((product) => (
<ProductCard key={product.id} product={product} onAddToCart={onAddToCart}/>
))}
</div>
);
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{products.map((product) => (
<ProductCard
key={product.id}
product={product}
onAddToCart={onAddToCart}
/>
))}
</div>
);
}
@@ -1,61 +1,61 @@
import {Button} from "#/components/ui/button";
import {Input} from "#/components/ui/input";
import { Button } from "#/components/ui/button";
import { Input } from "#/components/ui/input";
type QuantityStepperProps = {
value: number;
min?: number;
max: number;
disabled?: boolean;
onChange: (value: number) => void;
value: number;
min?: number;
max: number;
disabled?: boolean;
onChange: (value: number) => void;
};
export function QuantityStepper({
value,
min = 1,
max,
disabled = false,
onChange,
value,
min = 1,
max,
disabled = false,
onChange,
}: QuantityStepperProps) {
const normalizedMax = Math.max(max, min);
const normalizedMax = Math.max(max, min);
function setQuantity(nextValue: number) {
if (Number.isNaN(nextValue)) {
onChange(min);
return;
}
function setQuantity(nextValue: number) {
if (Number.isNaN(nextValue)) {
onChange(min);
return;
}
onChange(Math.min(Math.max(nextValue, min), normalizedMax));
}
onChange(Math.min(Math.max(nextValue, min), normalizedMax));
}
return (
<div className="inline-grid grid-cols-[2.25rem_4rem_2.25rem] overflow-hidden rounded-md border border-input">
<Button
type="button"
variant="secondary"
disabled={disabled || value <= min}
onClick={() => setQuantity(value - 1)}
className="h-9 rounded-none border-0 shadow-none"
>
-
</Button>
<Input
type="number"
min={min}
max={normalizedMax}
value={value}
disabled={disabled}
onChange={(event) => setQuantity(Number(event.target.value))}
className="h-9 rounded-none border-y-0 px-2 text-center shadow-none"
/>
<Button
type="button"
variant="secondary"
disabled={disabled || value >= normalizedMax}
onClick={() => setQuantity(value + 1)}
className="h-9 rounded-none border-0 shadow-none"
>
+
</Button>
</div>
);
return (
<div className="inline-grid grid-cols-[2.25rem_4rem_2.25rem] overflow-hidden rounded-md border border-input">
<Button
type="button"
variant="secondary"
disabled={disabled || value <= min}
onClick={() => setQuantity(value - 1)}
className="h-9 rounded-none border-0 shadow-none"
>
-
</Button>
<Input
type="number"
min={min}
max={normalizedMax}
value={value}
disabled={disabled}
onChange={(event) => setQuantity(Number(event.target.value))}
className="h-9 rounded-none border-y-0 px-2 text-center shadow-none"
/>
<Button
type="button"
variant="secondary"
disabled={disabled || value >= normalizedMax}
onClick={() => setQuantity(value + 1)}
className="h-9 rounded-none border-0 shadow-none"
>
+
</Button>
</div>
);
}
@@ -1,54 +1,67 @@
import {Button} from "#/components/ui/button";
import {ThemeToggle} from "#/components/theme-toggle";
import {useCart} from "#/hooks/useCart";
import type {AuthUser} from "#/types/auth";
import {Link} from "@tanstack/react-router";
import {LogOut, ShoppingCart, User} from "lucide-react";
import { Link } from "@tanstack/react-router";
import { LogOut, ShoppingCart, User } from "lucide-react";
import { ThemeToggle } from "#/components/theme-toggle";
import { Button } from "#/components/ui/button";
import { useCart } from "#/hooks/useCart";
import type { AuthUser } from "#/types/auth";
type SiteHeaderProps = {
user?: AuthUser | null;
onLogin?: () => void;
onRegister?: () => void;
onLogout?: () => void;
user?: AuthUser | null;
onLogin?: () => void;
onRegister?: () => void;
onLogout?: () => void;
};
export function SiteHeader({user, onLogin, onRegister, onLogout}: SiteHeaderProps) {
const cart = useCart();
export function SiteHeader({
user,
onLogin,
onRegister,
onLogout,
}: SiteHeaderProps) {
const cart = useCart();
return (
<header className="sticky top-0 z-30 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex max-w-7xl flex-col gap-4 px-4 py-4 md:flex-row md:items-center md:justify-between">
<div>
<Link to="/" className="text-2xl font-bold text-foreground">Cymelle</Link>
<p className="text-sm text-muted-foreground">Commerce, delivery, and rides</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<ThemeToggle/>
<Button asChild variant="outline">
<Link to="/cart">
<ShoppingCart/>
Cart ({cart.itemCount})
</Link>
</Button>
{user ? (
<>
<div className="inline-flex items-center gap-2 rounded-md border border-border bg-card px-3 py-2 text-sm">
<User className="size-4"/>
<span>{user.displayName}</span>
</div>
<Button type="button" variant="outline" onClick={onLogout}>
<LogOut/>
Sign out
</Button>
</>
) : (
<>
<Button type="button" onClick={onLogin}>Sign in</Button>
<Button type="button" variant="outline" onClick={onRegister}>Create account</Button>
</>
)}
</div>
</div>
</header>
);
return (
<header className="sticky top-0 z-30 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex max-w-7xl flex-col gap-4 px-4 py-4 md:flex-row md:items-center md:justify-between">
<div>
<Link to="/" className="text-2xl font-bold text-foreground">
Cymelle
</Link>
<p className="text-sm text-muted-foreground">
Commerce, delivery, and rides
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<ThemeToggle />
<Button asChild variant="outline">
<Link to="/cart">
<ShoppingCart />
Cart ({cart.itemCount})
</Link>
</Button>
{user ? (
<>
<div className="inline-flex items-center gap-2 rounded-md border border-border bg-card px-3 py-2 text-sm">
<User className="size-4" />
<span>{user.displayName}</span>
</div>
<Button type="button" variant="outline" onClick={onLogout}>
<LogOut />
Sign out
</Button>
</>
) : (
<>
<Button type="button" onClick={onLogin}>
Sign in
</Button>
<Button type="button" variant="outline" onClick={onRegister}>
Create account
</Button>
</>
)}
</div>
</div>
</header>
);
}
@@ -1,60 +1,68 @@
import {AuthDialog} from "#/components/store/AuthDialog";
import {ProductGrid} from "#/components/store/ProductGrid";
import {SiteHeader} from "#/components/store/SiteHeader";
import {useAuth} from "#/hooks/useAuth";
import {useCart} from "#/hooks/useCart";
import {useProducts} from "#/hooks/useProducts";
import {navigateToRoleHome} from "#/lib/auth-navigation";
import {useNavigate} from "@tanstack/react-router";
import {useState} from "react";
import { useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { AuthDialog } from "#/components/store/AuthDialog";
import { ProductGrid } from "#/components/store/ProductGrid";
import { SiteHeader } from "#/components/store/SiteHeader";
import { useAuth } from "#/hooks/useAuth";
import { useCart } from "#/hooks/useCart";
import { useProducts } from "#/hooks/useProducts";
import { navigateToRoleHome } from "#/lib/auth-navigation";
export function StoreHome() {
const {user, login, register, logout} = useAuth();
const productsQuery = useProducts();
const cart = useCart();
const navigate = useNavigate();
const [authMode, setAuthMode] = useState<"login" | "register" | null>(null);
const { user, login, register, logout } = useAuth();
const productsQuery = useProducts();
const cart = useCart();
const navigate = useNavigate();
const [authMode, setAuthMode] = useState<"login" | "register" | null>(null);
async function handleLogin(request: {username: string; password: string}) {
const user = await login(request);
setAuthMode(null);
await navigateToRoleHome(navigate, user);
return user;
}
async function handleLogin(request: { username: string; password: string }) {
const user = await login(request);
setAuthMode(null);
await navigateToRoleHome(navigate, user);
return user;
}
async function handleRegister(request: {username: string; password: string; displayName: string}) {
const user = await register(request);
setAuthMode(null);
await navigateToRoleHome(navigate, user);
return user;
}
async function handleRegister(request: {
username: string;
password: string;
displayName: string;
}) {
const user = await register(request);
setAuthMode(null);
await navigateToRoleHome(navigate, user);
return user;
}
return (
<main className="min-h-screen bg-background">
<SiteHeader
user={user}
onLogin={() => setAuthMode("login")}
onRegister={() => setAuthMode("register")}
onLogout={logout}
/>
<section className="mx-auto max-w-7xl px-4 py-8">
<div className="mb-6 max-w-2xl">
<p className="text-sm font-medium uppercase text-muted-foreground">Store</p>
<h1 className="mt-2 text-4xl font-bold tracking-normal text-foreground">Shop Cymelle products</h1>
</div>
<ProductGrid
products={productsQuery.data ?? []}
isLoading={productsQuery.isLoading}
onAddToCart={cart.addItem}
/>
</section>
<AuthDialog
open={authMode !== null}
initialMode={authMode ?? "login"}
onOpenChange={(open) => !open && setAuthMode(null)}
onLogin={handleLogin}
onRegister={handleRegister}
/>
</main>
);
return (
<main className="min-h-screen bg-background">
<SiteHeader
user={user}
onLogin={() => setAuthMode("login")}
onRegister={() => setAuthMode("register")}
onLogout={logout}
/>
<section className="mx-auto max-w-7xl px-4 py-8">
<div className="mb-6 max-w-2xl">
<p className="text-sm font-medium uppercase text-muted-foreground">
Store
</p>
<h1 className="mt-2 text-4xl font-bold tracking-normal text-foreground">
Shop Cymelle products
</h1>
</div>
<ProductGrid
products={productsQuery.data ?? []}
isLoading={productsQuery.isLoading}
onAddToCart={cart.addItem}
/>
</section>
<AuthDialog
open={authMode !== null}
initialMode={authMode ?? "login"}
onOpenChange={(open) => !open && setAuthMode(null)}
onLogin={handleLogin}
onRegister={handleRegister}
/>
</main>
);
}