Add payment details component

This commit is contained in:
Kuba Orlik 2026-08-10 23:59:47 +02:00
parent f87ba31a0c
commit 1105cc947f
8 changed files with 307 additions and 0 deletions

25
package-lock.json generated
View File

@ -25,6 +25,7 @@
"@types/kill-port": "^2.0.0", "@types/kill-port": "^2.0.0",
"@types/leaflet": "^1.9.8", "@types/leaflet": "^1.9.8",
"@types/object-hash": "^3.0.6", "@types/object-hash": "^3.0.6",
"@types/qrcode-svg": "^1.1.5",
"@types/simplemde": "^1.11.11", "@types/simplemde": "^1.11.11",
"@types/wcag-contrast": "^3.0.3", "@types/wcag-contrast": "^3.0.3",
"a11y.css": "^5.3.0", "a11y.css": "^5.3.0",
@ -49,6 +50,7 @@
"normalize-diacritics": "^5.0.0", "normalize-diacritics": "^5.0.0",
"nyc": "^17.1.0", "nyc": "^17.1.0",
"object-path": "^0.11.8", "object-path": "^0.11.8",
"qrcode-svg": "^1.1.0",
"qs": "^6.12.0", "qs": "^6.12.0",
"sealious": "^0.21.30", "sealious": "^0.21.30",
"slug": "^9.1.0", "slug": "^9.1.0",
@ -1962,6 +1964,11 @@
"resolved": "https://registry.npmjs.org/@types/object-path/-/object-path-0.11.4.tgz", "resolved": "https://registry.npmjs.org/@types/object-path/-/object-path-0.11.4.tgz",
"integrity": "sha512-4tgJ1Z3elF/tOMpA8JLVuR9spt9Ynsf7+JjqsQ2IqtiPJtcLoHoXcT6qU4E10cPFqyXX5HDm9QwIzZhBSkLxsw==" "integrity": "sha512-4tgJ1Z3elF/tOMpA8JLVuR9spt9Ynsf7+JjqsQ2IqtiPJtcLoHoXcT6qU4E10cPFqyXX5HDm9QwIzZhBSkLxsw=="
}, },
"node_modules/@types/qrcode-svg": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@types/qrcode-svg/-/qrcode-svg-1.1.5.tgz",
"integrity": "sha512-GjkD+HB8S1wrIsf3skHDtcYBjzNhTxocMbX+wG166xDkaVOnLiMUla7bLjbwxo6mMvqqWQNP0Dk8nkIeizSmnw=="
},
"node_modules/@types/qs": { "node_modules/@types/qs": {
"version": "6.14.0", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
@ -11369,6 +11376,14 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/qrcode-svg": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/qrcode-svg/-/qrcode-svg-1.1.0.tgz",
"integrity": "sha512-XyQCIXux1zEIA3NPb0AeR8UMYvXZzWEhgdBgBjH9gO7M48H9uoHzviNz8pXw3UzrAcxRRRn9gxHewAVK7bn9qw==",
"bin": {
"qrcode-svg": "bin/qrcode-svg.js"
}
},
"node_modules/qs": { "node_modules/qs": {
"version": "6.14.0", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
@ -16562,6 +16577,11 @@
"resolved": "https://registry.npmjs.org/@types/object-path/-/object-path-0.11.4.tgz", "resolved": "https://registry.npmjs.org/@types/object-path/-/object-path-0.11.4.tgz",
"integrity": "sha512-4tgJ1Z3elF/tOMpA8JLVuR9spt9Ynsf7+JjqsQ2IqtiPJtcLoHoXcT6qU4E10cPFqyXX5HDm9QwIzZhBSkLxsw==" "integrity": "sha512-4tgJ1Z3elF/tOMpA8JLVuR9spt9Ynsf7+JjqsQ2IqtiPJtcLoHoXcT6qU4E10cPFqyXX5HDm9QwIzZhBSkLxsw=="
}, },
"@types/qrcode-svg": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@types/qrcode-svg/-/qrcode-svg-1.1.5.tgz",
"integrity": "sha512-GjkD+HB8S1wrIsf3skHDtcYBjzNhTxocMbX+wG166xDkaVOnLiMUla7bLjbwxo6mMvqqWQNP0Dk8nkIeizSmnw=="
},
"@types/qs": { "@types/qs": {
"version": "6.14.0", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
@ -23520,6 +23540,11 @@
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="
}, },
"qrcode-svg": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/qrcode-svg/-/qrcode-svg-1.1.0.tgz",
"integrity": "sha512-XyQCIXux1zEIA3NPb0AeR8UMYvXZzWEhgdBgBjH9gO7M48H9uoHzviNz8pXw3UzrAcxRRRn9gxHewAVK7bn9qw=="
},
"qs": { "qs": {
"version": "6.14.0", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",

View File

@ -108,6 +108,7 @@
"@types/kill-port": "^2.0.0", "@types/kill-port": "^2.0.0",
"@types/leaflet": "^1.9.8", "@types/leaflet": "^1.9.8",
"@types/object-hash": "^3.0.6", "@types/object-hash": "^3.0.6",
"@types/qrcode-svg": "^1.1.5",
"@types/simplemde": "^1.11.11", "@types/simplemde": "^1.11.11",
"@types/wcag-contrast": "^3.0.3", "@types/wcag-contrast": "^3.0.3",
"a11y.css": "^5.3.0", "a11y.css": "^5.3.0",
@ -132,6 +133,7 @@
"normalize-diacritics": "^5.0.0", "normalize-diacritics": "^5.0.0",
"nyc": "^17.1.0", "nyc": "^17.1.0",
"object-path": "^0.11.8", "object-path": "^0.11.8",
"qrcode-svg": "^1.1.0",
"qs": "^6.12.0", "qs": "^6.12.0",
"sealious": "^0.21.30", "sealious": "^0.21.30",
"slug": "^9.1.0", "slug": "^9.1.0",

View File

@ -0,0 +1,57 @@
.payment-details {
display: grid;
gap: 24px;
grid-template-columns: 2fr 1fr;
@container (max-width: 580px) {
grid-template-columns: 1fr;
}
.left,
.right {
background-color: var(--color-brand-canvas);
box-shadow: 4px 4px #00000026;
padding: 16px;
h2 {
margin: 0;
}
}
.left {
display: flex;
flex-flow: column;
gap: 24px;
&:first-child() {
flex-grow: 3;
}
& > * {
flex-grow: 1;
}
.copy-section {
label {
color: var(--color-brand-accent);
text-transform: uppercase;
font-weight: bold;
font-size: 16px;
button {
background: none;
border: none;
cursor: pointer;
svg {
height: 16px;
width: 16px;
}
}
}
.copy-value {
font-size: 20px;
}
}
}
}

View File

@ -0,0 +1,84 @@
import { TempstreamJSX } from "tempstream";
import type { ComponentToHTMLArgs } from "@sealcode/jdd";
import { Component, ComponentArguments, insert_nbsp } from "@sealcode/jdd";
import { icon } from "src/back/icons.js";
import generateQRCached from "src/back/qr.js";
const component_arguments = {
title: new ComponentArguments.ShortText(),
recipient: new ComponentArguments.ShortText(),
account_no: new ComponentArguments.ShortText(),
payment_title: new ComponentArguments.ShortText(),
qr_title: new ComponentArguments.ShortText(),
qr_subtitle: new ComponentArguments.ShortText(),
} as const;
export class PaymentDetails extends Component<typeof component_arguments> {
getArguments() {
return component_arguments;
}
async toHTML({
args: { title, recipient, account_no, payment_title, qr_title, qr_subtitle },
classes,
}: ComponentToHTMLArgs<typeof component_arguments>): Promise<string> {
return (
<div class={["payment-details", ...classes]}>
<div class="left">
<h2>{insert_nbsp(title)}</h2>
<div class="copy-section" data-controller="copy">
<label>
Odbiorca{" "}
<button
data-action="click->copy#copy"
data-copy-target="button"
>
{icon("copy")}
</button>
</label>
<div class="copy-value" data-copy-target="content">
{recipient}
</div>
</div>
<div class="copy-section" data-controller="copy">
<label>
Numer rachunku{" "}
<button
data-action="click->copy#copy"
data-copy-target="button"
>
{icon("copy")}
</button>
</label>
<div class="copy-value" data-copy-target="content">
{account_no}
</div>
</div>
<div class="copy-section" data-controller="copy">
<label>
Tytuł przelewu{" "}
<button
data-action="click->copy#copy"
data-copy-target="button"
>
{icon("copy")}
</button>
</label>
<div class="copy-value" data-copy-target="content">
{payment_title}
</div>
</div>
</div>
<div class="right">
<h2>{qr_title}</h2>
<div class="qr">
{generateQRCached(
`|PL|${account_no.replaceAll(/[^0-9]/g, "")}|010000|${recipient.trim()}|${payment_title.trim()}|||`
)}
</div>
<p>{qr_subtitle}</p>
</div>
</div>
);
}
}

10
src/back/qr.ts Normal file
View File

@ -0,0 +1,10 @@
import { default as qrcode } from "qrcode-svg";
const qr_cache: Record<string, string> = {};
export default function generateQRCached(text: string) {
if (!qr_cache[text]) {
qr_cache[text] = new qrcode(text).svg();
}
return qr_cache[text];
}

View File

@ -16,6 +16,9 @@ application.register("map-with-pins", MapWithPins);
import { default as HorizontalScroller } from "./../back/routes/common/horizontal-scroller/horizontal-scroller.stimulus.js"; import { default as HorizontalScroller } from "./../back/routes/common/horizontal-scroller/horizontal-scroller.stimulus.js";
application.register("horizontal-scroller", HorizontalScroller); application.register("horizontal-scroller", HorizontalScroller);
import { default as Copy } from "./controllers/copy.stimulus.js";
application.register("copy", Copy);
import { default as AutogrowTextarea } from "./../../node_modules/@sealcode/jdd-editor/src/controllers/autogrow-textarea.stimulus.js"; import { default as AutogrowTextarea } from "./../../node_modules/@sealcode/jdd-editor/src/controllers/autogrow-textarea.stimulus.js";
application.register("autogrow-textarea", AutogrowTextarea); application.register("autogrow-textarea", AutogrowTextarea);

View File

@ -0,0 +1,85 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Controller } from "@hotwired/stimulus";
export default class CopyController extends Controller {
static targets = ["content", "button"];
declare contentTarget: HTMLElement;
declare buttonTarget: HTMLButtonElement;
private toast: HTMLDivElement | null = null;
private anchorName = `--copy-button-${crypto.randomUUID()}`;
connect() {
// Anchor the toast to the copy button.
(this.buttonTarget.style as any).anchorName = this.anchorName;
}
async copy() {
const value = this.contentTarget.textContent.trim();
if (!value) return;
try {
await navigator.clipboard.writeText(value);
this.showToast();
} catch (e) {
console.error(e);
this.showToast("Nie udało się skopiować");
}
}
private showToast(message = "Skopiowano!") {
this.removeToast();
const toast = document.createElement("div");
Object.assign(toast.style, {
position: "fixed",
positionAnchor: this.anchorName,
positionArea: "right",
marginLeft: "8px",
padding: "5px 8px",
borderRadius: "6px",
background: "#222",
color: "#fff",
fontSize: "12px",
lineHeight: "1",
whiteSpace: "nowrap",
opacity: "0",
transform: "translateX(-4px)",
transition: "opacity 120ms ease, transform 120ms ease",
pointerEvents: "none",
zIndex: "1000",
});
toast.textContent = message;
this.element.appendChild(toast);
requestAnimationFrame(() => {
toast.style.opacity = "1";
toast.style.transform = "translateX(0)";
});
this.toast = toast;
setTimeout(() => {
toast.style.opacity = "0";
toast.style.transform = "translateX(-4px)";
toast.addEventListener("transitionend", () => toast.remove(), {
once: true,
});
}, 1500);
}
private removeToast() {
this.toast?.remove();
this.toast = null;
}
}

41
src/icons/copy.svg Normal file
View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="32"
height="32"
fill="currentColor"
class="bi bi-copy"
viewBox="0 0 32 32"
version="1.1"
id="svg1"
sodipodi:docname="copy.svg"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="17.699693"
inkscape:cx="13.361814"
inkscape:cy="13.983294"
inkscape:window-width="1920"
inkscape:window-height="1048"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" />
<path
fill-rule="evenodd"
d="m 8,4 a 4,4 0 0 1 4,-4 h 16 a 4,4 0 0 1 4,4 v 16 a 4,4 0 0 1 -4,4 H 12 A 4,4 0 0 1 8,20 Z m 4,-2 a 2,2 0 0 0 -2,2 v 16 a 2,2 0 0 0 2,2 h 16 a 2,2 0 0 0 2,-2 V 4 A 2,2 0 0 0 28,2 Z m -8,8 a 2,2 0 0 0 -2,2 v 16 a 2,2 0 0 0 2,2 h 16 a 2,2 0 0 0 2,-2 v -2 h 2 v 2 a 4,4 0 0 1 -4,4 H 4 A 4,4 0 0 1 0,28 V 12 A 4,4 0 0 1 4,8 h 2 v 2 z"
id="path1"
style="stroke-width:1.99999" />
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB