mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
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:
co-authored by
Cursor
parent
a50685ec69
commit
f21041b950
@@ -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>
|
||||
,
|
||||
</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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -395,6 +395,7 @@ Auth: platform template routes → super-admin. Business routes → `BusinessPer
|
||||
|
||||
**Public invoice viewer:**
|
||||
- Super-admin SPA `/invoices/:publicId` (`PublicInvoicePage`) — print-to-PDF; opaque 12-digit `publicId`
|
||||
- Payload includes `locale` (`fa` | `en` from business `settings.branding.defaultLocale`) — page is RTL + Farsi chrome when `fa`
|
||||
- Platform invoices: `https://{INVOICE_PUBLIC_DOMAIN}/invoices/{publicId}` (default `meshkee.com`)
|
||||
- Business invoices: `https://{primaryBusinessDomain}/invoices/{publicId}` (e.g. `sanihome.ir`)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user