refactor(web): split PurchasesView into purchases-view module

- types: PurchasesViewProps, Purchase
- usePurchasesView: commerceService.getPurchases, search, refund, download
- PurchasesViewHeader, PurchasesViewItem, PurchasesViewList, PurchasesViewSkeleton
- RefundRequestModal in orchestrator; Loading renders Skeleton
- Stories: Default, Empty (initialPurchases []), Loading (Skeleton)
- Re-export from PurchasesView.tsx

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
senke 2026-02-06 21:42:35 +01:00
parent 54ce30c318
commit 6a51afb0a7
10 changed files with 349 additions and 189 deletions

View file

@ -1,23 +1,31 @@
import type { Meta, StoryObj } from '@storybook/react';
import { PurchasesView } from './PurchasesView';
import { PurchasesView, PurchasesViewSkeleton } from './purchases-view';
const meta: Meta<typeof PurchasesView> = {
title: 'Components/Features/Views/PurchasesView',
component: PurchasesView,
parameters: { layout: 'fullscreen' },
tags: ['autodocs'],
decorators: [
(Story) => (
<div className="bg-kodo-background min-h-screen">
<Story />
</div>
),
],
title: 'Components/Features/Views/PurchasesView',
component: PurchasesView,
parameters: { layout: 'fullscreen' },
tags: ['autodocs'],
decorators: [
(Story) => (
<div className="bg-kodo-background min-h-layout-page">
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Loading: Story = {
name: 'Loading',
render: () => <PurchasesViewSkeleton />,
};
export const Empty: Story = {
name: 'Vide',
args: { initialPurchases: [] },
};
export const Default: Story = { name: 'Par défaut' };
export const Empty: Story = { name: 'Vide' };
export const Loading: Story = { name: 'Chargement' };

View file

@ -1,175 +1 @@
import React, { useState, useEffect } from 'react';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { SearchInput } from '../ui/input';
import { Purchase } from '../../types';
import { Download, FileText, RefreshCcw, Loader2 } from 'lucide-react';
import { RefundRequestModal } from '../commerce/modals/RefundRequestModal';
import { useToast } from '../../components/feedback/ToastProvider';
import { commerceService } from '../../services/commerceService';
import { logger } from '@/utils/logger';
export const PurchasesView: React.FC = () => {
const { addToast } = useToast();
const [search, setSearch] = useState('');
const [refundOrderId, setRefundOrderId] = useState<string | null>(null);
const [activeDownloadId, setActiveDownloadId] = useState<string | null>(null);
const [purchases, setPurchases] = useState<Purchase[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadPurchases = async () => {
setLoading(true);
try {
const data = await commerceService.getPurchases();
setPurchases(data);
} catch (e) {
logger.error('Error loading purchases', {
error: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined,
});
} finally {
setLoading(false);
}
};
loadPurchases();
}, []);
const filteredPurchases = purchases.filter((p) =>
p.product.title.toLowerCase().includes(search.toLowerCase()),
);
const handleDownload = (format: string) => {
addToast(`Downloading ${format}...`, 'success');
setActiveDownloadId(null);
};
return (
<div className="animate-fadeIn max-w-5xl mx-auto pb-20">
<div className="flex flex-col md:flex-row justify-between items-end border-b border-kodo-steel/50 pb-6 gap-4 mb-8">
<div>
<h1 className="text-3xl font-display font-bold text-white mb-2">
MY PURCHASES
</h1>
<p className="text-kodo-content-dim font-mono text-sm">
Access your sounds, licenses, and receipts.
</p>
</div>
<div className="w-full md:w-64">
<SearchInput
placeholder="Search library..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<div className="space-y-4">
{loading && (
<div className="flex justify-center py-24">
<Loader2 className="w-8 h-8 text-kodo-steel animate-spin" />
</div>
)}
{!loading && filteredPurchases.length === 0 ? (
<div className="text-center py-24 text-kodo-content-dim">
<p>No purchases found.</p>
</div>
) : (
filteredPurchases.map((purchase) => (
<Card
key={purchase.id}
variant="default"
className="flex flex-col md:flex-row items-center gap-8 p-4"
>
{/* Image */}
<div className="w-20 h-20 rounded-lg overflow-hidden flex-shrink-0 bg-kodo-graphite">
<img
src={purchase.product.coverUrl}
className="w-full h-full object-cover"
/>
</div>
{/* Details */}
<div className="flex-1 w-full text-center md:text-left">
<h3 className="font-bold text-white text-lg mb-1">
{purchase.product.title}
</h3>
<div className="flex flex-wrap items-center justify-center md:justify-start gap-4 text-xs text-kodo-content-dim">
<span className="bg-kodo-ink px-2 py-1 rounded border border-kodo-steel text-kodo-steel">
{purchase.product.type}
</span>
<span>Order #{purchase.orderId}</span>
<span></span>
<span>{purchase.date}</span>
<span></span>
<span className="text-white">${purchase.price}</span>
</div>
</div>
{/* Actions */}
<div className="flex flex-col sm:flex-row gap-4 w-full md:w-auto">
<div className="relative">
<Button
variant="primary"
size="sm"
icon={<Download className="w-4 h-4" />}
onClick={() =>
setActiveDownloadId(
activeDownloadId === purchase.id ? null : purchase.id,
)
}
>
Download
</Button>
{activeDownloadId === purchase.id && (
<div className="absolute top-full right-0 mt-2 w-40 bg-kodo-graphite border border-kodo-steel rounded-lg shadow-xl z-20 overflow-hidden animate-fadeIn">
{['WAV (24-bit)', 'MP3 (320kbps)', 'Stems (ZIP)'].map(
(fmt) => (
<button
key={fmt}
className="w-full text-left px-4 py-2 text-xs text-white hover:bg-white/10"
onClick={() => handleDownload(fmt)}
>
{fmt}
</button>
),
)}
</div>
)}
</div>
<Button
variant="ghost"
size="sm"
className="border border-kodo-steel"
icon={<FileText className="w-4 h-4" />}
onClick={() => addToast('License document opened')}
>
License
</Button>
<Button
variant="ghost"
size="sm"
className="text-kodo-content-dim hover:text-white"
title="Request Refund"
onClick={() => setRefundOrderId(purchase.orderId)}
>
<RefreshCcw className="w-4 h-4" />
</Button>
</div>
</Card>
))
)}
</div>
{refundOrderId && (
<RefundRequestModal
orderId={refundOrderId}
onClose={() => setRefundOrderId(null)}
/>
)}
</div>
);
};
export { PurchasesView } from './purchases-view';

View file

@ -0,0 +1,49 @@
import React from 'react';
import { RefundRequestModal } from '@/components/commerce/modals/RefundRequestModal';
import { usePurchasesView } from './usePurchasesView';
import { PurchasesViewHeader } from './PurchasesViewHeader';
import { PurchasesViewList } from './PurchasesViewList';
import { PurchasesViewSkeleton } from './PurchasesViewSkeleton';
import type { PurchasesViewProps } from './types';
export function PurchasesView({ initialPurchases }: PurchasesViewProps = {}) {
const {
search,
setSearch,
refundOrderId,
setRefundOrderId,
activeDownloadId,
setActiveDownloadId,
purchases,
loading,
addToast,
handleDownload,
} = usePurchasesView(initialPurchases ?? undefined);
if (loading) {
return <PurchasesViewSkeleton />;
}
return (
<div className="animate-fadeIn max-w-5xl mx-auto pb-20">
<PurchasesViewHeader search={search} onSearchChange={setSearch} />
<PurchasesViewList
purchases={purchases}
loading={false}
activeDownloadId={activeDownloadId}
setActiveDownloadId={setActiveDownloadId}
onDownloadFormat={handleDownload}
onLicense={() => addToast('License document opened')}
onRefund={setRefundOrderId}
/>
{refundOrderId && (
<RefundRequestModal
orderId={refundOrderId}
onClose={() => setRefundOrderId(null)}
/>
)}
</div>
);
}

View file

@ -0,0 +1,27 @@
import React from 'react';
import { SearchInput } from '@/components/ui/input';
interface PurchasesViewHeaderProps {
search: string;
onSearchChange: (value: string) => void;
}
export function PurchasesViewHeader({ search, onSearchChange }: PurchasesViewHeaderProps) {
return (
<div className="flex flex-col md:flex-row justify-between items-end border-b border-kodo-steel/50 pb-6 gap-4 mb-8">
<div>
<h1 className="text-3xl font-display font-bold text-white mb-2">MY PURCHASES</h1>
<p className="text-kodo-content-dim font-mono text-sm">
Access your sounds, licenses, and receipts.
</p>
</div>
<div className="w-full md:w-64">
<SearchInput
placeholder="Search library..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,102 @@
import React from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Download, FileText, RefreshCcw } from 'lucide-react';
import type { Purchase } from '@/types';
interface PurchasesViewItemProps {
purchase: Purchase;
isDownloadOpen: boolean;
onToggleDownload: () => void;
onDownloadFormat: (format: string) => void;
onLicense: () => void;
onRefund: () => void;
}
const DOWNLOAD_FORMATS = ['WAV (24-bit)', 'MP3 (320kbps)', 'Stems (ZIP)'];
export function PurchasesViewItem({
purchase,
isDownloadOpen,
onToggleDownload,
onDownloadFormat,
onLicense,
onRefund,
}: PurchasesViewItemProps) {
const product = purchase.product as { title: string; coverUrl?: string; type?: string };
return (
<Card
variant="default"
className="flex flex-col md:flex-row items-center gap-8 p-4"
>
<div className="w-20 h-20 rounded-lg overflow-hidden flex-shrink-0 bg-kodo-graphite">
<img
src={product.coverUrl}
alt=""
className="w-full h-full object-cover"
/>
</div>
<div className="flex-1 w-full text-center md:text-left">
<h3 className="font-bold text-white text-lg mb-1">{product.title}</h3>
<div className="flex flex-wrap items-center justify-center md:justify-start gap-4 text-xs text-kodo-content-dim">
<span className="bg-kodo-ink px-2 py-1 rounded border border-kodo-steel text-kodo-steel">
{product.type ?? 'pack'}
</span>
<span>Order #{purchase.orderId}</span>
<span></span>
<span>{purchase.date}</span>
<span></span>
<span className="text-white">${purchase.price}</span>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-4 w-full md:w-auto">
<div className="relative">
<Button
variant="primary"
size="sm"
icon={<Download className="w-4 h-4" />}
onClick={onToggleDownload}
>
Download
</Button>
{isDownloadOpen && (
<div className="absolute top-full right-0 mt-2 w-40 bg-kodo-graphite border border-kodo-steel rounded-lg shadow-xl z-20 overflow-hidden animate-fadeIn">
{DOWNLOAD_FORMATS.map((fmt) => (
<button
key={fmt}
type="button"
className="w-full text-left px-4 py-2 text-xs text-white hover:bg-white/10"
onClick={() => onDownloadFormat(fmt)}
>
{fmt}
</button>
))}
</div>
)}
</div>
<Button
variant="ghost"
size="sm"
className="border border-kodo-steel"
icon={<FileText className="w-4 h-4" />}
onClick={onLicense}
>
License
</Button>
<Button
variant="ghost"
size="sm"
className="text-kodo-content-dim hover:text-white"
title="Request Refund"
onClick={onRefund}
>
<RefreshCcw className="w-4 h-4" />
</Button>
</div>
</Card>
);
}

View file

@ -0,0 +1,51 @@
import React from 'react';
import { PurchasesViewItem } from './PurchasesViewItem';
import type { Purchase } from '@/types';
interface PurchasesViewListProps {
purchases: Purchase[];
loading: boolean;
activeDownloadId: string | null;
setActiveDownloadId: (id: string | null) => void;
onDownloadFormat: (format: string) => void;
onLicense: () => void;
onRefund: (orderId: string) => void;
}
export function PurchasesViewList({
purchases,
loading,
activeDownloadId,
setActiveDownloadId,
onDownloadFormat,
onLicense,
onRefund,
}: PurchasesViewListProps) {
if (loading) return null;
if (purchases.length === 0) {
return (
<div className="text-center py-24 text-kodo-content-dim">
<p>No purchases found.</p>
</div>
);
}
return (
<div className="space-y-4">
{purchases.map((purchase) => (
<PurchasesViewItem
key={purchase.id}
purchase={purchase}
isDownloadOpen={activeDownloadId === purchase.id}
onToggleDownload={() =>
setActiveDownloadId(activeDownloadId === purchase.id ? null : purchase.id)
}
onDownloadFormat={onDownloadFormat}
onLicense={onLicense}
onRefund={() => onRefund(purchase.orderId)}
/>
))}
</div>
);
}

View file

@ -0,0 +1,22 @@
import React from 'react';
import { Skeleton } from '@/components/ui/skeleton';
export function PurchasesViewSkeleton() {
return (
<div className="animate-fadeIn max-w-5xl mx-auto pb-20">
<div className="flex flex-col md:flex-row justify-between items-end border-b border-kodo-steel/50 pb-6 gap-4 mb-8">
<div>
<Skeleton className="h-9 w-56 mb-2" />
<Skeleton className="h-4 w-72" />
</div>
<Skeleton className="h-10 w-full md:w-64" />
</div>
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-28 w-full rounded-xl" />
))}
</div>
</div>
);
}

View file

@ -0,0 +1,4 @@
export type { PurchasesViewProps, Purchase } from './types';
export { PurchasesView } from './PurchasesView';
export { PurchasesViewSkeleton } from './PurchasesViewSkeleton';
export { usePurchasesView } from './usePurchasesView';

View file

@ -0,0 +1,8 @@
import type { Purchase } from '@/types';
export type { Purchase };
export interface PurchasesViewProps {
/** Optional initial purchases for stories */
initialPurchases?: Purchase[] | null;
}

View file

@ -0,0 +1,63 @@
import { useState, useEffect, useCallback } from 'react';
import { useToast } from '@/components/feedback/ToastProvider';
import { commerceService } from '@/services/commerceService';
import { logger } from '@/utils/logger';
import type { Purchase } from '@/types';
export function usePurchasesView(initialPurchases?: Purchase[] | null) {
const { addToast } = useToast();
const [search, setSearch] = useState('');
const [refundOrderId, setRefundOrderId] = useState<string | null>(null);
const [activeDownloadId, setActiveDownloadId] = useState<string | null>(null);
const [purchases, setPurchases] = useState<Purchase[]>(initialPurchases ?? []);
const [loading, setLoading] = useState(initialPurchases == null);
const loadPurchases = useCallback(async () => {
if (initialPurchases != null) {
setPurchases(initialPurchases);
setLoading(false);
return;
}
setLoading(true);
try {
const data = await commerceService.getPurchases();
setPurchases(data);
} catch (e) {
logger.error('Error loading purchases', {
error: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined,
});
} finally {
setLoading(false);
}
}, [initialPurchases]);
useEffect(() => {
loadPurchases();
}, [loadPurchases]);
const filteredPurchases = purchases.filter((p) =>
p.product.title.toLowerCase().includes(search.toLowerCase()),
);
const handleDownload = useCallback(
(format: string) => {
addToast(`Downloading ${format}...`, 'success');
setActiveDownloadId(null);
},
[addToast],
);
return {
search,
setSearch,
refundOrderId,
setRefundOrderId,
activeDownloadId,
setActiveDownloadId,
purchases: filteredPurchases,
loading,
addToast,
handleDownload,
};
}