Polish public invoice layout: account grid, duration, and copy-to-clipboard.

Also preserve item description line breaks and use local ported invoice links in DEV.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-11 16:03:33 +03:30
co-authored by Cursor
parent f21041b950
commit 2be3b3376d
7 changed files with 154 additions and 43 deletions
+6
View File
@@ -5,3 +5,9 @@ VITE_API_BASE_URL=http://localhost:3000/api/v1
# Leave unset for multi-tenant dev — tenant is resolved from hostname
# (e.g. business.sanihome.ir → sanihome.ir, business.safeteb.com → safeteb.com).
# VITE_BUSINESS_DOMAIN=sanihome.ir
# Local public invoice viewer runs on super-admin Vite (port 5174).
# In DEV, copy/open links use https://meshkee.app:5174/invoices/{id} automatically.
# VITE_INVOICE_PUBLIC_BASE_URL=https://meshkee.app:5174
# VITE_INVOICE_PUBLIC_DEV_HOST=meshkee.app
# VITE_INVOICE_PUBLIC_DEV_PORT=5174
+8
View File
@@ -39,6 +39,7 @@ export function getBusinessDomain(): string {
* Fallback public invoice URL if the API response is missing `publicUrl`.
* Prefer `invoice.publicUrl` returned by the API — this only covers edge cases.
* Business invoices use the tenant domain (e.g. sanihome.ir), not meshkee.com.
* Local/dev: public viewer lives on super-admin (`https://meshkee.app:5174`).
*/
export function getInvoicePublicUrlFallback(
publicId: string,
@@ -48,6 +49,13 @@ export function getInvoicePublicUrlFallback(
if (baseOverride) {
return `${baseOverride.replace(/\/$/, '')}/invoices/${publicId}`
}
if (import.meta.env.DEV) {
const protocol = window.location.protocol === 'http:' ? 'http:' : 'https:'
const host =
import.meta.env.VITE_INVOICE_PUBLIC_DEV_HOST?.trim() || 'meshkee.app'
const port = import.meta.env.VITE_INVOICE_PUBLIC_DEV_PORT?.trim() || '5174'
return `${protocol}//${host}:${port}/invoices/${publicId}`
}
const domain =
import.meta.env.VITE_INVOICE_PUBLIC_DOMAIN?.trim() ||
businessDomain.trim() ||
@@ -456,6 +456,7 @@
margin: 8px 0 0;
font-size: 13px;
color: var(--text-secondary);
white-space: pre-line;
}
.strike {
+4
View File
@@ -196,6 +196,10 @@ export function InvoicesPage() {
}
function invoicePublicUrl(invoice: Invoice) {
// Local: public viewer is on super-admin Vite (port 5174), not the apex domain.
if (import.meta.env.DEV || import.meta.env.VITE_INVOICE_PUBLIC_BASE_URL) {
return getInvoicePublicUrlFallback(invoice.publicId, getActiveBusinessDomain())
}
return (
invoice.publicUrl ||
getInvoicePublicUrlFallback(invoice.publicId, getActiveBusinessDomain())
@@ -403,6 +403,7 @@
margin: 8px 0 0;
font-size: 13px;
color: var(--text-secondary);
white-space: pre-line;
}
.strike {
@@ -203,14 +203,21 @@
font-size: 12px;
color: var(--text-secondary, #64748b);
line-height: 1.4;
white-space: pre-line;
}
.itemMeta {
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 11px;
color: var(--text-muted, #94a3b8);
justify-content: flex-end;
gap: 6px;
width: 100%;
margin: 0 0 6px;
padding-bottom: 6px;
border-bottom: 1px solid rgba(148, 163, 184, 0.28);
font-size: 13px;
font-weight: 600;
color: var(--text-secondary, #64748b);
}
.itemPrice {
@@ -272,6 +279,7 @@
.page[dir='rtl'] .itemTitle,
.page[dir='rtl'] .sectionTitle,
.page[dir='rtl'] .accountBank,
.page[dir='rtl'] .accountHolder,
.page[dir='rtl'] .topText,
.page[dir='rtl'] .keyPoints,
.page[dir='rtl'] .meta {
@@ -280,7 +288,7 @@
.page[dir='rtl'] .itemPrice,
.page[dir='rtl'] .totals strong,
.page[dir='rtl'] .accountDetails {
.page[dir='rtl'] .accountNumValue {
font-variant-numeric: tabular-nums;
}
@@ -291,6 +299,10 @@
}
.account {
display: grid;
grid-template-columns: 3fr 2fr 3fr 4fr;
align-items: center;
gap: 8px 12px;
padding: 8px 12px;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 10px;
@@ -302,20 +314,49 @@
}
.accountBank {
display: block;
font-size: 13px;
font-weight: 700;
color: var(--text-primary, #0f172a);
min-width: 0;
overflow-wrap: anywhere;
}
.accountDetails {
margin: 4px 0 0;
.accountHolder {
font-size: 12px;
line-height: 1.4;
color: var(--text-secondary, #475569);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
color: var(--text-secondary, #64748b);
min-width: 0;
overflow-wrap: anywhere;
}
.accountNumValue {
appearance: none;
border: none;
background: transparent;
padding: 0;
margin: 0;
text-align: start;
font: inherit;
font-size: 12px;
font-weight: 600;
color: var(--text-primary, #0f172a);
font-variant-numeric: tabular-nums;
letter-spacing: 0.01em;
min-width: 0;
overflow-wrap: anywhere;
cursor: pointer;
}
button.accountNumValue:hover {
color: var(--primary, #2563eb);
text-decoration: underline;
text-underline-offset: 2px;
}
button.accountNumValue:focus-visible {
outline: 2px solid var(--primary, #2563eb);
outline-offset: 2px;
border-radius: 4px;
}
.footer {
@@ -391,8 +432,9 @@
align-items: flex-start;
}
.accountDetails {
white-space: normal;
.account {
grid-template-columns: 1fr;
gap: 4px;
}
}
@@ -420,6 +462,12 @@
display: none !important;
}
button.accountNumValue {
color: inherit;
text-decoration: none;
cursor: default;
}
.sheet {
max-width: none;
margin: 0;
@@ -459,12 +507,6 @@
color: inherit;
}
.accountDetails {
white-space: normal;
overflow: visible;
text-overflow: unset;
}
:global(a) {
color: inherit;
text-decoration: none;
@@ -3,6 +3,7 @@ import { useParams } from 'react-router-dom'
import { FileDown } from 'lucide-react'
import meshkeeLogo from '../assets/meshkee-logo.png'
import { isEmptyRichText } from '../components/RichTextEditor'
import { useToast } from '../context/ToastContext'
import { ApiError, isAbortError } from '../lib/api'
import { getPlatformMarketingUrl } from '../lib/config'
import { getPublicInvoice } from '../services/invoiceService'
@@ -29,6 +30,11 @@ const COPY: Record<
printHint: string
print: string
invoiceFallback: string
copiedCard: string
copiedIban: string
copyFailed: string
copyCardAria: string
copyIbanAria: string
}
> = {
en: {
@@ -46,6 +52,11 @@ const COPY: Record<
printHint: 'Save as PDF from the print dialog (Destination → Save as PDF).',
print: 'Print to PDF',
invoiceFallback: 'Invoice',
copiedCard: 'Card number copied.',
copiedIban: 'IBAN copied.',
copyFailed: 'Unable to copy.',
copyCardAria: 'Copy card number',
copyIbanAria: 'Copy IBAN',
},
fa: {
loading: 'در حال بارگذاری فاکتور…',
@@ -62,6 +73,11 @@ const COPY: Record<
printHint: 'از پنجره چاپ، مقصد را روی «ذخیره به‌صورت PDF» بگذارید.',
print: 'چاپ / ذخیره PDF',
invoiceFallback: 'فاکتور',
copiedCard: 'شماره کارت کپی شد.',
copiedIban: 'شماره شبا کپی شد.',
copyFailed: 'کپی ممکن نشد.',
copyCardAria: 'کپی شماره کارت',
copyIbanAria: 'کپی شماره شبا',
},
}
@@ -77,6 +93,7 @@ function formatDate(value: string, locale: InvoiceLocale) {
export function PublicInvoicePage() {
const { invoiceId = '' } = useParams()
const { showToast } = useToast()
const [invoice, setInvoice] = useState<PublicInvoice | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
@@ -128,6 +145,17 @@ export function PublicInvoicePage() {
window.print()
}
async function copyValue(value: string, successMessage: string) {
const text = value.trim()
if (!text || text === '—') return
try {
await navigator.clipboard.writeText(text)
showToast(successMessage, 'success')
} catch {
showToast(t.copyFailed, 'error')
}
}
return (
<div className={styles.page} lang={locale} dir={isRtl ? 'rtl' : 'ltr'}>
<div className={styles.sheet}>
@@ -170,12 +198,14 @@ export function PublicInvoicePage() {
{item.description ? (
<p className={styles.itemDesc}>{item.description}</p>
) : null}
</div>
<div className={styles.itemPrice}>
{(item.duration || item.worktime) ? (
<div className={styles.itemMeta}>
{item.duration ? <span>{item.duration}</span> : null}
{item.worktime ? <span>{item.worktime}</span> : null}
</div>
</div>
<div className={styles.itemPrice}>
) : null}
{item.discountedPrice !== null &&
item.discountedPrice !== undefined &&
item.discountedPrice < item.price ? (
@@ -214,25 +244,44 @@ export function PublicInvoicePage() {
<section className={styles.section}>
<h2 className={styles.sectionTitle}>{t.accounts}</h2>
<div className={styles.accounts}>
{invoice.accounts!.map((acc) => {
const numberBits = [
acc.cardNumber ? `${t.card}: ${acc.cardNumber}` : null,
acc.iban ? `${t.iban}: ${acc.iban}` : null,
].filter(Boolean)
return (
{invoice.accounts!.map((acc) => (
<div key={acc.id} className={styles.account}>
<strong className={styles.accountBank}>{acc.bankName}</strong>
{acc.accountHolderName ? (
<p className={styles.accountDetails}>{acc.accountHolderName}</p>
) : null}
{numberBits.length > 0 ? (
<p className={styles.accountDetails} dir="ltr">
{numberBits.join(' · ')}
</p>
) : null}
<span className={styles.accountBank}>{acc.bankName}</span>
<span className={styles.accountHolder}>
{acc.accountHolderName || '—'}
</span>
{acc.cardNumber ? (
<button
type="button"
className={styles.accountNumValue}
dir="ltr"
onClick={() => void copyValue(acc.cardNumber!, t.copiedCard)}
aria-label={t.copyCardAria}
>
{acc.cardNumber}
</button>
) : (
<span className={styles.accountNumValue} dir="ltr">
</span>
)}
{acc.iban ? (
<button
type="button"
className={styles.accountNumValue}
dir="ltr"
onClick={() => void copyValue(acc.iban!, t.copiedIban)}
aria-label={t.copyIbanAria}
>
{acc.iban}
</button>
) : (
<span className={styles.accountNumValue} dir="ltr">
</span>
)}
</div>
)
})}
))}
</div>
</section>
) : null}