mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
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:
co-authored by
Cursor
parent
f21041b950
commit
2be3b3376d
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 className={styles.itemMeta}>
|
||||
{item.duration ? <span>{item.duration}</span> : null}
|
||||
{item.worktime ? <span>{item.worktime}</span> : null}
|
||||
</div>
|
||||
</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>
|
||||
) : 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 (
|
||||
<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}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{invoice.accounts!.map((acc) => (
|
||||
<div key={acc.id} className={styles.account}>
|
||||
<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}
|
||||
|
||||
Reference in New Issue
Block a user