From 1105cc947fc4a5e9f7f437d850c5c40d54511bfd Mon Sep 17 00:00:00 2001 From: Kuba Orlik Date: Mon, 10 Aug 2026 23:59:47 +0200 Subject: [PATCH] Add payment details component --- package-lock.json | 25 ++++++ package.json | 2 + .../payment-details/payment-details.css | 57 +++++++++++++ .../payment-details/payment-details.jdd.tsx | 84 ++++++++++++++++++ src/back/qr.ts | 10 +++ src/front/controllers.ts | 3 + src/front/controllers/copy.stimulus.ts | 85 +++++++++++++++++++ src/icons/copy.svg | 41 +++++++++ 8 files changed, 307 insertions(+) create mode 100644 src/back/jdd-components/payment-details/payment-details.css create mode 100644 src/back/jdd-components/payment-details/payment-details.jdd.tsx create mode 100644 src/back/qr.ts create mode 100644 src/front/controllers/copy.stimulus.ts create mode 100644 src/icons/copy.svg diff --git a/package-lock.json b/package-lock.json index 4b7ab88..6117c97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@types/kill-port": "^2.0.0", "@types/leaflet": "^1.9.8", "@types/object-hash": "^3.0.6", + "@types/qrcode-svg": "^1.1.5", "@types/simplemde": "^1.11.11", "@types/wcag-contrast": "^3.0.3", "a11y.css": "^5.3.0", @@ -49,6 +50,7 @@ "normalize-diacritics": "^5.0.0", "nyc": "^17.1.0", "object-path": "^0.11.8", + "qrcode-svg": "^1.1.0", "qs": "^6.12.0", "sealious": "^0.21.30", "slug": "^9.1.0", @@ -1962,6 +1964,11 @@ "resolved": "https://registry.npmjs.org/@types/object-path/-/object-path-0.11.4.tgz", "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": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -11369,6 +11376,14 @@ "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": { "version": "6.14.0", "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", "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": { "version": "6.14.0", "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", "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": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", diff --git a/package.json b/package.json index a4a7de4..97533b3 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,7 @@ "@types/kill-port": "^2.0.0", "@types/leaflet": "^1.9.8", "@types/object-hash": "^3.0.6", + "@types/qrcode-svg": "^1.1.5", "@types/simplemde": "^1.11.11", "@types/wcag-contrast": "^3.0.3", "a11y.css": "^5.3.0", @@ -132,6 +133,7 @@ "normalize-diacritics": "^5.0.0", "nyc": "^17.1.0", "object-path": "^0.11.8", + "qrcode-svg": "^1.1.0", "qs": "^6.12.0", "sealious": "^0.21.30", "slug": "^9.1.0", diff --git a/src/back/jdd-components/payment-details/payment-details.css b/src/back/jdd-components/payment-details/payment-details.css new file mode 100644 index 0000000..d8afbf1 --- /dev/null +++ b/src/back/jdd-components/payment-details/payment-details.css @@ -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; + } + } + } +} diff --git a/src/back/jdd-components/payment-details/payment-details.jdd.tsx b/src/back/jdd-components/payment-details/payment-details.jdd.tsx new file mode 100644 index 0000000..ad8ee80 --- /dev/null +++ b/src/back/jdd-components/payment-details/payment-details.jdd.tsx @@ -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 { + getArguments() { + return component_arguments; + } + + async toHTML({ + args: { title, recipient, account_no, payment_title, qr_title, qr_subtitle }, + classes, + }: ComponentToHTMLArgs): Promise { + return ( +
+
+

{insert_nbsp(title)}

+
+ +
+ {recipient} +
+
+
+ +
+ {account_no} +
+
+
+ +
+ {payment_title} +
+
+
+
+

{qr_title}

+
+ {generateQRCached( + `|PL|${account_no.replaceAll(/[^0-9]/g, "")}|010000|${recipient.trim()}|${payment_title.trim()}|||` + )} +
+

{qr_subtitle}

+
+
+ ); + } +} diff --git a/src/back/qr.ts b/src/back/qr.ts new file mode 100644 index 0000000..2af9bf3 --- /dev/null +++ b/src/back/qr.ts @@ -0,0 +1,10 @@ +import { default as qrcode } from "qrcode-svg"; + +const qr_cache: Record = {}; + +export default function generateQRCached(text: string) { + if (!qr_cache[text]) { + qr_cache[text] = new qrcode(text).svg(); + } + return qr_cache[text]; +} diff --git a/src/front/controllers.ts b/src/front/controllers.ts index 25e0910..af7e535 100644 --- a/src/front/controllers.ts +++ b/src/front/controllers.ts @@ -16,6 +16,9 @@ application.register("map-with-pins", MapWithPins); import { default as HorizontalScroller } from "./../back/routes/common/horizontal-scroller/horizontal-scroller.stimulus.js"; 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"; application.register("autogrow-textarea", AutogrowTextarea); diff --git a/src/front/controllers/copy.stimulus.ts b/src/front/controllers/copy.stimulus.ts new file mode 100644 index 0000000..09dee2b --- /dev/null +++ b/src/front/controllers/copy.stimulus.ts @@ -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; + } +} diff --git a/src/icons/copy.svg b/src/icons/copy.svg new file mode 100644 index 0000000..c934514 --- /dev/null +++ b/src/icons/copy.svg @@ -0,0 +1,41 @@ + + + + + +