Compare commits
37
Commits
4458edb0d1
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55630e33b4 | ||
|
|
e057b29cc7 | ||
|
|
226a31b937 | ||
|
|
6419439a3d | ||
|
|
762af5c4f3 | ||
|
|
17a26b60ed | ||
|
|
9cd7059928 | ||
|
|
9c7b8470d3 | ||
|
|
e5eb773404 | ||
|
|
0cd7158f7d | ||
|
|
09ad236cb9 | ||
|
|
a3d1842fb3 | ||
|
|
73f7ffed00 | ||
|
|
abe6208e4e | ||
|
|
d38def2302 | ||
|
|
92ccbec545 | ||
|
|
40bf16c03e | ||
|
|
54ec6edd0d | ||
|
|
8372c30e40 | ||
|
|
0345fe042e | ||
|
|
a9ffc57608 | ||
|
|
49d6b597c2 | ||
|
|
0ab7f21bea | ||
|
|
155549a2ec | ||
|
|
f1715f7e7f | ||
|
|
ffc9a0bbe5 | ||
|
|
77cd686347 | ||
|
|
08a0bdcdb8 | ||
|
|
93c132b0df | ||
|
|
771d95d1cd | ||
|
|
254736356b | ||
|
|
738e406725 | ||
|
|
de4630635d | ||
|
|
6b358011b0 | ||
|
|
8729098f20 | ||
|
|
deb5f8c2c3 | ||
|
|
b3e683cd71 |
+12
-4
@@ -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
|
||||||
|
|||||||
@@ -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: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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(',')
|
||||||
|
|||||||
@@ -4,9 +4,13 @@ export const storefrontImgUrlPrefix = '/shop/assets/img';
|
|||||||
|
|
||||||
export const storefrontCssUrlPrefix = '/shop/assets/css';
|
export const storefrontCssUrlPrefix = '/shop/assets/css';
|
||||||
|
|
||||||
|
export const storefrontJsUrlPrefix = '/shop/assets/js';
|
||||||
|
|
||||||
export const getStorefrontPublicDir = (): string =>
|
export const getStorefrontPublicDir = (): string =>
|
||||||
path.join(__dirname, '..', 'modules', 'storefrontCore', 'public');
|
path.join(__dirname, '..', 'modules', 'storefrontCore', 'public');
|
||||||
|
|
||||||
export const getStorefrontImgDir = (): string => path.join(getStorefrontPublicDir(), 'img');
|
export const getStorefrontImgDir = (): string => path.join(getStorefrontPublicDir(), 'img');
|
||||||
|
|
||||||
export const getStorefrontCssDir = (): string => path.join(getStorefrontPublicDir(), 'css');
|
export const getStorefrontCssDir = (): string => path.join(getStorefrontPublicDir(), 'css');
|
||||||
|
|
||||||
|
export const getStorefrontJsDir = (): string => path.join(getStorefrontPublicDir(), 'js');
|
||||||
|
|||||||
@@ -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/';
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
|
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"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-1
@@ -8,8 +8,10 @@ import path from 'node:path';
|
|||||||
import {
|
import {
|
||||||
getStorefrontCssDir,
|
getStorefrontCssDir,
|
||||||
getStorefrontImgDir,
|
getStorefrontImgDir,
|
||||||
|
getStorefrontJsDir,
|
||||||
storefrontCssUrlPrefix,
|
storefrontCssUrlPrefix,
|
||||||
storefrontImgUrlPrefix
|
storefrontImgUrlPrefix,
|
||||||
|
storefrontJsUrlPrefix
|
||||||
} from './config/storefrontAssetPaths';
|
} from './config/storefrontAssetPaths';
|
||||||
import { getPublicUploadsDir, publicUploadsUrlPrefix } from './config/uploadPaths';
|
import { getPublicUploadsDir, publicUploadsUrlPrefix } from './config/uploadPaths';
|
||||||
import { AppModule } from './AppModule';
|
import { AppModule } from './AppModule';
|
||||||
@@ -42,6 +44,10 @@ async function bootstrap() {
|
|||||||
prefix: storefrontCssUrlPrefix
|
prefix: storefrontCssUrlPrefix
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.useStaticAssets(getStorefrontJsDir(), {
|
||||||
|
prefix: storefrontJsUrlPrefix
|
||||||
|
});
|
||||||
|
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
|
|
||||||
if (nodeEnv === NodeEnv.Production) {
|
if (nodeEnv === NodeEnv.Production) {
|
||||||
@@ -71,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 },
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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,4 +1,4 @@
|
|||||||
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';
|
||||||
@@ -89,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);
|
||||||
@@ -100,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')
|
||||||
@@ -109,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);
|
||||||
@@ -125,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);
|
||||||
@@ -143,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);
|
||||||
|
|
||||||
@@ -160,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);
|
||||||
|
|
||||||
@@ -189,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}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -371,6 +402,26 @@ a:hover {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sf-header__inner {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-header__brand-group {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-nav,
|
||||||
|
.sf-rates {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.sf-crypto-icon {
|
.sf-crypto-icon {
|
||||||
width: 1.25rem;
|
width: 1.25rem;
|
||||||
height: 1.25rem;
|
height: 1.25rem;
|
||||||
@@ -510,6 +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;
|
||||||
@@ -530,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);
|
||||||
@@ -789,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -866,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;
|
||||||
@@ -1164,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 {
|
||||||
@@ -1203,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;
|
||||||
@@ -1423,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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -152,6 +159,7 @@ describe('StorefrontShopViewService', () => {
|
|||||||
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'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,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';
|
||||||
@@ -32,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> {
|
||||||
@@ -41,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'
|
||||||
@@ -48,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);
|
||||||
@@ -62,6 +65,7 @@ export class StorefrontShopViewService {
|
|||||||
title: page.title,
|
title: page.title,
|
||||||
shopName,
|
shopName,
|
||||||
cartTotalQty,
|
cartTotalQty,
|
||||||
|
hasActiveCheckout,
|
||||||
feedback,
|
feedback,
|
||||||
authorizedOrders,
|
authorizedOrders,
|
||||||
shopNavActive,
|
shopNavActive,
|
||||||
@@ -71,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)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}}
|
||||||
@@ -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'>
|
||||||
@@ -86,7 +86,6 @@
|
|||||||
{{> storefront-image
|
{{> storefront-image
|
||||||
class='sf-crypto-icon'
|
class='sf-crypto-icon'
|
||||||
src=iconUrl
|
src=iconUrl
|
||||||
alt=''
|
|
||||||
width='20'
|
width='20'
|
||||||
height='20'
|
height='20'
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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,7 +3,7 @@
|
|||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
{{#if showProminentAmount}}
|
{{#if showProminentAmount}}
|
||||||
<p class='sf-payment-amount'>{{expectedTotalCrypto}} {{paymentLabel}}</p>
|
<p class='sf-payment-amount'>{{instructionAmountCrypto}} {{paymentLabel}}</p>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
{{#if showReceivedTotal}}
|
{{#if showReceivedTotal}}
|
||||||
@@ -23,7 +23,13 @@
|
|||||||
{{#if showInstruction}}
|
{{#if showInstruction}}
|
||||||
<p class='sf-payment-detail'>
|
<p class='sf-payment-detail'>
|
||||||
{{instructionPrefix}}
|
{{instructionPrefix}}
|
||||||
<strong>{{instructionAmountCrypto}} {{paymentLabel}}</strong>
|
<span class='sf-copy-row sf-copy-row--inline'>
|
||||||
|
<strong>{{instructionAmountCrypto}} {{paymentLabel}}</strong>
|
||||||
|
{{> copy-button
|
||||||
|
copyText=instructionAmountCrypto
|
||||||
|
ariaLabel='Copy payment amount'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
{{instructionSuffix}}
|
{{instructionSuffix}}
|
||||||
{{#if showExpiry}}
|
{{#if showExpiry}}
|
||||||
Payment expires in
|
Payment expires in
|
||||||
@@ -40,7 +46,13 @@
|
|||||||
|
|
||||||
{{#if showAddress}}
|
{{#if showAddress}}
|
||||||
<p class='sf-field__label'>{{#if showQr}}Address{{else}}Payment address{{/if}}</p>
|
<p class='sf-field__label'>{{#if showQr}}Address{{else}}Payment address{{/if}}</p>
|
||||||
<p class='sf-mono sf-payment-detail'>{{paymentAddress}}</p>
|
<div class='sf-copy-row sf-copy-row--address'>
|
||||||
|
<p class='sf-mono sf-payment-detail sf-copy-row__value'>{{paymentAddress}}</p>
|
||||||
|
{{> copy-button
|
||||||
|
copyText=paymentAddress
|
||||||
|
ariaLabel='Copy payment address'
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
{{#if showPayments}}
|
{{#if showPayments}}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<section id='order-chat' class='sf-chat-panel'>
|
<section id='order-chat' class='sf-chat-panel'>
|
||||||
<div class='sf-chat-header'>
|
<div class='sf-chat-header'>
|
||||||
<h2 class='sf-section-title'>Order chat (encrypted)</h2>
|
<h2 class='sf-section-title'>Order chat</h2>
|
||||||
{{> refresh-link href=order.chatRefreshHref}}
|
{{> refresh-link href=order.chatRefreshHref}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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}`;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -463,19 +463,6 @@ describe('toStorefrontInvoiceView', () => {
|
|||||||
statusVariant: 'confirmed'
|
statusVariant: 'confirmed'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('treats tx-detected invoices as confirmed for payment status display', async () => {
|
|
||||||
const payment = buildPayment({ confirmations: 0 });
|
|
||||||
const invoice = buildInvoice({
|
|
||||||
moneroDetails: buildMoneroDetails({ requiredConfirmations: 0 }),
|
|
||||||
payments: [payment]
|
|
||||||
});
|
|
||||||
|
|
||||||
const view = await toStorefrontInvoiceView(invoice);
|
|
||||||
|
|
||||||
expect(view.showRefresh).toBe(false);
|
|
||||||
expect(view.payments[0].confirmationStatus).toBe('Confirmed');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('overpayment', () => {
|
describe('overpayment', () => {
|
||||||
|
|||||||
@@ -13,20 +13,13 @@ const validateTiers = (value: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const validTiers =
|
const validTiers =
|
||||||
'[{"upToTotalFiat":"25","minConfirmations":0},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
|
'[{"upToTotalFiat":"25","minConfirmations":1},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
|
||||||
|
|
||||||
describe('IsConfirmationTiers', () => {
|
describe('IsConfirmationTiers', () => {
|
||||||
it('accepts valid default tiers', () => {
|
it('accepts valid default tiers', () => {
|
||||||
expect(validateTiers(validTiers)).toHaveLength(0);
|
expect(validateTiers(validTiers)).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts numeric-only tiers without tx-detected (0)', () => {
|
|
||||||
const tiers =
|
|
||||||
'[{"upToTotalFiat":"25","minConfirmations":1},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
|
|
||||||
|
|
||||||
expect(validateTiers(tiers)).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects empty string', () => {
|
it('rejects empty string', () => {
|
||||||
expect(validateTiers('').length).toBeGreaterThan(0);
|
expect(validateTiers('').length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
@@ -39,22 +32,16 @@ describe('IsConfirmationTiers', () => {
|
|||||||
expect(validateTiers('[]').length).toBeGreaterThan(0);
|
expect(validateTiers('[]').length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects tx-detected (0) more than once', () => {
|
it('rejects minConfirmations: 0', () => {
|
||||||
const tiers =
|
const tiers =
|
||||||
'[{"upToTotalFiat":"25","minConfirmations":0},{"upToTotalFiat":"250","minConfirmations":0},{"minConfirmations":10}]';
|
'[{"upToTotalFiat":"25","minConfirmations":0},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
|
||||||
|
|
||||||
expect(validateTiers(tiers).length).toBeGreaterThan(0);
|
expect(validateTiers(tiers).length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects tx-detected (0) on catch-all tier', () => {
|
it('rejects non-numeric minConfirmations', () => {
|
||||||
const tiers = '[{"upToTotalFiat":"25","minConfirmations":1},{"minConfirmations":0}]';
|
|
||||||
|
|
||||||
expect(validateTiers(tiers).length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects legacy tx-detected string', () => {
|
|
||||||
const tiers =
|
const tiers =
|
||||||
'[{"upToTotalFiat":"25","minConfirmations":"tx-detected"},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
|
'[{"upToTotalFiat":"25","minConfirmations":"foo"},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
|
||||||
|
|
||||||
expect(validateTiers(tiers).length).toBeGreaterThan(0);
|
expect(validateTiers(tiers).length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const isPositiveDecimalString = (value: string): boolean => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isMinConfirmations = (value: unknown): boolean =>
|
const isMinConfirmations = (value: unknown): boolean =>
|
||||||
typeof value === 'number' && Number.isInteger(value) && value >= 0;
|
typeof value === 'number' && Number.isInteger(value) && value >= 1;
|
||||||
|
|
||||||
const isConfirmationTier = (value: unknown): value is ConfirmationTier => {
|
const isConfirmationTier = (value: unknown): value is ConfirmationTier => {
|
||||||
if (typeof value !== 'object' || value === null) {
|
if (typeof value !== 'object' || value === null) {
|
||||||
@@ -48,22 +48,12 @@ const isValidConfirmationTiersJson = (raw: string): boolean => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tiers = parsed;
|
const tiers = parsed;
|
||||||
const txDetectedTierCount = tiers.filter(tier => tier.minConfirmations === 0).length;
|
|
||||||
|
|
||||||
if (txDetectedTierCount > 1) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastTier = tiers[tiers.length - 1];
|
const lastTier = tiers[tiers.length - 1];
|
||||||
|
|
||||||
if (lastTier.upToTotalFiat !== undefined) {
|
if (lastTier.upToTotalFiat !== undefined) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastTier.minConfirmations === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let index = 0; index < tiers.length - 1; index++) {
|
for (let index = 0; index < tiers.length - 1; index++) {
|
||||||
const tier = tiers[index];
|
const tier = tiers[index];
|
||||||
|
|
||||||
@@ -86,7 +76,7 @@ class IsConfirmationTiersConstraint implements ValidatorConstraintInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defaultMessage(): string {
|
defaultMessage(): string {
|
||||||
return '$property must be a non-empty JSON array of confirmation tiers; minConfirmations must be 0 (tx-detected) or an integer >= 1, 0 may appear only once and not on the catch-all tier, non-final tiers need a positive upToTotalFiat in shop fiat currency, and the last tier must be a catch-all without upToTotalFiat';
|
return '$property must be a non-empty JSON array of confirmation tiers; minConfirmations must be an integer >= 1, non-final tiers need a positive upToTotalFiat in shop fiat currency, and the last tier must be a catch-all without upToTotalFiat';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
collapse-tags
|
collapse-tags
|
||||||
:max-collapse-tags="3"
|
:max-collapse-tags="3"
|
||||||
collapse-tags-tooltip
|
collapse-tags-tooltip
|
||||||
|
fit-input-width
|
||||||
class="cms-scope-select"
|
class="cms-scope-select"
|
||||||
placeholder="Select categories"
|
placeholder="Select categories"
|
||||||
@update:model-value="value => updateScope({ categoryIds: value })"
|
@update:model-value="value => updateScope({ categoryIds: value })"
|
||||||
@@ -42,6 +43,7 @@
|
|||||||
collapse-tags
|
collapse-tags
|
||||||
:max-collapse-tags="3"
|
:max-collapse-tags="3"
|
||||||
collapse-tags-tooltip
|
collapse-tags-tooltip
|
||||||
|
fit-input-width
|
||||||
class="cms-scope-select"
|
class="cms-scope-select"
|
||||||
:remote-method="loadProducts"
|
:remote-method="loadProducts"
|
||||||
:loading="productSearchLoading"
|
:loading="productSearchLoading"
|
||||||
@@ -69,6 +71,7 @@
|
|||||||
collapse-tags
|
collapse-tags
|
||||||
:max-collapse-tags="3"
|
:max-collapse-tags="3"
|
||||||
collapse-tags-tooltip
|
collapse-tags-tooltip
|
||||||
|
fit-input-width
|
||||||
class="cms-scope-select"
|
class="cms-scope-select"
|
||||||
:remote-method="loadVariants"
|
:remote-method="loadVariants"
|
||||||
:loading="variantSearchLoading"
|
:loading="variantSearchLoading"
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
>
|
>
|
||||||
{{ row.productTitle }}
|
{{ getProductTitle(row.productTitle) }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -113,6 +113,7 @@ import type { OrderExtended } from '@/types/order/OrderExtended';
|
|||||||
import { DeliveryMode } from '@/types/product/DeliveryMode';
|
import { DeliveryMode } from '@/types/product/DeliveryMode';
|
||||||
import { formatDeliveryMode } from '@/utils/product/formatDeliveryMode';
|
import { formatDeliveryMode } from '@/utils/product/formatDeliveryMode';
|
||||||
import { formatFiatPrice } from '@/utils/formatFiatPrice';
|
import { formatFiatPrice } from '@/utils/formatFiatPrice';
|
||||||
|
import { getProductTitle } from '@/utils/product/getProductTitle';
|
||||||
import { resolveAxiosErrorMessage } from '@/utils/resolveAxiosErrorMessage';
|
import { resolveAxiosErrorMessage } from '@/utils/resolveAxiosErrorMessage';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -143,7 +144,7 @@ const openAutoDelivery = (line: OrderLine): void => {
|
|||||||
const markAsFulfilled = async (line: OrderLine): Promise<void> => {
|
const markAsFulfilled = async (line: OrderLine): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
`Mark "${line.productTitle}" (${line.variantTitle}) as fulfilled? This cannot be undone.`,
|
`Mark "${getProductTitle(line.productTitle)}" (${line.variantTitle}) as fulfilled? This cannot be undone.`,
|
||||||
'Mark as fulfilled',
|
'Mark as fulfilled',
|
||||||
{
|
{
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ import type { OrderLine } from '@/types/order/OrderLine';
|
|||||||
import type { OrderLineAutoFulfillmentItem } from '@/types/order/OrderLineAutoFulfillmentItem';
|
import type { OrderLineAutoFulfillmentItem } from '@/types/order/OrderLineAutoFulfillmentItem';
|
||||||
import type { OrderLineAutoFulfillmentItemAttachment } from '@/types/order/OrderLineAutoFulfillmentItemAttachment';
|
import type { OrderLineAutoFulfillmentItemAttachment } from '@/types/order/OrderLineAutoFulfillmentItemAttachment';
|
||||||
import { formatFileSize } from '@/utils/formatFileSize';
|
import { formatFileSize } from '@/utils/formatFileSize';
|
||||||
|
import { getProductTitle } from '@/utils/product/getProductTitle';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
@@ -99,7 +100,7 @@ const lineSummary = computed((): string => {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${line.productTitle} · ${line.variantTitle}`;
|
return `${getProductTitle(line.productTitle)} · ${line.variantTitle}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const downloadAttachment = async (
|
const downloadAttachment = async (
|
||||||
|
|||||||
@@ -317,7 +317,7 @@ const stockRemoving = ref(false);
|
|||||||
const stockListLoading = ref(false);
|
const stockListLoading = ref(false);
|
||||||
|
|
||||||
const stockContentRules: FormRules = {
|
const stockContentRules: FormRules = {
|
||||||
content: [{ required: true, message: 'Required', trigger: 'blur' }]
|
content: [{ required: true, message: 'Content is required', trigger: 'blur' }]
|
||||||
};
|
};
|
||||||
|
|
||||||
const stockEditVisible = ref(false);
|
const stockEditVisible = ref(false);
|
||||||
|
|||||||
@@ -58,7 +58,12 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="Fee priority" prop="priority">
|
<el-form-item label="Fee priority" prop="priority">
|
||||||
<el-select v-model="withdrawForm.priority" class="fee-priority-select" :disabled="withdrawing">
|
<el-select
|
||||||
|
v-model="withdrawForm.priority"
|
||||||
|
class="fee-priority-select"
|
||||||
|
fit-input-width
|
||||||
|
:disabled="withdrawing"
|
||||||
|
>
|
||||||
<el-option
|
<el-option
|
||||||
v-for="option in moneroWithdrawPriorityOptions"
|
v-for="option in moneroWithdrawPriorityOptions"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ export const config = {
|
|||||||
maxFileBytes: parseInt(env('VITE_PRODUCT_THUMB_MAX_FILE_BYTES'), 10)
|
maxFileBytes: parseInt(env('VITE_PRODUCT_THUMB_MAX_FILE_BYTES'), 10)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
blogImage: {
|
||||||
|
accept: env('VITE_BLOG_IMAGE_ALLOWED_MIMES'),
|
||||||
|
maxFileBytes: parseInt(env('VITE_BLOG_IMAGE_MAX_FILE_BYTES'), 10)
|
||||||
|
},
|
||||||
|
|
||||||
shopLogo: {
|
shopLogo: {
|
||||||
accept: env('VITE_SHOP_LOGO_ALLOWED_MIMES'),
|
accept: env('VITE_SHOP_LOGO_ALLOWED_MIMES'),
|
||||||
maxFileBytes: parseInt(env('VITE_SHOP_LOGO_MAX_FILE_BYTES'), 10)
|
maxFileBytes: parseInt(env('VITE_SHOP_LOGO_MAX_FILE_BYTES'), 10)
|
||||||
@@ -36,6 +41,7 @@ export const config = {
|
|||||||
|
|
||||||
validation: {
|
validation: {
|
||||||
productTitleMaxLength: parseInt(env('VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH'), 10),
|
productTitleMaxLength: parseInt(env('VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH'), 10),
|
||||||
|
blogPostTitleMaxLength: parseInt(env('VITE_VALIDATION_BLOG_POST_TITLE_MAX_LENGTH'), 10),
|
||||||
categoryNameMaxLength: parseInt(env('VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH'), 10),
|
categoryNameMaxLength: parseInt(env('VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH'), 10),
|
||||||
discountCodeMaxLength: parseInt(env('VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH'), 10),
|
discountCodeMaxLength: parseInt(env('VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH'), 10),
|
||||||
variantImagesMax: parseInt(env('VITE_VALIDATION_VARIANT_IMAGES_MAX'), 10),
|
variantImagesMax: parseInt(env('VITE_VALIDATION_VARIANT_IMAGES_MAX'), 10),
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export const ROUTE_NAMES = {
|
|||||||
Products: 'Products',
|
Products: 'Products',
|
||||||
Categories: 'Categories',
|
Categories: 'Categories',
|
||||||
DiscountCodes: 'DiscountCodes',
|
DiscountCodes: 'DiscountCodes',
|
||||||
|
BlogPosts: 'BlogPosts',
|
||||||
|
BlogPostDetail: 'BlogPostDetail',
|
||||||
ShopSettings: 'ShopSettings',
|
ShopSettings: 'ShopSettings',
|
||||||
Notifications: 'Notifications',
|
Notifications: 'Notifications',
|
||||||
MoneroWallet: 'MoneroWallet',
|
MoneroWallet: 'MoneroWallet',
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export const UNTITLED_BLOG_POST_TITLE = '(untitled)';
|
||||||
@@ -41,6 +41,18 @@ const router = createRouter({
|
|||||||
component: () => import('../views/CmsDiscountCodesView.vue'),
|
component: () => import('../views/CmsDiscountCodesView.vue'),
|
||||||
meta: { requiresAuth: true, title: 'CMS - Discount codes' }
|
meta: { requiresAuth: true, title: 'CMS - Discount codes' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/blog/:id',
|
||||||
|
name: ROUTE_NAMES.BlogPostDetail,
|
||||||
|
component: () => import('../views/CmsBlogPostDetailView.vue'),
|
||||||
|
meta: { requiresAuth: true, title: 'CMS - Blog post', activeMenu: '/blog' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/blog',
|
||||||
|
name: ROUTE_NAMES.BlogPosts,
|
||||||
|
component: () => import('../views/CmsBlogPostsView.vue'),
|
||||||
|
meta: { requiresAuth: true, title: 'CMS - Blog' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/orders/:id',
|
path: '/orders/:id',
|
||||||
name: ROUTE_NAMES.OrderDetail,
|
name: ROUTE_NAMES.OrderDetail,
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { ROUTE_NAMES } from '@/consts/routeNames';
|
||||||
|
import { api } from '@/plugins/axios';
|
||||||
|
import type { BlogPost } from '@/types/blog/BlogPost';
|
||||||
|
import type { UpdateBlogPostPayload } from '@/types/blog/UpdateBlogPostPayload';
|
||||||
|
import type { PaginatedResponse } from '@/types/PaginatedResponse';
|
||||||
|
import { getBlogPostTitle } from '@/utils/blog/getBlogPostTitle';
|
||||||
|
|
||||||
|
export const useBlogPostsStore = defineStore('blogPosts', () => {
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const blogPosts = ref<BlogPost[]>([]);
|
||||||
|
|
||||||
|
const currentBlogPostId = computed<string | null>(() => {
|
||||||
|
if (route.name !== ROUTE_NAMES.BlogPostDetail) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = route.params.id;
|
||||||
|
|
||||||
|
return typeof id === 'string' && id ? id : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentBlogPost = computed<BlogPost | null>(() => {
|
||||||
|
if (!currentBlogPostId.value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return blogPosts.value.find(post => post.id === currentBlogPostId.value) ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentBlogPostDisplayTitle = computed(() =>
|
||||||
|
currentBlogPost.value ? getBlogPostTitle(currentBlogPost.value.title) : null
|
||||||
|
);
|
||||||
|
|
||||||
|
const upsertBlogPost = (data: BlogPost): void => {
|
||||||
|
const idx = blogPosts.value.findIndex(post => post.id === data.id);
|
||||||
|
|
||||||
|
if (idx !== -1) {
|
||||||
|
blogPosts.value[idx] = data;
|
||||||
|
} else {
|
||||||
|
blogPosts.value.unshift(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchBlogPosts = async ({
|
||||||
|
page,
|
||||||
|
limit
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
}): Promise<PaginatedResponse<BlogPost>> => {
|
||||||
|
const { data } = await api.get<PaginatedResponse<BlogPost>>('/blog-posts', {
|
||||||
|
params: { page, limit }
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchBlogPostById = async (id: string): Promise<BlogPost> => {
|
||||||
|
const { data } = await api.get<BlogPost>(`/blog-posts/${id}`);
|
||||||
|
|
||||||
|
upsertBlogPost(data);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createDraftBlogPost = async (): Promise<BlogPost> => {
|
||||||
|
const { data } = await api.post<BlogPost>('/blog-posts');
|
||||||
|
|
||||||
|
upsertBlogPost(data);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateBlogPost = async (id: string, payload: UpdateBlogPostPayload): Promise<BlogPost> => {
|
||||||
|
const { data } = await api.patch<BlogPost>(`/blog-posts/${id}`, payload);
|
||||||
|
|
||||||
|
upsertBlogPost(data);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteBlogPost = async (id: string): Promise<void> => {
|
||||||
|
await api.delete(`/blog-posts/${id}`);
|
||||||
|
|
||||||
|
blogPosts.value = blogPosts.value.filter(post => post.id !== id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadThumbnail = async (id: string, file: File): Promise<BlogPost> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const { data } = await api.post<BlogPost>(`/blog-posts/${id}/thumbnail`, formData);
|
||||||
|
|
||||||
|
upsertBlogPost(data);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadBanner = async (id: string, file: File): Promise<BlogPost> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const { data } = await api.post<BlogPost>(`/blog-posts/${id}/banner`, formData);
|
||||||
|
|
||||||
|
upsertBlogPost(data);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
blogPosts,
|
||||||
|
currentBlogPost,
|
||||||
|
currentBlogPostDisplayTitle,
|
||||||
|
currentBlogPostId,
|
||||||
|
fetchBlogPosts,
|
||||||
|
fetchBlogPostById,
|
||||||
|
createDraftBlogPost,
|
||||||
|
updateBlogPost,
|
||||||
|
deleteBlogPost,
|
||||||
|
uploadThumbnail,
|
||||||
|
uploadBanner
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -86,14 +86,14 @@ export const useProductsStore = defineStore('products', () => {
|
|||||||
|
|
||||||
const createOrEditCurrentProductVariantFormRules = computed<FormRules>(() => ({
|
const createOrEditCurrentProductVariantFormRules = computed<FormRules>(() => ({
|
||||||
title: [
|
title: [
|
||||||
{ required: true, message: 'Required', trigger: 'blur' },
|
{ required: true, message: 'Title is required', trigger: 'blur' },
|
||||||
{
|
{
|
||||||
max: validationProductTitleMaxLength,
|
max: validationProductTitleMaxLength,
|
||||||
message: `At most ${validationProductTitleMaxLength} characters`,
|
message: `At most ${validationProductTitleMaxLength} characters`,
|
||||||
trigger: 'blur'
|
trigger: 'blur'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
price: [{ required: true, message: 'Required', trigger: 'change' }],
|
price: [{ required: true, message: 'Price is required', trigger: 'change' }],
|
||||||
...(currentProductIsManual.value
|
...(currentProductIsManual.value
|
||||||
? {
|
? {
|
||||||
stockQuantity: [
|
stockQuantity: [
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { ConnectSimplexNotificationsPayload } from '@/types/shopSettings/Co
|
|||||||
import type { ShopSettings } from '@/types/shopSettings/ShopSettings';
|
import type { ShopSettings } from '@/types/shopSettings/ShopSettings';
|
||||||
import type { UpdateNotificationsPayload } from '@/types/shopSettings/UpdateNotificationsPayload';
|
import type { UpdateNotificationsPayload } from '@/types/shopSettings/UpdateNotificationsPayload';
|
||||||
import type { UpdateShippingNotePayload } from '@/types/shopSettings/UpdateShippingNotePayload';
|
import type { UpdateShippingNotePayload } from '@/types/shopSettings/UpdateShippingNotePayload';
|
||||||
|
import type { UpdateBlogEnabledPayload } from '@/types/shopSettings/UpdateBlogEnabledPayload';
|
||||||
import type { UpdateSimplexLinkPayload } from '@/types/shopSettings/UpdateSimplexLinkPayload';
|
import type { UpdateSimplexLinkPayload } from '@/types/shopSettings/UpdateSimplexLinkPayload';
|
||||||
|
|
||||||
export const useShopSettingsStore = defineStore('shopSettings', () => {
|
export const useShopSettingsStore = defineStore('shopSettings', () => {
|
||||||
@@ -42,6 +43,14 @@ export const useShopSettingsStore = defineStore('shopSettings', () => {
|
|||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateBlogEnabled = async (payload: UpdateBlogEnabledPayload): Promise<ShopSettings> => {
|
||||||
|
const { data } = await api.patch<ShopSettings>('/shop-settings/blog-enabled', payload);
|
||||||
|
|
||||||
|
settings.value = data;
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
const connectSimplexNotifications = async (payload: ConnectSimplexNotificationsPayload): Promise<ShopSettings> => {
|
const connectSimplexNotifications = async (payload: ConnectSimplexNotificationsPayload): Promise<ShopSettings> => {
|
||||||
const { data } = await api.post<ShopSettings>('/shop-settings/simplex-connect', payload);
|
const { data } = await api.post<ShopSettings>('/shop-settings/simplex-connect', payload);
|
||||||
|
|
||||||
@@ -80,6 +89,7 @@ export const useShopSettingsStore = defineStore('shopSettings', () => {
|
|||||||
updateSimplexLink,
|
updateSimplexLink,
|
||||||
updateShippingNote,
|
updateShippingNote,
|
||||||
updateNotifications,
|
updateNotifications,
|
||||||
|
updateBlogEnabled,
|
||||||
connectSimplexNotifications,
|
connectSimplexNotifications,
|
||||||
uploadShopLogo,
|
uploadShopLogo,
|
||||||
uploadShopFavicon
|
uploadShopFavicon
|
||||||
|
|||||||
@@ -16,16 +16,6 @@
|
|||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-select__popper {
|
|
||||||
max-width: min(560px, calc(100vw - 32px));
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-select-dropdown__item {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: $cms-bp-tablet) {
|
@media (max-width: $cms-bp-tablet) {
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
@@ -114,10 +104,6 @@
|
|||||||
width: min(180px, calc(100vw - 10px)) !important;
|
width: min(180px, calc(100vw - 10px)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-select__popper {
|
|
||||||
max-width: calc(100vw - 10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cms-gate {
|
.cms-gate {
|
||||||
padding: 12px 5px;
|
padding: 12px 5px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export interface BlogPost {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
thumbnailUrl: string | null;
|
||||||
|
bannerUrl: string | null;
|
||||||
|
contentHtml: string;
|
||||||
|
isDraft: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export interface UpdateBlogPostPayload {
|
||||||
|
title: string;
|
||||||
|
contentHtml: string;
|
||||||
|
isDraft: boolean;
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ export interface ShopSettings {
|
|||||||
notifyOnNewOrder: boolean;
|
notifyOnNewOrder: boolean;
|
||||||
notifyOnOrderMessage: boolean;
|
notifyOnOrderMessage: boolean;
|
||||||
simplexNotificationConnected: boolean;
|
simplexNotificationConnected: boolean;
|
||||||
|
blogEnabled: boolean;
|
||||||
isSetupComplete: boolean;
|
isSetupComplete: boolean;
|
||||||
setupChecklist: SetupChecklist;
|
setupChecklist: SetupChecklist;
|
||||||
createdAt: string | null;
|
createdAt: string | null;
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export interface UpdateBlogEnabledPayload {
|
||||||
|
blogEnabled: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { UNTITLED_BLOG_POST_TITLE } from '@/consts/untitledBlogPostTitle';
|
||||||
|
|
||||||
|
export const getBlogPostTitle = (title: string | undefined | null): string => title || UNTITLED_BLOG_POST_TITLE;
|
||||||
@@ -5,6 +5,7 @@ const MIME_LABEL: Record<string, string> = {
|
|||||||
'application/json': 'JSON',
|
'application/json': 'JSON',
|
||||||
'application/pdf': 'PDF',
|
'application/pdf': 'PDF',
|
||||||
'application/zip': 'ZIP',
|
'application/zip': 'ZIP',
|
||||||
|
'application/x-zip-compressed': 'ZIP',
|
||||||
'image/gif': 'GIF',
|
'image/gif': 'GIF',
|
||||||
'image/jpeg': 'JPEG',
|
'image/jpeg': 'JPEG',
|
||||||
'image/jpg': 'JPEG',
|
'image/jpg': 'JPEG',
|
||||||
|
|||||||
@@ -0,0 +1,467 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center mb-16">
|
||||||
|
<el-button link type="primary" @click="router.push({ name: ROUTE_NAMES.BlogPosts })"> ← Blog </el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-loading="loading" class="detail-loading-host" element-loading-text="Loading blog post…">
|
||||||
|
<el-empty v-if="!loading && loadError" description="Blog post not found" />
|
||||||
|
|
||||||
|
<template v-if="!loading && !loadError && currentBlogPost">
|
||||||
|
<div v-loading="deleteSaving" element-loading-text="Deleting blog post…">
|
||||||
|
<h3 class="m-0 mb-16">Edit blog post</h3>
|
||||||
|
|
||||||
|
<div v-if="currentBlogPost?.isDraft" class="mb-16">
|
||||||
|
<el-alert
|
||||||
|
type="info"
|
||||||
|
title="Draft"
|
||||||
|
description="This post is hidden from the storefront until you publish it."
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card class="mb-24" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<span>Details</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-form ref="postFormRef" label-position="top" :model="postForm" :rules="postRules">
|
||||||
|
<el-form-item label="Live" class="mb-4">
|
||||||
|
<el-switch v-model="publishSwitch" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="Title" prop="title">
|
||||||
|
<el-input
|
||||||
|
v-model="postForm.title"
|
||||||
|
:maxlength="validationBlogPostTitleMaxLength"
|
||||||
|
show-word-limit
|
||||||
|
@input="postFormRef?.clearValidate('title')"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="Content">
|
||||||
|
<rich-text-editor
|
||||||
|
v-model="postForm.contentHtml"
|
||||||
|
placeholder="Write your post…"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-button type="primary" :loading="postSaving" @click="submitPost"> Save post </el-button>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card class="mb-24" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<span>Thumbnail</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="mb-16 flex flex-col items-start gap-4">
|
||||||
|
<el-text tag="p" size="small" class="secondary-text w-full">
|
||||||
|
{{ blogImageHint }}
|
||||||
|
</el-text>
|
||||||
|
|
||||||
|
<el-text tag="p" size="small" class="secondary-text w-full">
|
||||||
|
Shown on the blog index. Recommended: 16:9 aspect ratio.
|
||||||
|
</el-text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="thumbnailPreviewUrl" class="mb-16">
|
||||||
|
<div class="blog-image-preview-wrap blog-image-preview-wrap--thumb">
|
||||||
|
<img :src="thumbnailPreviewUrl" alt="" class="blog-image-preview" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-12 min-w-0">
|
||||||
|
<el-upload
|
||||||
|
ref="thumbnailUploadRef"
|
||||||
|
:show-file-list="false"
|
||||||
|
:auto-upload="false"
|
||||||
|
:limit="1"
|
||||||
|
:accept="blogImageAccept"
|
||||||
|
:disabled="thumbnailUploadSaving"
|
||||||
|
:on-change="onThumbnailChange"
|
||||||
|
:on-remove="onThumbnailRemove"
|
||||||
|
>
|
||||||
|
<el-button type="default">Select image</el-button>
|
||||||
|
</el-upload>
|
||||||
|
|
||||||
|
<template v-if="thumbnailPendingFile">
|
||||||
|
<el-text size="small" class="flex-ellipsis" :title="thumbnailPendingFile.name">
|
||||||
|
{{ thumbnailPendingFile.name }}
|
||||||
|
</el-text>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="thumbnailUploadSaving"
|
||||||
|
@click="submitThumbnailUpload"
|
||||||
|
>
|
||||||
|
Upload
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card class="mb-24" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<span>Banner</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="mb-16 flex flex-col items-start gap-4">
|
||||||
|
<el-text tag="p" size="small" class="secondary-text w-full">
|
||||||
|
{{ blogImageHint }}
|
||||||
|
</el-text>
|
||||||
|
|
||||||
|
<el-text tag="p" size="small" class="secondary-text w-full">
|
||||||
|
Shown at the top of the post detail page. Recommended: 8:3 aspect ratio (e.g.
|
||||||
|
1600×600).
|
||||||
|
</el-text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="bannerPreviewUrl" class="mb-16">
|
||||||
|
<div class="blog-image-preview-wrap blog-image-preview-wrap--banner">
|
||||||
|
<img :src="bannerPreviewUrl" alt="" class="blog-image-preview" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-12 min-w-0">
|
||||||
|
<el-upload
|
||||||
|
ref="bannerUploadRef"
|
||||||
|
:show-file-list="false"
|
||||||
|
:auto-upload="false"
|
||||||
|
:limit="1"
|
||||||
|
:accept="blogImageAccept"
|
||||||
|
:disabled="bannerUploadSaving"
|
||||||
|
:on-change="onBannerChange"
|
||||||
|
:on-remove="onBannerRemove"
|
||||||
|
>
|
||||||
|
<el-button type="default">Select image</el-button>
|
||||||
|
</el-upload>
|
||||||
|
|
||||||
|
<template v-if="bannerPendingFile">
|
||||||
|
<el-text size="small" class="flex-ellipsis" :title="bannerPendingFile.name">
|
||||||
|
{{ bannerPendingFile.name }}
|
||||||
|
</el-text>
|
||||||
|
<el-button type="primary" :loading="bannerUploadSaving" @click="submitBannerUpload">
|
||||||
|
Upload
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-button type="danger" plain :loading="deleteSaving" @click="confirmDeletePost">
|
||||||
|
Delete post
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { config } from '@/config';
|
||||||
|
import { ROUTE_NAMES } from '@/consts/routeNames';
|
||||||
|
import { useBlogPostsStore } from '@/stores/blogPosts';
|
||||||
|
import { buildUploadHint } from '@/utils/upload/buildUploadHint';
|
||||||
|
import { resolveAxiosErrorMessage } from '@/utils/resolveAxiosErrorMessage';
|
||||||
|
import { resolveUploadPublicUrl } from '@/utils/upload/resolveUploadPublicUrl';
|
||||||
|
import { validateUpload } from '@/utils/upload/validateUpload';
|
||||||
|
import {
|
||||||
|
ElMessage,
|
||||||
|
ElMessageBox,
|
||||||
|
type FormInstance,
|
||||||
|
type FormRules,
|
||||||
|
type UploadInstance,
|
||||||
|
type UploadProps
|
||||||
|
} from 'element-plus';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { computed, onBeforeMount, reactive, ref } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const {
|
||||||
|
blogImage: { accept: blogImageAccept, maxFileBytes: blogImageMaxFileBytes },
|
||||||
|
validation: { blogPostTitleMaxLength: validationBlogPostTitleMaxLength }
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const blogPostsStore = useBlogPostsStore();
|
||||||
|
|
||||||
|
const { currentBlogPost, currentBlogPostDisplayTitle } = storeToRefs(blogPostsStore);
|
||||||
|
const { fetchBlogPostById, updateBlogPost, deleteBlogPost, uploadThumbnail, uploadBanner } = blogPostsStore;
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const loadError = ref(false);
|
||||||
|
const postFormRef = ref<FormInstance>();
|
||||||
|
const postForm = reactive({
|
||||||
|
title: '',
|
||||||
|
contentHtml: '',
|
||||||
|
isDraft: true
|
||||||
|
});
|
||||||
|
const postSaving = ref(false);
|
||||||
|
const deleteSaving = ref(false);
|
||||||
|
const thumbnailUploadSaving = ref(false);
|
||||||
|
const bannerUploadSaving = ref(false);
|
||||||
|
const thumbnailPendingFile = ref<File | null>(null);
|
||||||
|
const bannerPendingFile = ref<File | null>(null);
|
||||||
|
const thumbnailUploadRef = ref<UploadInstance>();
|
||||||
|
const bannerUploadRef = ref<UploadInstance>();
|
||||||
|
|
||||||
|
const postRules: FormRules = {
|
||||||
|
title: [
|
||||||
|
{ required: true, message: 'Title is required', trigger: 'blur' },
|
||||||
|
{
|
||||||
|
max: validationBlogPostTitleMaxLength,
|
||||||
|
message: `At most ${validationBlogPostTitleMaxLength} characters`,
|
||||||
|
trigger: 'blur'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
onBeforeMount(async () => {
|
||||||
|
loadError.value = false;
|
||||||
|
|
||||||
|
const id = route.params.id as string;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
loadError.value = true;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchBlogPostById(id);
|
||||||
|
|
||||||
|
if (currentBlogPost.value) {
|
||||||
|
postForm.title = currentBlogPost.value.title;
|
||||||
|
postForm.contentHtml = currentBlogPost.value.contentHtml;
|
||||||
|
postForm.isDraft = currentBlogPost.value.isDraft;
|
||||||
|
} else {
|
||||||
|
loadError.value = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
loadError.value = true;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const blogImageHint = computed(() =>
|
||||||
|
buildUploadHint({
|
||||||
|
allowedMimesCsv: blogImageAccept,
|
||||||
|
maxFileBytes: blogImageMaxFileBytes
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const thumbnailPreviewUrl = computed(() => {
|
||||||
|
const thumbnailUrl = currentBlogPost.value?.thumbnailUrl;
|
||||||
|
|
||||||
|
return thumbnailUrl ? resolveUploadPublicUrl(thumbnailUrl) : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const bannerPreviewUrl = computed(() => {
|
||||||
|
const bannerUrl = currentBlogPost.value?.bannerUrl;
|
||||||
|
|
||||||
|
return bannerUrl ? resolveUploadPublicUrl(bannerUrl) : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const publishSwitch = computed<boolean>({
|
||||||
|
get: () => !postForm.isDraft,
|
||||||
|
set: (published: boolean) => {
|
||||||
|
postForm.isDraft = !published;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const submitPost = async (): Promise<void> => {
|
||||||
|
const form = postFormRef.value;
|
||||||
|
|
||||||
|
if (!form) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await form.validate();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
postSaving.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateBlogPost(route.params.id as string, {
|
||||||
|
title: postForm.title.trim(),
|
||||||
|
contentHtml: postForm.contentHtml,
|
||||||
|
isDraft: postForm.isDraft
|
||||||
|
});
|
||||||
|
|
||||||
|
ElMessage.success('Blog post saved');
|
||||||
|
} catch (e) {
|
||||||
|
const message = resolveAxiosErrorMessage(e, 'Could not save blog post');
|
||||||
|
|
||||||
|
ElMessage.error(message);
|
||||||
|
} finally {
|
||||||
|
postSaving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onThumbnailChange: UploadProps['onChange'] = uploadFile => {
|
||||||
|
const file = uploadFile.raw;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationError = validateUpload(file, {
|
||||||
|
allowedMimesCsv: blogImageAccept,
|
||||||
|
maxFileBytes: blogImageMaxFileBytes
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validationError) {
|
||||||
|
ElMessage.error(validationError);
|
||||||
|
thumbnailUploadRef.value?.clearFiles();
|
||||||
|
thumbnailPendingFile.value = null;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
thumbnailPendingFile.value = file;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onThumbnailRemove: UploadProps['onRemove'] = () => {
|
||||||
|
thumbnailPendingFile.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitThumbnailUpload = async (): Promise<void> => {
|
||||||
|
if (!thumbnailPendingFile.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
thumbnailUploadSaving.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await uploadThumbnail(route.params.id as string, thumbnailPendingFile.value);
|
||||||
|
thumbnailPendingFile.value = null;
|
||||||
|
thumbnailUploadRef.value?.clearFiles();
|
||||||
|
ElMessage.success('Thumbnail uploaded');
|
||||||
|
} catch (e) {
|
||||||
|
const message = resolveAxiosErrorMessage(e, 'Could not upload thumbnail');
|
||||||
|
|
||||||
|
ElMessage.error(message);
|
||||||
|
} finally {
|
||||||
|
thumbnailUploadSaving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBannerChange: UploadProps['onChange'] = uploadFile => {
|
||||||
|
const file = uploadFile.raw;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationError = validateUpload(file, {
|
||||||
|
allowedMimesCsv: blogImageAccept,
|
||||||
|
maxFileBytes: blogImageMaxFileBytes
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validationError) {
|
||||||
|
ElMessage.error(validationError);
|
||||||
|
bannerUploadRef.value?.clearFiles();
|
||||||
|
bannerPendingFile.value = null;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bannerPendingFile.value = file;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBannerRemove: UploadProps['onRemove'] = () => {
|
||||||
|
bannerPendingFile.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitBannerUpload = async (): Promise<void> => {
|
||||||
|
if (!bannerPendingFile.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bannerUploadSaving.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await uploadBanner(route.params.id as string, bannerPendingFile.value);
|
||||||
|
bannerPendingFile.value = null;
|
||||||
|
bannerUploadRef.value?.clearFiles();
|
||||||
|
ElMessage.success('Banner uploaded');
|
||||||
|
} catch (e) {
|
||||||
|
const message = resolveAxiosErrorMessage(e, 'Could not upload banner');
|
||||||
|
|
||||||
|
ElMessage.error(message);
|
||||||
|
} finally {
|
||||||
|
bannerUploadSaving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDeletePost = async (): Promise<void> => {
|
||||||
|
const postId = currentBlogPost.value?.id;
|
||||||
|
|
||||||
|
if (!postId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`Delete «${currentBlogPostDisplayTitle.value}»? This cannot be undone.`,
|
||||||
|
'Delete blog post',
|
||||||
|
{
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: 'Delete',
|
||||||
|
cancelButtonText: 'Cancel'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSaving.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteBlogPost(postId);
|
||||||
|
ElMessage.success('Blog post deleted');
|
||||||
|
router.push({ name: ROUTE_NAMES.BlogPosts });
|
||||||
|
} catch (e) {
|
||||||
|
const message = resolveAxiosErrorMessage(e, 'Could not delete blog post');
|
||||||
|
|
||||||
|
ElMessage.error(message);
|
||||||
|
} finally {
|
||||||
|
deleteSaving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.blog-image-preview-wrap {
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: var(--el-border-radius-base);
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-image-preview-wrap--thumb {
|
||||||
|
width: 22rem;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-image-preview-wrap--banner {
|
||||||
|
width: min(100%, 40rem);
|
||||||
|
aspect-ratio: 8 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-image-preview {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user