Compare commits

..
2 Commits
Author SHA1 Message Date
nobswebdev 0345fe042e Add copy-to-clipboard for crypto payment amounts and addresses.
Mount storefront JS assets, hide copy controls without JavaScript, and load per-page copy handling on checkout and order views.
2026-09-14 15:20:17 +02:00
nobswebdev a9ffc57608 Add top margin to invoice payment QR code for even spacing. 2026-09-13 23:55:11 +02:00
14 changed files with 191 additions and 6 deletions
@@ -4,9 +4,13 @@ export const storefrontImgUrlPrefix = '/shop/assets/img';
export const storefrontCssUrlPrefix = '/shop/assets/css';
export const storefrontJsUrlPrefix = '/shop/assets/js';
export const getStorefrontPublicDir = (): string =>
path.join(__dirname, '..', 'modules', 'storefrontCore', 'public');
export const getStorefrontImgDir = (): string => path.join(getStorefrontPublicDir(), 'img');
export const getStorefrontCssDir = (): string => path.join(getStorefrontPublicDir(), 'css');
export const getStorefrontJsDir = (): string => path.join(getStorefrontPublicDir(), 'js');
+7 -1
View File
@@ -8,8 +8,10 @@ import path from 'node:path';
import {
getStorefrontCssDir,
getStorefrontImgDir,
getStorefrontJsDir,
storefrontCssUrlPrefix,
storefrontImgUrlPrefix
storefrontImgUrlPrefix,
storefrontJsUrlPrefix
} from './config/storefrontAssetPaths';
import { getPublicUploadsDir, publicUploadsUrlPrefix } from './config/uploadPaths';
import { AppModule } from './AppModule';
@@ -42,6 +44,10 @@ async function bootstrap() {
prefix: storefrontCssUrlPrefix
});
app.useStaticAssets(getStorefrontJsDir(), {
prefix: storefrontJsUrlPrefix
});
app.use(cookieParser());
if (nodeEnv === NodeEnv.Production) {
@@ -159,6 +159,26 @@
box-sizing: border-box;
}
.sf-js-only {
display: none;
}
.js .sf-js-only {
display: block;
}
.js .sf-js-only.sf-copy-btn {
display: inline-flex;
}
.sf-nojs-only {
display: block;
}
.js .sf-nojs-only {
display: none;
}
html {
scroll-padding-top: var(--sf-sticky-header-scroll-padding);
}
@@ -629,6 +649,10 @@ a:hover {
border-color 0.1s;
}
.sf-btn.sf-js-only {
display: none;
}
.sf-btn:hover {
color: var(--sf-accent);
background: var(--sf-accent-subtle);
@@ -1263,7 +1287,7 @@ a:hover {
.sf-qr-wrap {
text-align: center;
margin-bottom: var(--sf-space-4);
margin: var(--sf-space-4) 0;
}
.sf-tx-list {
@@ -1302,6 +1326,64 @@ a:hover {
font-size: 0.9rem;
}
.sf-copy-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sf-space-2);
}
.sf-copy-row .sf-payment-amount {
margin: 0;
}
.sf-copy-row--inline {
display: inline-flex;
vertical-align: baseline;
}
.sf-copy-row--address {
flex-wrap: nowrap;
align-items: center;
margin-bottom: var(--sf-space-4);
}
.sf-copy-row__value {
flex: 1 1 auto;
min-width: 0;
margin: 0;
word-break: break-all;
}
.sf-copy-btn {
flex-shrink: 0;
min-width: 1.375rem;
min-height: 1.375rem;
padding: 0.125rem;
}
.sf-copy-btn__icon {
display: block;
line-height: 0;
}
.sf-copy-btn__icon svg {
width: 0.875rem;
height: 0.875rem;
}
.sf-copy-btn__icon--success {
display: none;
}
.sf-copy-btn--copied .sf-copy-btn__icon--copy {
display: none;
}
.sf-copy-btn--copied .sf-copy-btn__icon--success {
display: block;
}
.sf-order-status {
font-weight: 600;
text-transform: capitalize;
@@ -0,0 +1,52 @@
(() => {
const copiedAriaLabel = 'Copied';
const resetMs = 2000;
const copyText = async text => {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
};
const handleCopyClick = async event => {
const button = event.currentTarget;
const text = button.dataset.copyText;
if (!text) {
return;
}
const defaultAriaLabel = button.dataset.copyAriaLabel ?? 'Copy';
try {
await copyText(text);
button.classList.add('sf-copy-btn--copied');
button.setAttribute('aria-label', copiedAriaLabel);
window.setTimeout(() => {
button.classList.remove('sf-copy-btn--copied');
button.setAttribute('aria-label', defaultAriaLabel);
}, resetMs);
} catch (error) {
console.error('Copy to clipboard failed', error);
}
};
for (const button of document.querySelectorAll('[data-action="copy"]')) {
button.addEventListener('click', handleCopyClick);
}
})();
@@ -0,0 +1 @@
document.documentElement.classList.replace('no-js', 'js');
@@ -12,6 +12,7 @@ const STOREFRONT_PARTIALS = [
{ name: 'cart-line-item', file: 'cart-line-item.hbs' },
{ name: 'checkout-line-item', file: 'checkout-line-item.hbs' },
{ name: 'confirm-action-details', file: 'confirm-action-details.hbs' },
{ name: 'copy-button', file: 'copy-button.hbs' },
{ name: 'dismiss-button', file: 'dismiss-button.hbs' },
{ name: 'order-line-item', file: 'order-line-item.hbs' },
{ name: 'order-digital-delivery', file: 'order-digital-delivery.hbs' },
@@ -45,3 +45,5 @@
</div>
</aside>
</div>
<script src='/shop/assets/js/copy.js' defer></script>
@@ -1,6 +1,7 @@
<!DOCTYPE html>
<html
lang='en'
class='no-js'
{{> html-theme-attributes}}
>
<head>
@@ -1,6 +1,7 @@
<!DOCTYPE html>
<html
lang='en'
class='no-js'
{{> html-theme-attributes}}
>
<head>
@@ -69,3 +69,5 @@
{{/if}}
</aside>
</div>
<script src='/shop/assets/js/copy.js' defer></script>
@@ -0,0 +1,20 @@
<button
type='button'
class='sf-btn sf-copy-btn sf-js-only'
data-action='copy'
data-copy-text='{{copyText}}'
data-copy-aria-label='{{ariaLabel}}'
aria-label='{{ariaLabel}}'
>
<span class='sf-copy-btn__icon sf-copy-btn__icon--copy' aria-hidden='true'>
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'>
<rect width='14' height='14' x='8' y='8' rx='2' ry='2' />
<path d='M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2' />
</svg>
</span>
<span class='sf-copy-btn__icon sf-copy-btn__icon--success' aria-hidden='true'>
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'>
<path d='M20 6 9 17l-5-5' />
</svg>
</span>
</button>
@@ -3,7 +3,7 @@
{{/if}}
{{#if showProminentAmount}}
<p class='sf-payment-amount'>{{expectedTotalCrypto}} {{paymentLabel}}</p>
<p class='sf-payment-amount'>{{instructionAmountCrypto}} {{paymentLabel}}</p>
{{/if}}
{{#if showReceivedTotal}}
@@ -23,7 +23,13 @@
{{#if showInstruction}}
<p class='sf-payment-detail'>
{{instructionPrefix}}
<strong>{{instructionAmountCrypto}} {{paymentLabel}}</strong>
<span class='sf-copy-row sf-copy-row--inline'>
<strong>{{instructionAmountCrypto}} {{paymentLabel}}</strong>
{{> copy-button
copyText=instructionAmountCrypto
ariaLabel='Copy payment amount'
}}
</span>
{{instructionSuffix}}
{{#if showExpiry}}
Payment expires in
@@ -40,7 +46,13 @@
{{#if showAddress}}
<p class='sf-field__label'>{{#if showQr}}Address{{else}}Payment address{{/if}}</p>
<p class='sf-mono sf-payment-detail'>{{paymentAddress}}</p>
<div class='sf-copy-row sf-copy-row--address'>
<p class='sf-mono sf-payment-detail sf-copy-row__value'>{{paymentAddress}}</p>
{{> copy-button
copyText=paymentAddress
ariaLabel='Copy payment address'
}}
</div>
{{/if}}
{{#if showPayments}}
@@ -1,6 +1,6 @@
<section id='order-chat' class='sf-chat-panel'>
<div class='sf-chat-header'>
<h2 class='sf-section-title'>Order chat (encrypted)</h2>
<h2 class='sf-section-title'>Order chat</h2>
{{> refresh-link href=order.chatRefreshHref}}
</div>
@@ -14,6 +14,7 @@
<meta http-equiv='refresh' content='{{refreshSec}}' />
{{/if}}
<link rel='stylesheet' href='/shop/assets/css/storefront.css' />
<script src='/shop/assets/js/js-enabled.js'></script>
{{> shop-favicon}}
{{#if pageMeta.productJsonLd}}
<script type='application/ld+json'>{{{pageMeta.productJsonLd}}}</script>