frontend phase 9

This commit is contained in:
hamid
2026-07-10 11:49:55 +03:30
parent cd6c2591a6
commit 40cc1d163b
49 changed files with 4130 additions and 20 deletions
@@ -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';
+8
View File
@@ -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';