diff --git a/apps/business/.env.example b/apps/business/.env.example index 45d98f6..5005676 100644 --- a/apps/business/.env.example +++ b/apps/business/.env.example @@ -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 diff --git a/apps/business/src/lib/config.ts b/apps/business/src/lib/config.ts index bbfc46e..589cb00 100644 --- a/apps/business/src/lib/config.ts +++ b/apps/business/src/lib/config.ts @@ -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() || diff --git a/apps/business/src/pages/InvoicesPage.module.css b/apps/business/src/pages/InvoicesPage.module.css index 89c3dbb..67b4901 100644 --- a/apps/business/src/pages/InvoicesPage.module.css +++ b/apps/business/src/pages/InvoicesPage.module.css @@ -456,6 +456,7 @@ margin: 8px 0 0; font-size: 13px; color: var(--text-secondary); + white-space: pre-line; } .strike { diff --git a/apps/business/src/pages/InvoicesPage.tsx b/apps/business/src/pages/InvoicesPage.tsx index e2e5f28..8027173 100644 --- a/apps/business/src/pages/InvoicesPage.tsx +++ b/apps/business/src/pages/InvoicesPage.tsx @@ -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()) diff --git a/apps/super-admin/src/pages/BusinessInvoicesPage.module.css b/apps/super-admin/src/pages/BusinessInvoicesPage.module.css index 8cd862b..2c0b2f4 100644 --- a/apps/super-admin/src/pages/BusinessInvoicesPage.module.css +++ b/apps/super-admin/src/pages/BusinessInvoicesPage.module.css @@ -403,6 +403,7 @@ margin: 8px 0 0; font-size: 13px; color: var(--text-secondary); + white-space: pre-line; } .strike { diff --git a/apps/super-admin/src/pages/PublicInvoicePage.module.css b/apps/super-admin/src/pages/PublicInvoicePage.module.css index 41b52cf..16d85fd 100644 --- a/apps/super-admin/src/pages/PublicInvoicePage.module.css +++ b/apps/super-admin/src/pages/PublicInvoicePage.module.css @@ -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; diff --git a/apps/super-admin/src/pages/PublicInvoicePage.tsx b/apps/super-admin/src/pages/PublicInvoicePage.tsx index e06dd6b..b9b669f 100644 --- a/apps/super-admin/src/pages/PublicInvoicePage.tsx +++ b/apps/super-admin/src/pages/PublicInvoicePage.tsx @@ -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(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 (
@@ -170,12 +198,14 @@ export function PublicInvoicePage() { {item.description ? (

{item.description}

) : null} -
- {item.duration ? {item.duration} : null} - {item.worktime ? {item.worktime} : null} -
+ {(item.duration || item.worktime) ? ( +
+ {item.duration ? {item.duration} : null} + {item.worktime ? {item.worktime} : null} +
+ ) : null} {item.discountedPrice !== null && item.discountedPrice !== undefined && item.discountedPrice < item.price ? ( @@ -214,25 +244,44 @@ export function PublicInvoicePage() {

{t.accounts}

- {invoice.accounts!.map((acc) => { - const numberBits = [ - acc.cardNumber ? `${t.card}: ${acc.cardNumber}` : null, - acc.iban ? `${t.iban}: ${acc.iban}` : null, - ].filter(Boolean) - return ( -
- {acc.bankName} - {acc.accountHolderName ? ( -

{acc.accountHolderName}

- ) : null} - {numberBits.length > 0 ? ( -

- {numberBits.join(' · ')} -

- ) : null} -
- ) - })} + {invoice.accounts!.map((acc) => ( +
+ {acc.bankName} + + {acc.accountHolderName || '—'} + + {acc.cardNumber ? ( + + ) : ( + + — + + )} + {acc.iban ? ( + + ) : ( + + — + + )} +
+ ))}
) : null}