From 6a51afb0a7e4e0ab2a0873e53edf6819acf85b2d Mon Sep 17 00:00:00 2001 From: senke Date: Fri, 6 Feb 2026 21:42:35 +0100 Subject: [PATCH] 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 --- .../views/PurchasesView.stories.tsx | 36 ++-- .../src/components/views/PurchasesView.tsx | 176 +----------------- .../views/purchases-view/PurchasesView.tsx | 49 +++++ .../purchases-view/PurchasesViewHeader.tsx | 27 +++ .../purchases-view/PurchasesViewItem.tsx | 102 ++++++++++ .../purchases-view/PurchasesViewList.tsx | 51 +++++ .../purchases-view/PurchasesViewSkeleton.tsx | 22 +++ .../components/views/purchases-view/index.ts | 4 + .../components/views/purchases-view/types.ts | 8 + .../views/purchases-view/usePurchasesView.ts | 63 +++++++ 10 files changed, 349 insertions(+), 189 deletions(-) create mode 100644 apps/web/src/components/views/purchases-view/PurchasesView.tsx create mode 100644 apps/web/src/components/views/purchases-view/PurchasesViewHeader.tsx create mode 100644 apps/web/src/components/views/purchases-view/PurchasesViewItem.tsx create mode 100644 apps/web/src/components/views/purchases-view/PurchasesViewList.tsx create mode 100644 apps/web/src/components/views/purchases-view/PurchasesViewSkeleton.tsx create mode 100644 apps/web/src/components/views/purchases-view/index.ts create mode 100644 apps/web/src/components/views/purchases-view/types.ts create mode 100644 apps/web/src/components/views/purchases-view/usePurchasesView.ts diff --git a/apps/web/src/components/views/PurchasesView.stories.tsx b/apps/web/src/components/views/PurchasesView.stories.tsx index 36b88d078..83604b021 100644 --- a/apps/web/src/components/views/PurchasesView.stories.tsx +++ b/apps/web/src/components/views/PurchasesView.stories.tsx @@ -1,23 +1,31 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { PurchasesView } from './PurchasesView'; +import { PurchasesView, PurchasesViewSkeleton } from './purchases-view'; const meta: Meta = { - title: 'Components/Features/Views/PurchasesView', - component: PurchasesView, - parameters: { layout: 'fullscreen' }, - tags: ['autodocs'], - decorators: [ - (Story) => ( -
- -
- ), - ], + title: 'Components/Features/Views/PurchasesView', + component: PurchasesView, + parameters: { layout: 'fullscreen' }, + tags: ['autodocs'], + decorators: [ + (Story) => ( +
+ +
+ ), + ], }; export default meta; type Story = StoryObj; +export const Loading: Story = { + name: 'Loading', + render: () => , +}; + +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' }; diff --git a/apps/web/src/components/views/PurchasesView.tsx b/apps/web/src/components/views/PurchasesView.tsx index 62f1c748a..146d12112 100644 --- a/apps/web/src/components/views/PurchasesView.tsx +++ b/apps/web/src/components/views/PurchasesView.tsx @@ -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(null); - const [activeDownloadId, setActiveDownloadId] = useState(null); - const [purchases, setPurchases] = useState([]); - 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 ( -
-
-
-

- MY PURCHASES -

-

- Access your sounds, licenses, and receipts. -

-
-
- setSearch(e.target.value)} - /> -
-
- -
- {loading && ( -
- -
- )} - - {!loading && filteredPurchases.length === 0 ? ( -
-

No purchases found.

-
- ) : ( - filteredPurchases.map((purchase) => ( - - {/* Image */} -
- -
- - {/* Details */} -
-

- {purchase.product.title} -

-
- - {purchase.product.type} - - Order #{purchase.orderId} - - {purchase.date} - - ${purchase.price} -
-
- - {/* Actions */} -
-
- - {activeDownloadId === purchase.id && ( -
- {['WAV (24-bit)', 'MP3 (320kbps)', 'Stems (ZIP)'].map( - (fmt) => ( - - ), - )} -
- )} -
- - - - -
-
- )) - )} -
- - {refundOrderId && ( - setRefundOrderId(null)} - /> - )} -
- ); -}; +export { PurchasesView } from './purchases-view'; diff --git a/apps/web/src/components/views/purchases-view/PurchasesView.tsx b/apps/web/src/components/views/purchases-view/PurchasesView.tsx new file mode 100644 index 000000000..1a86f5fcd --- /dev/null +++ b/apps/web/src/components/views/purchases-view/PurchasesView.tsx @@ -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 ; + } + + return ( +
+ + + addToast('License document opened')} + onRefund={setRefundOrderId} + /> + + {refundOrderId && ( + setRefundOrderId(null)} + /> + )} +
+ ); +} diff --git a/apps/web/src/components/views/purchases-view/PurchasesViewHeader.tsx b/apps/web/src/components/views/purchases-view/PurchasesViewHeader.tsx new file mode 100644 index 000000000..ed77d5d08 --- /dev/null +++ b/apps/web/src/components/views/purchases-view/PurchasesViewHeader.tsx @@ -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 ( +
+
+

MY PURCHASES

+

+ Access your sounds, licenses, and receipts. +

+
+
+ onSearchChange(e.target.value)} + /> +
+
+ ); +} diff --git a/apps/web/src/components/views/purchases-view/PurchasesViewItem.tsx b/apps/web/src/components/views/purchases-view/PurchasesViewItem.tsx new file mode 100644 index 000000000..9318f09e9 --- /dev/null +++ b/apps/web/src/components/views/purchases-view/PurchasesViewItem.tsx @@ -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 ( + +
+ +
+ +
+

{product.title}

+
+ + {product.type ?? 'pack'} + + Order #{purchase.orderId} + + {purchase.date} + + ${purchase.price} +
+
+ +
+
+ + {isDownloadOpen && ( +
+ {DOWNLOAD_FORMATS.map((fmt) => ( + + ))} +
+ )} +
+ + + + +
+
+ ); +} diff --git a/apps/web/src/components/views/purchases-view/PurchasesViewList.tsx b/apps/web/src/components/views/purchases-view/PurchasesViewList.tsx new file mode 100644 index 000000000..babb7c82c --- /dev/null +++ b/apps/web/src/components/views/purchases-view/PurchasesViewList.tsx @@ -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 ( +
+

No purchases found.

+
+ ); + } + + return ( +
+ {purchases.map((purchase) => ( + + setActiveDownloadId(activeDownloadId === purchase.id ? null : purchase.id) + } + onDownloadFormat={onDownloadFormat} + onLicense={onLicense} + onRefund={() => onRefund(purchase.orderId)} + /> + ))} +
+ ); +} diff --git a/apps/web/src/components/views/purchases-view/PurchasesViewSkeleton.tsx b/apps/web/src/components/views/purchases-view/PurchasesViewSkeleton.tsx new file mode 100644 index 000000000..9d84c998e --- /dev/null +++ b/apps/web/src/components/views/purchases-view/PurchasesViewSkeleton.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { Skeleton } from '@/components/ui/skeleton'; + +export function PurchasesViewSkeleton() { + return ( +
+
+
+ + +
+ +
+ +
+ {[1, 2, 3].map((i) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/src/components/views/purchases-view/index.ts b/apps/web/src/components/views/purchases-view/index.ts new file mode 100644 index 000000000..ccb5c8dfd --- /dev/null +++ b/apps/web/src/components/views/purchases-view/index.ts @@ -0,0 +1,4 @@ +export type { PurchasesViewProps, Purchase } from './types'; +export { PurchasesView } from './PurchasesView'; +export { PurchasesViewSkeleton } from './PurchasesViewSkeleton'; +export { usePurchasesView } from './usePurchasesView'; diff --git a/apps/web/src/components/views/purchases-view/types.ts b/apps/web/src/components/views/purchases-view/types.ts new file mode 100644 index 000000000..b679123be --- /dev/null +++ b/apps/web/src/components/views/purchases-view/types.ts @@ -0,0 +1,8 @@ +import type { Purchase } from '@/types'; + +export type { Purchase }; + +export interface PurchasesViewProps { + /** Optional initial purchases for stories */ + initialPurchases?: Purchase[] | null; +} diff --git a/apps/web/src/components/views/purchases-view/usePurchasesView.ts b/apps/web/src/components/views/purchases-view/usePurchasesView.ts new file mode 100644 index 000000000..ad12c7d19 --- /dev/null +++ b/apps/web/src/components/views/purchases-view/usePurchasesView.ts @@ -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(null); + const [activeDownloadId, setActiveDownloadId] = useState(null); + const [purchases, setPurchases] = useState(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, + }; +}