ui phase 6
This commit is contained in:
@@ -8,12 +8,12 @@ import { useWalletInstallments } from '@/services/bnpl';
|
||||
import type { WalletInstallmentPlan } from '@/services/bnpl/types';
|
||||
|
||||
/**
|
||||
* D5 · پیگیری اقساط — the Wallet view of active installment plans. It reads `useWalletInstallments` and
|
||||
* renders **provider-reported** status: an outstanding-balance card (terracotta), the next-installment
|
||||
* date + a provider hand-off «پرداخت زودهنگام» (early-pay is a *provider* action, never a Balinyaar
|
||||
* transaction), the per-installment due list with status chips, and the ownership note (Balinyaar displays,
|
||||
* it does not manage, this schedule). Self-contained under the Wallet route so f12 nurse-earnings content
|
||||
* can land beside it later.
|
||||
* D5 · پیگیری اقساط — the Wallet «اقساط» section (active installment plans). It reads
|
||||
* `useWalletInstallments` and renders **provider-reported** status: an outstanding-balance card
|
||||
* (terracotta), the next-installment date + a provider hand-off «پرداخت زودهنگام» (early-pay is a
|
||||
* *provider* action, never a Balinyaar transaction), the per-installment due list with status chips, and
|
||||
* the ownership note (Balinyaar displays, it does not manage, this schedule). Section body only — the
|
||||
* page-level heading + tab strip live in `WalletScreen`.
|
||||
*/
|
||||
const WalletInstallments: FunctionComponent = () => {
|
||||
const t = useTranslations('bnpl');
|
||||
@@ -21,11 +21,7 @@ const WalletInstallments: FunctionComponent = () => {
|
||||
const { data: plans, isLoading, isError, refetch } = useWalletInstallments();
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2, maxWidth: 560, mx: 'auto', width: '100%' }}>
|
||||
<Typography variant="h6" component="h1">
|
||||
{t('wallet_title')}
|
||||
</Typography>
|
||||
|
||||
<Stack sx={{ gap: 2, width: '100%' }}>
|
||||
{isLoading ? (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Skeleton variant="rounded" height={128} />
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppLink, EmptyState, ErrorState, Money, PaymentStatusBadge, SurfaceCard } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { formatShamsiDateTime } from '@/utils';
|
||||
import { useWalletHistoryRows } from './useWalletHistoryRows';
|
||||
|
||||
/**
|
||||
* The wallet «پرداختها» section — every card + BNPL payment (down-payment) the customer made, newest
|
||||
* first, with a deep-link to the booking. For a card-paying customer (the default path) this is what
|
||||
* finally fills the previously permanently-empty Wallet tab.
|
||||
*/
|
||||
const WalletPaymentHistory: FunctionComponent = () => {
|
||||
const t = useTranslations('bnpl');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const { rows, isLoading, bothErrored, refetch } = useWalletHistoryRows();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Skeleton variant="rounded" height={72} />
|
||||
<Skeleton variant="rounded" height={72} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
if (bothErrored) {
|
||||
return <ErrorState message={t('wallet_error_body')} retryLabel={tc('retry')} onRetry={refetch} />;
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
return <EmptyState icon="payment" title={t('history_empty_title')} body={t('history_empty_body')} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{rows.map((row) => (
|
||||
<SurfaceCard key={row.key} padding="sm">
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
|
||||
<Stack sx={{ gap: 0.25 }}>
|
||||
<Money amountIrr={row.amountIrr} tone="emphasis" size="sm" sx={{ fontWeight: 700 }} />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{formatShamsiDateTime(row.createdAt, locale)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack sx={{ alignItems: 'flex-end', gap: 0.5 }}>
|
||||
<PaymentStatusBadge status={row.status} />
|
||||
{row.bookingId != null ? (
|
||||
<AppLink to={`/${locale}${ROUTES.BOOKINGS}/${row.bookingId}`} sx={{ fontSize: '0.75rem' }}>
|
||||
{t('history_view_booking')}
|
||||
</AppLink>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</SurfaceCard>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default WalletPaymentHistory;
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, EmptyState, ErrorState, Money, SurfaceCard } from '@/components';
|
||||
import { bookingInvoicePath } from '@/constants';
|
||||
import { formatShamsiDateTime } from '@/utils';
|
||||
import { useWalletHistoryRows } from './useWalletHistoryRows';
|
||||
|
||||
/**
|
||||
* The wallet «رسیدها» section — no receipts endpoint exists; every succeeded, booking-linked payment
|
||||
* (card or BNPL down-payment) derives its invoice deep-link client-side (a UI join over the same rows the
|
||||
* «پرداختها» tab renders, filtered to `succeeded` + a known `bookingId` — no money math).
|
||||
*/
|
||||
const WalletReceipts: FunctionComponent = () => {
|
||||
const t = useTranslations('bnpl');
|
||||
const tp = useTranslations('payment');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const { rows, isLoading, bothErrored, refetch } = useWalletHistoryRows();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Skeleton variant="rounded" height={72} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
if (bothErrored) {
|
||||
return <ErrorState message={t('wallet_error_body')} retryLabel={tc('retry')} onRetry={refetch} />;
|
||||
}
|
||||
|
||||
const receipts = rows.filter((row) => row.status === 'succeeded' && row.bookingId != null);
|
||||
|
||||
if (receipts.length === 0) {
|
||||
return <EmptyState icon="document" title={t('receipts_empty_title')} body={t('receipts_empty_body')} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{receipts.map((row) => (
|
||||
<SurfaceCard key={row.key} padding="sm">
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
|
||||
<Stack sx={{ gap: 0.25 }}>
|
||||
<Money amountIrr={row.amountIrr} tone="emphasis" size="sm" sx={{ fontWeight: 700 }} />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{formatShamsiDateTime(row.createdAt, locale)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
size="small"
|
||||
startIcon="document"
|
||||
to={`/${locale}${bookingInvoicePath(row.bookingId as number)}`}
|
||||
>
|
||||
{tp('view_invoice_cta')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</SurfaceCard>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default WalletReceipts;
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppLink, EmptyState, ErrorState } from '@/components';
|
||||
import RefundStatusCard from '@/components/RefundStatusCard';
|
||||
import { bookingRefundStatusPath } from '@/constants';
|
||||
import { useMyRefunds } from '@/services/refunds';
|
||||
|
||||
/**
|
||||
* The wallet «استردادها» section — every refund the customer owns (REQ-048), each rendered via the shared
|
||||
* `RefundStatusCard` (step timeline + amount + per-channel ETA) with a link back to its booking.
|
||||
*/
|
||||
const WalletRefunds: FunctionComponent = () => {
|
||||
const t = useTranslations('refunds');
|
||||
const tw = useTranslations('bnpl');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const { data: refunds, isLoading, isError, refetch } = useMyRefunds();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Skeleton variant="rounded" height={160} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
if (isError) {
|
||||
return <ErrorState message={tw('wallet_error_body')} retryLabel={tc('retry')} onRetry={() => refetch()} />;
|
||||
}
|
||||
if (!refunds || refunds.length === 0) {
|
||||
return <EmptyState icon="refunds" title={t('wallet_empty_title')} body={t('wallet_empty_body')} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 3 }}>
|
||||
{refunds.map((refund) => (
|
||||
<Stack key={refund.id} sx={{ gap: 1 }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('wallet_booking_label', { id: refund.bookingId })}
|
||||
</Typography>
|
||||
<AppLink to={`/${locale}${bookingRefundStatusPath(refund.bookingId)}`} sx={{ fontSize: '0.75rem' }}>
|
||||
{t('view_refund_status')}
|
||||
</AppLink>
|
||||
</Stack>
|
||||
<RefundStatusCard refund={refund} />
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default WalletRefunds;
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Box, Stack, Tab, Tabs } from '@mui/material';
|
||||
import { AppIcon, PageHeader } from '@/components';
|
||||
import WalletPaymentHistory from './WalletPaymentHistory';
|
||||
import WalletInstallments from './WalletInstallments';
|
||||
import WalletRefunds from './WalletRefunds';
|
||||
import WalletReceipts from './WalletReceipts';
|
||||
|
||||
type WalletTab = 'payments' | 'installments' | 'refunds' | 'receipts';
|
||||
|
||||
/**
|
||||
* /wallet — the customer money hub (ui-phase-6). Four sections replace the old installments-only shell so
|
||||
* a card-paying customer (the default path) finally sees something other than a permanently empty tab:
|
||||
* «پرداختها» (payment history), «اقساط» (the unchanged f11 D5 installment tracker), «استردادها» (refunds,
|
||||
* REQ-048), «رسیدها» (client-derived invoice links). All four read at the shell's shared `CONTENT_MAX_WIDTH`
|
||||
* — no local width override.
|
||||
*/
|
||||
const WalletScreen: FunctionComponent = () => {
|
||||
const t = useTranslations('bnpl');
|
||||
const [tab, setTab] = useState<WalletTab>('payments');
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<PageHeader title={t('wallet_hub_title')} />
|
||||
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(_event, value: WalletTab) => setTab(value)}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
allowScrollButtonsMobile
|
||||
sx={{ borderBottom: '1px solid', borderColor: 'divider' }}
|
||||
>
|
||||
<Tab value="payments" label={t('tab_payments')} icon={<AppIcon icon="payment" size={18} />} iconPosition="start" />
|
||||
<Tab
|
||||
value="installments"
|
||||
label={t('tab_installments')}
|
||||
icon={<AppIcon icon="installments" size={18} />}
|
||||
iconPosition="start"
|
||||
/>
|
||||
<Tab value="refunds" label={t('tab_refunds')} icon={<AppIcon icon="refunds" size={18} />} iconPosition="start" />
|
||||
<Tab value="receipts" label={t('tab_receipts')} icon={<AppIcon icon="document" size={18} />} iconPosition="start" />
|
||||
</Tabs>
|
||||
|
||||
<Box role="tabpanel">
|
||||
{tab === 'payments' ? <WalletPaymentHistory /> : null}
|
||||
{tab === 'installments' ? <WalletInstallments /> : null}
|
||||
{tab === 'refunds' ? <WalletRefunds /> : null}
|
||||
{tab === 'receipts' ? <WalletReceipts /> : null}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default WalletScreen;
|
||||
@@ -1,10 +1,9 @@
|
||||
import WalletInstallments from './WalletInstallments';
|
||||
import WalletScreen from './WalletScreen';
|
||||
|
||||
/**
|
||||
* /wallet — the customer Wallet tab. Today it hosts the f11 D5 installment-status section (provider-reported,
|
||||
* self-contained so the f12 nurse-earnings Wallet content can land beside it later). The section is a client
|
||||
* component (TanStack Query); this page is the thin route shell.
|
||||
* /wallet — the customer money hub (ui-phase-6): پرداختها / اقساط / استردادها / رسیدها. Thin route shell;
|
||||
* the tabbed body is a client component (TanStack Query).
|
||||
*/
|
||||
export default function WalletPage() {
|
||||
return <WalletInstallments />;
|
||||
return <WalletScreen />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useMemo } from 'react';
|
||||
import { usePaymentHistory } from '@/services/payment';
|
||||
import { useWalletInstallments } from '@/services/bnpl';
|
||||
import type { PaymentTransactionStatus } from '@/services/payment/types';
|
||||
|
||||
export interface WalletHistoryRow {
|
||||
key: string;
|
||||
amountIrr: string;
|
||||
createdAt: string;
|
||||
status: PaymentTransactionStatus;
|
||||
bookingId: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges the two independent seams a wallet history/receipt row can come from — card transactions
|
||||
* (`services/payment`, REQ-047) and each settled BNPL plan's own down-payment leg (`services/bnpl`) — into
|
||||
* one newest-first list. Shared by the wallet «پرداختها» and «رسیدها» tabs so the merge logic lives once.
|
||||
* Degrades gracefully: either source failing alone still renders the other's rows.
|
||||
*/
|
||||
export function useWalletHistoryRows() {
|
||||
const paymentHistory = usePaymentHistory();
|
||||
const walletInstallments = useWalletInstallments();
|
||||
|
||||
const rows = useMemo<WalletHistoryRow[]>(() => {
|
||||
const cardRows: WalletHistoryRow[] = (paymentHistory.data ?? []).map((row) => ({
|
||||
key: `card-${row.transactionId}`,
|
||||
amountIrr: row.amountIrr,
|
||||
createdAt: row.createdAt,
|
||||
status: row.status,
|
||||
bookingId: row.bookingId,
|
||||
}));
|
||||
const bnplRows: WalletHistoryRow[] = (walletInstallments.data ?? []).map((plan) => {
|
||||
const downPayment = plan.installments.find((i) => i.kind === 'down_payment');
|
||||
return {
|
||||
key: `bnpl-${plan.bnplTransactionId}`,
|
||||
amountIrr: downPayment?.amountIrr ?? '0',
|
||||
createdAt: plan.createdAt,
|
||||
status: 'succeeded' as const,
|
||||
bookingId: plan.bookingId,
|
||||
};
|
||||
});
|
||||
return [...cardRows, ...bnplRows].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}, [paymentHistory.data, walletInstallments.data]);
|
||||
|
||||
return {
|
||||
rows,
|
||||
isLoading: paymentHistory.isLoading || walletInstallments.isLoading,
|
||||
bothErrored: paymentHistory.isError && walletInstallments.isError,
|
||||
refetch: () => {
|
||||
paymentHistory.refetch();
|
||||
walletInstallments.refetch();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user