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:
parent
54ce30c318
commit
6a51afb0a7
10 changed files with 349 additions and 189 deletions
|
|
@ -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' };
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
4
apps/web/src/components/views/purchases-view/index.ts
Normal file
4
apps/web/src/components/views/purchases-view/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export type { PurchasesViewProps, Purchase } from './types';
|
||||
export { PurchasesView } from './PurchasesView';
|
||||
export { PurchasesViewSkeleton } from './PurchasesViewSkeleton';
|
||||
export { usePurchasesView } from './usePurchasesView';
|
||||
8
apps/web/src/components/views/purchases-view/types.ts
Normal file
8
apps/web/src/components/views/purchases-view/types.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import type { Purchase } from '@/types';
|
||||
|
||||
export type { Purchase };
|
||||
|
||||
export interface PurchasesViewProps {
|
||||
/** Optional initial purchases for stories */
|
||||
initialPurchases?: Purchase[] | null;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue