Compare commits
22
Commits
abe6208e4e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
633099371d | ||
|
|
ded7407090 | ||
|
|
f60fb7bbaf | ||
|
|
321251daf4 | ||
|
|
c5bd4925d7 | ||
|
|
84bfe634ab | ||
|
|
3f0ea33f10 | ||
|
|
45a89a8bc3 | ||
|
|
f04c21ec17 | ||
|
|
55630e33b4 | ||
|
|
e057b29cc7 | ||
|
|
226a31b937 | ||
|
|
6419439a3d | ||
|
|
762af5c4f3 | ||
|
|
17a26b60ed | ||
|
|
9cd7059928 | ||
|
|
9c7b8470d3 | ||
|
|
e5eb773404 | ||
|
|
0cd7158f7d | ||
|
|
09ad236cb9 | ||
|
|
a3d1842fb3 | ||
|
|
73f7ffed00 |
+8
-2
@@ -37,13 +37,16 @@ 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
|
||||||
@@ -135,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
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
/deploy/certs
|
/deploy/certs
|
||||||
/deploy/certbot/www
|
/deploy/certbot/www
|
||||||
|
/deploy/backups
|
||||||
|
|
||||||
/monero-wallet-rpc/wallet
|
/monero-wallet-rpc/wallet
|
||||||
/electrum-daemon/wallet
|
/electrum-daemon/wallet
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:20-alpine
|
FROM node:22.23.4-alpine
|
||||||
|
|
||||||
WORKDIR /backend
|
WORKDIR /backend
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:20-alpine
|
FROM node:22.23.4-alpine
|
||||||
|
|
||||||
WORKDIR /backend
|
WORKDIR /backend
|
||||||
|
|
||||||
|
|||||||
@@ -38,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';
|
||||||
|
|
||||||
@@ -101,7 +102,8 @@ import { Config } from './types/Config';
|
|||||||
StorefrontProductModule,
|
StorefrontProductModule,
|
||||||
StorefrontCartModule,
|
StorefrontCartModule,
|
||||||
StorefrontCheckoutModule,
|
StorefrontCheckoutModule,
|
||||||
StorefrontOrderModule
|
StorefrontOrderModule,
|
||||||
|
StorefrontBlogModule
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -128,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(',')
|
||||||
|
|||||||
@@ -72,9 +72,19 @@ class EnvironmentVariables {
|
|||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
@Max(10 * 1024 * 1024) // 10MB
|
@Max(50 * 1024 * 1024) // 50MB
|
||||||
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;
|
||||||
|
|||||||
@@ -77,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 },
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { BlogPostsService } from './services/BlogPostsService';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([BlogPost])],
|
imports: [TypeOrmModule.forFeature([BlogPost])],
|
||||||
controllers: [BlogPostsController],
|
controllers: [BlogPostsController],
|
||||||
providers: [BlogPostsService]
|
providers: [BlogPostsService],
|
||||||
|
exports: [TypeOrmModule.forFeature([BlogPost])]
|
||||||
})
|
})
|
||||||
export class BlogModule {}
|
export class BlogModule {}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getProductThumbMulterConfig } from '../../../config';
|
import { getBlogImageMulterConfig } from '../../../config';
|
||||||
import { getBlogImagesDir } from '../../../config/uploadPaths';
|
import { getBlogImagesDir } from '../../../config/uploadPaths';
|
||||||
import { createDiskStorageUploadOptions } from '../../../utils/createDiskStorageUploadOptions';
|
import { createDiskStorageUploadOptions } from '../../../utils/createDiskStorageUploadOptions';
|
||||||
import { createUploadFilePipe } from '../../../utils/createUploadFilePipe';
|
import { createUploadFilePipe } from '../../../utils/createUploadFilePipe';
|
||||||
@@ -7,6 +7,6 @@ const uploadDir = getBlogImagesDir();
|
|||||||
|
|
||||||
export const blogImageUploadOptions = createDiskStorageUploadOptions(uploadDir);
|
export const blogImageUploadOptions = createDiskStorageUploadOptions(uploadDir);
|
||||||
|
|
||||||
const { allowedMimes, maxFileBytes } = getProductThumbMulterConfig();
|
const { allowedMimes, maxFileBytes } = getBlogImageMulterConfig();
|
||||||
|
|
||||||
export const blogImageFilePipe = createUploadFilePipe(allowedMimes, maxFileBytes);
|
export const blogImageFilePipe = createUploadFilePipe(allowedMimes, maxFileBytes);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NotFoundException } from '@nestjs/common';
|
import { NotFoundException } from '@nestjs/common';
|
||||||
import type { Repository } from 'typeorm';
|
import type { Repository } from 'typeorm';
|
||||||
import { resolveBlogImagePath } from '../../../config/uploadPaths';
|
import { getBlogImagePublicUrl, resolveBlogImagePath } from '../../../config/uploadPaths';
|
||||||
import { removeFileFromDisk } from '../../../utils/removeFileFromDisk';
|
import { removeFileFromDisk } from '../../../utils/removeFileFromDisk';
|
||||||
import { BlogPost } from '../entities/BlogPost';
|
import { BlogPost } from '../entities/BlogPost';
|
||||||
import { BlogPostsService } from './BlogPostsService';
|
import { BlogPostsService } from './BlogPostsService';
|
||||||
@@ -58,7 +58,28 @@ describe('BlogPostsService', () => {
|
|||||||
id: 'post-1',
|
id: 'post-1',
|
||||||
title: '',
|
title: '',
|
||||||
contentHtml: '',
|
contentHtml: '',
|
||||||
isDraft: true
|
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')
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { resolveBlogImagePath } from '../../../config/uploadPaths';
|
import { getBlogImagePublicUrl, resolveBlogImagePath } from '../../../config/uploadPaths';
|
||||||
import type { PaginatedResponse } from '../../../types/PaginatedResponse';
|
import type { PaginatedResponse } from '../../../types/PaginatedResponse';
|
||||||
import { removeFileFromDisk } from '../../../utils/removeFileFromDisk';
|
import { removeFileFromDisk } from '../../../utils/removeFileFromDisk';
|
||||||
import type { ListBlogPostsQueryDto } from '../dto/ListBlogPostsQueryDto';
|
import type { ListBlogPostsQueryDto } from '../dto/ListBlogPostsQueryDto';
|
||||||
import { UpdateBlogPostDto } from '../dto/UpdateBlogPostDto';
|
import { UpdateBlogPostDto } from '../dto/UpdateBlogPostDto';
|
||||||
import { BlogPost } from '../entities/BlogPost';
|
import { BlogPost } from '../entities/BlogPost';
|
||||||
|
import type { BlogPostExtended } from '../types/BlogPostExtended';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BlogPostsService {
|
export class BlogPostsService {
|
||||||
@@ -15,17 +16,19 @@ export class BlogPostsService {
|
|||||||
private readonly blogPostRepo: Repository<BlogPost>
|
private readonly blogPostRepo: Repository<BlogPost>
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll({ page = 1, limit = 20 }: ListBlogPostsQueryDto): Promise<PaginatedResponse<BlogPost>> {
|
async findAll({ page = 1, limit = 20 }: ListBlogPostsQueryDto): Promise<PaginatedResponse<BlogPostExtended>> {
|
||||||
const [items, total] = await this.blogPostRepo.findAndCount({
|
const [items, total] = await this.blogPostRepo.findAndCount({
|
||||||
order: { createdAt: 'DESC' },
|
order: { createdAt: 'DESC' },
|
||||||
skip: (page - 1) * limit,
|
skip: (page - 1) * limit,
|
||||||
take: limit
|
take: limit
|
||||||
});
|
});
|
||||||
|
|
||||||
return { items, total, page, limit };
|
const itemsExtended = items.map(post => this.toPostExtended(post));
|
||||||
|
|
||||||
|
return { items: itemsExtended, total, page, limit };
|
||||||
}
|
}
|
||||||
|
|
||||||
async createDraft(): Promise<BlogPost> {
|
async createDraft(): Promise<BlogPostExtended> {
|
||||||
const entity = this.blogPostRepo.create({
|
const entity = this.blogPostRepo.create({
|
||||||
title: '',
|
title: '',
|
||||||
contentHtml: '',
|
contentHtml: '',
|
||||||
@@ -37,17 +40,13 @@ export class BlogPostsService {
|
|||||||
return this.findOne(entity.id);
|
return this.findOne(entity.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: string): Promise<BlogPost> {
|
async findOne(id: string): Promise<BlogPostExtended> {
|
||||||
const entity = await this.blogPostRepo.findOne({ where: { id } });
|
const entity = await this.findEntity(id);
|
||||||
|
|
||||||
if (!entity) {
|
return this.toPostExtended(entity);
|
||||||
throw new NotFoundException();
|
|
||||||
}
|
|
||||||
|
|
||||||
return entity;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, { title, contentHtml, isDraft }: UpdateBlogPostDto): Promise<BlogPost> {
|
async update(id: string, { title, contentHtml, isDraft }: UpdateBlogPostDto): Promise<BlogPostExtended> {
|
||||||
const exists = await this.blogPostRepo.exists({ where: { id } });
|
const exists = await this.blogPostRepo.exists({ where: { id } });
|
||||||
|
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
@@ -63,8 +62,8 @@ export class BlogPostsService {
|
|||||||
return this.findOne(id);
|
return this.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadThumbnail(id: string, filename: string): Promise<BlogPost> {
|
async uploadThumbnail(id: string, filename: string): Promise<BlogPostExtended> {
|
||||||
const post = await this.findOne(id);
|
const post = await this.findEntity(id);
|
||||||
|
|
||||||
const previousStorageKey = post.thumbnailStorageKey;
|
const previousStorageKey = post.thumbnailStorageKey;
|
||||||
|
|
||||||
@@ -74,8 +73,8 @@ export class BlogPostsService {
|
|||||||
return this.findOne(id);
|
return this.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadBanner(id: string, filename: string): Promise<BlogPost> {
|
async uploadBanner(id: string, filename: string): Promise<BlogPostExtended> {
|
||||||
const post = await this.findOne(id);
|
const post = await this.findEntity(id);
|
||||||
|
|
||||||
const previousStorageKey = post.bannerStorageKey;
|
const previousStorageKey = post.bannerStorageKey;
|
||||||
|
|
||||||
@@ -86,7 +85,7 @@ export class BlogPostsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: string): Promise<void> {
|
async remove(id: string): Promise<void> {
|
||||||
const post = await this.findOne(id);
|
const post = await this.findEntity(id);
|
||||||
|
|
||||||
await this.blogPostRepo.delete(id);
|
await this.blogPostRepo.delete(id);
|
||||||
|
|
||||||
@@ -96,6 +95,24 @@ export class BlogPostsService {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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> {
|
private async removeBlogImageFile(storageKey: string | null): Promise<void> {
|
||||||
if (!storageKey) {
|
if (!storageKey) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,8 @@
|
|||||||
--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;
|
--sf-sticky-header-scroll-padding: 9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1598,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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ describe('StorefrontShopViewService', () => {
|
|||||||
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',
|
||||||
@@ -136,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
|
||||||
@@ -157,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'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -51,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);
|
||||||
@@ -75,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)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export type ShopNavKey = 'shop' | 'cart' | 'checkout' | 'check-order';
|
export type ShopNavKey = 'shop' | 'blog' | 'cart' | 'checkout' | 'check-order';
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export type ShopRenderLocals = {
|
|||||||
faviconUrl: string | null;
|
faviconUrl: string | null;
|
||||||
simplexLink: string | null;
|
simplexLink: string | null;
|
||||||
shippingNote: string | null;
|
shippingNote: string | null;
|
||||||
|
blogEnabled: boolean;
|
||||||
themePreference: StorefrontThemePreference;
|
themePreference: StorefrontThemePreference;
|
||||||
pageMeta: StorefrontPageMeta;
|
pageMeta: StorefrontPageMeta;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type StorefrontShopViewServiceOverrides = {
|
|||||||
faviconUrl: string | null;
|
faviconUrl: string | null;
|
||||||
simplexLink: string | null;
|
simplexLink: string | null;
|
||||||
shippingNote: string | null;
|
shippingNote: string | null;
|
||||||
|
blogEnabled: boolean;
|
||||||
};
|
};
|
||||||
shopSettings?: { shopName: string; shopFiatCurrency: string; enabledPaymentMethods?: PaymentMethod[] };
|
shopSettings?: { shopName: string; shopFiatCurrency: string; enabledPaymentMethods?: PaymentMethod[] };
|
||||||
fiatPerXmr?: number;
|
fiatPerXmr?: number;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const STOREFRONT_PARTIALS = [
|
|||||||
{ name: 'html-theme-attributes', file: 'html-theme-attributes.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' },
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ 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/theme')).toEqual({ activeShopNav: null, activeOrderId: null });
|
expect(resolveShopNavActive('/shop/theme')).toEqual({ activeShopNav: null, activeOrderId: null });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,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}}
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
{{> 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'
|
||||||
|
|||||||
@@ -17,6 +17,9 @@
|
|||||||
|
|
||||||
<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'}}
|
||||||
|
{{#if blogEnabled}}
|
||||||
|
{{> nav-link active=shopNavActive.activeShopNav key='blog' href='/shop/blog' label='Blog'}}
|
||||||
|
{{/if}}
|
||||||
{{#if hasActiveCheckout}}
|
{{#if hasActiveCheckout}}
|
||||||
{{> nav-link active=shopNavActive.activeShopNav key='checkout' href='/shop/checkout' label='Checkout' count=cartTotalQty}}
|
{{> nav-link active=shopNavActive.activeShopNav key='checkout' href='/shop/checkout' label='Checkout' count=cartTotalQty}}
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM node:20-alpine
|
FROM node:22.23.4-alpine
|
||||||
|
|
||||||
WORKDIR /cms
|
WORKDIR /cms
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="cms-page-header mb-24">
|
||||||
|
<h3 class="m-0">Blog</h3>
|
||||||
|
<div class="flex items-center gap-16">
|
||||||
|
<div class="flex items-center gap-8">
|
||||||
|
<span>Blog enabled</span>
|
||||||
|
<el-text v-if="settingsLoading" size="small" class="secondary-text">Loading…</el-text>
|
||||||
|
<el-switch
|
||||||
|
v-else
|
||||||
|
:model-value="settings?.blogEnabled ?? false"
|
||||||
|
:loading="blogEnabledSaving"
|
||||||
|
:disabled="!settings"
|
||||||
|
@change="onBlogEnabledChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<el-button type="primary" :loading="createDraftLoading" @click="handleNewPostDraft">
|
||||||
|
New post
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cms-table-scroll">
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="blogPosts"
|
||||||
|
row-key="id"
|
||||||
|
stripe
|
||||||
|
empty-text="No blog posts yet"
|
||||||
|
class="clickable-table"
|
||||||
|
@row-click="row => router.push({ name: ROUTE_NAMES.BlogPostDetail, params: { id: row.id } })"
|
||||||
|
>
|
||||||
|
<el-table-column prop="title" label="Title" min-width="200" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ getBlogPostTitle(row.title) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="Status" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.isDraft ? 'info' : 'success'" size="small">
|
||||||
|
{{ row.isDraft ? 'Draft' : 'Live' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="Updated" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDate(row.updatedAt) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column width="100" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
@click.stop="router.push({ name: ROUTE_NAMES.BlogPostDetail, params: { id: row.id } })"
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<cms-list-pagination v-model:page="page" v-model:limit="limit" :total="total" @change="loadBlogPosts" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ROUTE_NAMES } from '@/consts/routeNames';
|
||||||
|
import { useBlogPostsStore } from '@/stores/blogPosts';
|
||||||
|
import { useShopSettingsStore } from '@/stores/shopSettings';
|
||||||
|
import { getBlogPostTitle } from '@/utils/blog/getBlogPostTitle';
|
||||||
|
import { formatDate } from '@/utils/formatDate';
|
||||||
|
import { resolveAxiosErrorMessage } from '@/utils/resolveAxiosErrorMessage';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { onBeforeMount, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const blogPostsStore = useBlogPostsStore();
|
||||||
|
const shopSettingsStore = useShopSettingsStore();
|
||||||
|
|
||||||
|
const { blogPosts } = storeToRefs(blogPostsStore);
|
||||||
|
const { fetchBlogPosts, createDraftBlogPost } = blogPostsStore;
|
||||||
|
|
||||||
|
const { settings } = storeToRefs(shopSettingsStore);
|
||||||
|
const { fetchShopSettings, updateBlogEnabled } = shopSettingsStore;
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const settingsLoading = ref(false);
|
||||||
|
const createDraftLoading = ref(false);
|
||||||
|
const blogEnabledSaving = ref(false);
|
||||||
|
const page = ref(1);
|
||||||
|
const limit = ref(20);
|
||||||
|
const total = ref(0);
|
||||||
|
|
||||||
|
onBeforeMount(async () => {
|
||||||
|
await Promise.all([loadBlogPosts(), loadShopSettings()]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadShopSettings = async (): Promise<void> => {
|
||||||
|
settingsLoading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchShopSettings();
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('Failed to load shop settings');
|
||||||
|
} finally {
|
||||||
|
settingsLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBlogEnabledChange = async (value: string | number | boolean): Promise<void> => {
|
||||||
|
const enabled = Boolean(value);
|
||||||
|
|
||||||
|
blogEnabledSaving.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateBlogEnabled({ blogEnabled: enabled });
|
||||||
|
} catch (e) {
|
||||||
|
const message = resolveAxiosErrorMessage(e, 'Failed to update blog setting');
|
||||||
|
|
||||||
|
ElMessage.error(message);
|
||||||
|
} finally {
|
||||||
|
blogEnabledSaving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadBlogPosts = async (): Promise<void> => {
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchBlogPosts({
|
||||||
|
page: page.value,
|
||||||
|
limit: limit.value
|
||||||
|
});
|
||||||
|
|
||||||
|
blogPosts.value = data.items;
|
||||||
|
total.value = data.total;
|
||||||
|
page.value = data.page;
|
||||||
|
limit.value = data.limit;
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('Failed to load blog posts');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNewPostDraft = async (): Promise<void> => {
|
||||||
|
createDraftLoading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await createDraftBlogPost();
|
||||||
|
ElMessage.success('Draft created — add details and publish when ready');
|
||||||
|
router.push({ name: ROUTE_NAMES.BlogPostDetail, params: { id: created.id } });
|
||||||
|
} catch (e) {
|
||||||
|
const message = resolveAxiosErrorMessage(e, 'Could not create draft post');
|
||||||
|
|
||||||
|
ElMessage.error(message);
|
||||||
|
} finally {
|
||||||
|
createDraftLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -131,6 +131,7 @@ import { useCategoriesStore } from '@/stores/categories';
|
|||||||
import { useProductsStore } from '@/stores/products';
|
import { useProductsStore } from '@/stores/products';
|
||||||
import type { ProductVariantExtended } from '@/types/product/ProductVariantExtended';
|
import type { ProductVariantExtended } from '@/types/product/ProductVariantExtended';
|
||||||
import { formatFiatPrice } from '@/utils/formatFiatPrice';
|
import { formatFiatPrice } from '@/utils/formatFiatPrice';
|
||||||
|
import { resolveAxiosErrorMessage } from '@/utils/resolveAxiosErrorMessage';
|
||||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { computed, onBeforeMount, reactive, ref } from 'vue';
|
import { computed, onBeforeMount, reactive, ref } from 'vue';
|
||||||
@@ -168,7 +169,7 @@ const deleteSaving = ref(false);
|
|||||||
|
|
||||||
const productRules: FormRules = {
|
const productRules: 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`,
|
||||||
@@ -200,8 +201,6 @@ onBeforeMount(async () => {
|
|||||||
productForm.descriptionHtml = currentProduct.value.descriptionHtml;
|
productForm.descriptionHtml = currentProduct.value.descriptionHtml;
|
||||||
productForm.isDraft = currentProduct.value.isDraft;
|
productForm.isDraft = currentProduct.value.isDraft;
|
||||||
selectedCategoryIds.value = (currentProduct.value.categories ?? []).map(c => c.id);
|
selectedCategoryIds.value = (currentProduct.value.categories ?? []).map(c => c.id);
|
||||||
|
|
||||||
productFormRef.value?.clearValidate();
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
loadError.value = true;
|
loadError.value = true;
|
||||||
@@ -241,8 +240,10 @@ const submitProduct = async (): Promise<void> => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ElMessage.success('Product saved');
|
ElMessage.success('Product saved');
|
||||||
} catch {
|
} catch (e) {
|
||||||
ElMessage.error('Could not save product');
|
const message = resolveAxiosErrorMessage(e, 'Could not save product');
|
||||||
|
|
||||||
|
ElMessage.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
productSaving.value = false;
|
productSaving.value = false;
|
||||||
}
|
}
|
||||||
@@ -288,8 +289,10 @@ const confirmDeleteProduct = async (): Promise<void> => {
|
|||||||
await productsStore.deleteProduct(pid);
|
await productsStore.deleteProduct(pid);
|
||||||
ElMessage.success('Product deleted');
|
ElMessage.success('Product deleted');
|
||||||
router.push({ name: ROUTE_NAMES.Products });
|
router.push({ name: ROUTE_NAMES.Products });
|
||||||
} catch {
|
} catch (e) {
|
||||||
ElMessage.error('Could not delete product');
|
const message = resolveAxiosErrorMessage(e, 'Could not delete product');
|
||||||
|
|
||||||
|
ElMessage.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
deleteSaving.value = false;
|
deleteSaving.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<el-table
|
<el-table
|
||||||
v-loading="loading"
|
v-loading="loading"
|
||||||
:data="products"
|
:data="products"
|
||||||
|
row-key="id"
|
||||||
stripe
|
stripe
|
||||||
empty-text="No products yet"
|
empty-text="No products yet"
|
||||||
class="clickable-table"
|
class="clickable-table"
|
||||||
@@ -22,8 +23,9 @@
|
|||||||
|
|
||||||
<el-table-column label="Status" width="100">
|
<el-table-column label="Status" width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag v-if="row.isDraft" type="info" size="small">Draft</el-tag>
|
<el-tag :type="row.isDraft ? 'info' : 'success'" size="small">
|
||||||
<el-tag v-else type="success" size="small">Live</el-tag>
|
{{ row.isDraft ? 'Draft' : 'Live' }}
|
||||||
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
@@ -89,7 +91,6 @@ import { useProductsStore } from '@/stores/products';
|
|||||||
import type { Category } from '@/types/product/Category';
|
import type { Category } from '@/types/product/Category';
|
||||||
import type { ProductWithVariantsExtended } from '@/types/product/ProductWithVariantsExtended';
|
import type { ProductWithVariantsExtended } from '@/types/product/ProductWithVariantsExtended';
|
||||||
import { DeliveryMode } from '@/types/product/DeliveryMode';
|
import { DeliveryMode } from '@/types/product/DeliveryMode';
|
||||||
import { HttpStatusCode, isAxiosError } from 'axios';
|
|
||||||
import { formatDate } from '@/utils/formatDate';
|
import { formatDate } from '@/utils/formatDate';
|
||||||
import { formatDeliveryMode } from '@/utils/product/formatDeliveryMode';
|
import { formatDeliveryMode } from '@/utils/product/formatDeliveryMode';
|
||||||
import { getProductTitle } from '@/utils/product/getProductTitle';
|
import { getProductTitle } from '@/utils/product/getProductTitle';
|
||||||
@@ -127,10 +128,8 @@ const loadProducts = async (): Promise<void> => {
|
|||||||
total.value = data.total;
|
total.value = data.total;
|
||||||
page.value = data.page;
|
page.value = data.page;
|
||||||
limit.value = data.limit;
|
limit.value = data.limit;
|
||||||
} catch (e) {
|
} catch {
|
||||||
if (!isAxiosError(e) || e.response?.status !== HttpStatusCode.Unauthorized) {
|
ElMessage.error('Failed to load products');
|
||||||
ElMessage.error('Failed to load products');
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+3
@@ -15,6 +15,8 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_API_BASE_URL?: string;
|
readonly VITE_API_BASE_URL?: string;
|
||||||
readonly VITE_PRODUCT_THUMB_ALLOWED_MIMES?: string;
|
readonly VITE_PRODUCT_THUMB_ALLOWED_MIMES?: string;
|
||||||
readonly VITE_PRODUCT_THUMB_MAX_FILE_BYTES?: string;
|
readonly VITE_PRODUCT_THUMB_MAX_FILE_BYTES?: string;
|
||||||
|
readonly VITE_BLOG_IMAGE_ALLOWED_MIMES?: string;
|
||||||
|
readonly VITE_BLOG_IMAGE_MAX_FILE_BYTES?: string;
|
||||||
readonly VITE_SHOP_LOGO_ALLOWED_MIMES?: string;
|
readonly VITE_SHOP_LOGO_ALLOWED_MIMES?: string;
|
||||||
readonly VITE_SHOP_LOGO_MAX_FILE_BYTES?: string;
|
readonly VITE_SHOP_LOGO_MAX_FILE_BYTES?: string;
|
||||||
readonly VITE_SHOP_FAVICON_ALLOWED_MIMES?: string;
|
readonly VITE_SHOP_FAVICON_ALLOWED_MIMES?: string;
|
||||||
@@ -22,6 +24,7 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES?: string;
|
readonly VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES?: string;
|
||||||
readonly VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES?: string;
|
readonly VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES?: string;
|
||||||
readonly VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH?: string;
|
readonly VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH?: string;
|
||||||
|
readonly VITE_VALIDATION_BLOG_POST_TITLE_MAX_LENGTH?: string;
|
||||||
readonly VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH?: string;
|
readonly VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH?: string;
|
||||||
readonly VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH?: string;
|
readonly VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH?: string;
|
||||||
readonly VITE_VALIDATION_VARIANT_IMAGES_MAX?: string;
|
readonly VITE_VALIDATION_VARIANT_IMAGES_MAX?: string;
|
||||||
|
|||||||
@@ -159,10 +159,18 @@ Tor Browser will then show ".onion available" when visitors open the clearnet si
|
|||||||
3. Finish the setup checklist in settings.
|
3. Finish the setup checklist in settings.
|
||||||
4. Connect SimpleX notifications in shop settings.
|
4. Connect SimpleX notifications in shop settings.
|
||||||
|
|
||||||
## 10. Updates
|
## Updates
|
||||||
|
|
||||||
|
This pulls the latest code and rebuilds the stack (`deploy.sh`).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./deploy/scripts/update.sh
|
./deploy/scripts/update.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This pulls the latest code and rebuilds the stack (`deploy.sh`).
|
## Backups
|
||||||
|
|
||||||
|
This creates a full disaster-recovery archive.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy/scripts/backup.sh
|
||||||
|
```
|
||||||
|
|||||||
Executable
+337
@@ -0,0 +1,337 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
ENV_FILE="${ROOT_DIR}/.env.prod"
|
||||||
|
COMPOSE_FILE="${ROOT_DIR}/docker-compose.prod.yml"
|
||||||
|
BACKUP_DIR="${ROOT_DIR}/deploy/backups"
|
||||||
|
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
|
echo "Missing ${ENV_FILE}. Copy .env.example to .env.prod and configure it." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
set -a
|
||||||
|
source "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
if [[ -z "${COMPOSE_PROJECT_NAME:-}" ]]; then
|
||||||
|
echo "COMPOSE_PROJECT_NAME is not set in .env.prod" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${POSTGRES_USER:-}" || -z "${POSTGRES_DB:-}" ]]; then
|
||||||
|
echo "POSTGRES_USER and POSTGRES_DB must be set in .env.prod" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${MONERO_WALLET_DIR:-}" ]]; then
|
||||||
|
echo "MONERO_WALLET_DIR is not set in .env.prod" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${ELECTRUM_WALLET_DIR:-}" ]]; then
|
||||||
|
echo "ELECTRUM_WALLET_DIR is not set in .env.prod" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
compose() {
|
||||||
|
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_host_path() {
|
||||||
|
local rel="${1:-}"
|
||||||
|
local label="$2"
|
||||||
|
|
||||||
|
if [[ -z "$rel" ]]; then
|
||||||
|
echo "Cannot resolve empty path for ${label}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$rel" == /* ]]; then
|
||||||
|
echo "$rel"
|
||||||
|
else
|
||||||
|
echo "${ROOT_DIR}/${rel#./}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
build_compose_volume_name() {
|
||||||
|
local vol_key="$1"
|
||||||
|
echo "${COMPOSE_PROJECT_NAME}_${vol_key}"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_min_bytes() {
|
||||||
|
local file="$1"
|
||||||
|
local min_bytes="$2"
|
||||||
|
local label="$3"
|
||||||
|
|
||||||
|
if [[ ! -f "$file" ]] || (( $(stat -c%s "$file") < min_bytes )); then
|
||||||
|
echo "Backup failed: ${label} is missing or empty." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
require_tar_entries() {
|
||||||
|
local file="$1"
|
||||||
|
local label="$2"
|
||||||
|
local entry_count
|
||||||
|
|
||||||
|
if [[ ! -f "$file" ]]; then
|
||||||
|
echo "Backup failed: ${label} is missing or has no archive entries." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
entry_count="$(tar tf "$file" 2>/dev/null | wc -l)"
|
||||||
|
|
||||||
|
if (( entry_count < 1 )); then
|
||||||
|
echo "Backup failed: ${label} is missing or has no archive entries." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
tar_volume() {
|
||||||
|
local vol_key="$1"
|
||||||
|
local archive_name="$2"
|
||||||
|
local vol_name
|
||||||
|
|
||||||
|
vol_name="$(build_compose_volume_name "$vol_key")"
|
||||||
|
|
||||||
|
if ! docker volume inspect "$vol_name" >/dev/null 2>&1; then
|
||||||
|
echo "Backup failed: Docker volume ${vol_name} not found. Is the stack deployed?" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
-v "${vol_name}:/data:ro" \
|
||||||
|
-v "${STAGING_TMP}:/backup" \
|
||||||
|
alpine tar -cf "/backup/${archive_name}" -C /data .
|
||||||
|
}
|
||||||
|
|
||||||
|
SHOULD_STOP=false
|
||||||
|
STOPPED=false
|
||||||
|
STAGING_TMP=""
|
||||||
|
ARCHIVE_TMP=""
|
||||||
|
|
||||||
|
on_exit() {
|
||||||
|
if [[ -n "$STAGING_TMP" && -d "$STAGING_TMP" ]]; then
|
||||||
|
rm -rf "$STAGING_TMP"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$ARCHIVE_TMP" && -f "$ARCHIVE_TMP" ]]; then
|
||||||
|
rm -f "$ARCHIVE_TMP"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$STOPPED" == true ]]; then
|
||||||
|
echo "Restarting NullCart..."
|
||||||
|
|
||||||
|
compose start
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
trap on_exit EXIT
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
|
||||||
|
echo "Checking if PostgreSQL is running and accepting connections..."
|
||||||
|
|
||||||
|
cid="$(compose ps --status running -q postgres 2>/dev/null | head -n1)"
|
||||||
|
|
||||||
|
if [[ -z "$cid" ]]; then
|
||||||
|
echo "Backup failed: PostgreSQL is not running. Start the stack (for example: docker compose --env-file \"${ENV_FILE}\" -f \"${COMPOSE_FILE}\" up -d postgres)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! compose exec -T postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" >/dev/null 2>&1; then
|
||||||
|
echo "Backup failed: PostgreSQL is not accepting connections. Check container logs or wait until the database is healthy." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "PostgreSQL is running and accepting connections."
|
||||||
|
|
||||||
|
# Check if the script is running from an interactive terminal
|
||||||
|
if [[ ! -r /dev/tty ]]; then
|
||||||
|
echo "Run this script from an interactive terminal (for example: ssh -t)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'Would you like to stop NullCart before doing backup? Doing backup while NullCart is running may lead to backup data corruption [y/n] (recommended y): '
|
||||||
|
|
||||||
|
read -n 1 -r STOP_ANSWER </dev/tty || {
|
||||||
|
echo "Backup failed: could not read answer from the terminal." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
printf '\n'
|
||||||
|
|
||||||
|
case "$STOP_ANSWER" in
|
||||||
|
y)
|
||||||
|
SHOULD_STOP=true
|
||||||
|
;;
|
||||||
|
n)
|
||||||
|
echo "Continuing backup with NullCart running..."
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Invalid answer. Use y or n." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
STAGING_TMP="$(mktemp -d)"
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
TIMESTAMP="$(date -u +%Y%m%d-%H%M%S)"
|
||||||
|
ARCHIVE_PATH="${BACKUP_DIR}/nullcart-backup-${TIMESTAMP}.tar.gz"
|
||||||
|
ARCHIVE_TMP="${ARCHIVE_PATH}.tmp"
|
||||||
|
|
||||||
|
if [[ "$SHOULD_STOP" == true ]]; then
|
||||||
|
stop_services=()
|
||||||
|
compose_services="$(compose config --services)"
|
||||||
|
|
||||||
|
while IFS= read -r svc; do
|
||||||
|
if [[ -z "$svc" || "$svc" == "postgres" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
stop_services+=("$svc")
|
||||||
|
done <<<"$compose_services"
|
||||||
|
|
||||||
|
if ((${#stop_services[@]} == 0)); then
|
||||||
|
echo "Backup failed: no application services found to stop in ${COMPOSE_FILE}." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Stopping NullCart application services (PostgreSQL stays up for dump)..."
|
||||||
|
|
||||||
|
compose stop "${stop_services[@]}"
|
||||||
|
STOPPED=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Backing up PostgreSQL..."
|
||||||
|
|
||||||
|
compose exec -T postgres pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc > "${STAGING_TMP}/postgres.dump"
|
||||||
|
require_min_bytes "${STAGING_TMP}/postgres.dump" 64 "postgres.dump"
|
||||||
|
|
||||||
|
echo "Backing up environment file..."
|
||||||
|
|
||||||
|
cp "$ENV_FILE" "${STAGING_TMP}/env.prod"
|
||||||
|
require_min_bytes "${STAGING_TMP}/env.prod" 1 "env.prod"
|
||||||
|
|
||||||
|
echo "Backing up uploads..."
|
||||||
|
|
||||||
|
UPLOADS_DIR="${ROOT_DIR}/backend/uploads"
|
||||||
|
|
||||||
|
if [[ ! -d "$UPLOADS_DIR" ]]; then
|
||||||
|
echo "Backup failed: uploads directory not found at ${UPLOADS_DIR}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
tar -cf "${STAGING_TMP}/uploads.tar" -C "${ROOT_DIR}/backend" uploads
|
||||||
|
require_tar_entries "${STAGING_TMP}/uploads.tar" "uploads.tar"
|
||||||
|
|
||||||
|
echo "Backing up Monero wallet..."
|
||||||
|
|
||||||
|
MONERO_WALLET_PATH="$(resolve_host_path "$MONERO_WALLET_DIR" "Monero wallet")"
|
||||||
|
|
||||||
|
if [[ ! -d "$MONERO_WALLET_PATH" ]]; then
|
||||||
|
echo "Backup failed: Monero wallet directory not found at ${MONERO_WALLET_PATH}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$(ls -A "$MONERO_WALLET_PATH" 2>/dev/null || true)" ]]; then
|
||||||
|
echo "Backup failed: Monero wallet directory is empty at ${MONERO_WALLET_PATH}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
tar -cf "${STAGING_TMP}/monero-wallet.tar" -C "$(dirname "$MONERO_WALLET_PATH")" "$(basename "$MONERO_WALLET_PATH")"
|
||||||
|
require_tar_entries "${STAGING_TMP}/monero-wallet.tar" "monero-wallet.tar"
|
||||||
|
|
||||||
|
echo "Backing up Electrum wallet..."
|
||||||
|
|
||||||
|
ELECTRUM_WALLET_PATH="$(resolve_host_path "$ELECTRUM_WALLET_DIR" "Electrum wallet")"
|
||||||
|
|
||||||
|
if [[ ! -d "$ELECTRUM_WALLET_PATH" ]]; then
|
||||||
|
echo "Backup failed: Electrum wallet directory not found at ${ELECTRUM_WALLET_PATH}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$(ls -A "$ELECTRUM_WALLET_PATH" 2>/dev/null || true)" ]]; then
|
||||||
|
echo "Backup failed: Electrum wallet directory is empty at ${ELECTRUM_WALLET_PATH}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
tar -cf "${STAGING_TMP}/electrum-wallet.tar" -C "$(dirname "$ELECTRUM_WALLET_PATH")" "$(basename "$ELECTRUM_WALLET_PATH")"
|
||||||
|
require_tar_entries "${STAGING_TMP}/electrum-wallet.tar" "electrum-wallet.tar"
|
||||||
|
|
||||||
|
echo "Backing up Docker volumes..."
|
||||||
|
|
||||||
|
echo "Backing up Tor hidden service volume..."
|
||||||
|
|
||||||
|
tar_volume "nullcart.tor.prod.data" "volume-tor.tar"
|
||||||
|
require_tar_entries "${STAGING_TMP}/volume-tor.tar" "volume-tor.tar"
|
||||||
|
|
||||||
|
echo "Backing up SimpleX volume..."
|
||||||
|
|
||||||
|
tar_volume "nullcart.simplex.prod.data" "volume-simplex.tar"
|
||||||
|
require_tar_entries "${STAGING_TMP}/volume-simplex.tar" "volume-simplex.tar"
|
||||||
|
|
||||||
|
echo "Backing up pgAdmin volume..."
|
||||||
|
|
||||||
|
tar_volume "nullcart.pgadmin.prod.data" "volume-pgadmin.tar"
|
||||||
|
require_tar_entries "${STAGING_TMP}/volume-pgadmin.tar" "volume-pgadmin.tar"
|
||||||
|
|
||||||
|
echo "Backing up TLS certs directory..."
|
||||||
|
|
||||||
|
CERTS_DIR="${ROOT_DIR}/deploy/certs"
|
||||||
|
|
||||||
|
if [[ ! -d "$CERTS_DIR" ]]; then
|
||||||
|
echo "Backup failed: TLS certs directory not found at ${CERTS_DIR}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
tar -cf "${STAGING_TMP}/certs.tar" -C "${ROOT_DIR}/deploy" certs
|
||||||
|
require_tar_entries "${STAGING_TMP}/certs.tar" "certs.tar"
|
||||||
|
|
||||||
|
echo "Backing up Git commit..."
|
||||||
|
|
||||||
|
GIT_REV=""
|
||||||
|
|
||||||
|
if git -C "$ROOT_DIR" rev-parse HEAD >/dev/null 2>&1; then
|
||||||
|
GIT_REV="$(git -C "$ROOT_DIR" rev-parse HEAD)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "NullCart backup"
|
||||||
|
echo "Created (UTC): $(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||||
|
echo "Compose project: ${COMPOSE_PROJECT_NAME}"
|
||||||
|
echo "Stopped application services before pg_dump: $([[ "$STOPPED" == true ]] && echo yes || echo no)"
|
||||||
|
|
||||||
|
if [[ -n "$GIT_REV" ]]; then
|
||||||
|
echo "Git commit: ${GIT_REV}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Contents:"
|
||||||
|
echo " postgres.dump PostgreSQL custom-format dump (pg_restore)"
|
||||||
|
echo " env.prod Production environment file"
|
||||||
|
echo " uploads.tar backend/uploads"
|
||||||
|
echo " monero-wallet.tar Monero wallet directory"
|
||||||
|
echo " electrum-wallet.tar Electrum wallet directory"
|
||||||
|
echo " volume-tor.tar Tor hidden service keys"
|
||||||
|
echo " volume-simplex.tar SimpleX CLI data"
|
||||||
|
echo " volume-pgadmin.tar pgAdmin data"
|
||||||
|
echo " certs.tar deploy/certs (TLS)"
|
||||||
|
} > "${STAGING_TMP}/MANIFEST.txt"
|
||||||
|
|
||||||
|
require_min_bytes "${STAGING_TMP}/MANIFEST.txt" 1 "MANIFEST.txt"
|
||||||
|
|
||||||
|
rm -f "$ARCHIVE_TMP"
|
||||||
|
tar czf "$ARCHIVE_TMP" -C "$STAGING_TMP" .
|
||||||
|
tar tzf "$ARCHIVE_TMP" >/dev/null
|
||||||
|
chmod 600 "$ARCHIVE_TMP"
|
||||||
|
mv -f "$ARCHIVE_TMP" "$ARCHIVE_PATH"
|
||||||
|
|
||||||
|
echo "Backup complete: ${ARCHIVE_PATH}"
|
||||||
|
echo "This archive contains secrets. It is recommended to store it encrypted on an external storage."
|
||||||
@@ -126,6 +126,8 @@ services:
|
|||||||
VITE_SHOP_FIAT_CURRENCY: ${VITE_SHOP_FIAT_CURRENCY}
|
VITE_SHOP_FIAT_CURRENCY: ${VITE_SHOP_FIAT_CURRENCY}
|
||||||
VITE_PRODUCT_THUMB_ALLOWED_MIMES: ${VITE_PRODUCT_THUMB_ALLOWED_MIMES}
|
VITE_PRODUCT_THUMB_ALLOWED_MIMES: ${VITE_PRODUCT_THUMB_ALLOWED_MIMES}
|
||||||
VITE_PRODUCT_THUMB_MAX_FILE_BYTES: ${VITE_PRODUCT_THUMB_MAX_FILE_BYTES}
|
VITE_PRODUCT_THUMB_MAX_FILE_BYTES: ${VITE_PRODUCT_THUMB_MAX_FILE_BYTES}
|
||||||
|
VITE_BLOG_IMAGE_ALLOWED_MIMES: ${VITE_BLOG_IMAGE_ALLOWED_MIMES}
|
||||||
|
VITE_BLOG_IMAGE_MAX_FILE_BYTES: ${VITE_BLOG_IMAGE_MAX_FILE_BYTES}
|
||||||
VITE_SHOP_LOGO_ALLOWED_MIMES: ${VITE_SHOP_LOGO_ALLOWED_MIMES}
|
VITE_SHOP_LOGO_ALLOWED_MIMES: ${VITE_SHOP_LOGO_ALLOWED_MIMES}
|
||||||
VITE_SHOP_LOGO_MAX_FILE_BYTES: ${VITE_SHOP_LOGO_MAX_FILE_BYTES}
|
VITE_SHOP_LOGO_MAX_FILE_BYTES: ${VITE_SHOP_LOGO_MAX_FILE_BYTES}
|
||||||
VITE_SHOP_FAVICON_ALLOWED_MIMES: ${VITE_SHOP_FAVICON_ALLOWED_MIMES}
|
VITE_SHOP_FAVICON_ALLOWED_MIMES: ${VITE_SHOP_FAVICON_ALLOWED_MIMES}
|
||||||
@@ -133,6 +135,7 @@ services:
|
|||||||
VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES: ${VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES}
|
VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES: ${VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES}
|
||||||
VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES: ${VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES}
|
VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES: ${VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES}
|
||||||
VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH: ${VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH}
|
VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH: ${VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH}
|
||||||
|
VITE_VALIDATION_BLOG_POST_TITLE_MAX_LENGTH: ${VITE_VALIDATION_BLOG_POST_TITLE_MAX_LENGTH}
|
||||||
VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH: ${VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH}
|
VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH: ${VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH}
|
||||||
VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH: ${VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH}
|
VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH: ${VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH}
|
||||||
VITE_VALIDATION_VARIANT_IMAGES_MAX: ${VITE_VALIDATION_VARIANT_IMAGES_MAX}
|
VITE_VALIDATION_VARIANT_IMAGES_MAX: ${VITE_VALIDATION_VARIANT_IMAGES_MAX}
|
||||||
|
|||||||
@@ -4,10 +4,17 @@ set -euo pipefail
|
|||||||
NETWORK_FLAG="--${ELECTRUM_NETWORK}"
|
NETWORK_FLAG="--${ELECTRUM_NETWORK}"
|
||||||
WALLET_PATH="/electrum/wallet/${ELECTRUM_WALLET_NAME}"
|
WALLET_PATH="/electrum/wallet/${ELECTRUM_WALLET_NAME}"
|
||||||
DATA_DIR="/electrum/data"
|
DATA_DIR="/electrum/data"
|
||||||
|
LOAD_WALLET_MAX_ATTEMPTS=90
|
||||||
|
DAEMON_STARTUP_WAIT_SECONDS=5
|
||||||
|
|
||||||
run_electrum() { electrum "${NETWORK_FLAG}" --dir "${DATA_DIR}" "$@"; }
|
run_electrum() { electrum "${NETWORK_FLAG}" --dir "${DATA_DIR}" "$@"; }
|
||||||
run_electrum_offline() { electrum --offline "${NETWORK_FLAG}" --dir "${DATA_DIR}" "$@"; }
|
run_electrum_offline() { electrum --offline "${NETWORK_FLAG}" --dir "${DATA_DIR}" "$@"; }
|
||||||
|
|
||||||
|
maybe_stop_electrum() {
|
||||||
|
echo "Trying to stop Electrum if it may still be running..."
|
||||||
|
run_electrum stop >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
mkdir -p /electrum/wallet "${DATA_DIR}"
|
mkdir -p /electrum/wallet "${DATA_DIR}"
|
||||||
chmod 700 /electrum/wallet
|
chmod 700 /electrum/wallet
|
||||||
|
|
||||||
@@ -20,15 +27,38 @@ if [[ ! -f "${WALLET_PATH}" ]]; then
|
|||||||
echo "Wallet created at ${WALLET_PATH}"
|
echo "Wallet created at ${WALLET_PATH}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
maybe_stop_electrum
|
||||||
|
|
||||||
run_electrum_offline setconfig rpcuser "${ELECTRUM_DAEMON_RPC_USER}"
|
run_electrum_offline setconfig rpcuser "${ELECTRUM_DAEMON_RPC_USER}"
|
||||||
run_electrum_offline setconfig rpcpassword "${ELECTRUM_DAEMON_RPC_PASSWORD}"
|
run_electrum_offline setconfig rpcpassword "${ELECTRUM_DAEMON_RPC_PASSWORD}"
|
||||||
run_electrum_offline setconfig rpchost 0.0.0.0
|
run_electrum_offline setconfig rpchost 0.0.0.0
|
||||||
run_electrum_offline setconfig rpcport "${ELECTRUM_DAEMON_PORT}"
|
run_electrum_offline setconfig rpcport "${ELECTRUM_DAEMON_PORT}"
|
||||||
run_electrum_offline setconfig server "${ELECTRUM_SERVER}"
|
run_electrum_offline setconfig server "${ELECTRUM_SERVER}"
|
||||||
|
|
||||||
trap 'run_electrum daemon stop || true; exit 0' SIGTERM SIGINT
|
trap 'run_electrum stop || true' SIGTERM SIGINT
|
||||||
|
|
||||||
run_electrum daemon -d
|
echo "Starting Electrum daemon..."
|
||||||
run_electrum load_wallet -w "${WALLET_PATH}" --password "${ELECTRUM_WALLET_PASSWORD}"
|
run_electrum daemon &
|
||||||
|
|
||||||
sleep infinity
|
echo "Waiting for daemon connection (${DAEMON_STARTUP_WAIT_SECONDS}s)..."
|
||||||
|
sleep "${DAEMON_STARTUP_WAIT_SECONDS}"
|
||||||
|
|
||||||
|
load_wallet_attempt=0
|
||||||
|
echo "Loading wallet..."
|
||||||
|
|
||||||
|
until run_electrum load_wallet -w "${WALLET_PATH}" --password "${ELECTRUM_WALLET_PASSWORD}" >/dev/null 2>&1; do
|
||||||
|
load_wallet_attempt=$((load_wallet_attempt + 1))
|
||||||
|
|
||||||
|
if [[ load_wallet_attempt -ge LOAD_WALLET_MAX_ATTEMPTS ]]; then
|
||||||
|
echo "Timed out waiting to load wallet into Electrum daemon" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Load wallet attempt ${load_wallet_attempt}/${LOAD_WALLET_MAX_ATTEMPTS} failed. Daemon may not be ready yet. Retrying in 1s..."
|
||||||
|
sleep 1
|
||||||
|
echo "Loading wallet..."
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Wallet loaded"
|
||||||
|
|
||||||
|
wait
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:20-alpine AS cms-builder
|
FROM node:22.23.4-alpine AS cms-builder
|
||||||
|
|
||||||
WORKDIR /cms
|
WORKDIR /cms
|
||||||
|
|
||||||
@@ -11,6 +11,8 @@ ARG VITE_API_BASE_URL
|
|||||||
ARG VITE_SHOP_FIAT_CURRENCY
|
ARG VITE_SHOP_FIAT_CURRENCY
|
||||||
ARG VITE_PRODUCT_THUMB_ALLOWED_MIMES
|
ARG VITE_PRODUCT_THUMB_ALLOWED_MIMES
|
||||||
ARG VITE_PRODUCT_THUMB_MAX_FILE_BYTES
|
ARG VITE_PRODUCT_THUMB_MAX_FILE_BYTES
|
||||||
|
ARG VITE_BLOG_IMAGE_ALLOWED_MIMES
|
||||||
|
ARG VITE_BLOG_IMAGE_MAX_FILE_BYTES
|
||||||
ARG VITE_SHOP_LOGO_ALLOWED_MIMES
|
ARG VITE_SHOP_LOGO_ALLOWED_MIMES
|
||||||
ARG VITE_SHOP_LOGO_MAX_FILE_BYTES
|
ARG VITE_SHOP_LOGO_MAX_FILE_BYTES
|
||||||
ARG VITE_SHOP_FAVICON_ALLOWED_MIMES
|
ARG VITE_SHOP_FAVICON_ALLOWED_MIMES
|
||||||
@@ -18,6 +20,7 @@ ARG VITE_SHOP_FAVICON_MAX_FILE_BYTES
|
|||||||
ARG VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES
|
ARG VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES
|
||||||
ARG VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES
|
ARG VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES
|
||||||
ARG VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH
|
ARG VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH
|
||||||
|
ARG VITE_VALIDATION_BLOG_POST_TITLE_MAX_LENGTH
|
||||||
ARG VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH
|
ARG VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH
|
||||||
ARG VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH
|
ARG VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH
|
||||||
ARG VITE_VALIDATION_VARIANT_IMAGES_MAX
|
ARG VITE_VALIDATION_VARIANT_IMAGES_MAX
|
||||||
@@ -32,6 +35,8 @@ ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \
|
|||||||
VITE_SHOP_FIAT_CURRENCY=$VITE_SHOP_FIAT_CURRENCY \
|
VITE_SHOP_FIAT_CURRENCY=$VITE_SHOP_FIAT_CURRENCY \
|
||||||
VITE_PRODUCT_THUMB_ALLOWED_MIMES=$VITE_PRODUCT_THUMB_ALLOWED_MIMES \
|
VITE_PRODUCT_THUMB_ALLOWED_MIMES=$VITE_PRODUCT_THUMB_ALLOWED_MIMES \
|
||||||
VITE_PRODUCT_THUMB_MAX_FILE_BYTES=$VITE_PRODUCT_THUMB_MAX_FILE_BYTES \
|
VITE_PRODUCT_THUMB_MAX_FILE_BYTES=$VITE_PRODUCT_THUMB_MAX_FILE_BYTES \
|
||||||
|
VITE_BLOG_IMAGE_ALLOWED_MIMES=$VITE_BLOG_IMAGE_ALLOWED_MIMES \
|
||||||
|
VITE_BLOG_IMAGE_MAX_FILE_BYTES=$VITE_BLOG_IMAGE_MAX_FILE_BYTES \
|
||||||
VITE_SHOP_LOGO_ALLOWED_MIMES=$VITE_SHOP_LOGO_ALLOWED_MIMES \
|
VITE_SHOP_LOGO_ALLOWED_MIMES=$VITE_SHOP_LOGO_ALLOWED_MIMES \
|
||||||
VITE_SHOP_LOGO_MAX_FILE_BYTES=$VITE_SHOP_LOGO_MAX_FILE_BYTES \
|
VITE_SHOP_LOGO_MAX_FILE_BYTES=$VITE_SHOP_LOGO_MAX_FILE_BYTES \
|
||||||
VITE_SHOP_FAVICON_ALLOWED_MIMES=$VITE_SHOP_FAVICON_ALLOWED_MIMES \
|
VITE_SHOP_FAVICON_ALLOWED_MIMES=$VITE_SHOP_FAVICON_ALLOWED_MIMES \
|
||||||
@@ -39,6 +44,7 @@ ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \
|
|||||||
VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES=$VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES \
|
VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES=$VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES \
|
||||||
VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES=$VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES \
|
VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES=$VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES \
|
||||||
VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH=$VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH \
|
VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH=$VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH \
|
||||||
|
VITE_VALIDATION_BLOG_POST_TITLE_MAX_LENGTH=$VITE_VALIDATION_BLOG_POST_TITLE_MAX_LENGTH \
|
||||||
VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH=$VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH \
|
VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH=$VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH \
|
||||||
VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH=$VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH \
|
VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH=$VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH \
|
||||||
VITE_VALIDATION_VARIANT_IMAGES_MAX=$VITE_VALIDATION_VARIANT_IMAGES_MAX \
|
VITE_VALIDATION_VARIANT_IMAGES_MAX=$VITE_VALIDATION_VARIANT_IMAGES_MAX \
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ location = /api/shop-settings/simplex-connect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /api {
|
location /api {
|
||||||
|
client_max_body_size 20m;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|||||||
Reference in New Issue
Block a user