Redesign thermal receipts with Vazirmatn font and cafe branding

Embed Vazirmatn web font in printed bills, add branded header with logo
and tagline, and wait for fonts to load before printing for clean output.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-05-29 17:14:32 +03:30
parent 963d02a265
commit 923a00b113
5 changed files with 145 additions and 18 deletions
Binary file not shown.
@@ -624,6 +624,12 @@ export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPan
variant="bill" variant="bill"
order={receiptOrder} order={receiptOrder}
cafeName={cafeName} cafeName={cafeName}
logoUrl={cafeSettings?.logoUrl}
tagline={
[cafeSettings?.address, cafeSettings?.phone]
.filter(Boolean)
.join(" • ") || undefined
}
onClose={() => setReceiptOrder(null)} onClose={() => setReceiptOrder(null)}
/> />
) : null} ) : null}
@@ -53,7 +53,7 @@
#pos-slip-print-area { #pos-slip-print-area {
width: 100%; width: 100%;
max-width: 76mm; max-width: 76mm;
font-family: "Tahoma", "Vazirmatn", "B Nazanin", "Courier New", monospace; font-family: "Vazirmatn", "Tahoma", "Arial", sans-serif;
font-size: 12px; font-size: 12px;
direction: rtl; direction: rtl;
text-align: right; text-align: right;
@@ -6,6 +6,7 @@ import type { Order } from "@/lib/api/types";
import { formatCurrency } from "@/lib/format"; import { formatCurrency } from "@/lib/format";
import { formatOrderNumber } from "@/lib/order-number"; import { formatOrderNumber } from "@/lib/order-number";
import { buildThermalDocument, printThermal } from "@/lib/thermal-print"; import { buildThermalDocument, printThermal } from "@/lib/thermal-print";
import { resolveMediaUrl } from "@/lib/api/client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import "./pos-receipt-print.css"; import "./pos-receipt-print.css";
@@ -18,6 +19,10 @@ export type KitchenSlipLine = {
type PosSlipModalProps = { type PosSlipModalProps = {
variant: "kitchen" | "bill"; variant: "kitchen" | "bill";
cafeName: string; cafeName: string;
/** Café logo for receipt branding. */
logoUrl?: string;
/** Address / phone line shown under the café name on the bill. */
tagline?: string;
onClose: () => void; onClose: () => void;
/** Full order for customer bill */ /** Full order for customer bill */
order?: Order; order?: Order;
@@ -32,6 +37,8 @@ type PosSlipModalProps = {
export function PosSlipModal({ export function PosSlipModal({
variant, variant,
cafeName, cafeName,
logoUrl,
tagline,
onClose, onClose,
order, order,
kitchenLines = [], kitchenLines = [],
@@ -94,6 +101,8 @@ export function PosSlipModal({
} }
: { : {
cafeName, cafeName,
logoUrl: resolveMediaUrl(logoUrl),
tagline,
title: t("billTitle"), title: t("billTitle"),
date: formattedDate, date: formattedDate,
metaRow, metaRow,
@@ -126,8 +135,19 @@ export function PosSlipModal({
id="pos-slip-print-area" id="pos-slip-print-area"
className="mb-4 rounded-md border border-dashed border-border p-3" className="mb-4 rounded-md border border-dashed border-border p-3"
> >
<div className="text-center text-base font-bold">{cafeName}</div> {variant === "bill" && logoUrl && (
<div className="mb-1 text-center text-xs font-semibold"> // eslint-disable-next-line @next/next/no-img-element
<img
src={resolveMediaUrl(logoUrl)}
alt=""
className="mx-auto mb-1.5 max-h-12 w-auto object-contain"
/>
)}
<div className="text-center text-lg font-extrabold leading-tight">{cafeName}</div>
{variant === "bill" && tagline && (
<div className="text-center text-[10px] text-muted-foreground">{tagline}</div>
)}
<div className="mb-1 mt-1.5 border-y border-foreground/60 py-0.5 text-center text-xs font-bold">
{variant === "kitchen" ? t("kitchenTitle") : t("billTitle")} {variant === "kitchen" ? t("kitchenTitle") : t("billTitle")}
</div> </div>
<div className="mb-2 text-center text-xs text-muted-foreground"> <div className="mb-2 text-center text-xs text-muted-foreground">
+116 -15
View File
@@ -40,8 +40,19 @@ export type ThermalSlipData = {
footer?: string; footer?: string;
/** 'fa' (default) → RTL, 'en' → LTR */ /** 'fa' (default) → RTL, 'en' → LTR */
locale?: string; locale?: string;
/** Optional café logo URL — rendered centered above the name for branding. */
logoUrl?: string;
/** Optional tagline / address line under the café name. */
tagline?: string;
}; };
/** Absolute URL to the bundled Vazirmatn web-font (same origin). */
function vazirmatnFontUrl(): string {
const origin =
typeof window !== "undefined" ? window.location.origin : "";
return `${origin}/fonts/Vazirmatn-Variable.woff2`;
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Helpers // Helpers
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -97,11 +108,28 @@ export function buildThermalDocument(data: ThermalSlipData): string {
? `<hr class="dashed"><div class="center sm muted">${esc(data.footer)}</div>` ? `<hr class="dashed"><div class="center sm muted">${esc(data.footer)}</div>`
: ""; : "";
const logoHtml = data.logoUrl
? `<img class="logo" src="${esc(data.logoUrl)}" alt="">`
: "";
const taglineHtml = data.tagline
? `<div class="center tagline">${esc(data.tagline)}</div>`
: "";
const fontUrl = vazirmatnFontUrl();
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html dir="${dir}" lang="${lang}"> <html dir="${dir}" lang="${lang}">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<style> <style>
/* ── Web-font: bundled Vazirmatn variable ─────────────────── */
@font-face {
font-family: 'Vazirmatn';
src: url('${fontUrl}') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: block;
}
/* ── Page ─────────────────────────────────────────────────── */ /* ── Page ─────────────────────────────────────────────────── */
@page { @page {
/* 80 mm wide; height tracks the content — no blank tail */ /* 80 mm wide; height tracks the content — no blank tail */
@@ -114,17 +142,64 @@ export function buildThermalDocument(data: ThermalSlipData): string {
html, body { html, body {
width: 80mm; width: 80mm;
direction: ${dir}; direction: ${dir};
font-family: 'Tahoma', 'Vazirmatn', 'B Nazanin', 'Arial', sans-serif; font-family: 'Vazirmatn', 'Tahoma', 'Arial', sans-serif;
font-size: 12pt; font-size: 11.5pt;
font-weight: 500;
line-height: 1.55;
color: #000; color: #000;
background: #fff; background: #fff;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
} }
/* ── Wrapper ──────────────────────────────────────────────── */ /* ── Wrapper ──────────────────────────────────────────────── */
.wrap { padding: 3mm 4mm 5mm; } .wrap { padding: 4mm 4.5mm 6mm; }
/* ── Branding header ──────────────────────────────────────── */
.logo {
display: block;
max-width: 36mm;
max-height: 18mm;
margin: 0 auto 1.5mm;
object-fit: contain;
}
.cafe-name {
text-align: center;
font-size: 16pt;
font-weight: 800;
letter-spacing: -0.2px;
line-height: 1.25;
}
.tagline {
font-size: 8.5pt;
font-weight: 500;
color: #444;
margin-top: 0.5mm;
}
.doc-title {
text-align: center;
font-size: 10pt;
font-weight: 700;
margin-top: 1.5mm;
padding: 0.8mm 0;
border-top: 1px solid #000;
border-bottom: 1px solid #000;
}
.date {
text-align: center;
font-size: 8.5pt;
color: #444;
margin-top: 1.5mm;
}
.meta {
font-size: 9pt;
font-weight: 600;
margin-top: 1.5mm;
text-align: center;
color: #222;
}
/* ── Text helpers ─────────────────────────────────────────── */ /* ── Text helpers ─────────────────────────────────────────── */
.center { text-align: center; } .center { text-align: center; }
.bold { font-weight: 700; } .bold { font-weight: 700; }
.sm { font-size: 10pt; } .sm { font-size: 9.5pt; }
.muted { color: #555; } .muted { color: #555; }
.mt1 { margin-top: 1mm; } .mt1 { margin-top: 1mm; }
.mt2 { margin-top: 2mm; } .mt2 { margin-top: 2mm; }
@@ -137,23 +212,30 @@ hr.solid { border: none; border-top: 1px solid #000; margin: 2.5mm 0; }
justify-content: space-between; justify-content: space-between;
align-items: baseline; align-items: baseline;
gap: 2mm; gap: 2mm;
line-height: 1.6; line-height: 1.7;
} }
.row .price { white-space: nowrap; } .row > span:first-child { flex: 1; }
.row .price { white-space: nowrap; font-weight: 700; font-variant-numeric: tabular-nums; }
.row.sm { color: #333; }
/* ── Totals ───────────────────────────────────────────────── */ /* ── Totals ───────────────────────────────────────────────── */
.total { .total {
font-size: 14pt; font-size: 14pt;
font-weight: 700; font-weight: 800;
margin-top: 1mm; margin-top: 1.5mm;
padding-top: 1.5mm;
border-top: 2px solid #000;
} }
.total .price { font-size: 14pt; }
</style> </style>
</head> </head>
<body> <body>
<div class="wrap"> <div class="wrap">
<div class="center bold">${esc(data.cafeName)}</div> ${logoHtml}
<div class="center bold sm mt1">${esc(data.title)}</div> <div class="cafe-name">${esc(data.cafeName)}</div>
<div class="center sm muted mt1">${esc(data.date)}</div> ${taglineHtml}
${data.metaRow ? `<div class="sm mt2">${esc(data.metaRow)}</div>` : ""} <div class="doc-title">${esc(data.title)}</div>
<div class="date">${esc(data.date)}</div>
${data.metaRow ? `<div class="meta">${esc(data.metaRow)}</div>` : ""}
<hr class="dashed"> <hr class="dashed">
${linesHtml} ${linesHtml}
${totalsHtml} ${totalsHtml}
@@ -199,8 +281,7 @@ export function printThermal(html: string): void {
doc.write(html); doc.write(html);
doc.close(); doc.close();
// rAF gives the iframe one layout pass before we trigger the print dialog const fire = () => {
requestAnimationFrame(() => {
try { try {
frame!.contentWindow?.focus(); frame!.contentWindow?.focus();
frame!.contentWindow?.print(); frame!.contentWindow?.print();
@@ -208,5 +289,25 @@ export function printThermal(html: string): void {
// Safari throws if the frame was cross-origin — shouldn't happen for // Safari throws if the frame was cross-origin — shouldn't happen for
// same-origin same-page iframes, but guard just in case. // same-origin same-page iframes, but guard just in case.
} }
}); };
// Wait for the embedded Vazirmatn web-font to finish loading inside the
// iframe before printing — otherwise the spooler may rasterise the receipt
// with a fallback font. `document.fonts.ready` resolves once @font-face
// loads complete; we cap the wait so a font failure never blocks printing.
const win = frame.contentWindow;
const fontSet = win?.document?.fonts;
if (fontSet) {
let printed = false;
const run = () => {
if (printed) return;
printed = true;
requestAnimationFrame(fire);
};
fontSet.ready.then(run).catch(run);
// Safety timeout in case fonts.ready never settles.
setTimeout(run, 1500);
} else {
requestAnimationFrame(fire);
}
} }