Compare commits

..
17 Commits
Author SHA1 Message Date
nobswebdev 155549a2ec Align CMS select dropdowns with their inputs using fit-input-width.
Remove global el-select popper width caps that caused mismatched dropdown sizing on full-width fields.
2026-09-11 21:27:17 +02:00
nobswebdev f1715f7e7f Switch shop nav from Cart to Checkout during an active checkout session.
Show item quantity on both nav states and highlight checkout while the user is on the payment page.
2026-09-11 20:22:22 +02:00
nobswebdev ffc9a0bbe5 Add system theme preference to the storefront.
Default to system when no theme cookie is set so pages follow OS appearance, and expose Light, Dark, and System in the theme switcher.
2026-09-11 19:15:46 +02:00
nobswebdev 77cd686347 Advertise the onion site on clearnet via Onion-Location header. 2026-09-11 15:26:03 +02:00
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
nobswebdev b3e683cd71 Merge pull request 'Slave/btc rbf edge case fix' (#5) from slave/btc-rbf-edge-case-fix into master
Reviewed-on: #5
2026-09-08 17:02:47 +00:00
nobswebdev 4458edb0d1 Prune stale unconfirmed invoice payments during wallet polling.
Refactor processInvoice into upsert and prune helpers so absent 0-conf payments are removed when the wallet no longer reports them.
2026-09-08 18:30:16 +02:00
nobswebdev dcd499664a Filter superseded Bitcoin transfers in Electrum wallet client.
Detect RBF conflicts via shared input outpoints so invoice polling no longer ingests both the original and replacement mempool transactions.
2026-09-08 18:30:06 +02:00
nobswebdev 0dcffb394e Cache storefront images aggressively while keeping CSS revalidatable.
Split static asset mounts by type and centralize paths in storefrontAssetPaths so icon URLs stay aligned with the server.
2026-09-07 14:06:44 +02:00
53 changed files with 679 additions and 187 deletions
+3 -2
View File
@@ -19,6 +19,7 @@ NODE_ENV=development
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
CLEARNET_DOMAIN=localhost
ONION_HOSTNAME=samplexxxxxxxxx.onion
JWT_SECRET=change-me-in-production
JWT_EXPIRES_IN_MS=604800000 # 7 days
@@ -90,7 +91,7 @@ BASE64_ENCRYPTION_KEY="nyRya1KpYSQ+drpO132mkOEMUR+uq6K7tWvpMfppIME=" # Generate
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_NETWORK=stagenet
MONERO_DAEMON_ADDRESS=xmr-lux.boldsuck.org:38081
@@ -104,7 +105,7 @@ MONERO_WALLET_NAME=shop
MONERO_WALLET_PASSWORD=change-me
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)
ELECTRUM_VERSION=4.8.1
@@ -0,0 +1,12 @@
import path from 'node:path';
export const storefrontImgUrlPrefix = '/shop/assets/img';
export const storefrontCssUrlPrefix = '/shop/assets/css';
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');
+3 -2
View File
@@ -1,6 +1,7 @@
import { storefrontImgUrlPrefix } from '../config/storefrontAssetPaths';
import { PaymentMethod } from '../modules/payment/types/PaymentMethod';
export const paymentMethodIconUrl: Record<PaymentMethod, string> = {
[PaymentMethod.Xmr]: '/shop/assets/img/xmr.png',
[PaymentMethod.Btc]: '/shop/assets/img/btc.png'
[PaymentMethod.Xmr]: `${storefrontImgUrlPrefix}/xmr.png`,
[PaymentMethod.Btc]: `${storefrontImgUrlPrefix}/btc.png`
};
@@ -0,0 +1 @@
export const STOREFRONT_THEME_PREFERENCES = ['light', 'dark', 'system'] as const;
+13 -3
View File
@@ -5,6 +5,12 @@ import { ConfigService } from '@nestjs/config';
import cookieParser from 'cookie-parser';
import hbs from 'hbs';
import path from 'node:path';
import {
getStorefrontCssDir,
getStorefrontImgDir,
storefrontCssUrlPrefix,
storefrontImgUrlPrefix
} from './config/storefrontAssetPaths';
import { getPublicUploadsDir, publicUploadsUrlPrefix } from './config/uploadPaths';
import { AppModule } from './AppModule';
import { registerStorefrontHelpers } from './modules/storefrontCore/utils/registerStorefrontHelpers';
@@ -26,10 +32,14 @@ async function bootstrap() {
immutable: true
});
const storefrontPublicDir = path.join(__dirname, 'modules', 'storefrontCore', 'public');
app.useStaticAssets(getStorefrontImgDir(), {
prefix: storefrontImgUrlPrefix,
maxAge: '1y',
immutable: true
});
app.useStaticAssets(storefrontPublicDir, {
prefix: '/shop/assets'
app.useStaticAssets(getStorefrontCssDir(), {
prefix: storefrontCssUrlPrefix
});
app.use(cookieParser());
@@ -34,13 +34,17 @@ describe('ElectrumWalletRpcClient', () => {
tx_hash: 'abc123',
height: 800_000
},
'50000',
{
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
},
'bc1qtest',
800_002
)
).toEqual({
txHash: 'abc123',
amountAtomic: '50000',
confirmations: 3
confirmations: 3,
inputOutpoints: []
});
});
@@ -51,13 +55,17 @@ describe('ElectrumWalletRpcClient', () => {
tx_hash: 'abc123',
height: 0
},
'50000',
{
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
},
'bc1qtest',
800_002
)
).toEqual({
txHash: 'abc123',
amountAtomic: '50000',
confirmations: 0
confirmations: 0,
inputOutpoints: []
});
});
@@ -68,11 +76,61 @@ describe('ElectrumWalletRpcClient', () => {
tx_hash: 'abc123',
height: 800_000
},
'0',
{
outputs: [{ address: 'bc1qother', value_sats: 10_000 }]
},
'bc1qtest',
800_002
)
).toBeNull();
});
it('maps input outpoints from transaction inputs', () => {
expect(
clientTest.mapIncomingTransfer(
{
tx_hash: 'abc123',
height: 800_000
},
{
inputs: [
{ prevout_hash: 'abc123', prevout_n: 0 },
{ prevout_hash: 'def456', prevout_n: 2 }
],
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
},
'bc1qtest',
800_002
)
).toEqual({
txHash: 'abc123',
amountAtomic: '50000',
confirmations: 3,
inputOutpoints: ['abc123:0', 'def456:2']
});
});
it('skips coinbase-like inputs without prevout data', () => {
expect(
clientTest.mapIncomingTransfer(
{
tx_hash: 'abc123',
height: 800_000
},
{
inputs: [{}, { prevout_hash: '', prevout_n: 0 }],
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
},
'bc1qtest',
800_002
)
).toEqual({
txHash: 'abc123',
amountAtomic: '50000',
confirmations: 3,
inputOutpoints: []
});
});
});
describe('sumOutputValueAtomic', () => {
@@ -125,6 +183,7 @@ describe('ElectrumWalletRpcClient', () => {
jsonrpc: '2.0',
id: 'nullcart',
result: {
inputs: [{ prevout_hash: 'input123', prevout_n: 0 }],
outputs: [
{ address: 'bc1qother', value_sats: 10_000 },
{ address: 'bc1qtest', value_sats: 50_000 }
@@ -137,7 +196,8 @@ describe('ElectrumWalletRpcClient', () => {
{
txHash: 'abc123',
amountAtomic: '50000',
confirmations: 3
confirmations: 3,
inputOutpoints: ['input123:0']
}
]);
@@ -164,6 +224,105 @@ describe('ElectrumWalletRpcClient', () => {
expect.any(Object)
);
});
it('drops superseded unconfirmed transfers that share inputs with a confirmed replacement', async () => {
mockedAxios.post
.mockResolvedValueOnce({
data: {
jsonrpc: '2.0',
id: 'nullcart',
result: [
{ tx_hash: 'original', height: 0 },
{ tx_hash: 'replacement', height: 800_000 }
]
}
})
.mockResolvedValueOnce({
data: {
jsonrpc: '2.0',
id: 'nullcart',
result: '01000000'
}
})
.mockResolvedValueOnce({
data: {
jsonrpc: '2.0',
id: 'nullcart',
result: {
inputs: [{ prevout_hash: 'shared-input', prevout_n: 0 }],
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
}
}
})
.mockResolvedValueOnce({
data: {
jsonrpc: '2.0',
id: 'nullcart',
result: '02000000'
}
})
.mockResolvedValueOnce({
data: {
jsonrpc: '2.0',
id: 'nullcart',
result: {
inputs: [{ prevout_hash: 'shared-input', prevout_n: 0 }],
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
}
}
});
await expect(client.getIncomingTransfers('bc1qtest', 800_002)).resolves.toEqual([
{
txHash: 'replacement',
amountAtomic: '50000',
confirmations: 3,
inputOutpoints: ['shared-input:0']
}
]);
});
});
describe('filterSupersededBitcoinTransfers', () => {
it('keeps unrelated transfers unchanged', () => {
const transfers = [
{ txHash: 'a', amountAtomic: '1', confirmations: 0, inputOutpoints: ['in1:0'] },
{ txHash: 'b', amountAtomic: '1', confirmations: 3, inputOutpoints: ['in2:1'] }
];
expect(clientTest.filterSupersededBitcoinTransfers(transfers)).toEqual(transfers);
});
it('drops an unconfirmed transfer superseded by a confirmed replacement', () => {
const transfers = [
{ txHash: 'original', amountAtomic: '1', confirmations: 0, inputOutpoints: ['in1:0'] },
{ txHash: 'replacement', amountAtomic: '1', confirmations: 2, inputOutpoints: ['in1:0'] }
];
expect(clientTest.filterSupersededBitcoinTransfers(transfers)).toEqual([
{ txHash: 'replacement', amountAtomic: '1', confirmations: 2, inputOutpoints: ['in1:0'] }
]);
});
it('keeps the later unconfirmed transfer when both conflict before confirmation', () => {
const transfers = [
{ txHash: 'original', amountAtomic: '1', confirmations: 0, inputOutpoints: ['in1:0'] },
{ txHash: 'replacement', amountAtomic: '1', confirmations: 0, inputOutpoints: ['in1:0'] }
];
expect(clientTest.filterSupersededBitcoinTransfers(transfers)).toEqual([
{ txHash: 'replacement', amountAtomic: '1', confirmations: 0, inputOutpoints: ['in1:0'] }
]);
});
it('keeps transfers without input outpoints', () => {
const transfers = [
{ txHash: 'coinbase', amountAtomic: '1', confirmations: 0, inputOutpoints: [] },
{ txHash: 'payment', amountAtomic: '1', confirmations: 1, inputOutpoints: ['in1:0'] }
];
expect(clientTest.filterSupersededBitcoinTransfers(transfers)).toEqual(transfers);
});
});
describe('createAddress', () => {
@@ -200,21 +200,26 @@ export class ElectrumWalletRpcClient {
const serializedTransaction = await this.call<string>('gettransaction', { txid: txHash });
const transaction = await this.deserializeTransaction(serializedTransaction);
const amountAtomic = this.sumOutputValueAtomic(transaction, address);
return this.mapIncomingTransfer(entry, amountAtomic, blockHeight);
return this.mapIncomingTransfer(entry, transaction, address, blockHeight);
})
);
return transfers.filter((transfer): transfer is ElectrumWalletIncomingTransfer => transfer !== null);
const resolvedTransfers = transfers.filter(
(transfer): transfer is ElectrumWalletIncomingTransfer => transfer !== null
);
return this.filterSupersededBitcoinTransfers(resolvedTransfers);
}
private mapIncomingTransfer(
entry: ElectrumWalletAddressHistoryEntry,
amountAtomic: string,
transaction: ElectrumWalletDeserializedTransaction,
address: string,
blockHeight: number | null
): ElectrumWalletIncomingTransfer | null {
const txHash = entry.tx_hash;
const amountAtomic = this.sumOutputValueAtomic(transaction, address);
if (!txHash || amountAtomic === '0') {
return null;
@@ -226,7 +231,60 @@ export class ElectrumWalletRpcClient {
return {
txHash,
amountAtomic,
confirmations
confirmations,
inputOutpoints: this.extractInputOutpoints(transaction)
};
}
private filterSupersededBitcoinTransfers(
transfers: ElectrumWalletIncomingTransfer[]
): ElectrumWalletIncomingTransfer[] {
return transfers.filter((transfer, index) => {
const isSuperseded = transfers.some((other, otherIndex) => {
if (otherIndex === index) {
return false;
}
if (!this.sharesInputOutpoint(transfer.inputOutpoints, other.inputOutpoints)) {
return false;
}
if (other.confirmations > transfer.confirmations) {
return true;
}
return other.confirmations === transfer.confirmations && otherIndex > index;
});
return !isSuperseded;
});
}
private extractInputOutpoints(transaction: ElectrumWalletDeserializedTransaction): string[] {
if (!Array.isArray(transaction.inputs)) {
return [];
}
return transaction.inputs.flatMap(input => {
if (typeof input.prevout_hash !== 'string' || input.prevout_hash.length === 0) {
return [];
}
if (typeof input.prevout_n !== 'number') {
return [];
}
return [`${input.prevout_hash}:${input.prevout_n}`];
});
}
private sharesInputOutpoint(left: readonly string[], right: readonly string[]): boolean {
if (left.length === 0 || right.length === 0) {
return false;
}
const rightOutpoints = new Set(right);
return left.some(outpoint => rightOutpoints.has(outpoint));
}
}
@@ -0,0 +1,4 @@
export type ElectrumWalletDeserializedInput = {
prevout_hash?: string;
prevout_n?: number;
};
@@ -0,0 +1,4 @@
export type ElectrumWalletDeserializedOutput = {
address?: string;
value_sats: number;
};
@@ -1,8 +1,7 @@
export type ElectrumWalletDeserializedOutput = {
address?: string;
value_sats: number;
};
import type { ElectrumWalletDeserializedInput } from './ElectrumWalletDeserializedInput';
import type { ElectrumWalletDeserializedOutput } from './ElectrumWalletDeserializedOutput';
export type ElectrumWalletDeserializedTransaction = {
inputs?: ElectrumWalletDeserializedInput[];
outputs: ElectrumWalletDeserializedOutput[];
};
@@ -2,4 +2,5 @@ export type ElectrumWalletIncomingTransfer = {
txHash: string;
amountAtomic: string;
confirmations: number;
inputOutpoints: string[];
};
@@ -5,8 +5,12 @@ import type { ElectrumWalletIncomingTransfer } from './ElectrumWalletIncomingTra
export type ElectrumWalletRpcClientTest = {
mapIncomingTransfer: (
entry: ElectrumWalletAddressHistoryEntry,
amountAtomic: string,
transaction: ElectrumWalletDeserializedTransaction,
address: string,
blockHeight: number | null
) => ElectrumWalletIncomingTransfer | null;
filterSupersededBitcoinTransfers: (
transfers: ElectrumWalletIncomingTransfer[]
) => ElectrumWalletIncomingTransfer[];
sumOutputValueAtomic: (transaction: ElectrumWalletDeserializedTransaction, address: string) => string;
};
@@ -87,6 +87,7 @@ describe('InvoicePaymentService', () => {
};
let paymentRepo: {
update: jest.Mock;
delete: jest.Mock;
createQueryBuilder: jest.Mock;
};
let insertQueryBuilder: {
@@ -133,6 +134,7 @@ describe('InvoicePaymentService', () => {
paymentRepo = {
update: jest.fn().mockResolvedValue(undefined),
delete: jest.fn().mockResolvedValue(undefined),
createQueryBuilder: jest.fn().mockReturnValue(insertQueryBuilder)
};
@@ -384,13 +386,35 @@ describe('InvoicePaymentService', () => {
expect(paymentRepo.update).not.toHaveBeenCalled();
});
it('does nothing when there are no transfers to process', async () => {
it('does not mutate payments when there are no transfers and no existing payments', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildXmrInvoice());
await service.processInvoice('invoice-1', []);
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
expect(paymentRepo.update).not.toHaveBeenCalled();
expect(paymentRepo.delete).not.toHaveBeenCalled();
});
it('removes unconfirmed payments that are no longer reported when transfers are empty', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
buildBtcInvoice({
payments: [
{
id: 'payment-ghost',
txHash: 'ghost',
amountAtomic: '50000',
confirmations: 0
} as InvoicePayment
]
})
);
await service.processInvoice('invoice-btc-1', []);
expect(paymentRepo.delete).toHaveBeenCalledWith('payment-ghost');
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
expect(paymentRepo.update).not.toHaveBeenCalled();
});
it('skips transfers below the configured minimum', async () => {
@@ -528,5 +552,34 @@ describe('InvoicePaymentService', () => {
confirmations: 1
});
});
it('removes unconfirmed payments that are no longer reported', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
buildBtcInvoice({
payments: [
{
id: 'payment-original',
txHash: 'original',
amountAtomic: '50000',
confirmations: 0
} as InvoicePayment,
{
id: 'payment-replacement',
txHash: 'replacement',
amountAtomic: '50000',
confirmations: 3
} as InvoicePayment
]
})
);
await service.processInvoice('invoice-btc-1', [
buildBtcTransfer({ txHash: 'replacement', amountAtomic: '50000', confirmations: 3 })
]);
expect(paymentRepo.delete).toHaveBeenCalledWith('payment-original');
expect(paymentRepo.update).not.toHaveBeenCalled();
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
});
});
});
@@ -135,8 +135,6 @@ export class InvoicePaymentService {
}
private async processInvoice(invoiceId: string, transfers: InvoiceIncomingTransfer[]): Promise<void> {
const { minByMethod } = this.configService.get('invoice') as Config['invoice'];
await this.dataSource.transaction(async manager => {
const invoiceRepo = manager.getRepository(Invoice);
const paymentRepo = manager.getRepository(InvoicePayment);
@@ -152,36 +150,62 @@ export class InvoicePaymentService {
return;
}
const minIncomingAtomic = minByMethod[invoice.paymentMethod];
const knownByTxHash = new Map((invoice.payments ?? []).map(payment => [payment.txHash, payment]));
await this.upsertIncomingPayments(paymentRepo, invoice, transfers);
for (const transfer of transfers) {
const existing = knownByTxHash.get(transfer.txHash);
if (existing) {
if (existing.confirmations !== transfer.confirmations) {
await paymentRepo.update(existing.id, { confirmations: transfer.confirmations });
}
continue;
}
if (!isAtomicGte(transfer.amountAtomic, minIncomingAtomic)) {
continue;
}
await paymentRepo
.createQueryBuilder()
.insert()
.values({
invoice: { id: invoiceId },
txHash: transfer.txHash,
amountAtomic: transfer.amountAtomic,
confirmations: transfer.confirmations
})
.orIgnore()
.execute();
}
await this.pruneAbsentUnconfirmedPayments(paymentRepo, invoice, transfers);
});
}
private async upsertIncomingPayments(
paymentRepo: Repository<InvoicePayment>,
invoice: Invoice,
transfers: InvoiceIncomingTransfer[]
): Promise<void> {
const { minByMethod } = this.configService.get('invoice') as Config['invoice'];
const minIncomingAtomic = minByMethod[invoice.paymentMethod];
const knownByTxHash = new Map((invoice.payments ?? []).map(payment => [payment.txHash, payment]));
for (const transfer of transfers) {
const existing = knownByTxHash.get(transfer.txHash);
if (existing) {
if (existing.confirmations !== transfer.confirmations) {
await paymentRepo.update(existing.id, { confirmations: transfer.confirmations });
}
continue;
}
if (!isAtomicGte(transfer.amountAtomic, minIncomingAtomic)) {
continue;
}
await paymentRepo
.createQueryBuilder()
.insert()
.values({
invoice: { id: invoice.id },
txHash: transfer.txHash,
amountAtomic: transfer.amountAtomic,
confirmations: transfer.confirmations
})
.orIgnore()
.execute();
}
}
private async pruneAbsentUnconfirmedPayments(
paymentRepo: Repository<InvoicePayment>,
invoice: Invoice,
transfers: InvoiceIncomingTransfer[]
): Promise<void> {
const activeTxHashes = new Set(transfers.map(transfer => transfer.txHash));
for (const payment of invoice.payments ?? []) {
if (payment.confirmations === 0 && !activeTxHashes.has(payment.txHash)) {
await paymentRepo.delete(payment.id);
}
}
}
}
@@ -100,7 +100,7 @@ export class StorefrontCartController {
text: 'Added to cart.'
});
res.redirect(HttpStatus.FOUND, safeInternalShopRedirectPath(req));
res.redirect(HttpStatus.FOUND, this.buildAddToCartRedirectPath(req, variantId));
}
@Post('shop/cart/product/update')
@@ -189,4 +189,18 @@ export class StorefrontCartController {
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}`;
}
}
@@ -1,8 +1,9 @@
import { IsIn, IsNotEmpty } from 'class-validator';
import { STOREFRONT_THEME_PREFERENCES } from '../../../consts/storefrontThemePreferences';
import type { StorefrontThemePreference } from '../types/StorefrontThemePreference';
export class SetThemePreferenceDto {
@IsNotEmpty()
@IsIn(['light', 'dark'])
@IsIn(STOREFRONT_THEME_PREFERENCES)
theme: StorefrontThemePreference;
}
@@ -49,6 +49,7 @@
--sf-split-sidebar: minmax(220px, 320px);
--sf-product-card-min: 11rem;
--sf-product-card-max: 22rem;
--sf-sticky-header-scroll-padding: 9rem;
}
@media (prefers-color-scheme: dark) {
@@ -158,6 +159,10 @@
box-sizing: border-box;
}
html {
scroll-padding-top: var(--sf-sticky-header-scroll-padding);
}
body.sf-body {
margin: 0;
min-height: 100vh;
@@ -288,9 +293,13 @@ a:hover {
}
.sf-header {
position: sticky;
top: 0;
z-index: 10;
margin-bottom: var(--sf-space-4);
padding: var(--sf-space-3) 0;
border-bottom: 1px solid var(--sf-border);
background: var(--sf-bg);
}
.sf-header__inner {
@@ -371,6 +380,26 @@ a:hover {
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 {
width: 1.25rem;
height: 1.25rem;
@@ -510,6 +539,76 @@ a:hover {
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 {
display: inline-flex;
align-items: center;
@@ -789,7 +888,7 @@ a:hover {
margin: 0;
}
.sf-line-item__body > .sf-discount-error {
.sf-line-item__body > .sf-cart-issue {
margin: 0.125rem 0 0;
}
@@ -866,8 +965,8 @@ a:hover {
color: var(--sf-text-muted);
}
.sf-discount-error {
margin: 0 0 var(--sf-space-2);
.sf-cart-issue {
margin: var(--sf-space-2) 0;
font-size: 0.85rem;
color: var(--sf-error);
line-height: 1.35;
@@ -7,40 +7,24 @@ import { StorefrontCartCookieService } from './StorefrontCartCookieService';
import { StorefrontFeedbackCookieService } from './StorefrontFeedbackCookieService';
import { StorefrontOrderAuthCookieService } from './StorefrontOrderAuthCookieService';
import { StorefrontThemeCookieService } from './StorefrontThemeCookieService';
import { StorefrontCheckoutSessionCookieService } from './StorefrontCheckoutSessionCookieService';
import type { StorefrontPageMetaInput } from '../types/StorefrontPageMetaInput';
import type { StorefrontShopViewServiceOverrides } from '../types/StorefrontShopViewServiceTestTypes';
import { PaymentMethod } from '../../payment/types/PaymentMethod';
describe('StorefrontShopViewService', () => {
type ServiceOverrides = {
cart?: { variantId: string; qty: number }[];
feedback?: { type: 'success'; text: string };
authorizedOrderIds?: string[];
theme?: 'light' | 'dark';
branding?: {
logoUrl: string | null;
faviconUrl: string | null;
simplexLink: string | null;
shippingNote: string | null;
};
shopSettings?: { shopName: string; shopFiatCurrency: string; enabledPaymentMethods?: PaymentMethod[] };
fiatPerXmr?: number;
fiatPerBtc?: number | null;
req?: Partial<Pick<Request, 'protocol' | 'path' | 'originalUrl'>> & {
host?: string;
};
};
const defaultPage: StorefrontPageMetaInput = {
title: 'Cart',
metaDescription: 'Review your cart.'
};
const createService = (overrides: ServiceOverrides = {}) => {
const createService = (overrides: StorefrontShopViewServiceOverrides = {}) => {
const {
cart = [],
feedback,
authorizedOrderIds = [],
theme,
checkoutSessionId,
theme = 'system',
branding = {
logoUrl: null,
faviconUrl: null,
@@ -80,6 +64,9 @@ describe('StorefrontShopViewService', () => {
const themeCookieService = {
getTheme: jest.fn().mockReturnValue(theme)
} as unknown as StorefrontThemeCookieService;
const checkoutSessionCookieService = {
getSessionId: jest.fn().mockReturnValue(checkoutSessionId)
} as unknown as StorefrontCheckoutSessionCookieService;
const service = new StorefrontShopViewService(
exchangeRateService,
@@ -88,7 +75,8 @@ describe('StorefrontShopViewService', () => {
cartCookieService,
feedbackCookieService,
orderAuthCookieService,
themeCookieService
themeCookieService,
checkoutSessionCookieService
);
const req = {
@@ -117,6 +105,23 @@ describe('StorefrontShopViewService', () => {
ogImageUrl: null,
productJsonLd: null
});
expect(locals.themePreference).toBe('system');
expect(locals.hasActiveCheckout).toBe(false);
});
it('exposes active checkout state from the checkout session cookie', async () => {
const { service, req, res } = createService({
checkoutSessionId: 'session-1',
req: { path: '/shop/checkout', originalUrl: '/shop/checkout' }
});
const locals = await service.buildShopRenderLocals(req, res, {
title: 'Checkout',
metaDescription: 'Complete your purchase at {shopName}.'
});
expect(locals.hasActiveCheckout).toBe(true);
expect(locals.shopNavActive).toEqual({ activeShopNav: 'checkout', activeOrderId: null });
});
it('exposes shop settings, cart qty, feedback, branding, theme, and request context', async () => {
@@ -19,6 +19,7 @@ import type { StorefrontPageMetaInput } from '../types/StorefrontPageMetaInput';
import type { StorefrontProductJsonLdInput } from '../types/StorefrontProductJsonLdInput';
import { resolveShopNavActive } from '../utils/resolveShopNavActive';
import { StorefrontCartCookieService } from './StorefrontCartCookieService';
import { StorefrontCheckoutSessionCookieService } from './StorefrontCheckoutSessionCookieService';
import { StorefrontFeedbackCookieService } from './StorefrontFeedbackCookieService';
import { StorefrontOrderAuthCookieService } from './StorefrontOrderAuthCookieService';
import { StorefrontThemeCookieService } from './StorefrontThemeCookieService';
@@ -32,7 +33,8 @@ export class StorefrontShopViewService {
private readonly cartCookieService: StorefrontCartCookieService,
private readonly feedbackCookieService: StorefrontFeedbackCookieService,
private readonly orderAuthCookieService: StorefrontOrderAuthCookieService,
private readonly themeCookieService: StorefrontThemeCookieService
private readonly themeCookieService: StorefrontThemeCookieService,
private readonly checkoutSessionCookieService: StorefrontCheckoutSessionCookieService
) {}
async buildShopRenderLocals(req: Request, res: Response, page: StorefrontPageMetaInput): Promise<ShopRenderLocals> {
@@ -41,6 +43,7 @@ export class StorefrontShopViewService {
const themePreference = this.themeCookieService.getTheme(req, res);
const cartTotalQty = getTotalCartQtyFromCart(cart);
const hasActiveCheckout = Boolean(this.checkoutSessionCookieService.getSessionId(req, res));
const { shopName, shopFiatCurrency, enabledPaymentMethods } = this.configService.get(
'shopSettings'
@@ -62,6 +65,7 @@ export class StorefrontShopViewService {
title: page.title,
shopName,
cartTotalQty,
hasActiveCheckout,
feedback,
authorizedOrders,
shopNavActive,
@@ -14,10 +14,16 @@ describe('StorefrontThemeCookieService', () => {
service = new StorefrontThemeCookieService(signedCookies as unknown as StorefrontSignedCookieService);
});
it('returns undefined for invalid theme values', () => {
signedCookies.getSignedCookie.mockReturnValue({ theme: 'system' });
it('returns system when no cookie is set', () => {
signedCookies.getSignedCookie.mockReturnValue(undefined);
expect(service.getTheme({} as never, {} as never)).toBeUndefined();
expect(service.getTheme({} as never, {} as never)).toBe('system');
});
it('returns system for invalid theme values', () => {
signedCookies.getSignedCookie.mockReturnValue({ theme: 'sepia' });
expect(service.getTheme({} as never, {} as never)).toBe('system');
});
it('returns a supported theme preference', () => {
@@ -25,4 +31,10 @@ describe('StorefrontThemeCookieService', () => {
expect(service.getTheme({} as never, {} as never)).toBe('dark');
});
it('returns system when stored in the cookie', () => {
signedCookies.getSignedCookie.mockReturnValue({ theme: 'system' });
expect(service.getTheme({} as never, {} as never)).toBe('system');
});
});
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import type { Request, Response } from 'express';
import { STOREFRONT_THEME_PREFERENCES } from '../../../consts/storefrontThemePreferences';
import type { StorefrontThemePreference } from '../types/StorefrontThemePreference';
import { StorefrontSignedCookieService } from './StorefrontSignedCookieService';
@@ -7,15 +8,15 @@ import { StorefrontSignedCookieService } from './StorefrontSignedCookieService';
export class StorefrontThemeCookieService {
constructor(private readonly signedCookies: StorefrontSignedCookieService) {}
getTheme(req: Request, res: Response): StorefrontThemePreference | undefined {
const payload = this.signedCookies.getSignedCookie<{ theme: string }>(req, res, 'theme');
getTheme(req: Request, res: Response): StorefrontThemePreference {
const payload = this.signedCookies.getSignedCookie<{ theme: StorefrontThemePreference }>(req, res, 'theme');
const theme = payload?.theme;
if (theme === 'light' || theme === 'dark') {
if (theme && STOREFRONT_THEME_PREFERENCES.includes(theme)) {
return theme;
}
return undefined;
return 'system';
}
setTheme(req: Request, res: Response, theme: StorefrontThemePreference): void {
@@ -1 +1 @@
export type ShopNavKey = 'shop' | 'cart' | 'check-order';
export type ShopNavKey = 'shop' | 'cart' | 'checkout' | 'check-order';
@@ -10,6 +10,7 @@ export type ShopRenderLocals = {
title: string;
shopName: string;
cartTotalQty: number;
hasActiveCheckout: boolean;
feedback: StorefrontFeedback | undefined;
authorizedOrders: AuthorizedOrderNavItem[];
shopNavActive: ShopNavActive;
@@ -19,6 +20,6 @@ export type ShopRenderLocals = {
faviconUrl: string | null;
simplexLink: string | null;
shippingNote: string | null;
themePreference: StorefrontThemePreference | undefined;
themePreference: StorefrontThemePreference;
pageMeta: StorefrontPageMeta;
};
@@ -0,0 +1,23 @@
import type { Request } from 'express';
import type { PaymentMethod } from '../../payment/types/PaymentMethod';
import type { StorefrontThemePreference } from './StorefrontThemePreference';
export type StorefrontShopViewServiceOverrides = {
cart?: { variantId: string; qty: number }[];
feedback?: { type: 'success'; text: string };
authorizedOrderIds?: string[];
checkoutSessionId?: string;
theme?: StorefrontThemePreference;
branding?: {
logoUrl: string | null;
faviconUrl: string | null;
simplexLink: string | null;
shippingNote: string | null;
};
shopSettings?: { shopName: string; shopFiatCurrency: string; enabledPaymentMethods?: PaymentMethod[] };
fiatPerXmr?: number;
fiatPerBtc?: number | null;
req?: Partial<Pick<Request, 'protocol' | 'path' | 'originalUrl'>> & {
host?: string;
};
};
@@ -1 +1,3 @@
export type StorefrontThemePreference = 'light' | 'dark';
import { STOREFRONT_THEME_PREFERENCES } from '../../../consts/storefrontThemePreferences';
export type StorefrontThemePreference = (typeof STOREFRONT_THEME_PREFERENCES)[number];
@@ -29,6 +29,7 @@ const STOREFRONT_PARTIALS = [
{ name: 'shop-footer', file: 'shop-footer.hbs' },
{ name: 'storefront-image', file: 'storefront-image.hbs' },
{ name: 'theme-switcher', file: 'theme-switcher.hbs' },
{ name: 'html-theme-attributes', file: 'html-theme-attributes.hbs' },
{ name: 'page-back-link', file: 'page-back-link.hbs' },
{ name: 'product-card', file: 'product-card.hbs' },
{ name: 'order-data-retention-notice', file: 'order-data-retention-notice.hbs' },
@@ -13,8 +13,9 @@ describe('resolveShopNavActive', () => {
});
});
it('highlights cart and check-order pages', () => {
it('highlights cart, checkout, and check-order pages', () => {
expect(resolveShopNavActive('/shop/cart')).toEqual({ activeShopNav: 'cart', activeOrderId: null });
expect(resolveShopNavActive('/shop/checkout')).toEqual({ activeShopNav: 'checkout', activeOrderId: null });
expect(resolveShopNavActive('/shop/check-order')).toEqual({
activeShopNav: 'check-order',
activeOrderId: null
@@ -29,6 +30,6 @@ describe('resolveShopNavActive', () => {
});
it('does not highlight nav items on unrelated pages', () => {
expect(resolveShopNavActive('/shop/checkout')).toEqual({ activeShopNav: null, activeOrderId: null });
expect(resolveShopNavActive('/shop/theme')).toEqual({ activeShopNav: null, activeOrderId: null });
});
});
@@ -11,6 +11,10 @@ export const resolveShopNavActive = (path: string): ShopNavActive => {
return { activeShopNav: 'cart', activeOrderId: null };
}
if (first === 'shop' && second === 'checkout') {
return { activeShopNav: 'checkout', activeOrderId: null };
}
if (first === 'shop' && second === 'check-order') {
return { activeShopNav: 'check-order', activeOrderId: null };
}
@@ -1,8 +1,7 @@
<!DOCTYPE html>
<html
lang='en'
{{#ifeq themePreference 'light'}}data-theme='light'{{/ifeq}}
{{#ifeq themePreference 'dark'}}data-theme='dark'{{/ifeq}}
{{> html-theme-attributes}}
>
<head>
{{> shop-head}}
@@ -1,8 +1,7 @@
<!DOCTYPE html>
<html
lang='en'
{{#ifeq themePreference 'light'}}data-theme='light'{{/ifeq}}
{{#ifeq themePreference 'dark'}}data-theme='dark'{{/ifeq}}
{{> html-theme-attributes}}
>
<head>
{{> shop-head}}
@@ -12,10 +11,10 @@
{{> shop-nav}}
{{> category-nav}}
<main class='sf-main'>
{{> feedback}}
{{{body}}}
</main>
{{> shop-footer}}
</div>
{{> feedback}}
</body>
</html>
@@ -28,7 +28,7 @@
{{@root.shopFiatCurrency}}</div>
<div class='sf-line-item__note'>{{> product-delivery-note deliveryMode=deliveryMode}}</div>
{{#if stockIssueMessage}}
<p class='sf-discount-error'>{{stockIssueMessage}}</p>
<p class='sf-cart-issue'>{{stockIssueMessage}}</p>
{{else}}
{{#unless stockForSession}}
<p class='sf-text-subtle'>No more in stock beside your order</p>
@@ -36,7 +36,7 @@
{{/if}}
{{#each @root.discounts}}
{{#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}}
{{/each}}
<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>
</div>
<p class='sf-discount-error'>{{issueMessage}}</p>
<p class='sf-cart-issue'>{{issueMessage}}</p>
{{else if amount}}
<div class='sf-discount-row sf-discount-item'>
{{> dismiss-button
@@ -62,7 +62,7 @@
</form>
{{#if cartTotalIssueMessage}}
<p class='sf-discount-error'>{{cartTotalIssueMessage}}</p>
<p class='sf-cart-issue'>{{cartTotalIssueMessage}}</p>
{{/if}}
<div class='sf-stack sf-mt-4'>
@@ -1,5 +1,5 @@
{{#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}}
</p>
{{/if}}
@@ -0,0 +1,2 @@
{{#ifeq themePreference 'light'}}data-theme='light'{{/ifeq}}
{{#ifeq themePreference 'dark'}}data-theme='dark'{{/ifeq}}
@@ -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}}
{{#if thumbnailUrl}}
<a class='sf-product-card__media-link' href='{{detailHref}}'>
@@ -2,7 +2,7 @@
<div class='sf-footer__row'>
<div class='sf-footer__start'>
{{#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}}
<a href='https://nullcart.net/' target='_blank' rel='noopener noreferrer'>Powered by NullCart</a>
</div>
@@ -17,7 +17,11 @@
<nav class='sf-nav' aria-label='Shop navigation'>
{{> nav-link active=shopNavActive.activeShopNav key='shop' href='/' label='Shop'}}
{{> nav-link active=shopNavActive.activeShopNav key='cart' href='/shop/cart' label='Cart' count=cartTotalQty}}
{{#if hasActiveCheckout}}
{{> nav-link active=shopNavActive.activeShopNav key='checkout' href='/shop/checkout' label='Checkout' count=cartTotalQty}}
{{else}}
{{> nav-link active=shopNavActive.activeShopNav key='cart' href='/shop/cart' label='Cart' count=cartTotalQty}}
{{/if}}
{{> nav-link active=shopNavActive.activeShopNav key='check-order' href='/shop/check-order' label='Check order'}}
{{#each authorizedOrders}}
@@ -15,5 +15,12 @@
class='sf-btn sf-btn--sm{{#ifeq themePreference 'dark'}} sf-btn--primary{{/ifeq}}'
{{#ifeq themePreference 'dark'}}aria-current='true'{{/ifeq}}
>Dark</button>
<button
type='submit'
name='theme'
value='system'
class='sf-btn sf-btn--sm{{#ifeq themePreference 'system'}} sf-btn--primary{{/ifeq}}'
{{#ifeq themePreference 'system'}}aria-current='true'{{/ifeq}}
>System</button>
</form>
</div>
@@ -1,14 +1,14 @@
import { resolveMinConfirmations } from './resolveMinConfirmations';
const tiers = [
{ upToTotalFiat: '25', minConfirmations: 0 },
{ upToTotalFiat: '25', minConfirmations: 1 },
{ upToTotalFiat: '250', minConfirmations: 5 },
{ minConfirmations: 10 }
] as const;
describe('resolveMinConfirmations', () => {
it('returns 0 for small orders (tx-detected tier)', () => {
expect(resolveMinConfirmations(10, [...tiers])).toBe(0);
it('returns the first tier for small orders', () => {
expect(resolveMinConfirmations(10, [...tiers])).toBe(1);
});
it('returns the middle tier for medium orders', () => {
@@ -35,14 +35,14 @@ describe('formatInvoicePaymentConfirmationStatus', () => {
).toBe('2/10');
});
it('treats zero-confirmation tiers as confirmed', () => {
it('returns compact progress at zero confirmations', () => {
expect(
formatInvoicePaymentConfirmationStatus({
confirmations: 0,
requiredConfirmations: 0,
requiredConfirmations: 3,
format: 'compact'
})
).toBe('Confirmed');
).toBe('0/3');
});
});
@@ -1,9 +1,6 @@
import { formatRelativeTimeAgo } from '../formatRelativeTimeAgo';
import type { InvoicePaymentConfirmationStatusFormat } from './types/InvoicePaymentConfirmationStatusFormat';
const formatRequiredConfirmationsLabel = (requiredConfirmations: number): string =>
requiredConfirmations === 0 ? '0 (tx-detected)' : String(requiredConfirmations);
export const formatInvoicePaymentConfirmationStatus = ({
confirmations,
requiredConfirmations,
@@ -29,5 +26,5 @@ export const formatInvoicePaymentConfirmationStatus = ({
return `${confirmations} / ${requiredConfirmations} confirmations · detected ${detectedAgo}`;
}
return `${confirmations}/${formatRequiredConfirmationsLabel(requiredConfirmations)}`;
return `${confirmations}/${requiredConfirmations}`;
};
@@ -463,19 +463,6 @@ describe('toStorefrontInvoiceView', () => {
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', () => {
@@ -13,20 +13,13 @@ const validateTiers = (value: string) => {
};
const validTiers =
'[{"upToTotalFiat":"25","minConfirmations":0},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
'[{"upToTotalFiat":"25","minConfirmations":1},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
describe('IsConfirmationTiers', () => {
it('accepts valid default tiers', () => {
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', () => {
expect(validateTiers('').length).toBeGreaterThan(0);
});
@@ -39,22 +32,16 @@ describe('IsConfirmationTiers', () => {
expect(validateTiers('[]').length).toBeGreaterThan(0);
});
it('rejects tx-detected (0) more than once', () => {
it('rejects minConfirmations: 0', () => {
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);
});
it('rejects tx-detected (0) on catch-all tier', () => {
const tiers = '[{"upToTotalFiat":"25","minConfirmations":1},{"minConfirmations":0}]';
expect(validateTiers(tiers).length).toBeGreaterThan(0);
});
it('rejects legacy tx-detected string', () => {
it('rejects non-numeric minConfirmations', () => {
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);
});
@@ -14,7 +14,7 @@ const isPositiveDecimalString = (value: string): 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 => {
if (typeof value !== 'object' || value === null) {
@@ -48,22 +48,12 @@ const isValidConfirmationTiersJson = (raw: string): boolean => {
}
const tiers = parsed;
const txDetectedTierCount = tiers.filter(tier => tier.minConfirmations === 0).length;
if (txDetectedTierCount > 1) {
return false;
}
const lastTier = tiers[tiers.length - 1];
if (lastTier.upToTotalFiat !== undefined) {
return false;
}
if (lastTier.minConfirmations === 0) {
return false;
}
for (let index = 0; index < tiers.length - 1; index++) {
const tier = tiers[index];
@@ -86,7 +76,7 @@ class IsConfirmationTiersConstraint implements ValidatorConstraintInterface {
}
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';
}
}
@@ -17,6 +17,7 @@
collapse-tags
:max-collapse-tags="3"
collapse-tags-tooltip
fit-input-width
class="cms-scope-select"
placeholder="Select categories"
@update:model-value="value => updateScope({ categoryIds: value })"
@@ -42,6 +43,7 @@
collapse-tags
:max-collapse-tags="3"
collapse-tags-tooltip
fit-input-width
class="cms-scope-select"
:remote-method="loadProducts"
:loading="productSearchLoading"
@@ -69,6 +71,7 @@
collapse-tags
:max-collapse-tags="3"
collapse-tags-tooltip
fit-input-width
class="cms-scope-select"
:remote-method="loadVariants"
:loading="variantSearchLoading"
+6 -1
View File
@@ -58,7 +58,12 @@
</el-form-item>
<el-form-item label="Fee priority" prop="priority">
<el-select v-model="withdrawForm.priority" class="fee-priority-select" :disabled="withdrawing">
<el-select
v-model="withdrawForm.priority"
class="fee-priority-select"
fit-input-width
:disabled="withdrawing"
>
<el-option
v-for="option in moneroWithdrawPriorityOptions"
:key="option.value"
-14
View File
@@ -16,16 +16,6 @@
padding: 24px;
}
.el-select__popper {
max-width: min(560px, calc(100vw - 32px));
}
.el-select-dropdown__item {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: $cms-bp-tablet) {
html,
body,
@@ -114,10 +104,6 @@
width: min(180px, calc(100vw - 10px)) !important;
}
.el-select__popper {
max-width: calc(100vw - 10px);
}
.cms-gate {
padding: 12px 5px;
}
+1
View File
@@ -49,6 +49,7 @@
collapse-tags
:max-collapse-tags="3"
collapse-tags-tooltip
fit-input-width
placeholder="Select categories"
>
<el-option
+1 -1
View File
@@ -468,7 +468,7 @@ const submitShippingNote = async (): Promise<void> => {
};
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) {
return `Above previous tiers → ${requirement}`;
+13 -5
View File
@@ -3,7 +3,7 @@
## 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.
- 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
@@ -61,20 +61,20 @@ Edit `.env.prod`. Mandatory configuration:
| `VITE_API_BASE_URL` | `/api` |
| `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`):
```json
[
{ "upToTotalFiat": "30", "minConfirmations": 0 },
{ "upToTotalFiat": "30", "minConfirmations": 1 },
{ "upToTotalFiat": "100", "minConfirmations": 3 },
{ "upToTotalFiat": "300", "minConfirmations": 5 },
{ "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
@@ -106,7 +106,7 @@ Remove the temporary bootstrap certificates under `deploy/certs/live/` (Certbot
rm -rf deploy/certs/live/*
```
Request the real certificate:
Request the real certificate (apex + www):
```bash
./deploy/scripts/issue-certs.sh --email you@example.com
@@ -144,6 +144,14 @@ Save and exit the editor. Optional — run once manually to verify:
./deploy/scripts/show-onion.sh
```
Copy the hostname into `.env.prod` as `ONION_HOSTNAME`, then recreate nginx:
```bash
docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --force-recreate nginx
```
Tor Browser will then show ".onion available" when visitors open the clearnet site over HTTPS.
## 9. Complete shop setup
1. Open the CMS on clearnet or onion (`/cms`).
+4 -3
View File
@@ -9,9 +9,9 @@ usage() {
cat <<EOF
Usage: $(basename "$0") --email you@example.com
Obtain or renew Let's Encrypt certificates for CLEARNET_DOMAIN using the webroot
challenge. Nginx must be running and serving /.well-known/acme-challenge/ from
deploy/certbot/www.
Obtain Let's Encrypt certificates for CLEARNET_DOMAIN and www.CLEARNET_DOMAIN using
the webroot challenge. Nginx must be running and serving /.well-known/acme-challenge/
from deploy/certbot/www.
Environment is read from .env.prod (CLEARNET_DOMAIN).
EOF
@@ -65,6 +65,7 @@ docker run --rm \
--webroot \
-w /var/www/certbot \
-d "$CLEARNET_DOMAIN" \
-d "www.${CLEARNET_DOMAIN}" \
--email "$CERTBOT_EMAIL" \
--agree-tos \
--non-interactive
+4 -2
View File
@@ -1,6 +1,6 @@
server {
listen 80;
server_name ${CLEARNET_DOMAIN};
server_name ${CLEARNET_DOMAIN} www.${CLEARNET_DOMAIN};
location /.well-known/acme-challenge/ {
root /var/www/certbot;
@@ -13,10 +13,12 @@ server {
server {
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_key /etc/nginx/certs/live/${CLEARNET_DOMAIN}/privkey.pem;
${ONION_LOCATION_HEADER}
include /etc/nginx/snippets/nullcart-locations-${SHOP_SURFACE}.conf;
}
+15
View File
@@ -16,6 +16,21 @@ render_server() {
surface="$3"
SHOP_SURFACE="$surface"
export SHOP_SURFACE CLEARNET_DOMAIN BACKEND_PORT
if [ "$surface" = "clearnet" ]; then
if [ -n "${ONION_HOSTNAME:-}" ]; then
export ONION_LOCATION_HEADER="add_header Onion-Location \"http://${ONION_HOSTNAME}\$request_uri\";"
else
export ONION_LOCATION_HEADER=""
fi
envsubst '${CLEARNET_DOMAIN} ${BACKEND_PORT} ${SHOP_SURFACE} ${ONION_LOCATION_HEADER}' \
< "$template_path" \
> "$output_path"
return
fi
envsubst '${CLEARNET_DOMAIN} ${BACKEND_PORT} ${SHOP_SURFACE}' \
< "$template_path" \
> "$output_path"