frontend phase 9
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
import faMessages from '../../../messages/fa.json';
|
||||
|
||||
// next-intl mocked to read the REAL fa message file, so the test pins the product-mandated escrow copy
|
||||
// as it actually ships — a rewording in fa.json (or a broken key) fails here as a conscious change.
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: (namespace: string) => (key: string) => {
|
||||
const messages = jest.requireActual('../../../messages/fa.json') as Record<string, Record<string, string>>;
|
||||
return messages[namespace][key];
|
||||
},
|
||||
}));
|
||||
|
||||
import EscrowNotice from './EscrowNotice';
|
||||
|
||||
function renderNotice() {
|
||||
return render(
|
||||
<ThemeProvider>
|
||||
<EscrowNotice />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('<EscrowNotice/> component', () => {
|
||||
it('renders the mandated escrow copy verbatim from fa.json', () => {
|
||||
renderNotice();
|
||||
expect(
|
||||
screen.getByText('مبلغ بهصورت امانی نزد بالینیار میماند و پس از پایان ویزیت آزاد میشود'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(faMessages.payment.escrow_notice)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders as an info (trust) surface, never an error tone', () => {
|
||||
renderNotice();
|
||||
const alert = screen.getByTestId('escrow-notice');
|
||||
expect(alert.className).toContain('MuiAlert-colorInfo');
|
||||
expect(alert.className).not.toContain('MuiAlert-colorError');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import AppAlert from '@/components/common/AppAlert';
|
||||
import AppIcon from '@/components/common/AppIcon';
|
||||
|
||||
/**
|
||||
* The escrow trust callout — product-mandated copy («مبلغ بهصورت امانی نزد بالینیار میماند و پس از
|
||||
* پایان ویزیت آزاد میشود»), rendered **verbatim in fa** as an info/trust surface (teal `--bal-info`
|
||||
* tokens, a lock — never an error tone). It is *why* the family pays on-platform, so f10/f11 must reuse
|
||||
* this exact component rather than re-writing the message. Self-translating (the copy is fixed), no props.
|
||||
* @component EscrowNotice
|
||||
*/
|
||||
const EscrowNotice: FunctionComponent = () => {
|
||||
const t = useTranslations('payment');
|
||||
return (
|
||||
<AppAlert
|
||||
severity="info"
|
||||
variant="outlined"
|
||||
icon={<AppIcon icon="lock" size={20} color="var(--bal-primary)" />}
|
||||
data-testid="escrow-notice"
|
||||
sx={{
|
||||
marginY: 0,
|
||||
// --bal-primary, not --bal-info: the info token is an alert *background* color and is too dark
|
||||
// to read as text on the dark scheme; primary is the same deep teal in light and lifts in dark.
|
||||
borderColor: 'var(--bal-primary)',
|
||||
color: 'var(--bal-primary)',
|
||||
backgroundColor: 'var(--bal-primary-soft)',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{t('escrow_notice')}
|
||||
</AppAlert>
|
||||
);
|
||||
};
|
||||
|
||||
export default EscrowNotice;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './EscrowNotice';
|
||||
@@ -0,0 +1,33 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
import type { PaymentTransactionStatus } from '@/services/payment/types';
|
||||
|
||||
// next-intl mocked to echo keys — each status must resolve its own pstatus_* label key.
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
import PaymentStatusBadge from './PaymentStatusBadge';
|
||||
|
||||
function renderBadge(status: PaymentTransactionStatus) {
|
||||
return render(
|
||||
<ThemeProvider>
|
||||
<PaymentStatusBadge status={status} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// Every b10 wire status → its label key + the semantic chip kind it must map to.
|
||||
const CASES: Array<{ status: PaymentTransactionStatus; kind: string }> = [
|
||||
{ status: 'pending', kind: 'pending' },
|
||||
{ status: 'succeeded', kind: 'verified' },
|
||||
{ status: 'failed', kind: 'rejected' },
|
||||
];
|
||||
|
||||
describe('<PaymentStatusBadge/> component', () => {
|
||||
it.each(CASES)('renders the $status status with its label key and chip kind', ({ status, kind }) => {
|
||||
const { container } = renderBadge(status);
|
||||
expect(screen.getByText(`pstatus_${status}`)).toBeInTheDocument();
|
||||
expect(container.querySelector(`[data-status="${kind}"]`)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import type { ChipProps } from '@mui/material/Chip';
|
||||
import StatusChip, { StatusKind } from '@/components/StatusChip';
|
||||
import type { PaymentTransactionStatus } from '@/services/payment/types';
|
||||
|
||||
// The full b10 enum → chip kind; a status missing here fails the type check, so a contract enum change
|
||||
// surfaces at build time instead of rendering an unmapped chip.
|
||||
const STATUS_KIND: Record<PaymentTransactionStatus, StatusKind> = {
|
||||
pending: 'pending',
|
||||
succeeded: 'verified',
|
||||
failed: 'rejected',
|
||||
};
|
||||
|
||||
export interface PaymentStatusBadgeProps extends Omit<ChipProps, 'color' | 'icon' | 'label'> {
|
||||
status: PaymentTransactionStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment-transaction status chip: maps the b10 wire code to a `--bal-*` semantic StatusChip variant and
|
||||
* an i18n label (`payment.pstatus_*` — labels are keys off the code, never derived from it). Shared by
|
||||
* the checkout return surface now and the f10 refund / f11 BNPL surfaces later.
|
||||
* @component PaymentStatusBadge
|
||||
*/
|
||||
const PaymentStatusBadge: FunctionComponent<PaymentStatusBadgeProps> = ({ status, ...rest }) => {
|
||||
const t = useTranslations('payment');
|
||||
return <StatusChip status={STATUS_KIND[status]} label={t(`pstatus_${status}`)} {...rest} />;
|
||||
};
|
||||
|
||||
export default PaymentStatusBadge;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './PaymentStatusBadge';
|
||||
export type { PaymentStatusBadgeProps } from './PaymentStatusBadge';
|
||||
@@ -0,0 +1,56 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
import { formatIrrToToman, parseIrr } from '@/utils';
|
||||
|
||||
// next-intl mocked to echo keys; locale = en so money formats with ASCII digits we can assert on.
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
import PriceBreakdown, { PriceBreakdownProps } from './PriceBreakdown';
|
||||
|
||||
const ComponentToTest: FunctionComponent<PriceBreakdownProps> = (props) => (
|
||||
<ThemeProvider>
|
||||
<PriceBreakdown {...props} />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
// Served-shaped figures: service + commission + VAT = total (the phase's reconciliation rule).
|
||||
const ROWS = [
|
||||
{ key: 'service_cost', label: 'Service cost', amountIrr: '39060000' },
|
||||
{ key: 'commission', label: 'Balinyaar fee', amountIrr: '5400000' },
|
||||
{ key: 'vat', label: 'VAT', amountIrr: '540000' },
|
||||
];
|
||||
const TOTAL = '45000000';
|
||||
|
||||
describe('<PriceBreakdown/> component', () => {
|
||||
it('renders every row label with its Toman-formatted amount', () => {
|
||||
render(<ComponentToTest rows={ROWS} totalLabel="Total" totalAmountIrr={TOTAL} />);
|
||||
for (const row of ROWS) {
|
||||
expect(screen.getByText(row.label)).toBeInTheDocument();
|
||||
expect(screen.getByText(formatIrrToToman(row.amountIrr, 'en'))).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders a total equal to the integer sum of the served rows', () => {
|
||||
render(<ComponentToTest rows={ROWS} totalLabel="Total" totalAmountIrr={TOTAL} />);
|
||||
const sum = ROWS.reduce((acc, row) => acc + parseIrr(row.amountIrr), BigInt(0));
|
||||
expect(sum.toString()).toBe(TOTAL);
|
||||
expect(screen.getByText(new RegExp(formatIrrToToman(TOTAL, 'en')))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes each row via a data attribute', () => {
|
||||
const { container } = render(<ComponentToTest rows={ROWS} totalLabel="Total" totalAmountIrr={TOTAL} />);
|
||||
expect(container.querySelector('[data-row="service_cost"]')).toBeInTheDocument();
|
||||
expect(container.querySelector('[data-row="total"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('warns loudly in dev when the rows do not reconcile to the total', () => {
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
render(<ComponentToTest rows={ROWS} totalLabel="Total" totalAmountIrr="45000001" />);
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('must reconcile'));
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Divider, Paper, Stack, Typography } from '@mui/material';
|
||||
import { formatIrrToToman, parseIrr } from '@/utils';
|
||||
|
||||
export interface PriceBreakdownRow {
|
||||
/** Stable row key (e.g. `service_cost`) — also exposed as `data-row` for tests/automation. */
|
||||
key: string;
|
||||
/** Display label — already translated by the caller (labels are i18n keys, never derived from codes). */
|
||||
label: string;
|
||||
/** IRR digit-string, straight off the wire. */
|
||||
amountIrr: string;
|
||||
}
|
||||
|
||||
export interface PriceBreakdownProps {
|
||||
rows: PriceBreakdownRow[];
|
||||
totalLabel: string;
|
||||
/** IRR digit-string. Must equal the integer sum of `rows` — see the reconciliation guard below. */
|
||||
totalAmountIrr: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The reconciling money breakdown (C6 checkout, invoice; f10/f11 reuse it for refunds/BNPL). Rows and
|
||||
* total are **served amounts** — this component only formats (BigInt-safe, Toman display via the money
|
||||
* util) and never computes a figure. The one thing it enforces is the phase's hard rule: the displayed
|
||||
* rows must sum to the displayed total. A mismatch is a data bug upstream, so it is surfaced loudly in
|
||||
* dev (console.error) rather than silently rendered.
|
||||
* @component PriceBreakdown
|
||||
*/
|
||||
const PriceBreakdown: FunctionComponent<PriceBreakdownProps> = ({ rows, totalLabel, totalAmountIrr }) => {
|
||||
const locale = useLocale();
|
||||
const tc = useTranslations('common');
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
const sum = rows.reduce((acc, row) => acc + parseIrr(row.amountIrr), BigInt(0));
|
||||
if (sum !== parseIrr(totalAmountIrr)) {
|
||||
console.error(
|
||||
`PriceBreakdown: rows sum to ${sum} but total is ${totalAmountIrr} — a breakdown must reconcile to the rial.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1.25 }}>
|
||||
{rows.map((row) => (
|
||||
<Stack key={row.key} data-row={row.key} direction="row" sx={{ justifyContent: 'space-between', gap: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{row.label}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{formatIrrToToman(row.amountIrr, locale)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
<Divider />
|
||||
<Stack data-row="total" direction="row" sx={{ justifyContent: 'space-between', gap: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
|
||||
{totalLabel}
|
||||
</Typography>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: 'var(--bal-secondary)' }}>
|
||||
{formatIrrToToman(totalAmountIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default PriceBreakdown;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './PriceBreakdown';
|
||||
export type { PriceBreakdownProps, PriceBreakdownRow } from './PriceBreakdown';
|
||||
@@ -21,6 +21,9 @@ import NurseResultCard from './NurseResultCard';
|
||||
import ServicePriceRow from './ServicePriceRow';
|
||||
import CountdownTimer from './CountdownTimer';
|
||||
import BookingRequestSummaryCard from './BookingRequestSummaryCard';
|
||||
import PriceBreakdown from './PriceBreakdown';
|
||||
import EscrowNotice from './EscrowNotice';
|
||||
import PaymentStatusBadge from './PaymentStatusBadge';
|
||||
|
||||
export {
|
||||
UserInfo,
|
||||
@@ -44,6 +47,9 @@ export {
|
||||
ServicePriceRow,
|
||||
CountdownTimer,
|
||||
BookingRequestSummaryCard,
|
||||
PriceBreakdown,
|
||||
EscrowNotice,
|
||||
PaymentStatusBadge,
|
||||
};
|
||||
export type { PlaceholderScreenProps } from './PlaceholderScreen';
|
||||
export type { OtpInputProps } from './OtpInput';
|
||||
@@ -65,3 +71,5 @@ export type { NurseResultCardProps } from './NurseResultCard';
|
||||
export type { ServicePriceRowProps } from './ServicePriceRow';
|
||||
export type { CountdownTimerProps } from './CountdownTimer';
|
||||
export type { BookingRequestSummaryCardProps } from './BookingRequestSummaryCard';
|
||||
export type { PriceBreakdownProps, PriceBreakdownRow } from './PriceBreakdown';
|
||||
export type { PaymentStatusBadgeProps } from './PaymentStatusBadge';
|
||||
|
||||
Reference in New Issue
Block a user