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 storefrontCssUrlPrefix = '/shop/assets/css';
export const storefrontJsUrlPrefix = '/shop/assets/js';
export const getStorefrontPublicDir = (): string => export const getStorefrontPublicDir = (): string =>
path.join(__dirname, '..', 'modules', 'storefrontCore', 'public'); path.join(__dirname, '..', 'modules', 'storefrontCore', 'public');
export const getStorefrontImgDir = (): string => path.join(getStorefrontPublicDir(), 'img'); export const getStorefrontImgDir = (): string => path.join(getStorefrontPublicDir(), 'img');
export const getStorefrontCssDir = (): string => path.join(getStorefrontPublicDir(), 'css'); 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 { import {
getStorefrontCssDir, getStorefrontCssDir,
getStorefrontImgDir, getStorefrontImgDir,
getStorefrontJsDir,
storefrontCssUrlPrefix, storefrontCssUrlPrefix,
storefrontImgUrlPrefix storefrontImgUrlPrefix,
storefrontJsUrlPrefix
} from './config/storefrontAssetPaths'; } from './config/storefrontAssetPaths';
import { getPublicUploadsDir, publicUploadsUrlPrefix } from './config/uploadPaths'; import { getPublicUploadsDir, publicUploadsUrlPrefix } from './config/uploadPaths';
import { AppModule } from './AppModule'; import { AppModule } from './AppModule';
@@ -42,6 +44,10 @@ async function bootstrap() {
prefix: storefrontCssUrlPrefix prefix: storefrontCssUrlPrefix
}); });
app.useStaticAssets(getStorefrontJsDir(), {
prefix: storefrontJsUrlPrefix
});
app.use(cookieParser()); app.use(cookieParser());
if (nodeEnv === NodeEnv.Production) { if (nodeEnv === NodeEnv.Production) {
@@ -159,6 +159,26 @@
box-sizing: border-box; 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 { html {
scroll-padding-top: var(--sf-sticky-header-scroll-padding); scroll-padding-top: var(--sf-sticky-header-scroll-padding);
} }
@@ -629,6 +649,10 @@ a:hover {
border-color 0.1s; border-color 0.1s;
} }
.sf-btn.sf-js-only {
display: none;
}
.sf-btn:hover { .sf-btn:hover {
color: var(--sf-accent); color: var(--sf-accent);
background: var(--sf-accent-subtle); background: var(--sf-accent-subtle);
@@ -1263,7 +1287,7 @@ a:hover {
.sf-qr-wrap { .sf-qr-wrap {
text-align: center; text-align: center;
margin-bottom: var(--sf-space-4); margin: var(--sf-space-4) 0;
} }
.sf-tx-list { .sf-tx-list {
@@ -1302,6 +1326,64 @@ a:hover {
font-size: 0.9rem; 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 { .sf-order-status {
font-weight: 600; font-weight: 600;
text-transform: capitalize; 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: 'cart-line-item', file: 'cart-line-item.hbs' },
{ name: 'checkout-line-item', file: 'checkout-line-item.hbs' }, { name: 'checkout-line-item', file: 'checkout-line-item.hbs' },
{ name: 'confirm-action-details', file: 'confirm-action-details.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: 'dismiss-button', file: 'dismiss-button.hbs' },
{ name: 'order-line-item', file: 'order-line-item.hbs' }, { name: 'order-line-item', file: 'order-line-item.hbs' },
{ name: 'order-digital-delivery', file: 'order-digital-delivery.hbs' }, { name: 'order-digital-delivery', file: 'order-digital-delivery.hbs' },
@@ -45,3 +45,5 @@
</div> </div>
</aside> </aside>
</div> </div>
<script src='/shop/assets/js/copy.js' defer></script>
@@ -1,6 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html <html
lang='en' lang='en'
class='no-js'
{{> html-theme-attributes}} {{> html-theme-attributes}}
> >
<head> <head>
@@ -1,6 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html <html
lang='en' lang='en'
class='no-js'
{{> html-theme-attributes}} {{> html-theme-attributes}}
> >
<head> <head>
@@ -69,3 +69,5 @@
{{/if}} {{/if}}
</aside> </aside>
</div> </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}}
{{#if showProminentAmount}} {{#if showProminentAmount}}
<p class='sf-payment-amount'>{{expectedTotalCrypto}} {{paymentLabel}}</p> <p class='sf-payment-amount'>{{instructionAmountCrypto}} {{paymentLabel}}</p>
{{/if}} {{/if}}
{{#if showReceivedTotal}} {{#if showReceivedTotal}}
@@ -23,7 +23,13 @@
{{#if showInstruction}} {{#if showInstruction}}
<p class='sf-payment-detail'> <p class='sf-payment-detail'>
{{instructionPrefix}} {{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}} {{instructionSuffix}}
{{#if showExpiry}} {{#if showExpiry}}
Payment expires in Payment expires in
@@ -40,7 +46,13 @@
{{#if showAddress}} {{#if showAddress}}
<p class='sf-field__label'>{{#if showQr}}Address{{else}}Payment address{{/if}}</p> <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}}
{{#if showPayments}} {{#if showPayments}}
@@ -1,6 +1,6 @@
<section id='order-chat' class='sf-chat-panel'> <section id='order-chat' class='sf-chat-panel'>
<div class='sf-chat-header'> <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}} {{> refresh-link href=order.chatRefreshHref}}
</div> </div>
@@ -14,6 +14,7 @@
<meta http-equiv='refresh' content='{{refreshSec}}' /> <meta http-equiv='refresh' content='{{refreshSec}}' />
{{/if}} {{/if}}
<link rel='stylesheet' href='/shop/assets/css/storefront.css' /> <link rel='stylesheet' href='/shop/assets/css/storefront.css' />
<script src='/shop/assets/js/js-enabled.js'></script>
{{> shop-favicon}} {{> shop-favicon}}
{{#if pageMeta.productJsonLd}} {{#if pageMeta.productJsonLd}}
<script type='application/ld+json'>{{{pageMeta.productJsonLd}}}</script> <script type='application/ld+json'>{{{pageMeta.productJsonLd}}}</script>