feat(checkout): add CheckoutSuccessView, CheckoutErrorView and getOrder

This commit is contained in:
senke 2026-02-22 14:42:15 +01:00
parent 5233a5b7f2
commit 508e082bcc
10 changed files with 204 additions and 0 deletions

View file

@ -34,5 +34,6 @@ export {
LazySellerDashboard,
LazyWishlist,
LazyPurchases,
LazyCheckoutComplete,
} from './lazy-component';
export type { LazyComponentProps, LazyErrorFallbackProps, LazyErrorBoundaryProps } from './lazy-component';

View file

@ -37,4 +37,5 @@ export {
LazySellerDashboard,
LazyWishlist,
LazyPurchases,
LazyCheckoutComplete,
} from './lazyExports';

View file

@ -227,3 +227,11 @@ export const LazyPurchases = createLazyComponent(
undefined,
'Purchases',
);
export const LazyCheckoutComplete = createLazyComponent(
() =>
import('@/features/checkout/CheckoutCompletePage').then((m) => ({
default: m.CheckoutCompletePage,
})),
undefined,
'Checkout Complete',
);

View file

@ -0,0 +1,55 @@
/**
* CheckoutCompletePage Page après redirect Hyperswitch
* v0.402 P1.2: Lit order_id depuis l'URL, charge la commande, affiche success ou error
*/
import { useSearchParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { marketplaceService } from '@/services/marketplaceService';
import { CheckoutSuccessView } from './CheckoutSuccessView';
import { CheckoutErrorView } from './CheckoutErrorView';
import { Skeleton } from '@/components/ui/skeleton';
export function CheckoutCompletePage() {
const [searchParams] = useSearchParams();
const orderId = searchParams.get('order_id');
const { data: order, isLoading, error } = useQuery({
queryKey: ['order', orderId],
queryFn: () => marketplaceService.getOrder(orderId!),
enabled: !!orderId,
});
if (!orderId) {
return (
<CheckoutErrorView />
);
}
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center min-h-layout-page px-4 py-12">
<div className="max-w-md w-full space-y-8">
<Skeleton className="h-24 w-24 rounded-full mx-auto" />
<div className="space-y-2 text-center">
<Skeleton className="h-8 w-48 mx-auto" />
<Skeleton className="h-4 w-64 mx-auto" />
</div>
<Skeleton className="h-32 w-full rounded-xl" />
</div>
</div>
);
}
if (error || !order) {
return <CheckoutErrorView orderId={orderId} />;
}
if (order.status === 'completed') {
return <CheckoutSuccessView order={order} />;
}
return <CheckoutErrorView orderId={orderId} status={order.status} />;
}
export default CheckoutCompletePage;

View file

@ -0,0 +1,53 @@
/**
* CheckoutErrorView Échec ou annulation du paiement
* v0.402 P1.2: Page affichée après redirect Hyperswitch (order failed/cancelled)
*/
import { Link } from 'react-router-dom';
import { AlertCircle, ShoppingCart } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface CheckoutErrorViewProps {
orderId?: string;
status?: string;
}
export function CheckoutErrorView({ orderId, status }: CheckoutErrorViewProps) {
const isCancelled = status === 'cancelled' || status === 'canceled';
const title = isCancelled ? 'Paiement annulé' : 'Échec du paiement';
const message = isCancelled
? 'Vous avez annulé le paiement. Votre panier n\'a pas été modifié.'
: 'Le paiement n\'a pas pu être traité. Veuillez réessayer ou utiliser une autre méthode.';
return (
<div className="flex flex-col items-center justify-center min-h-layout-page px-4 py-12">
<div className="max-w-md w-full space-y-8 text-center">
<div className="flex justify-center">
<div className="rounded-full bg-destructive/10 p-4">
<AlertCircle className="h-16 w-16 text-destructive" aria-hidden />
</div>
</div>
<div className="space-y-2">
<h1 className="text-2xl font-bold text-foreground">{title}</h1>
<p className="text-muted-foreground">{message}</p>
{orderId && (
<p className="text-xs text-muted-foreground font-mono">
Référence : {orderId.slice(0, 8)}
</p>
)}
</div>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Button variant="primary" asChild>
<Link to="/marketplace" className="inline-flex items-center gap-2">
<ShoppingCart className="h-4 w-4" />
Retour au panier
</Link>
</Button>
<Button variant="outline" asChild>
<Link to="/marketplace">Continuer mes achats</Link>
</Button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,68 @@
/**
* CheckoutSuccessView Confirmation de paiement réussi
* v0.402 P1.2: Page affichée après redirect Hyperswitch (order completed)
*/
import { Link } from 'react-router-dom';
import { CheckCircle2, ShoppingBag } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface Order {
id: string;
status: string;
total_amount: number;
currency: string;
items?: Array<{ product_id: string; price: number }>;
created_at?: string;
}
interface CheckoutSuccessViewProps {
order: Order;
}
const formatPrice = (amount: number, currency: string) =>
new Intl.NumberFormat('fr-FR', { style: 'currency', currency: currency || 'EUR' }).format(amount);
export function CheckoutSuccessView({ order }: CheckoutSuccessViewProps) {
return (
<div className="flex flex-col items-center justify-center min-h-layout-page px-4 py-12">
<div className="max-w-md w-full space-y-8 text-center">
<div className="flex justify-center">
<div className="rounded-full bg-success/10 p-4">
<CheckCircle2 className="h-16 w-16 text-success" aria-hidden />
</div>
</div>
<div className="space-y-2">
<h1 className="text-2xl font-bold text-foreground">Paiement réussi</h1>
<p className="text-muted-foreground">
Votre commande <span className="font-mono text-foreground">{order.id.slice(0, 8)}</span> a é confirmée.
</p>
</div>
<div className="rounded-xl border border-border bg-card p-6 text-left space-y-4">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Total</span>
<span className="font-semibold text-foreground">
{formatPrice(order.total_amount, order.currency)}
</span>
</div>
{order.items && order.items.length > 0 && (
<p className="text-xs text-muted-foreground">
{order.items.length} produit{order.items.length > 1 ? 's' : ''} dans cette commande
</p>
)}
</div>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Button variant="primary" asChild>
<Link to="/purchases" className="inline-flex items-center gap-2">
<ShoppingBag className="h-4 w-4" />
Mes achats
</Link>
</Button>
<Button variant="outline" asChild>
<Link to="/marketplace">Continuer mes achats</Link>
</Button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,3 @@
export { CheckoutCompletePage } from './CheckoutCompletePage';
export { CheckoutSuccessView } from './CheckoutSuccessView';
export { CheckoutErrorView } from './CheckoutErrorView';

View file

@ -74,6 +74,7 @@ vi.mock('@/components/ui/LazyComponent', () => ({
LazySellerDashboard: () => <div>Seller Page</div>,
LazyWishlist: () => <div>Wishlist Page</div>,
LazyPurchases: () => <div>Purchases Page</div>,
LazyCheckoutComplete: () => <div>Checkout Complete Page</div>,
}));
// Mock DashboardLayout (used by ProtectedLayoutRoute)

View file

@ -30,6 +30,7 @@ import {
LazySellerDashboard,
LazyWishlist,
LazyPurchases,
LazyCheckoutComplete,
LazyQueue,
LazyDeveloper,
LazyGear,
@ -81,6 +82,7 @@ export function getProtectedRoutes(): RouteEntry[] {
{ path: '/sell', element: wrapProtected(<LazySellerDashboard onCreateProduct={() => {}} />) },
{ path: '/wishlist', element: wrapProtected(<LazyWishlist />) },
{ path: '/purchases', element: wrapProtected(<LazyPurchases />) },
{ path: '/checkout/complete', element: wrapProtected(<LazyCheckoutComplete />) },
{ path: '/chat', element: wrapProtected(<LazyChat />) },
{ path: '/library', element: wrapProtected(<LazyLibrary />) },
{ path: '/profile', element: wrapProtected(<LazyProfile />) },

View file

@ -137,6 +137,18 @@ export const marketplaceService = {
return response.data;
},
getOrder: async (orderId: string) => {
const response = await apiClient.get<{
id: string;
status: string;
total_amount: number;
currency: string;
items?: Array<{ product_id: string; price: number }>;
created_at?: string;
}>(`/marketplace/orders/${orderId}`);
return response.data;
},
// Wishlist (requires auth)
getWishlist: async (): Promise<Product[]> => {
const response = await apiClient.get<{