Make public invoices RTL for Farsi businesses and fix invoice back-arrow direction.

Also add optional English name fields on the add-customer modal to match the API.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-11 15:46:18 +03:30
co-authored by Cursor
parent a50685ec69
commit f21041b950
9 changed files with 231 additions and 44 deletions
@@ -1,3 +1,7 @@
.wideModal {
max-width: min(800px, calc(100vw - 32px));
}
.formGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
@@ -8,15 +12,29 @@
grid-column: 1 / -1;
}
.hint {
margin-bottom: 12px;
font-size: 12px;
line-height: 1.45;
.keyPoints {
margin: 0 0 14px;
padding-inline-start: 1.15em;
list-style: disc;
font-size: 12.5px;
line-height: 1.5;
color: var(--text-secondary);
}
@media (max-width: 520px) {
.keyPoints li {
white-space: nowrap;
}
.keyPoints li + li {
margin-top: 4px;
}
@media (max-width: 640px) {
.formGrid {
grid-template-columns: 1fr;
}
.keyPoints li {
white-space: normal;
}
}
@@ -28,6 +28,8 @@ export function AddCustomerModal({ open, onClose, onCreated }: AddCustomerModalP
const [closing, setClosing] = useState(false)
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [firstNameEn, setFirstNameEn] = useState('')
const [lastNameEn, setLastNameEn] = useState('')
const [cellNumber, setCellNumber] = useState('')
const [password, setPassword] = useState('')
const [passwordConfirm, setPasswordConfirm] = useState('')
@@ -40,6 +42,8 @@ export function AddCustomerModal({ open, onClose, onCreated }: AddCustomerModalP
setClosing(false)
setFirstName('')
setLastName('')
setFirstNameEn('')
setLastNameEn('')
setCellNumber('')
setPassword('')
setPasswordConfirm('')
@@ -86,6 +90,16 @@ export function AddCustomerModal({ open, onClose, onCreated }: AddCustomerModalP
}
}
const trimmedFirstEn = firstNameEn.trim()
const trimmedLastEn = lastNameEn.trim()
if (
(trimmedFirstEn && trimmedFirstEn.length < 2) ||
(trimmedLastEn && trimmedLastEn.length < 2)
) {
setError(t('customers.addModal.errorNameEnLength'))
return
}
setIsSubmitting(true)
setError('')
try {
@@ -93,6 +107,8 @@ export function AddCustomerModal({ open, onClose, onCreated }: AddCustomerModalP
cellNumber: normalizedCell,
firstName: firstName.trim(),
lastName: lastName.trim(),
...(trimmedFirstEn ? { firstNameEn: trimmedFirstEn } : {}),
...(trimmedLastEn ? { lastNameEn: trimmedLastEn } : {}),
...(trimmedPassword ? { password: trimmedPassword } : {}),
})
onCreated({
@@ -119,7 +135,7 @@ export function AddCustomerModal({ open, onClose, onCreated }: AddCustomerModalP
onClick={onClose}
>
<div
className={`${modalStyles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
className={`${modalStyles.modal} ${formStyles.wideModal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
@@ -132,7 +148,6 @@ export function AddCustomerModal({ open, onClose, onCreated }: AddCustomerModalP
<h3 id="add-customer-title" className={modalStyles.title}>
{t('customers.addModal.title')}
</h3>
<p className={modalStyles.subtitle}>{t('customers.addModal.subtitle')}</p>
</div>
<button
type="button"
@@ -145,29 +160,58 @@ export function AddCustomerModal({ open, onClose, onCreated }: AddCustomerModalP
</div>
<div className={modalStyles.body}>
<p className={formStyles.hint}>{t('customers.addModal.hint')}</p>
<ul className={formStyles.keyPoints}>
<li>{t('customers.addModal.pointCreate')}</li>
<li>{t('customers.addModal.pointPassword')}</li>
</ul>
{error && <p className={modalStyles.errorText}>{error}</p>}
<div className={formStyles.formGrid}>
<div className={modalStyles.field}>
<label htmlFor="add-customer-first-name">{t('customers.addModal.firstName')}</label>
<label htmlFor="add-customer-first-name">{t('customers.addModal.firstNameFa')}</label>
<input
id="add-customer-first-name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
disabled={isSubmitting}
dir={isFa ? 'rtl' : 'ltr'}
dir="rtl"
lang="fa"
className="faText"
/>
</div>
<div className={modalStyles.field}>
<label htmlFor="add-customer-last-name">{t('customers.addModal.lastName')}</label>
<label htmlFor="add-customer-last-name">{t('customers.addModal.lastNameFa')}</label>
<input
id="add-customer-last-name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
disabled={isSubmitting}
dir={isFa ? 'rtl' : 'ltr'}
dir="rtl"
lang="fa"
className="faText"
/>
</div>
<div className={modalStyles.field}>
<label htmlFor="add-customer-first-name-en">{t('customers.addModal.firstNameEn')}</label>
<input
id="add-customer-first-name-en"
value={firstNameEn}
onChange={(e) => setFirstNameEn(e.target.value)}
disabled={isSubmitting}
dir="ltr"
lang="en"
/>
</div>
<div className={modalStyles.field}>
<label htmlFor="add-customer-last-name-en">{t('customers.addModal.lastNameEn')}</label>
<input
id="add-customer-last-name-en"
value={lastNameEn}
onChange={(e) => setLastNameEn(e.target.value)}
disabled={isSubmitting}
dir="ltr"
lang="en"
/>
</div>
<div className={`${modalStyles.field} ${formStyles.formFull}`}>
@@ -233,8 +277,7 @@ export function AddCustomerModal({ open, onClose, onCreated }: AddCustomerModalP
</div>
</div>
</div>
</div>
,
document.body,
</div>,
document.body,
)
}
@@ -192,6 +192,10 @@
text-decoration: underline;
}
:global([dir='rtl']) .backLink svg {
transform: scaleX(-1);
}
.section {
padding: 16px;
background: var(--glass-bg);
+18
View File
@@ -833,8 +833,16 @@ const en = {
'Creates a verified customer account or links an existing user to your business.',
'customers.addModal.hint':
'Password is required only for new accounts. Existing users are added as verified customers.',
'customers.addModal.pointCreate':
'Creates a verified customer account or links an existing user to your business.',
'customers.addModal.pointPassword':
'Password is required only for new accounts. Existing users are added as verified customers.',
'customers.addModal.firstName': 'First name',
'customers.addModal.lastName': 'Last name',
'customers.addModal.firstNameFa': 'First name (FA)',
'customers.addModal.lastNameFa': 'Last name (FA)',
'customers.addModal.firstNameEn': 'First name (EN)',
'customers.addModal.lastNameEn': 'Last name (EN)',
'customers.addModal.cell': 'Cell number',
'customers.addModal.cellPlaceholder': '0912...',
'customers.addModal.password': 'Password (new users)',
@@ -846,6 +854,7 @@ const en = {
'customers.addModal.error': 'Unable to add customer.',
'customers.addModal.errorPasswordMatch': 'Passwords do not match.',
'customers.addModal.errorPasswordLength': 'Password must be at least 8 characters.',
'customers.addModal.errorNameEnLength': 'English names must be at least 2 characters.',
'customers.access.title': 'Change access',
'customers.access.subtitle': 'Choose customer or manager access for {name}.',
'customers.access.customer': 'Customer',
@@ -2434,8 +2443,16 @@ const fa: Record<MessageKey, string> = {
'یک حساب تأییدشده مشتری می‌سازد یا کاربر موجود را به کسب‌وکار شما وصل می‌کند.',
'customers.addModal.hint':
'رمز عبور فقط برای حساب‌های جدید لازم است. کاربران موجود به‌عنوان مشتری تأییدشده اضافه می‌شوند.',
'customers.addModal.pointCreate':
'یک حساب تأییدشده مشتری می‌سازد یا کاربر موجود را به کسب‌وکار شما وصل می‌کند.',
'customers.addModal.pointPassword':
'رمز عبور فقط برای حساب‌های جدید لازم است. کاربران موجود به‌عنوان مشتری تأییدشده اضافه می‌شوند.',
'customers.addModal.firstName': 'نام',
'customers.addModal.lastName': 'نام خانوادگی',
'customers.addModal.firstNameFa': 'نام (فارسی)',
'customers.addModal.lastNameFa': 'نام خانوادگی (فارسی)',
'customers.addModal.firstNameEn': 'نام (انگلیسی)',
'customers.addModal.lastNameEn': 'نام خانوادگی (انگلیسی)',
'customers.addModal.cell': 'شماره موبایل',
'customers.addModal.cellPlaceholder': '۰۹۱۲...',
'customers.addModal.password': 'رمز عبور (کاربران جدید)',
@@ -2447,6 +2464,7 @@ const fa: Record<MessageKey, string> = {
'customers.addModal.error': 'افزودن مشتری ممکن نشد.',
'customers.addModal.errorPasswordMatch': 'رمزهای عبور یکسان نیستند.',
'customers.addModal.errorPasswordLength': 'رمز عبور باید حداقل ۸ کاراکتر باشد.',
'customers.addModal.errorNameEnLength': 'نام انگلیسی باید حداقل ۲ کاراکتر باشد.',
'customers.access.title': 'تغییر دسترسی',
'customers.access.subtitle': 'دسترسی مشتری یا مدیر را برای {name} انتخاب کنید.',
'customers.access.customer': 'مشتری',
@@ -76,6 +76,8 @@ export interface CreateCustomerPayload {
cellNumber: string
firstName: string
lastName: string
firstNameEn?: string
lastNameEn?: string
password?: string
email?: string
}
@@ -222,6 +222,7 @@
font-weight: 600;
color: var(--text-primary, #0f172a);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.priceWas {
@@ -257,12 +258,32 @@
.keyPoints {
margin: 0;
padding-left: 16px;
padding-inline-start: 16px;
font-size: 12px;
line-height: 1.45;
color: var(--text-secondary, #475569);
}
.page[dir='rtl'] {
font-family: var(--font-ui, var(--font-en), var(--font-fa), sans-serif);
}
.page[dir='rtl'] .title,
.page[dir='rtl'] .itemTitle,
.page[dir='rtl'] .sectionTitle,
.page[dir='rtl'] .accountBank,
.page[dir='rtl'] .topText,
.page[dir='rtl'] .keyPoints,
.page[dir='rtl'] .meta {
font-family: var(--font-ui, var(--font-en), var(--font-fa), sans-serif);
}
.page[dir='rtl'] .itemPrice,
.page[dir='rtl'] .totals strong,
.page[dir='rtl'] .accountDetails {
font-variant-numeric: tabular-nums;
}
.accounts {
display: flex;
flex-direction: column;
+103 -27
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useParams } from 'react-router-dom'
import { FileDown } from 'lucide-react'
import meshkeeLogo from '../assets/meshkee-logo.png'
@@ -10,10 +10,65 @@ import type { PublicInvoice } from '../types/invoice'
import { formatIrtPrice } from '../utils/irtPrice'
import styles from './PublicInvoicePage.module.css'
function formatDate(value: string) {
type InvoiceLocale = 'en' | 'fa'
const COPY: Record<
InvoiceLocale,
{
loading: string
loadError: string
issued: string
items: string
subtotal: string
total: string
keyPoints: string
accounts: string
card: string
iban: string
issuedBy: string
printHint: string
print: string
invoiceFallback: string
}
> = {
en: {
loading: 'Loading invoice…',
loadError: 'Unable to load invoice.',
issued: 'Issued',
items: 'Items',
subtotal: 'Subtotal',
total: 'Total',
keyPoints: 'Key points',
accounts: 'Account numbers',
card: 'Card',
iban: 'IBAN',
issuedBy: 'Issued by',
printHint: 'Save as PDF from the print dialog (Destination → Save as PDF).',
print: 'Print to PDF',
invoiceFallback: 'Invoice',
},
fa: {
loading: 'در حال بارگذاری فاکتور…',
loadError: 'بارگذاری فاکتور ممکن نشد.',
issued: 'صادر شده',
items: 'آیتم‌ها',
subtotal: 'جمع جزء',
total: 'مبلغ کل',
keyPoints: 'نکات کلیدی',
accounts: 'شماره حساب‌ها',
card: 'کارت',
iban: 'شبا',
issuedBy: 'صادر شده توسط',
printHint: 'از پنجره چاپ، مقصد را روی «ذخیره به‌صورت PDF» بگذارید.',
print: 'چاپ / ذخیره PDF',
invoiceFallback: 'فاکتور',
},
}
function formatDate(value: string, locale: InvoiceLocale) {
const d = new Date(value)
if (Number.isNaN(d.getTime())) return value
return d.toLocaleDateString('en-US', {
return d.toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
@@ -27,6 +82,21 @@ export function PublicInvoicePage() {
const [error, setError] = useState('')
const marketingUrl = getPlatformMarketingUrl()
const locale: InvoiceLocale = invoice?.locale === 'en' ? 'en' : 'fa'
const isRtl = locale === 'fa'
const t = COPY[locale]
const businessLabel = useMemo(() => {
if (!invoice?.business) return ''
return (
invoice.business.displayName?.trim() ||
(locale === 'fa'
? invoice.business.nameFa?.trim() || invoice.business.name
: invoice.business.name) ||
''
)
}, [invoice, locale])
useEffect(() => {
if (!invoiceId) return
const controller = new AbortController()
@@ -36,7 +106,7 @@ export function PublicInvoicePage() {
.then((data) => setInvoice(data))
.catch((err) => {
if (isAbortError(err)) return
setError(err instanceof ApiError ? err.message : 'Unable to load invoice.')
setError(err instanceof ApiError ? err.message : COPY.fa.loadError)
setInvoice(null)
})
.finally(() => setLoading(false))
@@ -46,29 +116,33 @@ export function PublicInvoicePage() {
useEffect(() => {
if (!invoice) return
const previous = document.title
document.title = invoice.name?.trim() || `Invoice ${invoice.publicId}`
document.title =
invoice.name?.trim() ||
`${t.invoiceFallback} ${invoice.publicId}`
return () => {
document.title = previous
}
}, [invoice])
}, [invoice, t.invoiceFallback])
function handlePrint() {
window.print()
}
return (
<div className={styles.page} lang="en">
<div className={styles.page} lang={locale} dir={isRtl ? 'rtl' : 'ltr'}>
<div className={styles.sheet}>
{loading ? <p className={styles.muted}>Loading invoice</p> : null}
{loading ? <p className={styles.muted}>{t.loading}</p> : null}
{error ? <p className={styles.error}>{error}</p> : null}
{invoice ? (
<>
<header className={styles.header}>
<h1 className={styles.title}>{invoice.name || `Invoice ${invoice.publicId}`}</h1>
<h1 className={styles.title}>
{invoice.name || `${t.invoiceFallback} ${invoice.publicId}`}
</h1>
<p className={styles.meta}>
Issued {formatDate(invoice.issuedAt)}
{invoice.business?.name ? ` · ${invoice.business.name}` : ''}
{t.issued} {formatDate(invoice.issuedAt, locale)}
{businessLabel ? ` · ${businessLabel}` : ''}
</p>
</header>
@@ -80,7 +154,7 @@ export function PublicInvoicePage() {
) : null}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Items</h2>
<h2 className={styles.sectionTitle}>{t.items}</h2>
<div className={styles.items}>
{(invoice.items ?? []).map((item) => {
const effective =
@@ -115,11 +189,11 @@ export function PublicInvoicePage() {
</div>
<div className={styles.totals}>
<div>
<span>Subtotal</span>
<span>{t.subtotal}</span>
<strong>{formatIrtPrice(invoice.subtotal ?? 0)}</strong>
</div>
<div className={styles.totalRow}>
<span>Total</span>
<span>{t.total}</span>
<strong>{formatIrtPrice(invoice.total ?? 0)}</strong>
</div>
</div>
@@ -127,7 +201,7 @@ export function PublicInvoicePage() {
{(invoice.keyPoints?.length ?? 0) > 0 ? (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Key points</h2>
<h2 className={styles.sectionTitle}>{t.keyPoints}</h2>
<ul className={styles.keyPoints}>
{invoice.keyPoints!.map((kp) => (
<li key={kp.id}>{kp.text}</li>
@@ -138,19 +212,23 @@ export function PublicInvoicePage() {
{(invoice.accounts?.length ?? 0) > 0 ? (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Account numbers</h2>
<h2 className={styles.sectionTitle}>{t.accounts}</h2>
<div className={styles.accounts}>
{invoice.accounts!.map((acc) => {
const details = [
acc.accountHolderName,
acc.cardNumber ? `Card: ${acc.cardNumber}` : null,
acc.iban ? `IBAN: ${acc.iban}` : null,
const numberBits = [
acc.cardNumber ? `${t.card}: ${acc.cardNumber}` : null,
acc.iban ? `${t.iban}: ${acc.iban}` : null,
].filter(Boolean)
return (
<div key={acc.id} className={styles.account}>
<strong className={styles.accountBank}>{acc.bankName}</strong>
{details.length > 0 ? (
<p className={styles.accountDetails}>{details.join(' · ')}</p>
{acc.accountHolderName ? (
<p className={styles.accountDetails}>{acc.accountHolderName}</p>
) : null}
{numberBits.length > 0 ? (
<p className={styles.accountDetails} dir="ltr">
{numberBits.join(' · ')}
</p>
) : null}
</div>
)
@@ -167,7 +245,7 @@ export function PublicInvoicePage() {
rel="noreferrer"
>
<img src={meshkeeLogo} alt="" className={styles.footerLogo} />
<span className={styles.footerLabel}>Issued by</span>
<span className={styles.footerLabel}>{t.issuedBy}</span>
<span className={styles.footerName}>Meshkee E-Commerce Group</span>
</a>
</footer>
@@ -177,12 +255,10 @@ export function PublicInvoicePage() {
{invoice ? (
<div className={styles.toolbar}>
<p className={styles.toolbarHint}>
Save as PDF from the print dialog (Destination Save as PDF).
</p>
<p className={styles.toolbarHint}>{t.printHint}</p>
<button type="button" className={styles.printBtn} onClick={handlePrint}>
<FileDown size={16} aria-hidden />
Print to PDF
{t.print}
</button>
</div>
) : null}
+5 -1
View File
@@ -91,7 +91,11 @@ export type PublicInvoice = {
name: string | null
topText: string | null
issuedAt: string
business?: Invoice['business']
/** Business dashboard default locale — drives public invoice RTL/LTR. */
locale?: 'en' | 'fa'
business?: Invoice['business'] & {
displayName?: string
}
user?: {
firstName: string | null
lastName: string | null