/*
* MIT License
*
* Copyright (c) 2026 OwnlyPlans
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
"use client";
import { useEffect, useRef, type CSSProperties } from "react";
const css = `.rcpt{--rcpt-w:290px;--rcpt-ink:#16181d;--rcpt-muted:#5f6470;--rcpt-paper:#fdfdfc;--rcpt-accent:#16181d;
position:relative;width:min(var(--rcpt-w),100%);display:grid;justify-items:center;font:13px/1.35 system-ui,-apple-system,"Segoe UI",sans-serif;color:var(--rcpt-ink);user-select:none;-webkit-user-select:none}
/* the printer slot: a brushed metal bar with a dark slit */
.rcpt-slot{position:relative;z-index:3;width:100%;height:38px;border-radius:10px;background:linear-gradient(180deg,#f4f5f7 0%,#d7dade 45%,#bfc3c9 100%);
box-shadow:inset 0 1px 0 #fff,inset 0 -2px 3px rgba(0,0,0,.18),0 10px 18px -10px rgba(0,0,0,.45)}
.rcpt-slot i{position:absolute;left:9%;right:9%;top:21px;height:5px;border-radius:3px;background:linear-gradient(180deg,#202227,#3a3d44);box-shadow:inset 0 1px 2px rgba(0,0,0,.6)}
.rcpt-slot b{position:absolute;right:12px;top:8px;width:6px;height:6px;border-radius:50%;background:#9aa0a8;transition:background .2s,box-shadow .2s}
.rcpt.is-printing .rcpt-slot b{background:#2fd16b;box-shadow:0 0 8px #2fd16b}
/* paper comes out from under the slit; only the top is clipped so a torn receipt can fall freely */
.rcpt-feed{position:relative;z-index:2;width:84%;margin-top:-14px;clip-path:inset(0 -60px -2000px -60px);filter:drop-shadow(0 10px 12px rgba(20,24,32,.18))}
.rcpt-paper{position:relative;padding:22px 18px 22px;background:linear-gradient(180deg,#f1f2f2 0,var(--rcpt-paper) 26px,var(--rcpt-paper) 80%,#f4f4f2);touch-action:none;cursor:grab;will-change:transform;
-webkit-mask:linear-gradient(#000 0 0) 0 0/100% calc(100% - 7px) no-repeat,conic-gradient(from -45deg at bottom,#0000,#000 1deg 89deg,#0000 90deg) 0 100%/14px 7px repeat-x;
mask:linear-gradient(#000 0 0) 0 0/100% calc(100% - 7px) no-repeat,conic-gradient(from -45deg at bottom,#0000,#000 1deg 89deg,#0000 90deg) 0 100%/14px 7px repeat-x}
.rcpt-paper:active{cursor:grabbing}
.rcpt.is-torn .rcpt-paper,.rcpt-paper.is-torn{
-webkit-mask:linear-gradient(#000 0 0) 0 7px/100% calc(100% - 14px) no-repeat,conic-gradient(from 135deg at top,#0000,#000 1deg 89deg,#0000 90deg) 0 0/14px 7px repeat-x,conic-gradient(from -45deg at bottom,#0000,#000 1deg 89deg,#0000 90deg) 0 100%/14px 7px repeat-x;
mask:linear-gradient(#000 0 0) 0 7px/100% calc(100% - 14px) no-repeat,conic-gradient(from 135deg at top,#0000,#000 1deg 89deg,#0000 90deg) 0 0/14px 7px repeat-x,conic-gradient(from -45deg at bottom,#0000,#000 1deg 89deg,#0000 90deg) 0 100%/14px 7px repeat-x}
.rcpt-title{margin:0;text-align:center;font-size:19px;font-weight:800;letter-spacing:.04em}
.rcpt-meta{display:flex;justify-content:space-between;margin:6px 0 8px;color:var(--rcpt-muted);font-size:10.5px;font-variant-numeric:tabular-nums}
.rcpt-rule{height:5px;border-top:1px solid var(--rcpt-ink);border-bottom:1px solid var(--rcpt-ink);opacity:.75;margin-bottom:10px}
.rcpt-table{width:100%;border-collapse:collapse;font-variant-numeric:tabular-nums}
.rcpt-table th{padding:0 0 6px;font-size:11.5px;font-weight:700;text-align:left}
.rcpt-table th:last-child,.rcpt-table td:last-child{text-align:right;white-space:nowrap;padding-left:10px}
.rcpt-table th:last-child{font-weight:500}
.rcpt-table td{padding:1.5px 0;vertical-align:top}
.rcpt-table td small{color:var(--rcpt-muted)}
.rcpt-sum{display:grid;grid-template-columns:1fr auto;gap:2px 10px;margin:10px 0 0;padding-top:8px;border-top:1px dashed rgba(22,24,29,.35);font-variant-numeric:tabular-nums}
.rcpt-sum dt,.rcpt-sum dd{margin:0}.rcpt-sum dd{text-align:right}
.rcpt-sum .tot{font-weight:800;font-size:14px;margin-top:3px}
.rcpt-bar{display:block;width:100%;height:30px;margin:12px 0 3px}
.rcpt-bar rect{fill:var(--rcpt-ink)}
.rcpt-id{margin:0;text-align:center;font-size:10.5px;letter-spacing:.08em;font-variant-numeric:tabular-nums}
.rcpt-note{margin:6px 0 0;text-align:center;color:var(--rcpt-muted);font-size:11px}
.rcpt-stub{position:absolute;left:0;right:0;top:0;height:10px;background:var(--rcpt-paper);opacity:0;pointer-events:none;
-webkit-mask:conic-gradient(from -45deg at bottom,#0000,#000 1deg 89deg,#0000 90deg) 0 100%/14px 7px repeat-x,linear-gradient(#000 0 0) 0 0/100% 3px no-repeat;
mask:conic-gradient(from -45deg at bottom,#0000,#000 1deg 89deg,#0000 90deg) 0 100%/14px 7px repeat-x,linear-gradient(#000 0 0) 0 0/100% 3px no-repeat}
.rcpt.is-torn .rcpt-stub{opacity:1}
.rcpt-actions{position:relative;z-index:4;display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:8px 12px;margin-top:12px;min-height:34px}
.rcpt-btn{height:34px;padding:0 16px;border:1px solid rgba(22,24,29,.18);border-radius:999px;background:var(--rcpt-accent);color:#fff;font:600 12.5px system-ui,sans-serif;cursor:pointer;transition:transform .15s,opacity .2s}
.rcpt-btn:active{transform:scale(.96)}.rcpt-btn:disabled{opacity:.45;cursor:default}
.rcpt-btn:focus-visible{outline:2px solid var(--rcpt-accent);outline-offset:3px}
.rcpt-hint{color:var(--rcpt-muted);font-size:11.5px}
.rcpt-hint.nudge{animation:rcpt-nudge .5s}
@keyframes rcpt-nudge{25%{transform:translateY(-3px)}60%{transform:translateY(2px)}}
:root[data-stage=dark] .rcpt,.rcpt.rcpt--dark{--rcpt-muted:#5f6470}
:root[data-stage=dark] .rcpt-hint{color:#aab0bd}
:root[data-stage=dark] .rcpt-btn{background:#f4f5f7;color:#16181d}
.rcpt-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
@media (max-height:560px){.rcpt{zoom:.82}}
@media (max-height:470px){.rcpt{zoom:.74}}
@media (max-height:380px){.rcpt{zoom:.6}}
@media (max-height:320px){.rcpt{zoom:.54}}
@media (prefers-reduced-motion:reduce){.rcpt-hint.nudge{animation:none}}`;
export type ReceiptItem = { name: string; price: number; qty?: number; /** e.g. "mo" shows "/mo" */ per?: string };
export type ReceiptOptions = {
title?: string;
items?: ReceiptItem[];
/** ISO currency code, e.g. "USD", "INR", "EUR". */
currency?: string;
locale?: string;
/** 0.08 = 8% */
taxRate?: number;
orderId?: string;
note?: string;
autoPrint?: boolean;
sound?: boolean;
};
/* Receipt Printer: builds the receipt inside `root`, prints it, and lets people pull it to tear it off. Returns a cleanup function. */
export function mountReceipt(root: HTMLElement, o: ReceiptOptions = {}): () => void {
const opt = {
title: 'RECEIPT', currency: 'USD', locale: 'en-US', taxRate: 0.08, note: 'Thank you for your purchase!', autoPrint: true, sound: true,
orderId: String(Date.now()).slice(-10) + String(Math.floor(Math.random() * 900 + 100)),
items: [
{ name: 'Camera', price: 2500 }, { name: 'Lens Upgrade', price: 1250 }, { name: 'Studio Lighting', price: 800, qty: 2 },
{ name: 'Microphone', price: 300 }, { name: 'Editing Software', price: 60, per: 'mo' },
],
...o,
};
const reduce = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
root.classList.add('rcpt');
root.innerHTML =
'<div class="rcpt-slot" aria-hidden="true"><i></i><b></b></div>' +
'<div class="rcpt-feed"><div class="rcpt-stub" aria-hidden="true"></div><div class="rcpt-paper" role="document" aria-label="Receipt">' +
'<h3 class="rcpt-title"></h3><div class="rcpt-meta"><span class="rcpt-date"></span><span class="rcpt-no"></span></div><div class="rcpt-rule" aria-hidden="true"></div>' +
'<table class="rcpt-table"><thead><tr><th scope="col">Description</th><th scope="col">Price</th></tr></thead><tbody></tbody></table>' +
'<dl class="rcpt-sum"><dt>Subtotal</dt><dd class="rcpt-sub"></dd><dt class="rcpt-taxl"></dt><dd class="rcpt-tax"></dd><dt class="tot">Total</dt><dd class="tot rcpt-total"></dd></dl>' +
'<svg class="rcpt-bar" viewBox="0 0 200 30" preserveAspectRatio="none" aria-hidden="true"></svg><p class="rcpt-id"></p><p class="rcpt-note"></p></div></div>' +
'<div class="rcpt-actions"><button type="button" class="rcpt-btn">Print receipt</button><span class="rcpt-hint" aria-live="polite"></span></div>';
const $ = (s: string) => root.querySelector(s) as HTMLElement;
const feed = $('.rcpt-feed'), paper = $('.rcpt-paper'), btn = $('.rcpt-btn') as HTMLButtonElement, hint = $('.rcpt-hint');
let state: 'idle' | 'printing' | 'printed' | 'torn' = 'idle', raf = 0, alive = true;
let drag: { id: number; x: number; y: number; moved: boolean } | null = null;
/* ---------- content ---------- */
const money = new Intl.NumberFormat(opt.locale, { style: 'currency', currency: opt.currency });
function fill() {
$('.rcpt-title').textContent = opt.title;
$('.rcpt-date').textContent = new Date().toLocaleString(opt.locale, { dateStyle: 'medium', timeStyle: 'short' });
$('.rcpt-no').textContent = 'No. ' + opt.orderId.slice(-6);
const body = $('.rcpt-table tbody'); body.replaceChildren();
let sub = 0;
opt.items.forEach((it) => {
const qty = it.qty || 1, line = it.price * qty; sub += line;
const tr = document.createElement('tr'), a = document.createElement('td'), b = document.createElement('td');
a.textContent = it.name;
if (qty > 1) { const s = document.createElement('small'); s.textContent = ' ×' + qty; a.append(s); }
b.textContent = money.format(line) + (it.per ? '/' + it.per : '');
tr.append(a, b); body.append(tr);
});
const tax = Math.round(sub * opt.taxRate * 100) / 100;
$('.rcpt-sub').textContent = money.format(sub);
$('.rcpt-taxl').textContent = 'Tax (' + Math.round(opt.taxRate * 1000) / 10 + '%)';
$('.rcpt-tax').textContent = money.format(tax);
$('.rcpt-total').textContent = money.format(sub + tax);
$('.rcpt-id').textContent = '#' + opt.orderId;
$('.rcpt-note').textContent = opt.note;
/* decorative barcode drawn from the order number */
let x = 2, svg = '', seed = 7;
for (let i = 0; x < 196 && i < 400; i++) {
seed = (seed * 31 + opt.orderId.charCodeAt(i % opt.orderId.length)) % 997;
const w = 0.8 + (seed % 4) * 0.7, gap = 0.8 + ((seed >> 2) % 3) * 0.7;
if (x + w > 198) break;
svg += '<rect x="' + x.toFixed(2) + '" y="0" width="' + w.toFixed(2) + '" height="' + (seed % 5 === 0 ? 30 : 26) + '"/>'; x += w + gap;
}
$('.rcpt-bar').innerHTML = svg;
}
/* ---------- sound (made in code, plays only after a click) ---------- */
let ac: AudioContext | null = null;
function noise(len: number): AudioBuffer {
const a = ac as AudioContext, b = a.createBuffer(1, Math.floor(a.sampleRate * len), a.sampleRate), d = b.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
return b;
}
function sound(kind: 'feed' | 'rip', dur = 0.1) {
if (!opt.sound) return;
try {
const AC = window.AudioContext || (window as any).webkitAudioContext;
if (!AC) return;
ac = ac || new AC();
const a = ac as AudioContext, t = a.currentTime, src = a.createBufferSource(), f = a.createBiquadFilter(), g = a.createGain();
src.connect(f); f.connect(g); g.connect(a.destination);
if (kind === 'feed') {
/* thermal printer: a short buzzing burst per line */
src.buffer = noise(dur); f.type = 'bandpass'; f.frequency.value = 2300; f.Q.value = 3;
const lfo = a.createOscillator(), lg = a.createGain(); lfo.frequency.value = 95; lg.gain.value = 0.05; lfo.connect(lg); lg.connect(g.gain);
g.gain.setValueAtTime(0.06, t); g.gain.setValueAtTime(0.0001, t + dur); lfo.start(t); lfo.stop(t + dur); src.start(t); src.stop(t + dur);
} else {
/* paper rip: crackly noise sweeping down */
src.buffer = noise(0.32); f.type = 'bandpass'; f.Q.value = 0.9; f.frequency.setValueAtTime(4200, t); f.frequency.exponentialRampToValueAtTime(700, t + 0.3);
for (let i = 0; i < 12; i++) g.gain.setValueAtTime(i % 2 ? 0.08 : 0.32 + Math.random() * 0.2, t + i * 0.025);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.32); src.start(t); src.stop(t + 0.33);
}
} catch (e) { /* no audio: stay silent */ }
}
/* ---------- printing: the paper feeds out in small steps, like a real printer ---------- */
function setY(y: number, dx = 0, rot = 0) { paper.style.transform = 'translate(' + dx + 'px,' + y + 'px) rotate(' + rot + 'deg)'; }
function print() {
if (!alive) return;
cancelAnimationFrame(raf);
paper.getAnimations().forEach((a) => a.cancel());
root.classList.remove('is-torn'); paper.classList.remove('is-torn'); paper.style.opacity = '1';
fill();
const H = paper.offsetHeight;
feed.style.height = H + 'px';
state = 'printing'; root.classList.add('is-printing'); btn.disabled = true; btn.textContent = 'Printing…'; hint.textContent = '';
if (reduce) { setY(0); done(); return; }
const step = 18, steps = Math.ceil(H / step), stepMs = Math.max(55, Math.min(110, 2400 / steps));
let i = 0, t0 = -1;
setY(-H);
const tick = (now: number) => {
if (!alive) return;
if (t0 < 0) t0 = now;
const k = (now - t0) / stepMs, n = Math.floor(k), frac = Math.min(1, (k - n) / 0.55);
if (n > i) { i = n; if (i <= steps) sound('feed', stepMs / 1000 * 0.5); }
const shown = Math.min(H, (n + frac) * step);
setY(-H + shown);
if (shown >= H) { done(); return; }
raf = requestAnimationFrame(tick);
};
sound('feed', stepMs / 1000 * 0.5);
raf = requestAnimationFrame(tick);
}
function done() {
state = 'printed'; root.classList.remove('is-printing'); btn.disabled = false; btn.textContent = 'Tear off';
hint.textContent = 'Hold and pull the receipt down to tear it off';
root.dispatchEvent(new CustomEvent('receipt:printed', { bubbles: true, detail: { orderId: opt.orderId } }));
}
/* ---------- tearing ---------- */
function tear(dir: number) {
if (state !== 'printed') return;
state = 'torn'; sound('rip');
root.classList.add('is-torn'); paper.classList.add('is-torn');
const from = paper.style.transform || 'translate(0px,0px)';
const fall = paper.animate(
reduce ? [{ opacity: 1 }, { opacity: 0 }] : [{ transform: from, opacity: 1 }, { transform: 'translate(' + (dir * 46) + 'px,' + (feed.offsetHeight + 80) + 'px) rotate(' + (dir * 9 + 3) + 'deg)', opacity: 0 }],
{ duration: reduce ? 200 : 750, easing: 'cubic-bezier(.45,0,.8,.35)', fill: 'forwards' });
btn.textContent = 'Print again'; hint.textContent = 'Receipt torn off';
fall.onfinish = () => { if (alive) { paper.style.opacity = '0'; feed.style.height = '12px'; } };
root.dispatchEvent(new CustomEvent('receipt:torn', { bubbles: true, detail: { orderId: opt.orderId } }));
}
function nudge(text: string) { hint.textContent = text; hint.classList.remove('nudge'); void hint.offsetWidth; hint.classList.add('nudge'); }
function onDown(e: PointerEvent) {
if (state !== 'printed' || e.button > 0) return;
drag = { id: e.pointerId, x: e.clientX, y: e.clientY, moved: false };
try { paper.setPointerCapture(e.pointerId); } catch (err) { /* synthetic or finished pointer: dragging still works */ }
paper.style.transition = 'none';
}
function onMove(e: PointerEvent) {
if (!drag || e.pointerId !== drag.id) return;
const dx = e.clientX - drag.x, dy = Math.max(0, e.clientY - drag.y);
if (Math.hypot(dx, dy) > 4) drag.moved = true;
setY(dy * 0.35, dx * 0.12, dx * 0.025);
if (dy > 70 || Math.abs(dx) > 110) { const d = dx >= 0 ? 1 : -1; drag = null; tear(d); }
}
function onUp(e: PointerEvent) {
if (!drag || e.pointerId !== drag.id) return;
const moved = drag.moved; drag = null;
paper.style.transition = 'transform .35s cubic-bezier(.3,1.5,.5,1)'; setY(0);
setTimeout(() => { paper.style.transition = ''; }, 360);
nudge(moved ? 'Pull a little further to tear it' : 'Hold and pull down to tear it off');
}
const onBtn = () => { if (state === 'printed') tear(1); else if (state !== 'printing') print(); };
paper.addEventListener('pointerdown', onDown); paper.addEventListener('pointermove', onMove);
paper.addEventListener('pointerup', onUp); paper.addEventListener('pointercancel', onUp);
btn.addEventListener('click', onBtn);
fill(); feed.style.height = '12px'; setY(-paper.offsetHeight);
if (opt.autoPrint) print();
return () => { alive = false; cancelAnimationFrame(raf); root.innerHTML = ''; };
}
/** Shows a receipt that prints out of a slot after payment; people pull it down to tear it off. */
export function ReceiptPrinter({ onPrinted, onTorn, width = 290, ...options }: ReceiptOptions & {
onPrinted?: (orderId: string) => void;
onTorn?: (orderId: string) => void;
width?: number;
}) {
const ref = useRef<HTMLDivElement>(null);
const handlers = useRef({ onPrinted, onTorn });
handlers.current = { onPrinted, onTorn };
const key = JSON.stringify(options);
useEffect(() => {
const el = ref.current;
if (!el) return;
const printed = (e: Event) => handlers.current.onPrinted?.((e as CustomEvent).detail.orderId);
const torn = (e: Event) => handlers.current.onTorn?.((e as CustomEvent).detail.orderId);
el.addEventListener("receipt:printed", printed);
el.addEventListener("receipt:torn", torn);
const cleanup = mountReceipt(el, JSON.parse(key) as ReceiptOptions);
return () => { el.removeEventListener("receipt:printed", printed); el.removeEventListener("receipt:torn", torn); cleanup(); };
}, [key]);
return (
<>
<style>{css}</style>
<div ref={ref} className="rcpt" style={{ "--rcpt-w": `${width}px` } as CSSProperties} />
</>
);
}
/* Usage (after a successful payment):
<ReceiptPrinter currency="INR" taxRate={0.18} orderId={order.id}
items={order.lines.map((l) => ({ name: l.name, price: l.price, qty: l.qty }))}
onTorn={() => router.push("/orders")} />
*/