Compare commits

..
44 Commits
Author SHA1 Message Date
nobswebdev 55630e33b4 Allow application/x-zip-compressed for digital stock attachments.
Browsers often report ZIP files with this MIME type instead of application/zip.
2026-09-25 20:57:28 +02:00
nobswebdev e057b29cc7 Reject cart and discount mutations while checkout session is active.
Prevent cart cookie changes during payment so nav counts and checkout snapshots stay aligned until cancel or completion.
2026-09-18 13:31:10 +02:00
nobswebdev 226a31b937 Merge pull request 'Slave/blog' (#9) from slave/blog into master
Reviewed-on: http://4f5t7o2vgo4sdaliwax3isyxzfuxvbvkuwk4smfowutrvx2uneu4egyd.onion/nobswebdev/nullcart/pulls/9
2026-09-16 20:15:07 +00:00
nobswebdev 6419439a3d Improve storefront blog index card layout and sizing.
Use blog-specific grid breakpoints so mobile stays single-column while wider viewports grow naturally like the product list.
2026-09-16 21:43:30 +02:00
nobswebdev 762af5c4f3 Add CMS blog admin for managing posts and storefront visibility.
Wire list and detail views with image uploads, draft/live editing, and a blog-enabled toggle backed by the shop settings API.
2026-09-16 20:58:22 +02:00
nobswebdev 17a26b60ed Align CMS products list and detail with shared list-view patterns.
Drop mount-time form clearValidate, add table row-key, unify status tags, and simplify load error handling to match other CMS list views.
2026-09-16 20:42:28 +02:00
nobswebdev 9cd7059928 Expose blog image URLs on admin API and fix storefront card layout.
Map storage keys to public paths in BlogPostsService responses so the CMS can resolve uploads consistently, and wrap blog card thumbnails in a fixed aspect-ratio container.
2026-09-16 19:46:11 +02:00
nobswebdev 9c7b8470d3 Add dedicated blog image upload config separate from product thumbs.
Introduce MULTER_BLOG_IMAGE_* and VITE_BLOG_IMAGE_* env vars with backend multer helper and CMS config wiring for prod builds.
2026-09-16 19:13:23 +02:00
nobswebdev e5eb773404 Use descriptive required-field validation messages in CMS forms.
Replace generic "Required" copy with field-specific messages for product, variant, and digital stock forms.
2026-09-16 16:58:13 +02:00
nobswebdev 0cd7158f7d Align CMS title fallbacks and mutation error handling.
Add blog post untitled helper, use product title helper in order views, and surface API validation messages on product save/delete.
2026-09-16 16:20:52 +02:00
nobswebdev 09ad236cb9 Drop explicit empty alt attributes from storefront-image call sites.
Decorative thumbnails rely on the partial default instead of repeating alt='' at each call site.
2026-09-16 13:57:21 +02:00
nobswebdev a3d1842fb3 Add storefront blog pages with nav, templates, and SEO meta.
Wire blogEnabled into shop render locals and expose public blog index and detail routes when the shop toggle is on.
2026-09-16 13:55:12 +02:00
nobswebdev 73f7ffed00 Expose blog toggle via shop settings API and storefront branding.
Add PATCH /shop-settings/blog-enabled and surface blogEnabled in ShopSettingsView and StorefrontBranding.
2026-09-16 11:48:59 +02:00
nobswebdev abe6208e4e Add blog thumbnail and banner upload endpoints with storage keys.
Store image filenames in DB, resolve paths for cleanup on replace/delete, and reuse product-thumb multer limits.
2026-09-16 11:40:05 +02:00
nobswebdev d38def2302 Add blog post admin CRUD API.
Expose JWT-protected /blog-posts endpoints for draft creation, listing, update, and delete.
2026-09-15 20:22:49 +02:00
nobswebdev 92ccbec545 add blog post title validation config 2026-09-15 20:05:25 +02:00
nobswebdev 40bf16c03e Add blog schema with BlogPost entity and shop blog toggle.
Introduces blog_posts table and blogEnabled on shop_settings for optional storefront blog.
2026-09-15 19:36:30 +02:00
nobswebdev 54ec6edd0d remove reduced motion styling 2026-09-14 15:59:53 +02:00
nobswebdev 8372c30e40 Merge pull request 'Slave/add minimal js' (#8) from slave/add-minimal-js into master
Reviewed-on: #8
2026-09-14 13:21:32 +00:00
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
nobswebdev 49d6b597c2 Lower default Bitcoin confirmation tiers for faster on-chain checkout.
Use 1/2/3/6 confirmations by order value instead of 1/3/5/6, since Bitcoin blocks are much slower than Monero.
2026-09-13 12:52:04 +02:00
nobswebdev 0ab7f21bea Fix monero-wallet-rpc startup on mainnet by omitting --mainnet.
Monero treats mainnet as the default network; only --stagenet and --testnet are valid flags.
2026-09-12 23:01:20 +02:00
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
nobswebdev e6c21e58bb Merge pull request 'Slave/btc integration' (#4) from slave/btc-integration into master
Reviewed-on: #4
2026-09-07 11:11:09 +00:00
nobswebdev 8639176af4 Throw on unsupported payment methods in issueInvoice.
Prevent checkout sessions from being created without an invoice when
the payment method switch has no matching case.
2026-09-07 12:39:31 +02:00
nobswebdev 68503ea01d Add payment method icons to storefront navbar and cart.
Show BTC/XMR icons beside exchange rates and pay buttons.
2026-09-07 12:28:14 +02:00
nobswebdev dd234421a4 Rename cryptoCurrency to paymentLabel in storefront invoice views.
Align invoice view naming with InvoiceExtended and CMS.
2026-09-07 12:28:09 +02:00
132 changed files with 2827 additions and 284 deletions
+12 -4
View File
@@ -19,6 +19,7 @@ NODE_ENV=development
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
CLEARNET_DOMAIN=localhost CLEARNET_DOMAIN=localhost
ONION_HOSTNAME=samplexxxxxxxxx.onion
JWT_SECRET=change-me-in-production JWT_SECRET=change-me-in-production
JWT_EXPIRES_IN_MS=604800000 # 7 days JWT_EXPIRES_IN_MS=604800000 # 7 days
@@ -36,16 +37,20 @@ THROTTLE_LIMIT=120
MULTER_PRODUCT_THUMB_ALLOWED_MIMES=image/jpeg,image/png MULTER_PRODUCT_THUMB_ALLOWED_MIMES=image/jpeg,image/png
MULTER_PRODUCT_THUMB_MAX_FILE_BYTES=1048576 # 1MB MULTER_PRODUCT_THUMB_MAX_FILE_BYTES=1048576 # 1MB
MULTER_BLOG_IMAGE_ALLOWED_MIMES=image/jpeg,image/png
MULTER_BLOG_IMAGE_MAX_FILE_BYTES=1048576 # 1MB
MULTER_SHOP_LOGO_ALLOWED_MIMES=image/jpeg,image/png MULTER_SHOP_LOGO_ALLOWED_MIMES=image/jpeg,image/png
MULTER_SHOP_LOGO_MAX_FILE_BYTES=1048576 # 1MB MULTER_SHOP_LOGO_MAX_FILE_BYTES=1048576 # 1MB
MULTER_SHOP_FAVICON_ALLOWED_MIMES=image/png,image/x-icon,image/vnd.microsoft.icon MULTER_SHOP_FAVICON_ALLOWED_MIMES=image/png,image/x-icon,image/vnd.microsoft.icon
MULTER_SHOP_FAVICON_MAX_FILE_BYTES=262144 # 256KB MULTER_SHOP_FAVICON_MAX_FILE_BYTES=262144 # 256KB
MULTER_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES=application/pdf,application/zip,image/jpeg,image/png MULTER_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES=application/pdf,application/zip,application/x-zip-compressed,image/jpeg,image/png
MULTER_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES=5242880 # 5MB MULTER_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES=5242880 # 5MB
VALIDATION_PRODUCT_TITLE_MAX_LENGTH=100 VALIDATION_PRODUCT_TITLE_MAX_LENGTH=100
VALIDATION_BLOG_POST_TITLE_MAX_LENGTH=200
VALIDATION_CATEGORY_NAME_MAX_LENGTH=50 VALIDATION_CATEGORY_NAME_MAX_LENGTH=50
VALIDATION_DISCOUNT_CODE_MAX_LENGTH=32 VALIDATION_DISCOUNT_CODE_MAX_LENGTH=32
VALIDATION_VARIANT_IMAGES_MAX=10 VALIDATION_VARIANT_IMAGES_MAX=10
@@ -90,7 +95,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 +109,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":"200","minConfirmations":1},{"upToTotalFiat":"1000","minConfirmations":2},{"upToTotalFiat":"2000","minConfirmations":3},{"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
@@ -133,13 +138,16 @@ VITE_API_BASE_URL=http://localhost:3000/api
VITE_SHOP_FIAT_CURRENCY=USD VITE_SHOP_FIAT_CURRENCY=USD
VITE_PRODUCT_THUMB_ALLOWED_MIMES=image/jpeg,image/png VITE_PRODUCT_THUMB_ALLOWED_MIMES=image/jpeg,image/png
VITE_PRODUCT_THUMB_MAX_FILE_BYTES=1048576 # 1MB VITE_PRODUCT_THUMB_MAX_FILE_BYTES=1048576 # 1MB
VITE_BLOG_IMAGE_ALLOWED_MIMES=image/jpeg,image/png
VITE_BLOG_IMAGE_MAX_FILE_BYTES=1048576 # 1MB
VITE_SHOP_LOGO_ALLOWED_MIMES=image/jpeg,image/png VITE_SHOP_LOGO_ALLOWED_MIMES=image/jpeg,image/png
VITE_SHOP_LOGO_MAX_FILE_BYTES=1048576 # 1MB VITE_SHOP_LOGO_MAX_FILE_BYTES=1048576 # 1MB
VITE_SHOP_FAVICON_ALLOWED_MIMES=image/png,image/x-icon,image/vnd.microsoft.icon VITE_SHOP_FAVICON_ALLOWED_MIMES=image/png,image/x-icon,image/vnd.microsoft.icon
VITE_SHOP_FAVICON_MAX_FILE_BYTES=262144 # 256KB VITE_SHOP_FAVICON_MAX_FILE_BYTES=262144 # 256KB
VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES=application/pdf,application/zip,image/jpeg,image/png VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES=application/pdf,application/zip,application/x-zip-compressed,image/jpeg,image/png
VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES=5242880 # 5MB VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES=5242880 # 5MB
VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH=100 VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH=100
VITE_VALIDATION_BLOG_POST_TITLE_MAX_LENGTH=200
VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH=50 VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH=50
VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH=32 VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH=32
VITE_VALIDATION_VARIANT_IMAGES_MAX=10 VITE_VALIDATION_VARIANT_IMAGES_MAX=10
+5 -1
View File
@@ -20,6 +20,7 @@ import {
} from './config'; } from './config';
import { validate } from './config/validate'; import { validate } from './config/validate';
import { AuthModule } from './modules/auth/AuthModule'; import { AuthModule } from './modules/auth/AuthModule';
import { BlogModule } from './modules/blog/BlogModule';
import { EncryptionModule } from './modules/encryption/EncryptionModule'; import { EncryptionModule } from './modules/encryption/EncryptionModule';
import { HealthCheckModule } from './modules/healthCheck/HealthCheckModule'; import { HealthCheckModule } from './modules/healthCheck/HealthCheckModule';
import { MoneroWalletModule } from './modules/moneroWallet/MoneroWalletModule'; import { MoneroWalletModule } from './modules/moneroWallet/MoneroWalletModule';
@@ -37,6 +38,7 @@ import { StorefrontCheckoutModule } from './modules/storefrontCheckout/Storefron
import { StorefrontOrderModule } from './modules/storefrontOrder/StorefrontOrderModule'; import { StorefrontOrderModule } from './modules/storefrontOrder/StorefrontOrderModule';
import { StorefrontCoreModule } from './modules/storefrontCore/StorefrontCoreModule'; import { StorefrontCoreModule } from './modules/storefrontCore/StorefrontCoreModule';
import { StorefrontProductModule } from './modules/storefrontProduct/StorefrontProductModule'; import { StorefrontProductModule } from './modules/storefrontProduct/StorefrontProductModule';
import { StorefrontBlogModule } from './modules/storefrontBlog/StorefrontBlogModule';
import { ExchangeRateModule } from './modules/exchangeRate/ExchangeRateModule'; import { ExchangeRateModule } from './modules/exchangeRate/ExchangeRateModule';
import { Config } from './types/Config'; import { Config } from './types/Config';
@@ -85,6 +87,7 @@ import { Config } from './types/Config';
HealthCheckModule, HealthCheckModule,
EncryptionModule, EncryptionModule,
AuthModule, AuthModule,
BlogModule,
ProductsModule, ProductsModule,
ShopSettingsModule, ShopSettingsModule,
SimplexModule, SimplexModule,
@@ -99,7 +102,8 @@ import { Config } from './types/Config';
StorefrontProductModule, StorefrontProductModule,
StorefrontCartModule, StorefrontCartModule,
StorefrontCheckoutModule, StorefrontCheckoutModule,
StorefrontOrderModule StorefrontOrderModule,
StorefrontBlogModule
], ],
providers: [ providers: [
{ {
+12
View File
@@ -97,6 +97,7 @@ export const getAppConfig = (): AppConfig => {
}, },
validation: { validation: {
productTitleMaxLength: envInt('VALIDATION_PRODUCT_TITLE_MAX_LENGTH'), productTitleMaxLength: envInt('VALIDATION_PRODUCT_TITLE_MAX_LENGTH'),
blogPostTitleMaxLength: envInt('VALIDATION_BLOG_POST_TITLE_MAX_LENGTH'),
categoryNameMaxLength: envInt('VALIDATION_CATEGORY_NAME_MAX_LENGTH'), categoryNameMaxLength: envInt('VALIDATION_CATEGORY_NAME_MAX_LENGTH'),
discountCodeMaxLength: envInt('VALIDATION_DISCOUNT_CODE_MAX_LENGTH'), discountCodeMaxLength: envInt('VALIDATION_DISCOUNT_CODE_MAX_LENGTH'),
variantImagesMax: envInt('VALIDATION_VARIANT_IMAGES_MAX'), variantImagesMax: envInt('VALIDATION_VARIANT_IMAGES_MAX'),
@@ -127,6 +128,17 @@ export const getProductThumbMulterConfig = (): MulterConfig => {
return { allowedMimes, maxFileBytes }; return { allowedMimes, maxFileBytes };
}; };
export const getBlogImageMulterConfig = (): MulterConfig => {
const allowedMimes = env('MULTER_BLOG_IMAGE_ALLOWED_MIMES')
.split(',')
.map(v => v.trim())
.filter(Boolean);
const maxFileBytes = envInt('MULTER_BLOG_IMAGE_MAX_FILE_BYTES');
return { allowedMimes, maxFileBytes };
};
export const getShopLogoMulterConfig = (): MulterConfig => { export const getShopLogoMulterConfig = (): MulterConfig => {
const allowedMimes = env('MULTER_SHOP_LOGO_ALLOWED_MIMES') const allowedMimes = env('MULTER_SHOP_LOGO_ALLOWED_MIMES')
.split(',') .split(',')
@@ -0,0 +1,16 @@
import path from 'node:path';
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');
+14
View File
@@ -31,6 +31,20 @@ export const variantImagesUrlPrefix = '/uploads/public/variants/images/';
export const getVariantImagePublicUrl = (filename: string): string => `${variantImagesUrlPrefix}${filename}`; export const getVariantImagePublicUrl = (filename: string): string => `${variantImagesUrlPrefix}${filename}`;
export const getBlogImagesDir = (): string => join(getPublicUploadsDir(), 'blog', 'images');
export const blogImagesUrlPrefix = '/uploads/public/blog/images/';
export const getBlogImagePublicUrl = (filename: string): string => `${blogImagesUrlPrefix}${filename}`;
export const resolveBlogImagePath = (storageKey: string): string => {
const baseDir = getBlogImagesDir();
const absolutePath = resolvePathWithinDir(baseDir, storageKey);
return absolutePath;
};
export const getShopBrandingDir = (): string => join(getPublicUploadsDir(), 'shop'); export const getShopBrandingDir = (): string => join(getPublicUploadsDir(), 'shop');
export const shopBrandingUrlPrefix = '/uploads/public/shop/'; export const shopBrandingUrlPrefix = '/uploads/public/shop/';
+16
View File
@@ -75,6 +75,16 @@ class EnvironmentVariables {
@Max(10 * 1024 * 1024) // 10MB @Max(10 * 1024 * 1024) // 10MB
MULTER_PRODUCT_THUMB_MAX_FILE_BYTES: number; MULTER_PRODUCT_THUMB_MAX_FILE_BYTES: number;
@IsNotEmpty()
@IsString()
MULTER_BLOG_IMAGE_ALLOWED_MIMES: string;
@IsNotEmpty()
@IsNumber()
@Min(1)
@Max(10 * 1024 * 1024) // 10MB
MULTER_BLOG_IMAGE_MAX_FILE_BYTES: number;
@IsNotEmpty() @IsNotEmpty()
@IsString() @IsString()
MULTER_SHOP_LOGO_ALLOWED_MIMES: string; MULTER_SHOP_LOGO_ALLOWED_MIMES: string;
@@ -101,6 +111,12 @@ class EnvironmentVariables {
@Max(256) @Max(256)
VALIDATION_PRODUCT_TITLE_MAX_LENGTH: number; VALIDATION_PRODUCT_TITLE_MAX_LENGTH: number;
@IsNotEmpty()
@IsNumber()
@Min(1)
@Max(256)
VALIDATION_BLOG_POST_TITLE_MAX_LENGTH: number;
@IsNotEmpty() @IsNotEmpty()
@IsNumber() @IsNumber()
@Min(1) @Min(1)
@@ -0,0 +1,7 @@
import { storefrontImgUrlPrefix } from '../config/storefrontAssetPaths';
import { PaymentMethod } from '../modules/payment/types/PaymentMethod';
export const paymentMethodIconUrl: Record<PaymentMethod, string> = {
[PaymentMethod.Xmr]: `${storefrontImgUrlPrefix}/xmr.png`,
[PaymentMethod.Btc]: `${storefrontImgUrlPrefix}/btc.png`
};
@@ -0,0 +1 @@
export const STOREFRONT_THEME_PREFERENCES = ['light', 'dark', 'system'] as const;
@@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddBlog1784900000000 implements MigrationInterface {
name = 'AddBlog1784900000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "blog_posts" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "title" character varying NOT NULL, "thumbnailStorageKey" character varying, "bannerStorageKey" character varying, "contentHtml" text NOT NULL, "isDraft" boolean NOT NULL DEFAULT true, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_blog_posts_id" PRIMARY KEY ("id"))`
);
await queryRunner.query(`ALTER TABLE "shop_settings" ADD "blogEnabled" boolean NOT NULL DEFAULT false`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "shop_settings" DROP COLUMN "blogEnabled"`);
await queryRunner.query(`DROP TABLE "blog_posts"`);
}
}
+21 -3
View File
@@ -5,6 +5,14 @@ import { ConfigService } from '@nestjs/config';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import hbs from 'hbs'; import hbs from 'hbs';
import path from 'node:path'; import path from 'node:path';
import {
getStorefrontCssDir,
getStorefrontImgDir,
getStorefrontJsDir,
storefrontCssUrlPrefix,
storefrontImgUrlPrefix,
storefrontJsUrlPrefix
} from './config/storefrontAssetPaths';
import { getPublicUploadsDir, publicUploadsUrlPrefix } from './config/uploadPaths'; import { getPublicUploadsDir, publicUploadsUrlPrefix } from './config/uploadPaths';
import { AppModule } from './AppModule'; import { AppModule } from './AppModule';
import { registerStorefrontHelpers } from './modules/storefrontCore/utils/registerStorefrontHelpers'; import { registerStorefrontHelpers } from './modules/storefrontCore/utils/registerStorefrontHelpers';
@@ -26,10 +34,18 @@ async function bootstrap() {
immutable: true immutable: true
}); });
const storefrontPublicDir = path.join(__dirname, 'modules', 'storefrontCore', 'public'); app.useStaticAssets(getStorefrontImgDir(), {
prefix: storefrontImgUrlPrefix,
maxAge: '1y',
immutable: true
});
app.useStaticAssets(storefrontPublicDir, { app.useStaticAssets(getStorefrontCssDir(), {
prefix: '/shop/assets' prefix: storefrontCssUrlPrefix
});
app.useStaticAssets(getStorefrontJsDir(), {
prefix: storefrontJsUrlPrefix
}); });
app.use(cookieParser()); app.use(cookieParser());
@@ -61,6 +77,8 @@ async function bootstrap() {
{ path: '/', method: RequestMethod.GET }, { path: '/', method: RequestMethod.GET },
{ path: 'shop/categories/:id', method: RequestMethod.GET }, { path: 'shop/categories/:id', method: RequestMethod.GET },
{ path: 'shop/products/:id/variants/:variantId', method: RequestMethod.GET }, { path: 'shop/products/:id/variants/:variantId', method: RequestMethod.GET },
{ path: 'shop/blog', method: RequestMethod.GET },
{ path: 'shop/blog/:id', method: RequestMethod.GET },
{ path: 'shop/assets/{*path}', method: RequestMethod.GET }, { path: 'shop/assets/{*path}', method: RequestMethod.GET },
{ path: 'shop/preferences/theme', method: RequestMethod.POST }, { path: 'shop/preferences/theme', method: RequestMethod.POST },
{ path: 'shop/error', method: RequestMethod.GET }, { path: 'shop/error', method: RequestMethod.GET },
@@ -34,13 +34,17 @@ describe('ElectrumWalletRpcClient', () => {
tx_hash: 'abc123', tx_hash: 'abc123',
height: 800_000 height: 800_000
}, },
'50000', {
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
},
'bc1qtest',
800_002 800_002
) )
).toEqual({ ).toEqual({
txHash: 'abc123', txHash: 'abc123',
amountAtomic: '50000', amountAtomic: '50000',
confirmations: 3 confirmations: 3,
inputOutpoints: []
}); });
}); });
@@ -51,13 +55,17 @@ describe('ElectrumWalletRpcClient', () => {
tx_hash: 'abc123', tx_hash: 'abc123',
height: 0 height: 0
}, },
'50000', {
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
},
'bc1qtest',
800_002 800_002
) )
).toEqual({ ).toEqual({
txHash: 'abc123', txHash: 'abc123',
amountAtomic: '50000', amountAtomic: '50000',
confirmations: 0 confirmations: 0,
inputOutpoints: []
}); });
}); });
@@ -68,11 +76,61 @@ describe('ElectrumWalletRpcClient', () => {
tx_hash: 'abc123', tx_hash: 'abc123',
height: 800_000 height: 800_000
}, },
'0', {
outputs: [{ address: 'bc1qother', value_sats: 10_000 }]
},
'bc1qtest',
800_002 800_002
) )
).toBeNull(); ).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', () => { describe('sumOutputValueAtomic', () => {
@@ -125,6 +183,7 @@ describe('ElectrumWalletRpcClient', () => {
jsonrpc: '2.0', jsonrpc: '2.0',
id: 'nullcart', id: 'nullcart',
result: { result: {
inputs: [{ prevout_hash: 'input123', prevout_n: 0 }],
outputs: [ outputs: [
{ address: 'bc1qother', value_sats: 10_000 }, { address: 'bc1qother', value_sats: 10_000 },
{ address: 'bc1qtest', value_sats: 50_000 } { address: 'bc1qtest', value_sats: 50_000 }
@@ -137,7 +196,8 @@ describe('ElectrumWalletRpcClient', () => {
{ {
txHash: 'abc123', txHash: 'abc123',
amountAtomic: '50000', amountAtomic: '50000',
confirmations: 3 confirmations: 3,
inputOutpoints: ['input123:0']
} }
]); ]);
@@ -164,6 +224,105 @@ describe('ElectrumWalletRpcClient', () => {
expect.any(Object) 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', () => { describe('createAddress', () => {
@@ -200,21 +200,26 @@ export class ElectrumWalletRpcClient {
const serializedTransaction = await this.call<string>('gettransaction', { txid: txHash }); const serializedTransaction = await this.call<string>('gettransaction', { txid: txHash });
const transaction = await this.deserializeTransaction(serializedTransaction); 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( private mapIncomingTransfer(
entry: ElectrumWalletAddressHistoryEntry, entry: ElectrumWalletAddressHistoryEntry,
amountAtomic: string, transaction: ElectrumWalletDeserializedTransaction,
address: string,
blockHeight: number | null blockHeight: number | null
): ElectrumWalletIncomingTransfer | null { ): ElectrumWalletIncomingTransfer | null {
const txHash = entry.tx_hash; const txHash = entry.tx_hash;
const amountAtomic = this.sumOutputValueAtomic(transaction, address);
if (!txHash || amountAtomic === '0') { if (!txHash || amountAtomic === '0') {
return null; return null;
@@ -226,7 +231,60 @@ export class ElectrumWalletRpcClient {
return { return {
txHash, txHash,
amountAtomic, 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 = { import type { ElectrumWalletDeserializedInput } from './ElectrumWalletDeserializedInput';
address?: string; import type { ElectrumWalletDeserializedOutput } from './ElectrumWalletDeserializedOutput';
value_sats: number;
};
export type ElectrumWalletDeserializedTransaction = { export type ElectrumWalletDeserializedTransaction = {
inputs?: ElectrumWalletDeserializedInput[];
outputs: ElectrumWalletDeserializedOutput[]; outputs: ElectrumWalletDeserializedOutput[];
}; };
@@ -2,4 +2,5 @@ export type ElectrumWalletIncomingTransfer = {
txHash: string; txHash: string;
amountAtomic: string; amountAtomic: string;
confirmations: number; confirmations: number;
inputOutpoints: string[];
}; };
@@ -5,8 +5,12 @@ import type { ElectrumWalletIncomingTransfer } from './ElectrumWalletIncomingTra
export type ElectrumWalletRpcClientTest = { export type ElectrumWalletRpcClientTest = {
mapIncomingTransfer: ( mapIncomingTransfer: (
entry: ElectrumWalletAddressHistoryEntry, entry: ElectrumWalletAddressHistoryEntry,
amountAtomic: string, transaction: ElectrumWalletDeserializedTransaction,
address: string,
blockHeight: number | null blockHeight: number | null
) => ElectrumWalletIncomingTransfer | null; ) => ElectrumWalletIncomingTransfer | null;
filterSupersededBitcoinTransfers: (
transfers: ElectrumWalletIncomingTransfer[]
) => ElectrumWalletIncomingTransfer[];
sumOutputValueAtomic: (transaction: ElectrumWalletDeserializedTransaction, address: string) => string; sumOutputValueAtomic: (transaction: ElectrumWalletDeserializedTransaction, address: string) => string;
}; };
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BlogPostsController } from './controllers/BlogPostsController';
import { BlogPost } from './entities/BlogPost';
import { BlogPostsService } from './services/BlogPostsService';
@Module({
imports: [TypeOrmModule.forFeature([BlogPost])],
controllers: [BlogPostsController],
providers: [BlogPostsService],
exports: [TypeOrmModule.forFeature([BlogPost])]
})
export class BlogModule {}
@@ -0,0 +1,12 @@
import { getBlogImageMulterConfig } from '../../../config';
import { getBlogImagesDir } from '../../../config/uploadPaths';
import { createDiskStorageUploadOptions } from '../../../utils/createDiskStorageUploadOptions';
import { createUploadFilePipe } from '../../../utils/createUploadFilePipe';
const uploadDir = getBlogImagesDir();
export const blogImageUploadOptions = createDiskStorageUploadOptions(uploadDir);
const { allowedMimes, maxFileBytes } = getBlogImageMulterConfig();
export const blogImageFilePipe = createUploadFilePipe(allowedMimes, maxFileBytes);
@@ -0,0 +1,72 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
UploadedFile,
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { JwtGuard } from '../../../guards/JwtGuard';
import { blogImageFilePipe, blogImageUploadOptions } from '../config/blogImageUpload';
import { ListBlogPostsQueryDto } from '../dto/ListBlogPostsQueryDto';
import { UpdateBlogPostDto } from '../dto/UpdateBlogPostDto';
import { BlogPostsService } from '../services/BlogPostsService';
@Controller('blog-posts')
@UseGuards(JwtGuard)
export class BlogPostsController {
constructor(private readonly blogPostsService: BlogPostsService) {}
@Post('/')
createDraft() {
return this.blogPostsService.createDraft();
}
@Get('/')
findAll(@Query() query: ListBlogPostsQueryDto) {
return this.blogPostsService.findAll(query);
}
@Get('/:id')
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.blogPostsService.findOne(id);
}
@Patch('/:id')
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateBlogPostDto) {
return this.blogPostsService.update(id, dto);
}
@Post('/:id/thumbnail')
@UseInterceptors(FileInterceptor('file', blogImageUploadOptions))
uploadThumbnail(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFile(blogImageFilePipe) file: Express.Multer.File
) {
return this.blogPostsService.uploadThumbnail(id, file.filename);
}
@Post('/:id/banner')
@UseInterceptors(FileInterceptor('file', blogImageUploadOptions))
uploadBanner(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFile(blogImageFilePipe) file: Express.Multer.File
) {
return this.blogPostsService.uploadBanner(id, file.filename);
}
@Delete('/:id')
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id', ParseUUIDPipe) id: string) {
await this.blogPostsService.remove(id);
}
}
@@ -0,0 +1,17 @@
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
export class ListBlogPostsQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number;
}
@@ -0,0 +1,21 @@
import { IsBoolean, IsNotEmpty, IsString, MaxLength } from 'class-validator';
import { getAppConfig } from '../../../config';
const {
validation: { blogPostTitleMaxLength }
} = getAppConfig();
export class UpdateBlogPostDto {
@IsNotEmpty()
@IsString()
@MaxLength(blogPostTitleMaxLength)
title: string;
@IsString()
contentHtml: string;
@IsNotEmpty()
@IsBoolean()
isDraft: boolean;
}
@@ -0,0 +1,28 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
@Entity('blog_posts')
export class BlogPost {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
title: string;
@Column({ type: 'varchar', nullable: true })
thumbnailStorageKey: string | null;
@Column({ type: 'varchar', nullable: true })
bannerStorageKey: string | null;
@Column({ type: 'text' })
contentHtml: string;
@Column({ type: 'boolean', default: true })
isDraft: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,181 @@
import { NotFoundException } from '@nestjs/common';
import type { Repository } from 'typeorm';
import { getBlogImagePublicUrl, resolveBlogImagePath } from '../../../config/uploadPaths';
import { removeFileFromDisk } from '../../../utils/removeFileFromDisk';
import { BlogPost } from '../entities/BlogPost';
import { BlogPostsService } from './BlogPostsService';
jest.mock('../../../utils/removeFileFromDisk', () => ({
removeFileFromDisk: jest.fn().mockResolvedValue(undefined)
}));
describe('BlogPostsService', () => {
let service: BlogPostsService;
let blogPostRepo: {
findAndCount: jest.Mock;
findOne: jest.Mock;
create: jest.Mock;
insert: jest.Mock;
update: jest.Mock;
exists: jest.Mock;
delete: jest.Mock;
};
beforeEach(() => {
jest.mocked(removeFileFromDisk).mockClear();
blogPostRepo = {
findAndCount: jest.fn().mockResolvedValue([[], 0]),
findOne: jest.fn().mockResolvedValue(null),
create: jest.fn(data => ({ id: 'post-1', ...data })),
insert: jest.fn().mockResolvedValue(undefined),
update: jest.fn().mockResolvedValue(undefined),
exists: jest.fn().mockResolvedValue(false),
delete: jest.fn().mockResolvedValue(undefined)
};
service = new BlogPostsService(blogPostRepo as unknown as Repository<BlogPost>);
});
it('creates a draft post with empty title and content', async () => {
blogPostRepo.findOne.mockResolvedValue({
id: 'post-1',
title: '',
contentHtml: '',
isDraft: true
});
const created = await service.createDraft();
expect(blogPostRepo.create).toHaveBeenCalledWith({
title: '',
contentHtml: '',
isDraft: true
});
expect(blogPostRepo.insert).toHaveBeenCalled();
expect(created).toEqual(
expect.objectContaining({
id: 'post-1',
title: '',
contentHtml: '',
isDraft: true,
thumbnailUrl: null,
bannerUrl: null
})
);
});
it('maps image storage keys to public urls', async () => {
blogPostRepo.findOne.mockResolvedValue({
id: 'post-1',
title: 'Hello',
thumbnailStorageKey: 'thumb.webp',
bannerStorageKey: 'banner.webp',
contentHtml: '<p>Body</p>',
isDraft: false
});
await expect(service.findOne('post-1')).resolves.toEqual(
expect.objectContaining({
title: 'Hello',
thumbnailUrl: getBlogImagePublicUrl('thumb.webp'),
bannerUrl: getBlogImagePublicUrl('banner.webp')
})
);
});
it('lists posts ordered by createdAt descending', async () => {
await service.findAll({ page: 2, limit: 10 });
expect(blogPostRepo.findAndCount).toHaveBeenCalledWith({
order: { createdAt: 'DESC' },
skip: 10,
take: 10
});
});
it('trims title on update', async () => {
blogPostRepo.exists.mockResolvedValueOnce(true);
blogPostRepo.findOne.mockResolvedValueOnce({
id: 'post-1',
title: 'Hello',
contentHtml: '<p>Body</p>',
isDraft: false
});
await service.update('post-1', {
title: ' Hello ',
contentHtml: '<p>Body</p>',
isDraft: false
});
expect(blogPostRepo.update).toHaveBeenCalledWith('post-1', {
title: 'Hello',
contentHtml: '<p>Body</p>',
isDraft: false
});
});
it('uploads a thumbnail and removes the previous file', async () => {
blogPostRepo.findOne
.mockResolvedValueOnce({ id: 'post-1', thumbnailStorageKey: 'old.webp' })
.mockResolvedValueOnce({ id: 'post-1', thumbnailStorageKey: 'new.webp' });
await service.uploadThumbnail('post-1', 'new.webp');
expect(blogPostRepo.update).toHaveBeenCalledWith('post-1', { thumbnailStorageKey: 'new.webp' });
expect(removeFileFromDisk).toHaveBeenCalledWith(resolveBlogImagePath('old.webp'), BlogPostsService.name);
});
it('uploads a banner and removes the previous file', async () => {
blogPostRepo.findOne
.mockResolvedValueOnce({ id: 'post-1', bannerStorageKey: 'old-banner.webp' })
.mockResolvedValueOnce({ id: 'post-1', bannerStorageKey: 'new-banner.webp' });
await service.uploadBanner('post-1', 'new-banner.webp');
expect(blogPostRepo.update).toHaveBeenCalledWith('post-1', { bannerStorageKey: 'new-banner.webp' });
expect(removeFileFromDisk).toHaveBeenCalledWith(
resolveBlogImagePath('old-banner.webp'),
BlogPostsService.name
);
});
it('throws when loading a missing post', async () => {
await expect(service.findOne('missing-id')).rejects.toThrow(NotFoundException);
});
it('throws when updating a missing post', async () => {
blogPostRepo.exists.mockResolvedValueOnce(false);
await expect(
service.update('missing-id', {
title: 'Hello',
contentHtml: '',
isDraft: true
})
).rejects.toThrow(NotFoundException);
});
it('deletes an existing post and its image files', async () => {
blogPostRepo.findOne.mockResolvedValueOnce({
id: 'post-1',
thumbnailStorageKey: 'thumb.webp',
bannerStorageKey: 'banner.webp'
});
await service.remove('post-1');
expect(blogPostRepo.delete).toHaveBeenCalledWith('post-1');
expect(removeFileFromDisk).toHaveBeenCalledWith(resolveBlogImagePath('thumb.webp'), BlogPostsService.name);
expect(removeFileFromDisk).toHaveBeenCalledWith(
resolveBlogImagePath('banner.webp'),
BlogPostsService.name
);
});
it('throws when deleting a missing post', async () => {
await expect(service.remove('missing-id')).rejects.toThrow(NotFoundException);
expect(blogPostRepo.delete).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,125 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { getBlogImagePublicUrl, resolveBlogImagePath } from '../../../config/uploadPaths';
import type { PaginatedResponse } from '../../../types/PaginatedResponse';
import { removeFileFromDisk } from '../../../utils/removeFileFromDisk';
import type { ListBlogPostsQueryDto } from '../dto/ListBlogPostsQueryDto';
import { UpdateBlogPostDto } from '../dto/UpdateBlogPostDto';
import { BlogPost } from '../entities/BlogPost';
import type { BlogPostExtended } from '../types/BlogPostExtended';
@Injectable()
export class BlogPostsService {
constructor(
@InjectRepository(BlogPost)
private readonly blogPostRepo: Repository<BlogPost>
) {}
async findAll({ page = 1, limit = 20 }: ListBlogPostsQueryDto): Promise<PaginatedResponse<BlogPostExtended>> {
const [items, total] = await this.blogPostRepo.findAndCount({
order: { createdAt: 'DESC' },
skip: (page - 1) * limit,
take: limit
});
const itemsExtended = items.map(post => this.toPostExtended(post));
return { items: itemsExtended, total, page, limit };
}
async createDraft(): Promise<BlogPostExtended> {
const entity = this.blogPostRepo.create({
title: '',
contentHtml: '',
isDraft: true
});
await this.blogPostRepo.insert(entity);
return this.findOne(entity.id);
}
async findOne(id: string): Promise<BlogPostExtended> {
const entity = await this.findEntity(id);
return this.toPostExtended(entity);
}
async update(id: string, { title, contentHtml, isDraft }: UpdateBlogPostDto): Promise<BlogPostExtended> {
const exists = await this.blogPostRepo.exists({ where: { id } });
if (!exists) {
throw new NotFoundException();
}
await this.blogPostRepo.update(id, {
title: title.trim(),
contentHtml,
isDraft
});
return this.findOne(id);
}
async uploadThumbnail(id: string, filename: string): Promise<BlogPostExtended> {
const post = await this.findEntity(id);
const previousStorageKey = post.thumbnailStorageKey;
await this.blogPostRepo.update(id, { thumbnailStorageKey: filename });
await this.removeBlogImageFile(previousStorageKey);
return this.findOne(id);
}
async uploadBanner(id: string, filename: string): Promise<BlogPostExtended> {
const post = await this.findEntity(id);
const previousStorageKey = post.bannerStorageKey;
await this.blogPostRepo.update(id, { bannerStorageKey: filename });
await this.removeBlogImageFile(previousStorageKey);
return this.findOne(id);
}
async remove(id: string): Promise<void> {
const post = await this.findEntity(id);
await this.blogPostRepo.delete(id);
await Promise.all([
this.removeBlogImageFile(post.thumbnailStorageKey),
this.removeBlogImageFile(post.bannerStorageKey)
]);
}
private async findEntity(id: string): Promise<BlogPost> {
const entity = await this.blogPostRepo.findOne({ where: { id } });
if (!entity) {
throw new NotFoundException();
}
return entity;
}
private toPostExtended(post: BlogPost): BlogPostExtended {
return {
...post,
thumbnailUrl: post.thumbnailStorageKey ? getBlogImagePublicUrl(post.thumbnailStorageKey) : null,
bannerUrl: post.bannerStorageKey ? getBlogImagePublicUrl(post.bannerStorageKey) : null
};
}
private async removeBlogImageFile(storageKey: string | null): Promise<void> {
if (!storageKey) {
return;
}
const path = resolveBlogImagePath(storageKey);
await removeFileFromDisk(path, BlogPostsService.name);
}
}
@@ -0,0 +1,6 @@
import type { BlogPost } from '../entities/BlogPost';
export type BlogPostExtended = BlogPost & {
thumbnailUrl: string | null;
bannerUrl: string | null;
};
@@ -260,10 +260,6 @@ export class OrderService {
amountFiat: deliveryCost amountFiat: deliveryCost
}); });
if (!shippingInvoice) {
throw new InternalServerErrorException('Failed to issue shipping invoice');
}
await this.orderRepo.update(orderId, { await this.orderRepo.update(orderId, {
shippingInvoice: { id: shippingInvoice.id }, shippingInvoice: { id: shippingInvoice.id },
quotedAt quotedAt
@@ -87,6 +87,7 @@ describe('InvoicePaymentService', () => {
}; };
let paymentRepo: { let paymentRepo: {
update: jest.Mock; update: jest.Mock;
delete: jest.Mock;
createQueryBuilder: jest.Mock; createQueryBuilder: jest.Mock;
}; };
let insertQueryBuilder: { let insertQueryBuilder: {
@@ -133,6 +134,7 @@ describe('InvoicePaymentService', () => {
paymentRepo = { paymentRepo = {
update: jest.fn().mockResolvedValue(undefined), update: jest.fn().mockResolvedValue(undefined),
delete: jest.fn().mockResolvedValue(undefined),
createQueryBuilder: jest.fn().mockReturnValue(insertQueryBuilder) createQueryBuilder: jest.fn().mockReturnValue(insertQueryBuilder)
}; };
@@ -384,13 +386,35 @@ describe('InvoicePaymentService', () => {
expect(paymentRepo.update).not.toHaveBeenCalled(); 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()); transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildXmrInvoice());
await service.processInvoice('invoice-1', []); await service.processInvoice('invoice-1', []);
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled(); expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
expect(paymentRepo.update).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 () => { it('skips transfers below the configured minimum', async () => {
@@ -528,5 +552,34 @@ describe('InvoicePaymentService', () => {
confirmations: 1 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> { private async processInvoice(invoiceId: string, transfers: InvoiceIncomingTransfer[]): Promise<void> {
const { minByMethod } = this.configService.get('invoice') as Config['invoice'];
await this.dataSource.transaction(async manager => { await this.dataSource.transaction(async manager => {
const invoiceRepo = manager.getRepository(Invoice); const invoiceRepo = manager.getRepository(Invoice);
const paymentRepo = manager.getRepository(InvoicePayment); const paymentRepo = manager.getRepository(InvoicePayment);
@@ -152,36 +150,62 @@ export class InvoicePaymentService {
return; return;
} }
const minIncomingAtomic = minByMethod[invoice.paymentMethod]; await this.upsertIncomingPayments(paymentRepo, invoice, transfers);
const knownByTxHash = new Map((invoice.payments ?? []).map(payment => [payment.txHash, payment]));
for (const transfer of transfers) { await this.pruneAbsentUnconfirmedPayments(paymentRepo, invoice, 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();
}
}); });
} }
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);
}
}
}
} }
@@ -1,4 +1,4 @@
import { Logger, ServiceUnavailableException } from '@nestjs/common'; import { Logger, InternalServerErrorException, ServiceUnavailableException } from '@nestjs/common';
import type { ConfigService } from '@nestjs/config'; import type { ConfigService } from '@nestjs/config';
import type { Repository } from 'typeorm'; import type { Repository } from 'typeorm';
import type { ElectrumWalletRpcClient } from '../../bitcoinWallet/services/ElectrumWalletRpcClient'; import type { ElectrumWalletRpcClient } from '../../bitcoinWallet/services/ElectrumWalletRpcClient';
@@ -301,4 +301,15 @@ describe('InvoiceService', () => {
) )
); );
}); });
it('throws for unsupported payment methods', async () => {
await expect(
service.issueInvoice({
paymentMethod: 'eth' as PaymentMethod,
reason: InvoiceReason.Checkout,
contextId: 'session-uuid',
amountFiat: 15
})
).rejects.toThrow(new InternalServerErrorException('Unsupported payment method: eth'));
});
}); });
@@ -1,4 +1,4 @@
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; import { Injectable, InternalServerErrorException, Logger, ServiceUnavailableException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
@@ -32,12 +32,14 @@ export class InvoiceService {
private readonly exchangeRateService: ExchangeRateService private readonly exchangeRateService: ExchangeRateService
) {} ) {}
async issueInvoice(data: IssueInvoiceData): Promise<Invoice | undefined> { async issueInvoice(data: IssueInvoiceData): Promise<Invoice> {
switch (data.paymentMethod) { switch (data.paymentMethod) {
case PaymentMethod.Xmr: case PaymentMethod.Xmr:
return this.issueXmrInvoice(data); return this.issueXmrInvoice(data);
case PaymentMethod.Btc: case PaymentMethod.Btc:
return this.issueBtcInvoice(data); return this.issueBtcInvoice(data);
default:
throw new InternalServerErrorException(`Unsupported payment method: ${String(data.paymentMethod)}`);
} }
} }
@@ -13,6 +13,7 @@ import { JwtGuard } from '../../../guards/JwtGuard';
import { shopFaviconFilePipe, shopFaviconUploadOptions } from '../config/shopFaviconUpload'; import { shopFaviconFilePipe, shopFaviconUploadOptions } from '../config/shopFaviconUpload';
import { shopLogoFilePipe, shopLogoUploadOptions } from '../config/shopLogoUpload'; import { shopLogoFilePipe, shopLogoUploadOptions } from '../config/shopLogoUpload';
import { ConnectSimplexNotificationsDto } from '../dto/ConnectSimplexNotificationsDto'; import { ConnectSimplexNotificationsDto } from '../dto/ConnectSimplexNotificationsDto';
import { UpdateBlogEnabledDto } from '../dto/UpdateBlogEnabledDto';
import { UpdateNotificationsDto } from '../dto/UpdateNotificationsDto'; import { UpdateNotificationsDto } from '../dto/UpdateNotificationsDto';
import { UpdateShippingNoteDto } from '../dto/UpdateShippingNoteDto'; import { UpdateShippingNoteDto } from '../dto/UpdateShippingNoteDto';
import { UpdateSimplexLinkDto } from '../dto/UpdateSimplexLinkDto'; import { UpdateSimplexLinkDto } from '../dto/UpdateSimplexLinkDto';
@@ -43,6 +44,11 @@ export class ShopSettingsController {
return this.shopSettingsService.updateNotifications(dto); return this.shopSettingsService.updateNotifications(dto);
} }
@Patch('/blog-enabled')
updateBlogEnabled(@Body() dto: UpdateBlogEnabledDto) {
return this.shopSettingsService.updateBlogEnabled(dto);
}
@Post('/simplex-connect') @Post('/simplex-connect')
connectSimplexNotifications(@Body() dto: ConnectSimplexNotificationsDto) { connectSimplexNotifications(@Body() dto: ConnectSimplexNotificationsDto) {
return this.shopSettingsService.connectSimplexNotifications(dto.simplexNotificationLink); return this.shopSettingsService.connectSimplexNotifications(dto.simplexNotificationLink);
@@ -0,0 +1,7 @@
import { IsBoolean, IsNotEmpty } from 'class-validator';
export class UpdateBlogEnabledDto {
@IsNotEmpty()
@IsBoolean()
blogEnabled: boolean;
}
@@ -32,6 +32,9 @@ export class ShopSettings {
@Column({ type: 'integer', nullable: true }) @Column({ type: 'integer', nullable: true })
simplexNotificationContactId: number | null; simplexNotificationContactId: number | null;
@Column({ type: 'boolean', default: false })
blogEnabled: boolean;
@CreateDateColumn() @CreateDateColumn()
createdAt: Date; createdAt: Date;
@@ -25,6 +25,7 @@ describe('ShopSettingsService', () => {
notifyOnNewOrder: false, notifyOnNewOrder: false,
notifyOnOrderMessage: false, notifyOnOrderMessage: false,
simplexNotificationContactId: null, simplexNotificationContactId: null,
blogEnabled: false,
createdAt: new Date('2026-01-01T00:00:00.000Z'), createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z') updatedAt: new Date('2026-01-01T00:00:00.000Z')
} as ShopSettings; } as ShopSettings;
@@ -67,10 +68,27 @@ describe('ShopSettingsService', () => {
logoUrl: '/uploads/public/shop/logo.png', logoUrl: '/uploads/public/shop/logo.png',
faviconUrl: null, faviconUrl: null,
simplexLink: 'https://simplex.example', simplexLink: 'https://simplex.example',
shippingNote: 'Ships in 3 days' shippingNote: 'Ships in 3 days',
blogEnabled: false
}); });
}); });
it('updates blog enabled flag', async () => {
await service.updateBlogEnabled({ blogEnabled: true });
expect(shopSettingsRepo.update).toHaveBeenCalledWith('settings-1', { blogEnabled: true });
});
it('includes blog enabled in shop settings view', async () => {
shopSettingsRepo.find.mockResolvedValue([{ ...settings, blogEnabled: true }]);
await expect(service.getView()).resolves.toEqual(
expect.objectContaining({
blogEnabled: true
})
);
});
it('trims simplex links on update', async () => { it('trims simplex links on update', async () => {
await service.updateSimplexLink({ simplexLink: ' https://simplex.example/new ' }); await service.updateSimplexLink({ simplexLink: ' https://simplex.example/new ' });
@@ -6,6 +6,7 @@ import { getShopBrandingPublicUrl, resolveShopBrandingPath } from '../../../conf
import { Config } from '../../../types/Config'; import { Config } from '../../../types/Config';
import { removeFileFromDisk } from '../../../utils/removeFileFromDisk'; import { removeFileFromDisk } from '../../../utils/removeFileFromDisk';
import { SimplexChatClient } from '../../simplex/services/SimplexChatClient'; import { SimplexChatClient } from '../../simplex/services/SimplexChatClient';
import { UpdateBlogEnabledDto } from '../dto/UpdateBlogEnabledDto';
import { UpdateNotificationsDto } from '../dto/UpdateNotificationsDto'; import { UpdateNotificationsDto } from '../dto/UpdateNotificationsDto';
import { UpdateShippingNoteDto } from '../dto/UpdateShippingNoteDto'; import { UpdateShippingNoteDto } from '../dto/UpdateShippingNoteDto';
import { UpdateSimplexLinkDto } from '../dto/UpdateSimplexLinkDto'; import { UpdateSimplexLinkDto } from '../dto/UpdateSimplexLinkDto';
@@ -43,7 +44,8 @@ export class ShopSettingsService {
logoUrl, logoUrl,
faviconUrl, faviconUrl,
simplexLink: settings.simplexLink, simplexLink: settings.simplexLink,
shippingNote: settings.shippingNote shippingNote: settings.shippingNote,
blogEnabled: settings.blogEnabled
}; };
} }
@@ -93,6 +95,14 @@ export class ShopSettingsService {
return this.getView(); return this.getView();
} }
async updateBlogEnabled({ blogEnabled }: UpdateBlogEnabledDto): Promise<ShopSettingsView> {
const settings = await this.findSettings();
await this.shopSettingsRepo.update(settings.id, { blogEnabled });
return this.getView();
}
async uploadLogo(filename: string): Promise<ShopSettingsView> { async uploadLogo(filename: string): Promise<ShopSettingsView> {
const settings = await this.findSettings(); const settings = await this.findSettings();
@@ -191,6 +201,7 @@ export class ShopSettingsService {
notifyOnNewOrder, notifyOnNewOrder,
notifyOnOrderMessage, notifyOnOrderMessage,
simplexNotificationContactId, simplexNotificationContactId,
blogEnabled,
createdAt, createdAt,
updatedAt updatedAt
}: ShopSettings): ShopSettingsView { }: ShopSettings): ShopSettingsView {
@@ -220,6 +231,7 @@ export class ShopSettingsService {
notifyOnNewOrder, notifyOnNewOrder,
notifyOnOrderMessage, notifyOnOrderMessage,
simplexNotificationConnected: simplexNotificationContactId !== null, simplexNotificationConnected: simplexNotificationContactId !== null,
blogEnabled,
isSetupComplete, isSetupComplete,
setupChecklist, setupChecklist,
createdAt, createdAt,
@@ -18,6 +18,7 @@ export interface ShopSettingsView {
notifyOnNewOrder: boolean; notifyOnNewOrder: boolean;
notifyOnOrderMessage: boolean; notifyOnOrderMessage: boolean;
simplexNotificationConnected: boolean; simplexNotificationConnected: boolean;
blogEnabled: boolean;
isSetupComplete: boolean; isSetupComplete: boolean;
setupChecklist: SetupChecklist; setupChecklist: SetupChecklist;
createdAt: Date | null; createdAt: Date | null;
@@ -3,4 +3,5 @@ export interface StorefrontBranding {
faviconUrl: string | null; faviconUrl: string | null;
simplexLink: string | null; simplexLink: string | null;
shippingNote: string | null; shippingNote: string | null;
blogEnabled: boolean;
} }
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { BlogModule } from '../blog/BlogModule';
import { ShopSettingsModule } from '../shopSettings/ShopSettingsModule';
import { StorefrontCoreModule } from '../storefrontCore/StorefrontCoreModule';
import { StorefrontBlogController } from './controllers/StorefrontBlogController';
import { StorefrontBlogPostsService } from './services/StorefrontBlogPostsService';
import { StorefrontBlogViewService } from './services/StorefrontBlogViewService';
@Module({
imports: [BlogModule, ShopSettingsModule, StorefrontCoreModule],
controllers: [StorefrontBlogController],
providers: [StorefrontBlogPostsService, StorefrontBlogViewService]
})
export class StorefrontBlogModule {}
@@ -0,0 +1,69 @@
import { Controller, Get, NotFoundException, Param, ParseUUIDPipe, Req, Res, UseFilters } from '@nestjs/common';
import type { Request, Response } from 'express';
import { ShopSettingsService } from '../../shopSettings/services/ShopSettingsService';
import { StorefrontExceptionFilter } from '../../storefrontCore/filters/StorefrontExceptionFilter';
import { StorefrontShopViewService } from '../../storefrontCore/services/StorefrontShopViewService';
import { StorefrontBlogPostsService } from '../services/StorefrontBlogPostsService';
import { StorefrontBlogViewService } from '../services/StorefrontBlogViewService';
@Controller()
@UseFilters(StorefrontExceptionFilter)
export class StorefrontBlogController {
constructor(
private readonly blogPostsService: StorefrontBlogPostsService,
private readonly blogViewService: StorefrontBlogViewService,
private readonly shopViewService: StorefrontShopViewService,
private readonly shopSettingsService: ShopSettingsService
) {}
@Get('shop/blog')
async blogIndex(@Req() req: Request, @Res() res: Response) {
const { blogEnabled } = await this.shopSettingsService.getStorefrontBranding();
if (!blogEnabled) {
throw new NotFoundException('We could not find that page.');
}
const [posts, shopLocals] = await Promise.all([
this.blogPostsService.listLivePosts(),
this.shopViewService.buildShopRenderLocals(req, res, {
title: 'Blog',
metaDescription: 'News and updates from {shopName}.'
})
]);
const postsView = this.blogViewService.toPostsIndexView(posts);
return res.render('blog-index', {
posts: postsView,
...shopLocals
});
}
@Get('shop/blog/:id')
async blogDetail(@Param('id', ParseUUIDPipe) id: string, @Req() req: Request, @Res() res: Response) {
const { blogEnabled } = await this.shopSettingsService.getStorefrontBranding();
if (!blogEnabled) {
throw new NotFoundException('We could not find that page.');
}
const post = await this.blogPostsService.getLivePost(id);
const postView = this.blogViewService.toPostDetailView(post);
const shopLocals = await this.shopViewService.buildShopRenderLocals(req, res, {
title: post.title,
metaDescription: `Read ${post.title} at {shopName}.`,
ogType: 'article',
ogImageUrl: postView.bannerUrl ?? postView.thumbnailUrl
});
return res.render('blog-detail', {
post: postView,
backHref: '/shop/blog',
backLabel: 'Blog',
...shopLocals
});
}
}
@@ -0,0 +1,42 @@
import { NotFoundException } from '@nestjs/common';
import type { Repository } from 'typeorm';
import { BlogPost } from '../../blog/entities/BlogPost';
import { StorefrontBlogPostsService } from './StorefrontBlogPostsService';
describe('StorefrontBlogPostsService', () => {
let service: StorefrontBlogPostsService;
let blogPostRepo: {
find: jest.Mock;
findOne: jest.Mock;
};
beforeEach(() => {
blogPostRepo = {
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn().mockResolvedValue(null)
};
service = new StorefrontBlogPostsService(blogPostRepo as unknown as Repository<BlogPost>);
});
it('lists live posts ordered by createdAt descending', async () => {
await service.listLivePosts();
expect(blogPostRepo.find).toHaveBeenCalledWith({
where: { isDraft: false },
order: { createdAt: 'DESC' }
});
});
it('returns a live post by id', async () => {
const post = { id: 'post-1', title: 'Hello', isDraft: false } as BlogPost;
blogPostRepo.findOne.mockResolvedValue(post);
await expect(service.getLivePost('post-1')).resolves.toBe(post);
});
it('throws when a draft or missing post is requested', async () => {
await expect(service.getLivePost('missing-id')).rejects.toThrow(NotFoundException);
});
});
@@ -0,0 +1,31 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BlogPost } from '../../blog/entities/BlogPost';
@Injectable()
export class StorefrontBlogPostsService {
constructor(
@InjectRepository(BlogPost)
private readonly blogPostRepo: Repository<BlogPost>
) {}
async listLivePosts(): Promise<BlogPost[]> {
return this.blogPostRepo.find({
where: { isDraft: false },
order: { createdAt: 'DESC' }
});
}
async getLivePost(id: string): Promise<BlogPost> {
const post = await this.blogPostRepo.findOne({
where: { id, isDraft: false }
});
if (!post) {
throw new NotFoundException('We could not find that blog post.');
}
return post;
}
}
@@ -0,0 +1,34 @@
import { BlogPost } from '../../blog/entities/BlogPost';
import { StorefrontBlogViewService } from './StorefrontBlogViewService';
describe('StorefrontBlogViewService', () => {
let service: StorefrontBlogViewService;
beforeEach(() => {
service = new StorefrontBlogViewService();
});
it('maps posts to card views with image urls', () => {
const views = service.toPostsIndexView([
{
id: 'post-1',
title: 'Hello',
thumbnailStorageKey: 'thumb.webp',
bannerStorageKey: null,
contentHtml: '',
isDraft: false,
createdAt: new Date(),
updatedAt: new Date()
} as BlogPost
]);
expect(views).toEqual([
{
id: 'post-1',
title: 'Hello',
thumbnailUrl: '/uploads/public/blog/images/thumb.webp',
detailHref: '/shop/blog/post-1'
}
]);
});
});
@@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import { getBlogImagePublicUrl } from '../../../config/uploadPaths';
import { BlogPost } from '../../blog/entities/BlogPost';
import type { StorefrontBlogPostCardView } from '../types/StorefrontBlogPostCardView';
import type { StorefrontBlogPostDetailView } from '../types/StorefrontBlogPostDetailView';
@Injectable()
export class StorefrontBlogViewService {
toPostsIndexView(posts: BlogPost[]): StorefrontBlogPostCardView[] {
return posts.map(post => ({
id: post.id,
title: post.title,
thumbnailUrl: post.thumbnailStorageKey ? getBlogImagePublicUrl(post.thumbnailStorageKey) : null,
detailHref: `/shop/blog/${post.id}`
}));
}
toPostDetailView(post: BlogPost): StorefrontBlogPostDetailView {
return {
id: post.id,
title: post.title,
bannerUrl: post.bannerStorageKey ? getBlogImagePublicUrl(post.bannerStorageKey) : null,
thumbnailUrl: post.thumbnailStorageKey ? getBlogImagePublicUrl(post.thumbnailStorageKey) : null,
contentHtml: post.contentHtml
};
}
}
@@ -0,0 +1,6 @@
export interface StorefrontBlogPostCardView {
id: string;
title: string;
thumbnailUrl: string | null;
detailHref: string;
}
@@ -0,0 +1,7 @@
export interface StorefrontBlogPostDetailView {
id: string;
title: string;
bannerUrl: string | null;
thumbnailUrl: string | null;
contentHtml: string;
}
@@ -1,9 +1,10 @@
import { Body, Controller, Get, HttpStatus, Post, Req, Res, UseFilters } from '@nestjs/common'; import { BadRequestException, Body, Controller, Get, HttpStatus, Post, Req, Res, UseFilters } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Throttle } from '@nestjs/throttler'; import { Throttle } from '@nestjs/throttler';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { throttleProfiles } from '../../../config/throttleProfiles'; import { throttleProfiles } from '../../../config/throttleProfiles';
import type { Config } from '../../../types/Config'; import type { Config } from '../../../types/Config';
import { paymentMethodIconUrl } from '../../../consts/paymentMethodIconUrl';
import { paymentMethodLabel } from '../../../consts/paymentMethodLabel'; import { paymentMethodLabel } from '../../../consts/paymentMethodLabel';
import { StorefrontExceptionFilter } from '../../storefrontCore/filters/StorefrontExceptionFilter'; import { StorefrontExceptionFilter } from '../../storefrontCore/filters/StorefrontExceptionFilter';
import { StorefrontCartCookieService } from '../../storefrontCore/services/StorefrontCartCookieService'; import { StorefrontCartCookieService } from '../../storefrontCore/services/StorefrontCartCookieService';
@@ -70,7 +71,8 @@ export class StorefrontCartController {
const paymentMethods = enabledPaymentMethods.map(paymentMethod => ({ const paymentMethods = enabledPaymentMethods.map(paymentMethod => ({
paymentMethod, paymentMethod,
paymentMethodLabel: paymentMethodLabel[paymentMethod] paymentMethodLabel: paymentMethodLabel[paymentMethod],
iconUrl: paymentMethodIconUrl[paymentMethod]
})); }));
return res.render('cart-summary', { return res.render('cart-summary', {
@@ -87,6 +89,10 @@ export class StorefrontCartController {
@Res() res: Response, @Res() res: Response,
@Body() { variantId, qty }: AddToCartDto @Body() { variantId, qty }: AddToCartDto
): Promise<void> { ): Promise<void> {
if (this.checkoutSessionCookieService.getSessionId(req, res)) {
throw new BadRequestException('Finish or cancel checkout before changing your cart.');
}
const cart = this.cartCookieService.getCart(req, res); const cart = this.cartCookieService.getCart(req, res);
const cartMutation = await this.cartService.addToCart(cart, variantId, qty); const cartMutation = await this.cartService.addToCart(cart, variantId, qty);
@@ -98,7 +104,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')
@@ -107,6 +113,10 @@ export class StorefrontCartController {
@Res() res: Response, @Res() res: Response,
@Body() { variantId, qty }: UpdateCartQtyDto @Body() { variantId, qty }: UpdateCartQtyDto
): Promise<void> { ): Promise<void> {
if (this.checkoutSessionCookieService.getSessionId(req, res)) {
throw new BadRequestException('Finish or cancel checkout before changing your cart.');
}
const cart = this.cartCookieService.getCart(req, res); const cart = this.cartCookieService.getCart(req, res);
const cartMutation = await this.cartService.updateCartQty(cart, variantId, qty); const cartMutation = await this.cartService.updateCartQty(cart, variantId, qty);
@@ -123,6 +133,10 @@ export class StorefrontCartController {
@Post('shop/cart/product/remove') @Post('shop/cart/product/remove')
removeFromCart(@Req() req: Request, @Res() res: Response, @Body() { variantId }: RemoveFromCartDto): void { removeFromCart(@Req() req: Request, @Res() res: Response, @Body() { variantId }: RemoveFromCartDto): void {
if (this.checkoutSessionCookieService.getSessionId(req, res)) {
throw new BadRequestException('Finish or cancel checkout before changing your cart.');
}
const cart = this.cartCookieService.getCart(req, res); const cart = this.cartCookieService.getCart(req, res);
const cartMutation = this.cartService.removeFromCart(cart, variantId); const cartMutation = this.cartService.removeFromCart(cart, variantId);
@@ -141,6 +155,10 @@ export class StorefrontCartController {
@Res() res: Response, @Res() res: Response,
@Body() { code }: ApplyDiscountCodeDto @Body() { code }: ApplyDiscountCodeDto
): Promise<void> { ): Promise<void> {
if (this.checkoutSessionCookieService.getSessionId(req, res)) {
throw new BadRequestException('Finish or cancel checkout before changing your cart.');
}
const cart = this.cartCookieService.getCart(req, res); const cart = this.cartCookieService.getCart(req, res);
const discountCodes = this.discountCookieService.getDiscountCodes(req, res); const discountCodes = this.discountCookieService.getDiscountCodes(req, res);
@@ -158,6 +176,10 @@ export class StorefrontCartController {
@Post('shop/cart/discount/remove') @Post('shop/cart/discount/remove')
removeDiscount(@Req() req: Request, @Res() res: Response, @Body() { code }: RemoveDiscountCodeDto): void { removeDiscount(@Req() req: Request, @Res() res: Response, @Body() { code }: RemoveDiscountCodeDto): void {
if (this.checkoutSessionCookieService.getSessionId(req, res)) {
throw new BadRequestException('Finish or cancel checkout before changing your cart.');
}
const discountCodes = this.discountCookieService.getDiscountCodes(req, res); const discountCodes = this.discountCookieService.getDiscountCodes(req, res);
const discountMutation = this.discountService.removeDiscountCode(discountCodes, code); const discountMutation = this.discountService.removeDiscountCode(discountCodes, code);
@@ -187,4 +209,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}`;
}
} }
@@ -56,7 +56,7 @@ describe('StorefrontCheckoutViewService', () => {
let toStorefrontInvoiceViewSpy: jest.SpiedFunction<typeof toStorefrontInvoiceViewModule.toStorefrontInvoiceView>; let toStorefrontInvoiceViewSpy: jest.SpiedFunction<typeof toStorefrontInvoiceViewModule.toStorefrontInvoiceView>;
const checkoutInvoiceView = { const checkoutInvoiceView = {
cryptoCurrency: 'XMR', paymentLabel: 'XMR',
amountFiat: 9 amountFiat: 9
} as unknown as StorefrontInvoiceView; } as unknown as StorefrontInvoiceView;
@@ -1,8 +1,9 @@
import { IsIn, IsNotEmpty } from 'class-validator'; import { IsIn, IsNotEmpty } from 'class-validator';
import { STOREFRONT_THEME_PREFERENCES } from '../../../consts/storefrontThemePreferences';
import type { StorefrontThemePreference } from '../types/StorefrontThemePreference'; import type { StorefrontThemePreference } from '../types/StorefrontThemePreference';
export class SetThemePreferenceDto { export class SetThemePreferenceDto {
@IsNotEmpty() @IsNotEmpty()
@IsIn(['light', 'dark']) @IsIn(STOREFRONT_THEME_PREFERENCES)
theme: StorefrontThemePreference; theme: StorefrontThemePreference;
} }
@@ -49,6 +49,9 @@
--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-blog-card-min: 17rem;
--sf-blog-card-max: 28rem;
--sf-sticky-header-scroll-padding: 9rem;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@@ -158,6 +161,30 @@
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 {
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 +315,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 {
@@ -355,9 +386,51 @@ a:hover {
gap: var(--sf-space-1); gap: var(--sf-space-1);
} }
.sf-rates {
display: flex;
flex-wrap: wrap;
gap: var(--sf-space-3);
align-items: center;
}
.sf-rate { .sf-rate {
display: inline-flex;
align-items: center;
gap: var(--sf-space-1);
font-size: 0.95rem; font-size: 0.95rem;
color: var(--sf-text-muted); color: var(--sf-text-muted);
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;
object-fit: contain;
flex-shrink: 0;
}
.sf-btn--pay {
gap: var(--sf-space-2);
} }
.sf-category-nav { .sf-category-nav {
@@ -488,6 +561,70 @@ 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;
}
}
.sf-btn { .sf-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -508,6 +645,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);
@@ -767,7 +908,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;
} }
@@ -844,8 +985,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;
@@ -1142,7 +1283,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 {
@@ -1181,6 +1322,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;
@@ -1401,3 +1600,75 @@ a:hover {
font-size: 2rem; font-size: 2rem;
margin: 0 0 var(--sf-space-2); margin: 0 0 var(--sf-space-2);
} }
.sf-blog-grid {
display: grid;
gap: var(--sf-space-3);
grid-template-columns: minmax(0, 1fr);
}
@media (min-width: 36rem) {
.sf-blog-grid {
grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--sf-blog-card-min)), 1fr));
}
}
.sf-blog-card {
width: 100%;
max-width: var(--sf-blog-card-max);
min-width: 0;
justify-self: start;
}
.sf-blog-card__link {
display: flex;
flex-direction: column;
gap: var(--sf-space-2);
color: inherit;
text-decoration: none;
}
.sf-blog-card__link:hover .sf-blog-card__title {
color: var(--sf-link-hover);
}
.sf-blog-card__media {
aspect-ratio: 16 / 9;
background: var(--sf-surface-muted);
border-radius: var(--sf-radius-sm);
overflow: hidden;
}
.sf-blog-card__image {
width: 100%;
height: 100%;
object-fit: cover;
}
.sf-blog-card__title {
font-size: 0.9375rem;
line-height: 1.4;
font-weight: 600;
}
.sf-blog-detail__banner {
margin-bottom: var(--sf-space-4);
background: var(--sf-surface-muted);
border-radius: var(--sf-radius-sm);
overflow: hidden;
}
.sf-blog-detail__banner-image {
display: block;
width: 100%;
max-height: 24rem;
object-fit: cover;
}
.sf-blog-detail__title {
margin-bottom: var(--sf-space-4);
}
.sf-blog-detail__content {
max-width: 42rem;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -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');
@@ -7,45 +7,30 @@ import { StorefrontCartCookieService } from './StorefrontCartCookieService';
import { StorefrontFeedbackCookieService } from './StorefrontFeedbackCookieService'; import { StorefrontFeedbackCookieService } from './StorefrontFeedbackCookieService';
import { StorefrontOrderAuthCookieService } from './StorefrontOrderAuthCookieService'; import { StorefrontOrderAuthCookieService } from './StorefrontOrderAuthCookieService';
import { StorefrontThemeCookieService } from './StorefrontThemeCookieService'; import { StorefrontThemeCookieService } from './StorefrontThemeCookieService';
import { StorefrontCheckoutSessionCookieService } from './StorefrontCheckoutSessionCookieService';
import type { StorefrontPageMetaInput } from '../types/StorefrontPageMetaInput'; import type { StorefrontPageMetaInput } from '../types/StorefrontPageMetaInput';
import type { StorefrontShopViewServiceOverrides } from '../types/StorefrontShopViewServiceTestTypes';
import { PaymentMethod } from '../../payment/types/PaymentMethod'; import { PaymentMethod } from '../../payment/types/PaymentMethod';
describe('StorefrontShopViewService', () => { 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 = { const defaultPage: StorefrontPageMetaInput = {
title: 'Cart', title: 'Cart',
metaDescription: 'Review your cart.' metaDescription: 'Review your cart.'
}; };
const createService = (overrides: ServiceOverrides = {}) => { const createService = (overrides: StorefrontShopViewServiceOverrides = {}) => {
const { const {
cart = [], cart = [],
feedback, feedback,
authorizedOrderIds = [], authorizedOrderIds = [],
theme, checkoutSessionId,
theme = 'system',
branding = { branding = {
logoUrl: null, logoUrl: null,
faviconUrl: null, faviconUrl: null,
simplexLink: null, simplexLink: null,
shippingNote: null shippingNote: null,
blogEnabled: false
}, },
shopSettings = { shopSettings = {
shopName: 'Demo Shop', shopName: 'Demo Shop',
@@ -80,6 +65,9 @@ describe('StorefrontShopViewService', () => {
const themeCookieService = { const themeCookieService = {
getTheme: jest.fn().mockReturnValue(theme) getTheme: jest.fn().mockReturnValue(theme)
} as unknown as StorefrontThemeCookieService; } as unknown as StorefrontThemeCookieService;
const checkoutSessionCookieService = {
getSessionId: jest.fn().mockReturnValue(checkoutSessionId)
} as unknown as StorefrontCheckoutSessionCookieService;
const service = new StorefrontShopViewService( const service = new StorefrontShopViewService(
exchangeRateService, exchangeRateService,
@@ -88,7 +76,8 @@ describe('StorefrontShopViewService', () => {
cartCookieService, cartCookieService,
feedbackCookieService, feedbackCookieService,
orderAuthCookieService, orderAuthCookieService,
themeCookieService themeCookieService,
checkoutSessionCookieService
); );
const req = { const req = {
@@ -117,6 +106,23 @@ describe('StorefrontShopViewService', () => {
ogImageUrl: null, ogImageUrl: null,
productJsonLd: 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 () => { it('exposes shop settings, cart qty, feedback, branding, theme, and request context', async () => {
@@ -131,7 +137,8 @@ describe('StorefrontShopViewService', () => {
logoUrl: '/uploads/logo.png', logoUrl: '/uploads/logo.png',
faviconUrl: '/uploads/favicon.ico', faviconUrl: '/uploads/favicon.ico',
simplexLink: 'https://simplex.example', simplexLink: 'https://simplex.example',
shippingNote: 'Ships in 3 days' shippingNote: 'Ships in 3 days',
blogEnabled: true
}, },
fiatPerXmr: 200, fiatPerXmr: 200,
fiatPerBtc: 80_000 fiatPerBtc: 80_000
@@ -145,13 +152,14 @@ describe('StorefrontShopViewService', () => {
feedback: { type: 'success', text: 'Added to cart' }, feedback: { type: 'success', text: 'Added to cart' },
shopFiatCurrency: 'USD', shopFiatCurrency: 'USD',
cryptoRates: [ cryptoRates: [
{ cryptoCurrency: 'XMR', fiatPerCrypto: 200 }, { paymentLabel: 'XMR', fiatPerCrypto: 200, iconUrl: '/shop/assets/img/xmr.png' },
{ cryptoCurrency: 'BTC', fiatPerCrypto: 80_000 } { paymentLabel: 'BTC', fiatPerCrypto: 80_000, iconUrl: '/shop/assets/img/btc.png' }
], ],
logoUrl: '/uploads/logo.png', logoUrl: '/uploads/logo.png',
faviconUrl: '/uploads/favicon.ico', faviconUrl: '/uploads/favicon.ico',
simplexLink: 'https://simplex.example', simplexLink: 'https://simplex.example',
shippingNote: 'Ships in 3 days', shippingNote: 'Ships in 3 days',
blogEnabled: true,
themePreference: 'dark' themePreference: 'dark'
}); });
}); });
@@ -8,6 +8,7 @@ import { formatShortOrderId } from '../../../utils/order/formatShortOrderId';
import { toAbsoluteUrl } from '../../../utils/toAbsoluteUrl'; import { toAbsoluteUrl } from '../../../utils/toAbsoluteUrl';
import { ShopSettingsService } from '../../shopSettings/services/ShopSettingsService'; import { ShopSettingsService } from '../../shopSettings/services/ShopSettingsService';
import { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService'; import { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
import { paymentMethodIconUrl } from '../../../consts/paymentMethodIconUrl';
import { paymentMethodLabel } from '../../../consts/paymentMethodLabel'; import { paymentMethodLabel } from '../../../consts/paymentMethodLabel';
import { PaymentMethod } from '../../payment/types/PaymentMethod'; import { PaymentMethod } from '../../payment/types/PaymentMethod';
import type { AuthorizedOrderNavItem } from '../types/AuthorizedOrderNavItem'; import type { AuthorizedOrderNavItem } from '../types/AuthorizedOrderNavItem';
@@ -18,6 +19,7 @@ import type { StorefrontPageMetaInput } from '../types/StorefrontPageMetaInput';
import type { StorefrontProductJsonLdInput } from '../types/StorefrontProductJsonLdInput'; import type { StorefrontProductJsonLdInput } from '../types/StorefrontProductJsonLdInput';
import { resolveShopNavActive } from '../utils/resolveShopNavActive'; import { resolveShopNavActive } from '../utils/resolveShopNavActive';
import { StorefrontCartCookieService } from './StorefrontCartCookieService'; import { StorefrontCartCookieService } from './StorefrontCartCookieService';
import { StorefrontCheckoutSessionCookieService } from './StorefrontCheckoutSessionCookieService';
import { StorefrontFeedbackCookieService } from './StorefrontFeedbackCookieService'; import { StorefrontFeedbackCookieService } from './StorefrontFeedbackCookieService';
import { StorefrontOrderAuthCookieService } from './StorefrontOrderAuthCookieService'; import { StorefrontOrderAuthCookieService } from './StorefrontOrderAuthCookieService';
import { StorefrontThemeCookieService } from './StorefrontThemeCookieService'; import { StorefrontThemeCookieService } from './StorefrontThemeCookieService';
@@ -31,7 +33,8 @@ export class StorefrontShopViewService {
private readonly cartCookieService: StorefrontCartCookieService, private readonly cartCookieService: StorefrontCartCookieService,
private readonly feedbackCookieService: StorefrontFeedbackCookieService, private readonly feedbackCookieService: StorefrontFeedbackCookieService,
private readonly orderAuthCookieService: StorefrontOrderAuthCookieService, 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> { async buildShopRenderLocals(req: Request, res: Response, page: StorefrontPageMetaInput): Promise<ShopRenderLocals> {
@@ -40,6 +43,7 @@ export class StorefrontShopViewService {
const themePreference = this.themeCookieService.getTheme(req, res); const themePreference = this.themeCookieService.getTheme(req, res);
const cartTotalQty = getTotalCartQtyFromCart(cart); const cartTotalQty = getTotalCartQtyFromCart(cart);
const hasActiveCheckout = Boolean(this.checkoutSessionCookieService.getSessionId(req, res));
const { shopName, shopFiatCurrency, enabledPaymentMethods } = this.configService.get( const { shopName, shopFiatCurrency, enabledPaymentMethods } = this.configService.get(
'shopSettings' 'shopSettings'
@@ -47,7 +51,7 @@ export class StorefrontShopViewService {
const cryptoRates = this.buildCryptoRates(enabledPaymentMethods); const cryptoRates = this.buildCryptoRates(enabledPaymentMethods);
const { logoUrl, faviconUrl, simplexLink, shippingNote } = const { logoUrl, faviconUrl, simplexLink, shippingNote, blogEnabled } =
await this.shopSettingsService.getStorefrontBranding(); await this.shopSettingsService.getStorefrontBranding();
const authorizedOrders = this.buildAuthorizedOrderNavItems(req, res); const authorizedOrders = this.buildAuthorizedOrderNavItems(req, res);
@@ -61,6 +65,7 @@ export class StorefrontShopViewService {
title: page.title, title: page.title,
shopName, shopName,
cartTotalQty, cartTotalQty,
hasActiveCheckout,
feedback, feedback,
authorizedOrders, authorizedOrders,
shopNavActive, shopNavActive,
@@ -70,6 +75,7 @@ export class StorefrontShopViewService {
faviconUrl, faviconUrl,
simplexLink, simplexLink,
shippingNote, shippingNote,
blogEnabled,
themePreference, themePreference,
pageMeta: this.buildPageMeta(siteOrigin, shopName, shopFiatCurrency, currentPath, page) pageMeta: this.buildPageMeta(siteOrigin, shopName, shopFiatCurrency, currentPath, page)
}; };
@@ -145,8 +151,9 @@ export class StorefrontShopViewService {
return [ return [
{ {
cryptoCurrency: paymentMethodLabel[paymentMethod], paymentLabel: paymentMethodLabel[paymentMethod],
fiatPerCrypto fiatPerCrypto,
iconUrl: paymentMethodIconUrl[paymentMethod]
} }
]; ];
}); });
@@ -14,10 +14,16 @@ describe('StorefrontThemeCookieService', () => {
service = new StorefrontThemeCookieService(signedCookies as unknown as StorefrontSignedCookieService); service = new StorefrontThemeCookieService(signedCookies as unknown as StorefrontSignedCookieService);
}); });
it('returns undefined for invalid theme values', () => { it('returns system when no cookie is set', () => {
signedCookies.getSignedCookie.mockReturnValue({ theme: 'system' }); 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', () => { it('returns a supported theme preference', () => {
@@ -25,4 +31,10 @@ describe('StorefrontThemeCookieService', () => {
expect(service.getTheme({} as never, {} as never)).toBe('dark'); 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 { Injectable } from '@nestjs/common';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { STOREFRONT_THEME_PREFERENCES } from '../../../consts/storefrontThemePreferences';
import type { StorefrontThemePreference } from '../types/StorefrontThemePreference'; import type { StorefrontThemePreference } from '../types/StorefrontThemePreference';
import { StorefrontSignedCookieService } from './StorefrontSignedCookieService'; import { StorefrontSignedCookieService } from './StorefrontSignedCookieService';
@@ -7,15 +8,15 @@ import { StorefrontSignedCookieService } from './StorefrontSignedCookieService';
export class StorefrontThemeCookieService { export class StorefrontThemeCookieService {
constructor(private readonly signedCookies: StorefrontSignedCookieService) {} constructor(private readonly signedCookies: StorefrontSignedCookieService) {}
getTheme(req: Request, res: Response): StorefrontThemePreference | undefined { getTheme(req: Request, res: Response): StorefrontThemePreference {
const payload = this.signedCookies.getSignedCookie<{ theme: string }>(req, res, 'theme'); const payload = this.signedCookies.getSignedCookie<{ theme: StorefrontThemePreference }>(req, res, 'theme');
const theme = payload?.theme; const theme = payload?.theme;
if (theme === 'light' || theme === 'dark') { if (theme && STOREFRONT_THEME_PREFERENCES.includes(theme)) {
return theme; return theme;
} }
return undefined; return 'system';
} }
setTheme(req: Request, res: Response, theme: StorefrontThemePreference): void { setTheme(req: Request, res: Response, theme: StorefrontThemePreference): void {
@@ -1 +1 @@
export type ShopNavKey = 'shop' | 'cart' | 'check-order'; export type ShopNavKey = 'shop' | 'blog' | 'cart' | 'checkout' | 'check-order';
@@ -10,6 +10,7 @@ export type ShopRenderLocals = {
title: string; title: string;
shopName: string; shopName: string;
cartTotalQty: number; cartTotalQty: number;
hasActiveCheckout: boolean;
feedback: StorefrontFeedback | undefined; feedback: StorefrontFeedback | undefined;
authorizedOrders: AuthorizedOrderNavItem[]; authorizedOrders: AuthorizedOrderNavItem[];
shopNavActive: ShopNavActive; shopNavActive: ShopNavActive;
@@ -19,6 +20,7 @@ export type ShopRenderLocals = {
faviconUrl: string | null; faviconUrl: string | null;
simplexLink: string | null; simplexLink: string | null;
shippingNote: string | null; shippingNote: string | null;
themePreference: StorefrontThemePreference | undefined; blogEnabled: boolean;
themePreference: StorefrontThemePreference;
pageMeta: StorefrontPageMeta; pageMeta: StorefrontPageMeta;
}; };
@@ -1,4 +1,5 @@
export type StorefrontCryptoRate = { export type StorefrontCryptoRate = {
cryptoCurrency: string; paymentLabel: string;
fiatPerCrypto: number; fiatPerCrypto: number;
iconUrl: string;
}; };
@@ -3,7 +3,7 @@ import type { InvoiceStatusLabel } from '../../../utils/invoice/types/InvoiceSta
import type { InvoiceStatusVariant } from '../../../utils/invoice/types/InvoiceStatusVariant'; import type { InvoiceStatusVariant } from '../../../utils/invoice/types/InvoiceStatusVariant';
export type StorefrontInvoiceView = { export type StorefrontInvoiceView = {
cryptoCurrency: string; paymentLabel: string;
expectedTotalCrypto: string; expectedTotalCrypto: string;
receivedTotalCrypto: string | null; receivedTotalCrypto: string | null;
paymentAddress: string; paymentAddress: string;
@@ -0,0 +1,24 @@
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;
blogEnabled: boolean;
};
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];
@@ -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' },
@@ -29,8 +30,10 @@ const STOREFRONT_PARTIALS = [
{ name: 'shop-footer', file: 'shop-footer.hbs' }, { name: 'shop-footer', file: 'shop-footer.hbs' },
{ name: 'storefront-image', file: 'storefront-image.hbs' }, { name: 'storefront-image', file: 'storefront-image.hbs' },
{ name: 'theme-switcher', file: 'theme-switcher.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: 'page-back-link', file: 'page-back-link.hbs' },
{ name: 'product-card', file: 'product-card.hbs' }, { name: 'product-card', file: 'product-card.hbs' },
{ name: 'blog-card', file: 'blog-card.hbs' },
{ name: 'order-data-retention-notice', file: 'order-data-retention-notice.hbs' }, { name: 'order-data-retention-notice', file: 'order-data-retention-notice.hbs' },
{ name: 'category-nav', file: 'category-nav.hbs' }, { name: 'category-nav', file: 'category-nav.hbs' },
{ name: 'product-delivery-note', file: 'product-delivery-note.hbs' }, { name: 'product-delivery-note', file: 'product-delivery-note.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/cart')).toEqual({ activeShopNav: 'cart', activeOrderId: null });
expect(resolveShopNavActive('/shop/checkout')).toEqual({ activeShopNav: 'checkout', activeOrderId: null });
expect(resolveShopNavActive('/shop/check-order')).toEqual({ expect(resolveShopNavActive('/shop/check-order')).toEqual({
activeShopNav: 'check-order', activeShopNav: 'check-order',
activeOrderId: null activeOrderId: null
@@ -28,7 +29,12 @@ describe('resolveShopNavActive', () => {
}); });
}); });
it('highlights blog pages', () => {
expect(resolveShopNavActive('/shop/blog')).toEqual({ activeShopNav: 'blog', activeOrderId: null });
expect(resolveShopNavActive('/shop/blog/post-1')).toEqual({ activeShopNav: 'blog', activeOrderId: null });
});
it('does not highlight nav items on unrelated pages', () => { 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 }; return { activeShopNav: 'cart', activeOrderId: null };
} }
if (first === 'shop' && second === 'checkout') {
return { activeShopNav: 'checkout', activeOrderId: null };
}
if (first === 'shop' && second === 'check-order') { if (first === 'shop' && second === 'check-order') {
return { activeShopNav: 'check-order', activeOrderId: null }; return { activeShopNav: 'check-order', activeOrderId: null };
} }
@@ -19,6 +23,10 @@ export const resolveShopNavActive = (path: string): ShopNavActive => {
return { activeShopNav: null, activeOrderId: third }; return { activeShopNav: null, activeOrderId: third };
} }
if (first === 'shop' && second === 'blog') {
return { activeShopNav: 'blog', activeOrderId: null };
}
if (first === 'shop' && (second === 'categories' || second === 'products')) { if (first === 'shop' && (second === 'categories' || second === 'products')) {
return { activeShopNav: 'shop', activeOrderId: null }; return { activeShopNav: 'shop', activeOrderId: null };
} }
@@ -0,0 +1,16 @@
{{> page-back-link href=backHref label=backLabel}}
{{#if post.bannerUrl}}
<div class='sf-blog-detail__banner'>
{{> storefront-image
class='sf-blog-detail__banner-image'
src=post.bannerUrl
width='1600'
height='600'
}}
</div>
{{/if}}
<h1 class='sf-page-title sf-blog-detail__title'>{{post.title}}</h1>
<div class='sf-prose sf-blog-detail__content'>{{{post.contentHtml}}}</div>
@@ -0,0 +1,11 @@
<h1 class='sf-page-title'>{{title}}</h1>
{{#if posts.length}}
<ul class='sf-list-reset sf-blog-grid'>
{{#each posts}}
{{> blog-card}}
{{/each}}
</ul>
{{else}}
<p class='sf-text-muted'>No posts yet.</p>
{{/if}}
@@ -23,14 +23,14 @@
<p class='sf-payment-status sf-payment-status--confirmed'>Payment received</p> <p class='sf-payment-status sf-payment-status--confirmed'>Payment received</p>
<p class='sf-payment-detail'> <p class='sf-payment-detail'>
Received: Received:
<strong>{{checkout.checkoutInvoice.receivedTotalCrypto}} {{checkout.checkoutInvoice.cryptoCurrency}}</strong> <strong>{{checkout.checkoutInvoice.receivedTotalCrypto}} {{checkout.checkoutInvoice.paymentLabel}}</strong>
</p> </p>
<p class='sf-payment-detail'> <p class='sf-payment-detail'>
Your order is being prepared. This page will redirect automatically once it is ready. Your order is being prepared. This page will redirect automatically once it is ready.
</p> </p>
{{> refresh-link href=checkout.refreshHref showAutoRefreshNote=true}} {{> refresh-link href=checkout.refreshHref showAutoRefreshNote=true}}
{{else}} {{else}}
<h2 class='sf-section-title'>Pay with {{checkout.checkoutInvoice.cryptoCurrency}}</h2> <h2 class='sf-section-title'>Pay with {{checkout.checkoutInvoice.paymentLabel}}</h2>
{{#with checkout.checkoutInvoice}} {{#with checkout.checkoutInvoice}}
{{> invoice-payment refreshHref=../checkout.refreshHref showAutoRefreshNote=true}} {{> invoice-payment refreshHref=../checkout.refreshHref showAutoRefreshNote=true}}
{{/with}} {{/with}}
@@ -45,3 +45,5 @@
</div> </div>
</aside> </aside>
</div> </div>
<script src='/shop/assets/js/copy.js' defer></script>
@@ -1,8 +1,8 @@
<!DOCTYPE html> <!DOCTYPE html>
<html <html
lang='en' lang='en'
{{#ifeq themePreference 'light'}}data-theme='light'{{/ifeq}} class='no-js'
{{#ifeq themePreference 'dark'}}data-theme='dark'{{/ifeq}} {{> html-theme-attributes}}
> >
<head> <head>
{{> shop-head}} {{> shop-head}}
@@ -1,8 +1,8 @@
<!DOCTYPE html> <!DOCTYPE html>
<html <html
lang='en' lang='en'
{{#ifeq themePreference 'light'}}data-theme='light'{{/ifeq}} class='no-js'
{{#ifeq themePreference 'dark'}}data-theme='dark'{{/ifeq}} {{> html-theme-attributes}}
> >
<head> <head>
{{> shop-head}} {{> shop-head}}
@@ -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>
@@ -69,3 +69,5 @@
{{/if}} {{/if}}
</aside> </aside>
</div> </div>
<script src='/shop/assets/js/copy.js' defer></script>
@@ -0,0 +1,16 @@
<li class='sf-card sf-blog-card'>
<a class='sf-blog-card__link' href='{{detailHref}}'>
{{#if thumbnailUrl}}
<div class='sf-blog-card__media'>
{{> storefront-image
class='sf-blog-card__image'
src=thumbnailUrl
width='1'
height='1'
loading='lazy'
}}
</div>
{{/if}}
<strong class='sf-blog-card__title'>{{title}}</strong>
</a>
</li>
@@ -5,7 +5,6 @@
{{#if thumbnailUrl}} {{#if thumbnailUrl}}
{{> storefront-image {{> storefront-image
src=thumbnailUrl src=thumbnailUrl
alt=''
width='1' width='1'
height='1' height='1'
loading='lazy' loading='lazy'
@@ -28,7 +27,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 +35,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'>
@@ -80,9 +80,15 @@
type='submit' type='submit'
name='paymentMethod' name='paymentMethod'
value='{{paymentMethod}}' value='{{paymentMethod}}'
class='sf-btn sf-btn--primary' class='sf-btn sf-btn--primary sf-btn--pay'
> >
Pay with {{paymentMethodLabel}} Pay with {{paymentMethodLabel}}
{{> storefront-image
class='sf-crypto-icon'
src=iconUrl
width='20'
height='20'
}}
</button> </button>
{{/each}} {{/each}}
</div> </div>
@@ -6,7 +6,6 @@
{{#if thumbnailUrl}} {{#if thumbnailUrl}}
{{> storefront-image {{> storefront-image
src=thumbnailUrl src=thumbnailUrl
alt=''
width='1' width='1'
height='1' height='1'
loading='lazy' loading='lazy'
@@ -19,7 +18,6 @@
{{#if thumbnailUrl}} {{#if thumbnailUrl}}
{{> storefront-image {{> storefront-image
src=thumbnailUrl src=thumbnailUrl
alt=''
width='1' width='1'
height='1' height='1'
loading='lazy' loading='lazy'
@@ -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>
@@ -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}}
@@ -0,0 +1,2 @@
{{#ifeq themePreference 'light'}}data-theme='light'{{/ifeq}}
{{#ifeq themePreference 'dark'}}data-theme='dark'{{/ifeq}}
@@ -3,27 +3,33 @@
{{/if}} {{/if}}
{{#if showProminentAmount}} {{#if showProminentAmount}}
<p class='sf-payment-amount'>{{expectedTotalCrypto}} {{cryptoCurrency}}</p> <p class='sf-payment-amount'>{{instructionAmountCrypto}} {{paymentLabel}}</p>
{{/if}} {{/if}}
{{#if showReceivedTotal}} {{#if showReceivedTotal}}
<p class='sf-payment-detail'> <p class='sf-payment-detail'>
Received: Received:
<strong>{{receivedTotalCrypto}} {{cryptoCurrency}}</strong> <strong>{{receivedTotalCrypto}} {{paymentLabel}}</strong>
</p> </p>
{{/if}} {{/if}}
{{#if showExpectedTotal}} {{#if showExpectedTotal}}
<p class='sf-payment-detail'> <p class='sf-payment-detail'>
Expected total: Expected total:
<strong>{{expectedTotalCrypto}} {{cryptoCurrency}}</strong> <strong>{{expectedTotalCrypto}} {{paymentLabel}}</strong>
</p> </p>
{{/if}} {{/if}}
{{#if showInstruction}} {{#if showInstruction}}
<p class='sf-payment-detail'> <p class='sf-payment-detail'>
{{instructionPrefix}} {{instructionPrefix}}
<strong>{{instructionAmountCrypto}} {{cryptoCurrency}}</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
@@ -34,13 +40,19 @@
{{#if showQr}} {{#if showQr}}
<div class='sf-qr-wrap'> <div class='sf-qr-wrap'>
<img src='{{qrCodeUrl}}' width='220' height='220' alt='{{cryptoCurrency}} payment QR code' /> <img src='{{qrCodeUrl}}' width='220' height='220' alt='{{paymentLabel}} payment QR code' />
</div> </div>
{{/if}} {{/if}}
{{#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}}
@@ -48,7 +60,7 @@
<ul class='sf-list-reset sf-tx-list'> <ul class='sf-list-reset sf-tx-list'>
{{#each payments}} {{#each payments}}
<li class='sf-tx-item'> <li class='sf-tx-item'>
<div><strong>{{amountCrypto}} {{../cryptoCurrency}}</strong></div> <div><strong>{{amountCrypto}} {{../paymentLabel}}</strong></div>
<div class='sf-mono sf-text-subtle'>{{txHash}}</div> <div class='sf-mono sf-text-subtle'>{{txHash}}</div>
<div class='sf-tx-status sf-tx-status--{{confirmationStatusVariant}}'>{{confirmationStatus}}</div> <div class='sf-tx-status sf-tx-status--{{confirmationStatusVariant}}'>{{confirmationStatus}}</div>
</li> </li>
@@ -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>
@@ -6,7 +6,6 @@
{{#if thumbnailUrl}} {{#if thumbnailUrl}}
{{> storefront-image {{> storefront-image
src=thumbnailUrl src=thumbnailUrl
alt=''
width='1' width='1'
height='1' height='1'
loading='lazy' loading='lazy'
@@ -19,7 +18,6 @@
{{#if thumbnailUrl}} {{#if thumbnailUrl}}
{{> storefront-image {{> storefront-image
src=thumbnailUrl src=thumbnailUrl
alt=''
width='1' width='1'
height='1' height='1'
loading='lazy' loading='lazy'
@@ -1,11 +1,10 @@
<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}}'>
{{> storefront-image {{> storefront-image
class='sf-product-card__image' class='sf-product-card__image'
src=thumbnailUrl src=thumbnailUrl
alt=''
width='1' width='1'
height='1' height='1'
loading='lazy' loading='lazy'
@@ -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>
@@ -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>
@@ -17,7 +17,14 @@
<nav class='sf-nav' aria-label='Shop navigation'> <nav class='sf-nav' aria-label='Shop navigation'>
{{> nav-link active=shopNavActive.activeShopNav key='shop' href='/' label='Shop'}} {{> 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 blogEnabled}}
{{> nav-link active=shopNavActive.activeShopNav key='blog' href='/shop/blog' label='Blog'}}
{{/if}}
{{#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'}} {{> nav-link active=shopNavActive.activeShopNav key='check-order' href='/shop/check-order' label='Check order'}}
{{#each authorizedOrders}} {{#each authorizedOrders}}
@@ -32,7 +39,16 @@
{{#if cryptoRates.length}} {{#if cryptoRates.length}}
<div class='sf-rates'> <div class='sf-rates'>
{{#each cryptoRates}} {{#each cryptoRates}}
<div class='sf-rate'>1 {{cryptoCurrency}} = {{fiatPerCrypto}} {{../shopFiatCurrency}}</div> <span class='sf-rate'>
{{> storefront-image
class='sf-crypto-icon'
src=iconUrl
alt=paymentLabel
width='20'
height='20'
}}
<span>{{fiatPerCrypto}} {{../shopFiatCurrency}}</span>
</span>
{{/each}} {{/each}}
</div> </div>
{{/if}} {{/if}}
@@ -15,5 +15,12 @@
class='sf-btn sf-btn--sm{{#ifeq themePreference 'dark'}} sf-btn--primary{{/ifeq}}' class='sf-btn sf-btn--sm{{#ifeq themePreference 'dark'}} sf-btn--primary{{/ifeq}}'
{{#ifeq themePreference 'dark'}}aria-current='true'{{/ifeq}} {{#ifeq themePreference 'dark'}}aria-current='true'{{/ifeq}}
>Dark</button> >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> </form>
</div> </div>
@@ -22,7 +22,6 @@
> >
{{> storefront-image {{> storefront-image
src=url src=url
alt=''
width='1' width='1'
height='1' height='1'
loading='lazy' loading='lazy'
@@ -8,7 +8,6 @@
{{#if thumbnailUrl}} {{#if thumbnailUrl}}
{{> storefront-image {{> storefront-image
src=thumbnailUrl src=thumbnailUrl
alt=''
width='1' width='1'
height='1' height='1'
loading='lazy' loading='lazy'
@@ -198,7 +198,7 @@ describe('StorefrontOrderViewService', () => {
expect(view.totals.shippingCostFiat).toBe(5); expect(view.totals.shippingCostFiat).toBe(5);
expect(view.totals.grandTotalFiat).toBe(15); expect(view.totals.grandTotalFiat).toBe(15);
expect(view.shippingInvoice).toMatchObject({ expect(view.shippingInvoice).toMatchObject({
cryptoCurrency: 'XMR', paymentLabel: 'XMR',
paymentAddress: '4shipping' paymentAddress: '4shipping'
}); });
}); });
+1
View File
@@ -42,6 +42,7 @@ export interface AppConfig {
signedCookie: SignedCookieConfig; signedCookie: SignedCookieConfig;
validation: { validation: {
productTitleMaxLength: number; productTitleMaxLength: number;
blogPostTitleMaxLength: number;
categoryNameMaxLength: number; categoryNameMaxLength: number;
discountCodeMaxLength: number; discountCodeMaxLength: number;
variantImagesMax: number; variantImagesMax: number;
@@ -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}`;
}; };
@@ -109,7 +109,7 @@ describe('toStorefrontInvoiceView', () => {
const view = await toStorefrontInvoiceView(invoice); const view = await toStorefrontInvoiceView(invoice);
expect(view).toMatchObject({ expect(view).toMatchObject({
cryptoCurrency: 'XMR', paymentLabel: 'XMR',
expectedTotalCrypto: '1.00000000', expectedTotalCrypto: '1.00000000',
receivedTotalCrypto: null, receivedTotalCrypto: null,
paymentAddress, paymentAddress,
@@ -154,7 +154,7 @@ describe('toStorefrontInvoiceView', () => {
const view = await toStorefrontInvoiceView(invoice); const view = await toStorefrontInvoiceView(invoice);
expect(view).toMatchObject({ expect(view).toMatchObject({
cryptoCurrency: 'BTC', paymentLabel: 'BTC',
expectedTotalCrypto: '1.00000000', expectedTotalCrypto: '1.00000000',
paymentAddress: 'bc1qstorefronttest' paymentAddress: 'bc1qstorefronttest'
}); });
@@ -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', () => {
@@ -114,7 +114,7 @@ const buildStorefrontInvoiceView = async ({
const statusVariant = resolveInvoiceStatusVariant(invoiceState); const statusVariant = resolveInvoiceStatusVariant(invoiceState);
return { return {
cryptoCurrency: paymentLabel, paymentLabel,
expectedTotalCrypto, expectedTotalCrypto,
receivedTotalCrypto, receivedTotalCrypto,
paymentAddress: invoice.paymentAddress, paymentAddress: invoice.paymentAddress,
@@ -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
View File
@@ -16,6 +16,7 @@
<el-menu-item index="/products">Products</el-menu-item> <el-menu-item index="/products">Products</el-menu-item>
<el-menu-item index="/categories">Categories</el-menu-item> <el-menu-item index="/categories">Categories</el-menu-item>
<el-menu-item index="/discount-codes">Discount codes</el-menu-item> <el-menu-item index="/discount-codes">Discount codes</el-menu-item>
<el-menu-item index="/blog">Blog</el-menu-item>
<el-menu-item index="/orders">Orders</el-menu-item> <el-menu-item index="/orders">Orders</el-menu-item>
<el-menu-item index="/wallet">Wallet</el-menu-item> <el-menu-item index="/wallet">Wallet</el-menu-item>
<el-menu-item index="/settings">Settings</el-menu-item> <el-menu-item index="/settings">Settings</el-menu-item>

Some files were not shown because too many files have changed in this diff Show More