Compare commits

..
9 Commits
Author SHA1 Message Date
nobswebdev 08a0bdcdb8 Include www subdomain in clearnet TLS issuance.
Request apex and www from Let's Encrypt and serve both names in nginx.
2026-09-10 16:50:26 +02:00
nobswebdev 93c132b0df Fix SimpleX branding in shop footer link label. 2026-09-09 16:33:04 +02:00
nobswebdev 771d95d1cd Stack and center the shop header on mobile.
Column-layout logo, nav, and crypto rates with full-width wrapping for many order links.
2026-09-09 16:27:58 +02:00
nobswebdev 254736356b Show storefront feedback as fixed top-center toasts.
Use CSS-only dismiss animations, render the partial at the end of body, and add top margin to cart issue messages.
2026-09-09 16:07:58 +02:00
nobswebdev 738e406725 Rename sf-discount-error to sf-cart-issue for cart validation messages.
The class is used for stock, discount, and cart-total issues—not only discounts.
2026-09-09 15:38:15 +02:00
nobswebdev de4630635d Merge pull request 'restore scroll position after adding to cart' (#7) from slave/storefront-products-index-add-to-cart-ux-improvement into master
Reviewed-on: #7
2026-09-09 11:45:50 +00:00
nobswebdev 6b358011b0 restore scroll position after adding to cart 2026-09-09 13:44:25 +02:00
nobswebdev 8729098f20 Merge pull request 'Require min 1 confirmation in payment tier config.' (#6) from slave/get-rid-of-mempool-conf-tier into master
Reviewed-on: #6
2026-09-08 21:33:26 +00:00
nobswebdev deb5f8c2c3 Require min 1 confirmation in payment tier config.
Disallow minConfirmations: 0 for BTC and XMR tiers at startup validation, update env examples and deploy docs, and drop tx-detected UI copy and legacy tests.
2026-09-08 23:31:23 +02:00
19 changed files with 153 additions and 78 deletions
+2 -2
View File
@@ -90,7 +90,7 @@ BASE64_ENCRYPTION_KEY="nyRya1KpYSQ+drpO132mkOEMUR+uq6K7tWvpMfppIME=" # Generate
PAYMENT_METHODS_ENABLED=xmr,btc PAYMENT_METHODS_ENABLED=xmr,btc
MONERO_CONFIRMATION_TIERS='[{"upToTotalFiat":"30","minConfirmations":0},{"upToTotalFiat":"100","minConfirmations":3},{"upToTotalFiat":"300","minConfirmations":5},{"minConfirmations":10}]' MONERO_CONFIRMATION_TIERS='[{"upToTotalFiat":"30","minConfirmations":1},{"upToTotalFiat":"100","minConfirmations":3},{"upToTotalFiat":"300","minConfirmations":5},{"minConfirmations":10}]'
MONERO_VERSION=0.18.3.4 MONERO_VERSION=0.18.3.4
MONERO_NETWORK=stagenet MONERO_NETWORK=stagenet
MONERO_DAEMON_ADDRESS=xmr-lux.boldsuck.org:38081 MONERO_DAEMON_ADDRESS=xmr-lux.boldsuck.org:38081
@@ -104,7 +104,7 @@ MONERO_WALLET_NAME=shop
MONERO_WALLET_PASSWORD=change-me MONERO_WALLET_PASSWORD=change-me
MONERO_MIN_INCOMING_ATOMIC=10000000 # 0.00001 XMR (~half a USD cent at that moment) MONERO_MIN_INCOMING_ATOMIC=10000000 # 0.00001 XMR (~half a USD cent at that moment)
BITCOIN_CONFIRMATION_TIERS='[{"upToTotalFiat":"30","minConfirmations":0},{"upToTotalFiat":"100","minConfirmations":1},{"upToTotalFiat":"300","minConfirmations":3},{"minConfirmations":6}]' BITCOIN_CONFIRMATION_TIERS='[{"upToTotalFiat":"30","minConfirmations":1},{"upToTotalFiat":"100","minConfirmations":3},{"upToTotalFiat":"300","minConfirmations":5},{"minConfirmations":6}]'
BITCOIN_MIN_INCOMING_ATOMIC=7 # 0.00000007 BTC (~half a USD cent at that moment) BITCOIN_MIN_INCOMING_ATOMIC=7 # 0.00000007 BTC (~half a USD cent at that moment)
ELECTRUM_VERSION=4.8.1 ELECTRUM_VERSION=4.8.1
@@ -100,7 +100,7 @@ export class StorefrontCartController {
text: 'Added to cart.' text: 'Added to cart.'
}); });
res.redirect(HttpStatus.FOUND, safeInternalShopRedirectPath(req)); res.redirect(HttpStatus.FOUND, this.buildAddToCartRedirectPath(req, variantId));
} }
@Post('shop/cart/product/update') @Post('shop/cart/product/update')
@@ -189,4 +189,18 @@ export class StorefrontCartController {
this.cartCookieService.setCart(req, res, cartMutation); this.cartCookieService.setCart(req, res, cartMutation);
} }
} }
private buildAddToCartRedirectPath(req: Request, variantId: string): string {
const redirectPath = safeInternalShopRedirectPath(req);
const pathname = redirectPath.split('?')[0] ?? redirectPath;
const isProductsIndexPath = pathname === '/' || pathname.startsWith('/shop/categories/');
if (!isProductsIndexPath) {
return redirectPath;
}
const anchor = `product-card-${variantId}`;
return `${redirectPath}#${anchor}`;
}
} }
@@ -49,6 +49,7 @@
--sf-split-sidebar: minmax(220px, 320px); --sf-split-sidebar: minmax(220px, 320px);
--sf-product-card-min: 11rem; --sf-product-card-min: 11rem;
--sf-product-card-max: 22rem; --sf-product-card-max: 22rem;
--sf-sticky-header-scroll-padding: 9rem;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@@ -158,6 +159,10 @@
box-sizing: border-box; box-sizing: border-box;
} }
html {
scroll-padding-top: var(--sf-sticky-header-scroll-padding);
}
body.sf-body { body.sf-body {
margin: 0; margin: 0;
min-height: 100vh; min-height: 100vh;
@@ -288,9 +293,13 @@ a:hover {
} }
.sf-header { .sf-header {
position: sticky;
top: 0;
z-index: 10;
margin-bottom: var(--sf-space-4); margin-bottom: var(--sf-space-4);
padding: var(--sf-space-3) 0; padding: var(--sf-space-3) 0;
border-bottom: 1px solid var(--sf-border); border-bottom: 1px solid var(--sf-border);
background: var(--sf-bg);
} }
.sf-header__inner { .sf-header__inner {
@@ -371,6 +380,26 @@ a:hover {
white-space: nowrap; white-space: nowrap;
} }
@media (max-width: 768px) {
.sf-header__inner {
flex-direction: column;
align-items: center;
justify-content: center;
}
.sf-header__brand-group {
flex-direction: column;
align-items: center;
}
.sf-nav,
.sf-rates {
width: 100%;
max-width: 100%;
justify-content: center;
}
}
.sf-crypto-icon { .sf-crypto-icon {
width: 1.25rem; width: 1.25rem;
height: 1.25rem; height: 1.25rem;
@@ -510,6 +539,76 @@ a:hover {
border-color: var(--sf-warning-border); border-color: var(--sf-warning-border);
} }
.sf-alert--toast {
position: fixed;
top: var(--sf-space-5);
left: 50%;
z-index: 20;
display: inline-flex;
align-items: center;
gap: var(--sf-space-2);
width: max-content;
max-width: min(24rem, calc(100% - 2 * var(--sf-space-4)));
margin: 0;
padding: 0.6875rem 0.9375rem;
font-size: 0.875rem;
line-height: 1.25;
box-shadow: 0 2px 12px rgb(0 0 0 / 10%);
pointer-events: none;
transform: translateX(-50%);
}
.sf-alert--toast.sf-alert--success {
animation: sf-toast-dismiss 3s ease forwards;
}
.sf-alert--toast.sf-alert--error {
animation: sf-toast-dismiss 6s ease forwards;
}
.sf-alert--toast::before {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
border-radius: var(--sf-radius-full);
color: var(--sf-on-accent);
font-size: 0.6875rem;
font-weight: 700;
line-height: 1;
}
.sf-alert--toast.sf-alert--success::before {
content: '✓';
background: var(--sf-success);
}
.sf-alert--toast.sf-alert--error::before {
content: '×';
background: var(--sf-error);
}
@keyframes sf-toast-dismiss {
0%,
70% {
opacity: 1;
visibility: visible;
}
100% {
opacity: 0;
visibility: hidden;
}
}
@media (prefers-reduced-motion: reduce) {
.sf-alert--toast {
animation: none;
}
}
.sf-btn { .sf-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -789,7 +888,7 @@ a:hover {
margin: 0; margin: 0;
} }
.sf-line-item__body > .sf-discount-error { .sf-line-item__body > .sf-cart-issue {
margin: 0.125rem 0 0; margin: 0.125rem 0 0;
} }
@@ -866,8 +965,8 @@ a:hover {
color: var(--sf-text-muted); color: var(--sf-text-muted);
} }
.sf-discount-error { .sf-cart-issue {
margin: 0 0 var(--sf-space-2); margin: var(--sf-space-2) 0;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--sf-error); color: var(--sf-error);
line-height: 1.35; line-height: 1.35;
@@ -12,10 +12,10 @@
{{> shop-nav}} {{> shop-nav}}
{{> category-nav}} {{> category-nav}}
<main class='sf-main'> <main class='sf-main'>
{{> feedback}}
{{{body}}} {{{body}}}
</main> </main>
{{> shop-footer}} {{> shop-footer}}
</div> </div>
{{> feedback}}
</body> </body>
</html> </html>
@@ -28,7 +28,7 @@
{{@root.shopFiatCurrency}}</div> {{@root.shopFiatCurrency}}</div>
<div class='sf-line-item__note'>{{> product-delivery-note deliveryMode=deliveryMode}}</div> <div class='sf-line-item__note'>{{> product-delivery-note deliveryMode=deliveryMode}}</div>
{{#if stockIssueMessage}} {{#if stockIssueMessage}}
<p class='sf-discount-error'>{{stockIssueMessage}}</p> <p class='sf-cart-issue'>{{stockIssueMessage}}</p>
{{else}} {{else}}
{{#unless stockForSession}} {{#unless stockForSession}}
<p class='sf-text-subtle'>No more in stock beside your order</p> <p class='sf-text-subtle'>No more in stock beside your order</p>
@@ -36,7 +36,7 @@
{{/if}} {{/if}}
{{#each @root.discounts}} {{#each @root.discounts}}
{{#if (includes ineligibleVariantIds ../id)}} {{#if (includes ineligibleVariantIds ../id)}}
<p class='sf-discount-error'>Not eligible for code {{code}}</p> <p class='sf-cart-issue'>Not eligible for code {{code}}</p>
{{/if}} {{/if}}
{{/each}} {{/each}}
<form class='sf-form-row' method='post' action='/shop/cart/product/update'> <form class='sf-form-row' method='post' action='/shop/cart/product/update'>
@@ -23,7 +23,7 @@
}} }}
<span class='sf-discount-row__code sf-text-error'>{{code}}</span> <span class='sf-discount-row__code sf-text-error'>{{code}}</span>
</div> </div>
<p class='sf-discount-error'>{{issueMessage}}</p> <p class='sf-cart-issue'>{{issueMessage}}</p>
{{else if amount}} {{else if amount}}
<div class='sf-discount-row sf-discount-item'> <div class='sf-discount-row sf-discount-item'>
{{> dismiss-button {{> dismiss-button
@@ -62,7 +62,7 @@
</form> </form>
{{#if cartTotalIssueMessage}} {{#if cartTotalIssueMessage}}
<p class='sf-discount-error'>{{cartTotalIssueMessage}}</p> <p class='sf-cart-issue'>{{cartTotalIssueMessage}}</p>
{{/if}} {{/if}}
<div class='sf-stack sf-mt-4'> <div class='sf-stack sf-mt-4'>
@@ -1,5 +1,5 @@
{{#if feedback}} {{#if feedback}}
<p class='sf-alert sf-alert--{{feedback.type}}' role='status'> <p class='sf-alert sf-alert--{{feedback.type}} sf-alert--toast' role='status'>
{{feedback.text}} {{feedback.text}}
</p> </p>
{{/if}} {{/if}}
@@ -1,4 +1,4 @@
<li class='sf-card sf-product-card'> <li class='sf-card sf-product-card' id='product-card-{{selectedVariant.id}}'>
{{#with selectedVariant}} {{#with selectedVariant}}
{{#if thumbnailUrl}} {{#if thumbnailUrl}}
<a class='sf-product-card__media-link' href='{{detailHref}}'> <a class='sf-product-card__media-link' href='{{detailHref}}'>
@@ -2,7 +2,7 @@
<div class='sf-footer__row'> <div class='sf-footer__row'>
<div class='sf-footer__start'> <div class='sf-footer__start'>
{{#if simplexLink}} {{#if simplexLink}}
<a href='{{simplexLink}}' target='_blank' rel='noopener noreferrer'>Contact via Simplex</a> <a href='{{simplexLink}}' target='_blank' rel='noopener noreferrer'>Contact via SimpleX</a>
{{/if}} {{/if}}
<a href='https://nullcart.net/' target='_blank' rel='noopener noreferrer'>Powered by NullCart</a> <a href='https://nullcart.net/' target='_blank' rel='noopener noreferrer'>Powered by NullCart</a>
</div> </div>
@@ -1,14 +1,14 @@
import { resolveMinConfirmations } from './resolveMinConfirmations'; import { resolveMinConfirmations } from './resolveMinConfirmations';
const tiers = [ const tiers = [
{ upToTotalFiat: '25', minConfirmations: 0 }, { upToTotalFiat: '25', minConfirmations: 1 },
{ upToTotalFiat: '250', minConfirmations: 5 }, { upToTotalFiat: '250', minConfirmations: 5 },
{ minConfirmations: 10 } { minConfirmations: 10 }
] as const; ] as const;
describe('resolveMinConfirmations', () => { describe('resolveMinConfirmations', () => {
it('returns 0 for small orders (tx-detected tier)', () => { it('returns the first tier for small orders', () => {
expect(resolveMinConfirmations(10, [...tiers])).toBe(0); expect(resolveMinConfirmations(10, [...tiers])).toBe(1);
}); });
it('returns the middle tier for medium orders', () => { it('returns the middle tier for medium orders', () => {
@@ -35,14 +35,14 @@ describe('formatInvoicePaymentConfirmationStatus', () => {
).toBe('2/10'); ).toBe('2/10');
}); });
it('treats zero-confirmation tiers as confirmed', () => { it('returns compact progress at zero confirmations', () => {
expect( expect(
formatInvoicePaymentConfirmationStatus({ formatInvoicePaymentConfirmationStatus({
confirmations: 0, confirmations: 0,
requiredConfirmations: 0, requiredConfirmations: 3,
format: 'compact' format: 'compact'
}) })
).toBe('Confirmed'); ).toBe('0/3');
}); });
}); });
@@ -1,9 +1,6 @@
import { formatRelativeTimeAgo } from '../formatRelativeTimeAgo'; import { formatRelativeTimeAgo } from '../formatRelativeTimeAgo';
import type { InvoicePaymentConfirmationStatusFormat } from './types/InvoicePaymentConfirmationStatusFormat'; import type { InvoicePaymentConfirmationStatusFormat } from './types/InvoicePaymentConfirmationStatusFormat';
const formatRequiredConfirmationsLabel = (requiredConfirmations: number): string =>
requiredConfirmations === 0 ? '0 (tx-detected)' : String(requiredConfirmations);
export const formatInvoicePaymentConfirmationStatus = ({ export const formatInvoicePaymentConfirmationStatus = ({
confirmations, confirmations,
requiredConfirmations, requiredConfirmations,
@@ -29,5 +26,5 @@ export const formatInvoicePaymentConfirmationStatus = ({
return `${confirmations} / ${requiredConfirmations} confirmations · detected ${detectedAgo}`; return `${confirmations} / ${requiredConfirmations} confirmations · detected ${detectedAgo}`;
} }
return `${confirmations}/${formatRequiredConfirmationsLabel(requiredConfirmations)}`; return `${confirmations}/${requiredConfirmations}`;
}; };
@@ -463,19 +463,6 @@ describe('toStorefrontInvoiceView', () => {
statusVariant: 'confirmed' statusVariant: 'confirmed'
}); });
}); });
it('treats tx-detected invoices as confirmed for payment status display', async () => {
const payment = buildPayment({ confirmations: 0 });
const invoice = buildInvoice({
moneroDetails: buildMoneroDetails({ requiredConfirmations: 0 }),
payments: [payment]
});
const view = await toStorefrontInvoiceView(invoice);
expect(view.showRefresh).toBe(false);
expect(view.payments[0].confirmationStatus).toBe('Confirmed');
});
}); });
describe('overpayment', () => { describe('overpayment', () => {
@@ -13,20 +13,13 @@ const validateTiers = (value: string) => {
}; };
const validTiers = const validTiers =
'[{"upToTotalFiat":"25","minConfirmations":0},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]'; '[{"upToTotalFiat":"25","minConfirmations":1},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
describe('IsConfirmationTiers', () => { describe('IsConfirmationTiers', () => {
it('accepts valid default tiers', () => { it('accepts valid default tiers', () => {
expect(validateTiers(validTiers)).toHaveLength(0); expect(validateTiers(validTiers)).toHaveLength(0);
}); });
it('accepts numeric-only tiers without tx-detected (0)', () => {
const tiers =
'[{"upToTotalFiat":"25","minConfirmations":1},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
expect(validateTiers(tiers)).toHaveLength(0);
});
it('rejects empty string', () => { it('rejects empty string', () => {
expect(validateTiers('').length).toBeGreaterThan(0); expect(validateTiers('').length).toBeGreaterThan(0);
}); });
@@ -39,22 +32,16 @@ describe('IsConfirmationTiers', () => {
expect(validateTiers('[]').length).toBeGreaterThan(0); expect(validateTiers('[]').length).toBeGreaterThan(0);
}); });
it('rejects tx-detected (0) more than once', () => { it('rejects minConfirmations: 0', () => {
const tiers = const tiers =
'[{"upToTotalFiat":"25","minConfirmations":0},{"upToTotalFiat":"250","minConfirmations":0},{"minConfirmations":10}]'; '[{"upToTotalFiat":"25","minConfirmations":0},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
expect(validateTiers(tiers).length).toBeGreaterThan(0); expect(validateTiers(tiers).length).toBeGreaterThan(0);
}); });
it('rejects tx-detected (0) on catch-all tier', () => { it('rejects non-numeric minConfirmations', () => {
const tiers = '[{"upToTotalFiat":"25","minConfirmations":1},{"minConfirmations":0}]';
expect(validateTiers(tiers).length).toBeGreaterThan(0);
});
it('rejects legacy tx-detected string', () => {
const tiers = const tiers =
'[{"upToTotalFiat":"25","minConfirmations":"tx-detected"},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]'; '[{"upToTotalFiat":"25","minConfirmations":"foo"},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
expect(validateTiers(tiers).length).toBeGreaterThan(0); expect(validateTiers(tiers).length).toBeGreaterThan(0);
}); });
@@ -14,7 +14,7 @@ const isPositiveDecimalString = (value: string): boolean => {
}; };
const isMinConfirmations = (value: unknown): boolean => const isMinConfirmations = (value: unknown): boolean =>
typeof value === 'number' && Number.isInteger(value) && value >= 0; typeof value === 'number' && Number.isInteger(value) && value >= 1;
const isConfirmationTier = (value: unknown): value is ConfirmationTier => { const isConfirmationTier = (value: unknown): value is ConfirmationTier => {
if (typeof value !== 'object' || value === null) { if (typeof value !== 'object' || value === null) {
@@ -48,22 +48,12 @@ const isValidConfirmationTiersJson = (raw: string): boolean => {
} }
const tiers = parsed; const tiers = parsed;
const txDetectedTierCount = tiers.filter(tier => tier.minConfirmations === 0).length;
if (txDetectedTierCount > 1) {
return false;
}
const lastTier = tiers[tiers.length - 1]; const lastTier = tiers[tiers.length - 1];
if (lastTier.upToTotalFiat !== undefined) { if (lastTier.upToTotalFiat !== undefined) {
return false; return false;
} }
if (lastTier.minConfirmations === 0) {
return false;
}
for (let index = 0; index < tiers.length - 1; index++) { for (let index = 0; index < tiers.length - 1; index++) {
const tier = tiers[index]; const tier = tiers[index];
@@ -86,7 +76,7 @@ class IsConfirmationTiersConstraint implements ValidatorConstraintInterface {
} }
defaultMessage(): string { defaultMessage(): string {
return '$property must be a non-empty JSON array of confirmation tiers; minConfirmations must be 0 (tx-detected) or an integer >= 1, 0 may appear only once and not on the catch-all tier, non-final tiers need a positive upToTotalFiat in shop fiat currency, and the last tier must be a catch-all without upToTotalFiat'; return '$property must be a non-empty JSON array of confirmation tiers; minConfirmations must be an integer >= 1, non-final tiers need a positive upToTotalFiat in shop fiat currency, and the last tier must be a catch-all without upToTotalFiat';
} }
} }
+1 -1
View File
@@ -468,7 +468,7 @@ const submitShippingNote = async (): Promise<void> => {
}; };
const formatConfirmationTier = (tier: ConfirmationTier, currency: string): string => { const formatConfirmationTier = (tier: ConfirmationTier, currency: string): string => {
const requirement = tier.minConfirmations === 0 ? '0 (tx-detected)' : `${tier.minConfirmations} confirmations`; const requirement = `${tier.minConfirmations} confirmations`;
if (tier.upToTotalFiat === undefined) { if (tier.upToTotalFiat === undefined) {
return `Above previous tiers → ${requirement}`; return `Above previous tiers → ${requirement}`;
+5 -5
View File
@@ -3,7 +3,7 @@
## 1. Requirements ## 1. Requirements
- Ubuntu 22.04+ or similar Linux with Docker Engine and the Compose plugin — follow [Install Docker Engine on Ubuntu](https://docs.docker.com/engine/install/ubuntu/#install-using-the-repository). Tested and recommended on Ubuntu 22.04 LTS. - Ubuntu 22.04+ or similar Linux with Docker Engine and the Compose plugin — follow [Install Docker Engine on Ubuntu](https://docs.docker.com/engine/install/ubuntu/#install-using-the-repository). Tested and recommended on Ubuntu 22.04 LTS.
- A domain name pointing at your server (A record for clearnet HTTPS) - A domain name pointing at your server (A records for apex and `www`)
## 2. Server setup ## 2. Server setup
@@ -61,20 +61,20 @@ Edit `.env.prod`. Mandatory configuration:
| `VITE_API_BASE_URL` | `/api` | | `VITE_API_BASE_URL` | `/api` |
| `VITE_SHOP_FIAT_CURRENCY` | same as `SHOP_FIAT_CURRENCY` | | `VITE_SHOP_FIAT_CURRENCY` | same as `SHOP_FIAT_CURRENCY` |
Optional — adjust per-method payment confirmation rules (`MONERO_CONFIRMATION_TIERS`, `BITCOIN_CONFIRMATION_TIERS`). Each is a JSON array with the same shape. For each order, the shop uses `minConfirmations` from the first tier where the order total (in `SHOP_FIAT_CURRENCY`) is `<= upToTotalFiat`. The last tier is a catch-all and must omit `upToTotalFiat`. At most one tier may use `minConfirmations: 0` (accept unconfirmed / mempool); that tier cannot be the catch-all. Optional — adjust per-method payment confirmation rules (`MONERO_CONFIRMATION_TIERS`, `BITCOIN_CONFIRMATION_TIERS`). Each is a JSON array with the same shape. For each order, the shop uses `minConfirmations` from the first tier where the order total (in `SHOP_FIAT_CURRENCY`) is `<= upToTotalFiat`. The last tier is a catch-all and must omit `upToTotalFiat`. Every tier must use `minConfirmations` >= 1.
Monero example (default in `.env.example`): Monero example (default in `.env.example`):
```json ```json
[ [
{ "upToTotalFiat": "30", "minConfirmations": 0 }, { "upToTotalFiat": "30", "minConfirmations": 1 },
{ "upToTotalFiat": "100", "minConfirmations": 3 }, { "upToTotalFiat": "100", "minConfirmations": 3 },
{ "upToTotalFiat": "300", "minConfirmations": 5 }, { "upToTotalFiat": "300", "minConfirmations": 5 },
{ "minConfirmations": 10 } { "minConfirmations": 10 }
] ]
``` ```
Orders up to 30 → 0 confirmations; up to 100 → 3; up to 300 → 5; above 300 → 10. Tiers are shown read-only in CMS shop settings. Orders up to 30 → 1 confirmation; up to 100 → 3; up to 300 → 5; above 300 → 10. Tiers are shown read-only in CMS shop settings.
## 5. Bootstrap TLS certificates ## 5. Bootstrap TLS certificates
@@ -106,7 +106,7 @@ Remove the temporary bootstrap certificates under `deploy/certs/live/` (Certbot
rm -rf deploy/certs/live/* rm -rf deploy/certs/live/*
``` ```
Request the real certificate: Request the real certificate (apex + www):
```bash ```bash
./deploy/scripts/issue-certs.sh --email you@example.com ./deploy/scripts/issue-certs.sh --email you@example.com
+4 -3
View File
@@ -9,9 +9,9 @@ usage() {
cat <<EOF cat <<EOF
Usage: $(basename "$0") --email you@example.com Usage: $(basename "$0") --email you@example.com
Obtain or renew Let's Encrypt certificates for CLEARNET_DOMAIN using the webroot Obtain Let's Encrypt certificates for CLEARNET_DOMAIN and www.CLEARNET_DOMAIN using
challenge. Nginx must be running and serving /.well-known/acme-challenge/ from the webroot challenge. Nginx must be running and serving /.well-known/acme-challenge/
deploy/certbot/www. from deploy/certbot/www.
Environment is read from .env.prod (CLEARNET_DOMAIN). Environment is read from .env.prod (CLEARNET_DOMAIN).
EOF EOF
@@ -65,6 +65,7 @@ docker run --rm \
--webroot \ --webroot \
-w /var/www/certbot \ -w /var/www/certbot \
-d "$CLEARNET_DOMAIN" \ -d "$CLEARNET_DOMAIN" \
-d "www.${CLEARNET_DOMAIN}" \
--email "$CERTBOT_EMAIL" \ --email "$CERTBOT_EMAIL" \
--agree-tos \ --agree-tos \
--non-interactive --non-interactive
+2 -2
View File
@@ -1,6 +1,6 @@
server { server {
listen 80; listen 80;
server_name ${CLEARNET_DOMAIN}; server_name ${CLEARNET_DOMAIN} www.${CLEARNET_DOMAIN};
location /.well-known/acme-challenge/ { location /.well-known/acme-challenge/ {
root /var/www/certbot; root /var/www/certbot;
@@ -13,7 +13,7 @@ server {
server { server {
listen 443 ssl; listen 443 ssl;
server_name ${CLEARNET_DOMAIN}; server_name ${CLEARNET_DOMAIN} www.${CLEARNET_DOMAIN};
ssl_certificate /etc/nginx/certs/live/${CLEARNET_DOMAIN}/fullchain.pem; ssl_certificate /etc/nginx/certs/live/${CLEARNET_DOMAIN}/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/live/${CLEARNET_DOMAIN}/privkey.pem; ssl_certificate_key /etc/nginx/certs/live/${CLEARNET_DOMAIN}/privkey.pem;