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:
Alireza Hassani
2026-07-26 12:00:10 +03:30
co-authored by Cursor
parent cbffb23ec3
commit 2e40d5eb4c
11 changed files with 152 additions and 107 deletions
View File

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>
+6 -6
View File
@@ -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,
})
+16 -16
View File
@@ -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