mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
Harden invoice public links and tighten draft UI.
Use publicId in links, compact key-point/account labels, English business names, and readable favicon deploy perms. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
cbffb23ec3
commit
2e40d5eb4c
Executable → Regular
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
@@ -11,6 +11,12 @@ import {
|
||||
import tableStyles from '../pages/BusinessesPage.module.css'
|
||||
import styles from '../pages/BusinessInvoicesPage.module.css'
|
||||
|
||||
function focusKeyPointInput(index: number) {
|
||||
const el = document.querySelector<HTMLInputElement>(`input[data-keypoint-index="${index}"]`)
|
||||
el?.focus()
|
||||
el?.select()
|
||||
}
|
||||
|
||||
type Props = {
|
||||
itemTemplates: InvoiceItemTemplate[]
|
||||
items: DraftLineItem[]
|
||||
@@ -192,9 +198,9 @@ export function InvoiceDraftFields({
|
||||
<div className={styles.repeatStack}>
|
||||
{keyPoints.map((point, index) => (
|
||||
<div key={point.key} className={styles.repeatRow}>
|
||||
<div className={`${tableStyles.field} ${styles.flexGrow}`}>
|
||||
<label>Point {index + 1}</label>
|
||||
<div className={`${tableStyles.field} ${styles.flexGrow} ${styles.fieldNoLabel}`}>
|
||||
<input
|
||||
data-keypoint-index={index}
|
||||
value={point.text}
|
||||
onChange={(e) =>
|
||||
onKeyPointsChange(
|
||||
@@ -203,7 +209,19 @@ export function InvoiceDraftFields({
|
||||
),
|
||||
)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Enter') return
|
||||
e.preventDefault()
|
||||
const next = index + 1
|
||||
if (next < keyPoints.length) {
|
||||
focusKeyPointInput(next)
|
||||
return
|
||||
}
|
||||
onKeyPointsChange([...keyPoints, emptyDraftKeyPoint()])
|
||||
window.setTimeout(() => focusKeyPointInput(next), 0)
|
||||
}}
|
||||
placeholder="e.g. Payment due within 7 days"
|
||||
aria-label={`Key point ${index + 1}`}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
@@ -237,75 +255,85 @@ export function InvoiceDraftFields({
|
||||
<p className={styles.templateHint}>No bank accounts yet.</p>
|
||||
) : (
|
||||
<div className={styles.repeatStack}>
|
||||
{accounts.map((acc) => (
|
||||
<div key={acc.key} className={styles.accountRow}>
|
||||
<div className={`${tableStyles.field} ${styles.accountCol2}`}>
|
||||
<label>Bank name</label>
|
||||
<input
|
||||
value={acc.bankName}
|
||||
onChange={(e) =>
|
||||
onAccountsChange(
|
||||
accounts.map((a) =>
|
||||
a.key === acc.key ? { ...a, bankName: e.target.value } : a,
|
||||
),
|
||||
)
|
||||
}
|
||||
placeholder="Bank name"
|
||||
/>
|
||||
</div>
|
||||
<div className={`${tableStyles.field} ${styles.accountCol2}`}>
|
||||
<label>Account holder</label>
|
||||
<input
|
||||
value={acc.accountHolderName}
|
||||
onChange={(e) =>
|
||||
onAccountsChange(
|
||||
accounts.map((a) =>
|
||||
a.key === acc.key ? { ...a, accountHolderName: e.target.value } : a,
|
||||
),
|
||||
)
|
||||
}
|
||||
placeholder="Account holder name"
|
||||
/>
|
||||
</div>
|
||||
<div className={`${tableStyles.field} ${styles.accountCol3}`}>
|
||||
<label>Card number</label>
|
||||
<input
|
||||
value={acc.cardNumber}
|
||||
onChange={(e) =>
|
||||
onAccountsChange(
|
||||
accounts.map((a) =>
|
||||
a.key === acc.key ? { ...a, cardNumber: e.target.value } : a,
|
||||
),
|
||||
)
|
||||
}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
<div className={`${tableStyles.field} ${styles.accountCol5}`}>
|
||||
<label>IBAN</label>
|
||||
<input
|
||||
value={acc.iban}
|
||||
onChange={(e) =>
|
||||
onAccountsChange(
|
||||
accounts.map((a) =>
|
||||
a.key === acc.key ? { ...a, iban: e.target.value } : a,
|
||||
),
|
||||
)
|
||||
}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeFieldBtn}
|
||||
onClick={() => onAccountsChange(accounts.filter((a) => a.key !== acc.key))}
|
||||
aria-label="Remove account"
|
||||
title="Remove"
|
||||
{accounts.map((acc, index) => {
|
||||
const showLabels = index === 0
|
||||
return (
|
||||
<div
|
||||
key={acc.key}
|
||||
className={`${styles.accountRow} ${showLabels ? '' : styles.accountRowPlain}`}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className={`${tableStyles.field} ${styles.accountCol2}`}>
|
||||
{showLabels ? <label>Bank name</label> : null}
|
||||
<input
|
||||
value={acc.bankName}
|
||||
onChange={(e) =>
|
||||
onAccountsChange(
|
||||
accounts.map((a) =>
|
||||
a.key === acc.key ? { ...a, bankName: e.target.value } : a,
|
||||
),
|
||||
)
|
||||
}
|
||||
placeholder="Bank name"
|
||||
aria-label="Bank name"
|
||||
/>
|
||||
</div>
|
||||
<div className={`${tableStyles.field} ${styles.accountCol2}`}>
|
||||
{showLabels ? <label>Account holder</label> : null}
|
||||
<input
|
||||
value={acc.accountHolderName}
|
||||
onChange={(e) =>
|
||||
onAccountsChange(
|
||||
accounts.map((a) =>
|
||||
a.key === acc.key ? { ...a, accountHolderName: e.target.value } : a,
|
||||
),
|
||||
)
|
||||
}
|
||||
placeholder="Account holder name"
|
||||
aria-label="Account holder"
|
||||
/>
|
||||
</div>
|
||||
<div className={`${tableStyles.field} ${styles.accountCol3}`}>
|
||||
{showLabels ? <label>Card number</label> : null}
|
||||
<input
|
||||
value={acc.cardNumber}
|
||||
onChange={(e) =>
|
||||
onAccountsChange(
|
||||
accounts.map((a) =>
|
||||
a.key === acc.key ? { ...a, cardNumber: e.target.value } : a,
|
||||
),
|
||||
)
|
||||
}
|
||||
placeholder="Optional"
|
||||
aria-label="Card number"
|
||||
/>
|
||||
</div>
|
||||
<div className={`${tableStyles.field} ${styles.accountCol5}`}>
|
||||
{showLabels ? <label>IBAN</label> : null}
|
||||
<input
|
||||
value={acc.iban}
|
||||
onChange={(e) =>
|
||||
onAccountsChange(
|
||||
accounts.map((a) =>
|
||||
a.key === acc.key ? { ...a, iban: e.target.value } : a,
|
||||
),
|
||||
)
|
||||
}
|
||||
placeholder="Optional"
|
||||
aria-label="IBAN"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeFieldBtn}
|
||||
onClick={() => onAccountsChange(accounts.filter((a) => a.key !== acc.key))}
|
||||
aria-label="Remove account"
|
||||
title="Remove"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,20 +11,20 @@ export function isAllowedAdminHost(hostname = window.location.hostname): boolean
|
||||
|
||||
/**
|
||||
* Public invoice URL for platform invoices.
|
||||
* Local/dev: current origin (`https://meshkee.app:5174/invoices/{id}`) so the show page is reachable.
|
||||
* Production: `https://{VITE_INVOICE_PUBLIC_DOMAIN}/invoices/{id}` (default meshkee.com).
|
||||
* Local/dev: current origin (`https://meshkee.app:5174/invoices/{publicId}`) so the show page is reachable.
|
||||
* Production: `https://{VITE_INVOICE_PUBLIC_DOMAIN}/invoices/{publicId}` (default meshkee.com).
|
||||
* Override either with `VITE_INVOICE_PUBLIC_BASE_URL` (full origin, optional path prefix).
|
||||
*/
|
||||
export function getPlatformInvoicePublicUrl(invoiceId: string): string {
|
||||
export function getPlatformInvoicePublicUrl(publicId: string): string {
|
||||
const baseOverride = import.meta.env.VITE_INVOICE_PUBLIC_BASE_URL?.trim()
|
||||
if (baseOverride) {
|
||||
return `${baseOverride.replace(/\/$/, '')}/invoices/${invoiceId}`
|
||||
return `${baseOverride.replace(/\/$/, '')}/invoices/${publicId}`
|
||||
}
|
||||
if (import.meta.env.DEV) {
|
||||
return `${window.location.origin}/invoices/${invoiceId}`
|
||||
return `${window.location.origin}/invoices/${publicId}`
|
||||
}
|
||||
const domain = import.meta.env.VITE_INVOICE_PUBLIC_DOMAIN?.trim() || 'meshkee.com'
|
||||
return `https://${domain}/invoices/${invoiceId}`
|
||||
return `https://${domain}/invoices/${publicId}`
|
||||
}
|
||||
|
||||
/** Marketing / main business site for platform invoices (default https://meshkee.com). */
|
||||
|
||||
@@ -138,7 +138,6 @@
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid rgba(239, 68, 68, 0.18);
|
||||
flex-shrink: 0;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.removeFieldBtn:hover:not(:disabled) {
|
||||
@@ -147,7 +146,7 @@
|
||||
|
||||
.repeatRow {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@@ -156,6 +155,14 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fieldNoLabel {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fieldNoLabel label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.itemGrid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(160px, 2fr) minmax(90px, 1fr) minmax(90px, 1fr) minmax(110px, 1.1fr) minmax(120px, 1.2fr);
|
||||
@@ -406,6 +413,10 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.accountRowPlain {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.accountCol2,
|
||||
.accountCol3,
|
||||
.accountCol5 {
|
||||
|
||||
@@ -111,7 +111,7 @@ export function BusinessInvoicesPage() {
|
||||
}
|
||||
|
||||
async function copyPublicLink(invoice: Invoice) {
|
||||
const url = invoice.publicUrl || getPlatformInvoicePublicUrl(invoice.id)
|
||||
const url = invoice.publicUrl || getPlatformInvoicePublicUrl(invoice.publicId)
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
showToast('Invoice link copied.', 'success')
|
||||
@@ -123,12 +123,12 @@ export function BusinessInvoicesPage() {
|
||||
function invoicePublicUrl(invoice: Invoice) {
|
||||
// Prefer local/dev origin so the public show page is reachable while designing.
|
||||
if (import.meta.env.DEV || import.meta.env.VITE_INVOICE_PUBLIC_BASE_URL) {
|
||||
return getPlatformInvoicePublicUrl(invoice.id)
|
||||
return getPlatformInvoicePublicUrl(invoice.publicId)
|
||||
}
|
||||
return invoice.publicUrl || getPlatformInvoicePublicUrl(invoice.id)
|
||||
return invoice.publicUrl || getPlatformInvoicePublicUrl(invoice.publicId)
|
||||
}
|
||||
|
||||
const businessName = business?.nameFa || business?.name || 'Business'
|
||||
const businessName = business?.name || business?.nameFa || 'Business'
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
|
||||
@@ -59,7 +59,7 @@ export function IssueInvoicePage() {
|
||||
const [formError, setFormError] = useState('')
|
||||
|
||||
const listPath = `/businesses/${businessId}/invoices`
|
||||
const businessName = business?.nameFa || business?.name || 'Business'
|
||||
const businessName = business?.name || business?.nameFa || 'Business'
|
||||
|
||||
const createTotal = useMemo(() => {
|
||||
return draftItems.reduce((sum, item) => {
|
||||
|
||||
@@ -46,7 +46,7 @@ export function PublicInvoicePage() {
|
||||
useEffect(() => {
|
||||
if (!invoice) return
|
||||
const previous = document.title
|
||||
document.title = invoice.name?.trim() || `Invoice #${invoice.id}`
|
||||
document.title = invoice.name?.trim() || `Invoice ${invoice.publicId}`
|
||||
return () => {
|
||||
document.title = previous
|
||||
}
|
||||
@@ -65,7 +65,7 @@ export function PublicInvoicePage() {
|
||||
{invoice ? (
|
||||
<>
|
||||
<header className={styles.header}>
|
||||
<h1 className={styles.title}>{invoice.name || `Invoice #${invoice.id}`}</h1>
|
||||
<h1 className={styles.title}>{invoice.name || `Invoice ${invoice.publicId}`}</h1>
|
||||
<p className={styles.meta}>
|
||||
Issued {formatDate(invoice.issuedAt)}
|
||||
{invoice.business?.name ? ` · ${invoice.business.name}` : ''}
|
||||
|
||||
@@ -141,9 +141,9 @@ export function deleteBusinessInvoice(businessId: string, invoiceId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/** Public show-page payload (no auth). */
|
||||
export function getPublicInvoice(invoiceId: string, signal?: AbortSignal) {
|
||||
return apiRequest<PublicInvoice>(`/public/invoices/${invoiceId}`, {
|
||||
/** Public show-page payload (no auth). Lookup by opaque publicId. */
|
||||
export function getPublicInvoice(publicId: string, signal?: AbortSignal) {
|
||||
return apiRequest<PublicInvoice>(`/public/invoices/${publicId}`, {
|
||||
auth: false,
|
||||
signal,
|
||||
})
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface InvoiceAccount {
|
||||
|
||||
export interface Invoice {
|
||||
id: string
|
||||
publicId: string
|
||||
businessId: string
|
||||
ownerScope: 'platform' | 'business'
|
||||
issuerBusinessId: string | null
|
||||
@@ -76,22 +77,21 @@ export interface Invoice {
|
||||
total?: number
|
||||
}
|
||||
|
||||
/** Public viewer payload (no notes / issuer). */
|
||||
export type PublicInvoice = Pick<
|
||||
Invoice,
|
||||
| 'id'
|
||||
| 'status'
|
||||
| 'name'
|
||||
| 'topText'
|
||||
| 'issuedAt'
|
||||
| 'business'
|
||||
| 'items'
|
||||
| 'keyPoints'
|
||||
| 'accounts'
|
||||
| 'subtotal'
|
||||
| 'total'
|
||||
| 'publicUrl'
|
||||
>
|
||||
/** Public viewer payload (no notes / issuer / sequential id). */
|
||||
export type PublicInvoice = {
|
||||
publicId: string
|
||||
status: InvoiceStatus
|
||||
name: string | null
|
||||
topText: string | null
|
||||
issuedAt: string
|
||||
business?: Invoice['business']
|
||||
items?: InvoiceItem[]
|
||||
keyPoints?: InvoiceKeyPoint[]
|
||||
accounts?: InvoiceAccount[]
|
||||
subtotal?: number
|
||||
total?: number
|
||||
publicUrl: string | null
|
||||
}
|
||||
|
||||
export interface InvoiceItemInput {
|
||||
templateId?: string
|
||||
|
||||
Reference in New Issue
Block a user