Compare commits
26
Commits
54ec6edd0d
...
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 | ||
|
|
abe6208e4e | ||
|
|
d38def2302 | ||
|
|
92ccbec545 | ||
|
|
40bf16c03e |
+9
-2
@@ -37,16 +37,20 @@ THROTTLE_LIMIT=120
|
||||
MULTER_PRODUCT_THUMB_ALLOWED_MIMES=image/jpeg,image/png
|
||||
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_MAX_FILE_BYTES=1048576 # 1MB
|
||||
|
||||
MULTER_SHOP_FAVICON_ALLOWED_MIMES=image/png,image/x-icon,image/vnd.microsoft.icon
|
||||
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
|
||||
|
||||
VALIDATION_PRODUCT_TITLE_MAX_LENGTH=100
|
||||
VALIDATION_BLOG_POST_TITLE_MAX_LENGTH=200
|
||||
VALIDATION_CATEGORY_NAME_MAX_LENGTH=50
|
||||
VALIDATION_DISCOUNT_CODE_MAX_LENGTH=32
|
||||
VALIDATION_VARIANT_IMAGES_MAX=10
|
||||
@@ -134,13 +138,16 @@ VITE_API_BASE_URL=http://localhost:3000/api
|
||||
VITE_SHOP_FIAT_CURRENCY=USD
|
||||
VITE_PRODUCT_THUMB_ALLOWED_MIMES=image/jpeg,image/png
|
||||
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_MAX_FILE_BYTES=1048576 # 1MB
|
||||
VITE_SHOP_FAVICON_ALLOWED_MIMES=image/png,image/x-icon,image/vnd.microsoft.icon
|
||||
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_VALIDATION_PRODUCT_TITLE_MAX_LENGTH=100
|
||||
VITE_VALIDATION_BLOG_POST_TITLE_MAX_LENGTH=200
|
||||
VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH=50
|
||||
VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH=32
|
||||
VITE_VALIDATION_VARIANT_IMAGES_MAX=10
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
/deploy/certs
|
||||
/deploy/certbot/www
|
||||
/deploy/backups
|
||||
|
||||
/monero-wallet-rpc/wallet
|
||||
/electrum-daemon/wallet
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:22.23.4-alpine
|
||||
|
||||
WORKDIR /backend
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:22.23.4-alpine
|
||||
|
||||
WORKDIR /backend
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from './config';
|
||||
import { validate } from './config/validate';
|
||||
import { AuthModule } from './modules/auth/AuthModule';
|
||||
import { BlogModule } from './modules/blog/BlogModule';
|
||||
import { EncryptionModule } from './modules/encryption/EncryptionModule';
|
||||
import { HealthCheckModule } from './modules/healthCheck/HealthCheckModule';
|
||||
import { MoneroWalletModule } from './modules/moneroWallet/MoneroWalletModule';
|
||||
@@ -37,6 +38,7 @@ import { StorefrontCheckoutModule } from './modules/storefrontCheckout/Storefron
|
||||
import { StorefrontOrderModule } from './modules/storefrontOrder/StorefrontOrderModule';
|
||||
import { StorefrontCoreModule } from './modules/storefrontCore/StorefrontCoreModule';
|
||||
import { StorefrontProductModule } from './modules/storefrontProduct/StorefrontProductModule';
|
||||
import { StorefrontBlogModule } from './modules/storefrontBlog/StorefrontBlogModule';
|
||||
import { ExchangeRateModule } from './modules/exchangeRate/ExchangeRateModule';
|
||||
import { Config } from './types/Config';
|
||||
|
||||
@@ -85,6 +87,7 @@ import { Config } from './types/Config';
|
||||
HealthCheckModule,
|
||||
EncryptionModule,
|
||||
AuthModule,
|
||||
BlogModule,
|
||||
ProductsModule,
|
||||
ShopSettingsModule,
|
||||
SimplexModule,
|
||||
@@ -99,7 +102,8 @@ import { Config } from './types/Config';
|
||||
StorefrontProductModule,
|
||||
StorefrontCartModule,
|
||||
StorefrontCheckoutModule,
|
||||
StorefrontOrderModule
|
||||
StorefrontOrderModule,
|
||||
StorefrontBlogModule
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
|
||||
@@ -97,6 +97,7 @@ export const getAppConfig = (): AppConfig => {
|
||||
},
|
||||
validation: {
|
||||
productTitleMaxLength: envInt('VALIDATION_PRODUCT_TITLE_MAX_LENGTH'),
|
||||
blogPostTitleMaxLength: envInt('VALIDATION_BLOG_POST_TITLE_MAX_LENGTH'),
|
||||
categoryNameMaxLength: envInt('VALIDATION_CATEGORY_NAME_MAX_LENGTH'),
|
||||
discountCodeMaxLength: envInt('VALIDATION_DISCOUNT_CODE_MAX_LENGTH'),
|
||||
variantImagesMax: envInt('VALIDATION_VARIANT_IMAGES_MAX'),
|
||||
@@ -127,6 +128,17 @@ export const getProductThumbMulterConfig = (): MulterConfig => {
|
||||
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 => {
|
||||
const allowedMimes = env('MULTER_SHOP_LOGO_ALLOWED_MIMES')
|
||||
.split(',')
|
||||
|
||||
@@ -31,6 +31,20 @@ export const variantImagesUrlPrefix = '/uploads/public/variants/images/';
|
||||
|
||||
export const getVariantImagePublicUrl = (filename: string): string => `${variantImagesUrlPrefix}${filename}`;
|
||||
|
||||
export const getBlogImagesDir = (): string => join(getPublicUploadsDir(), 'blog', 'images');
|
||||
|
||||
export const blogImagesUrlPrefix = '/uploads/public/blog/images/';
|
||||
|
||||
export const getBlogImagePublicUrl = (filename: string): string => `${blogImagesUrlPrefix}${filename}`;
|
||||
|
||||
export const resolveBlogImagePath = (storageKey: string): string => {
|
||||
const baseDir = getBlogImagesDir();
|
||||
|
||||
const absolutePath = resolvePathWithinDir(baseDir, storageKey);
|
||||
|
||||
return absolutePath;
|
||||
};
|
||||
|
||||
export const getShopBrandingDir = (): string => join(getPublicUploadsDir(), 'shop');
|
||||
|
||||
export const shopBrandingUrlPrefix = '/uploads/public/shop/';
|
||||
|
||||
@@ -72,9 +72,19 @@ class EnvironmentVariables {
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(10 * 1024 * 1024) // 10MB
|
||||
@Max(50 * 1024 * 1024) // 50MB
|
||||
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()
|
||||
@IsString()
|
||||
MULTER_SHOP_LOGO_ALLOWED_MIMES: string;
|
||||
@@ -101,6 +111,12 @@ class EnvironmentVariables {
|
||||
@Max(256)
|
||||
VALIDATION_PRODUCT_TITLE_MAX_LENGTH: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(256)
|
||||
VALIDATION_BLOG_POST_TITLE_MAX_LENGTH: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddBlog1784900000000 implements MigrationInterface {
|
||||
name = 'AddBlog1784900000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "blog_posts" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "title" character varying NOT NULL, "thumbnailStorageKey" character varying, "bannerStorageKey" character varying, "contentHtml" text NOT NULL, "isDraft" boolean NOT NULL DEFAULT true, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_blog_posts_id" PRIMARY KEY ("id"))`
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "shop_settings" ADD "blogEnabled" boolean NOT NULL DEFAULT false`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "shop_settings" DROP COLUMN "blogEnabled"`);
|
||||
await queryRunner.query(`DROP TABLE "blog_posts"`);
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,8 @@ async function bootstrap() {
|
||||
{ path: '/', method: RequestMethod.GET },
|
||||
{ path: 'shop/categories/:id', 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/preferences/theme', method: RequestMethod.POST },
|
||||
{ path: 'shop/error', method: RequestMethod.GET },
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BlogPostsController } from './controllers/BlogPostsController';
|
||||
import { BlogPost } from './entities/BlogPost';
|
||||
import { BlogPostsService } from './services/BlogPostsService';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([BlogPost])],
|
||||
controllers: [BlogPostsController],
|
||||
providers: [BlogPostsService],
|
||||
exports: [TypeOrmModule.forFeature([BlogPost])]
|
||||
})
|
||||
export class BlogModule {}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { getBlogImageMulterConfig } from '../../../config';
|
||||
import { getBlogImagesDir } from '../../../config/uploadPaths';
|
||||
import { createDiskStorageUploadOptions } from '../../../utils/createDiskStorageUploadOptions';
|
||||
import { createUploadFilePipe } from '../../../utils/createUploadFilePipe';
|
||||
|
||||
const uploadDir = getBlogImagesDir();
|
||||
|
||||
export const blogImageUploadOptions = createDiskStorageUploadOptions(uploadDir);
|
||||
|
||||
const { allowedMimes, maxFileBytes } = getBlogImageMulterConfig();
|
||||
|
||||
export const blogImageFilePipe = createUploadFilePipe(allowedMimes, maxFileBytes);
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { JwtGuard } from '../../../guards/JwtGuard';
|
||||
import { blogImageFilePipe, blogImageUploadOptions } from '../config/blogImageUpload';
|
||||
import { ListBlogPostsQueryDto } from '../dto/ListBlogPostsQueryDto';
|
||||
import { UpdateBlogPostDto } from '../dto/UpdateBlogPostDto';
|
||||
import { BlogPostsService } from '../services/BlogPostsService';
|
||||
|
||||
@Controller('blog-posts')
|
||||
@UseGuards(JwtGuard)
|
||||
export class BlogPostsController {
|
||||
constructor(private readonly blogPostsService: BlogPostsService) {}
|
||||
|
||||
@Post('/')
|
||||
createDraft() {
|
||||
return this.blogPostsService.createDraft();
|
||||
}
|
||||
|
||||
@Get('/')
|
||||
findAll(@Query() query: ListBlogPostsQueryDto) {
|
||||
return this.blogPostsService.findAll(query);
|
||||
}
|
||||
|
||||
@Get('/:id')
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.blogPostsService.findOne(id);
|
||||
}
|
||||
|
||||
@Patch('/:id')
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateBlogPostDto) {
|
||||
return this.blogPostsService.update(id, dto);
|
||||
}
|
||||
|
||||
@Post('/:id/thumbnail')
|
||||
@UseInterceptors(FileInterceptor('file', blogImageUploadOptions))
|
||||
uploadThumbnail(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile(blogImageFilePipe) file: Express.Multer.File
|
||||
) {
|
||||
return this.blogPostsService.uploadThumbnail(id, file.filename);
|
||||
}
|
||||
|
||||
@Post('/:id/banner')
|
||||
@UseInterceptors(FileInterceptor('file', blogImageUploadOptions))
|
||||
uploadBanner(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile(blogImageFilePipe) file: Express.Multer.File
|
||||
) {
|
||||
return this.blogPostsService.uploadBanner(id, file.filename);
|
||||
}
|
||||
|
||||
@Delete('/:id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
await this.blogPostsService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
|
||||
export class ListBlogPostsQueryDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { IsBoolean, IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
import { getAppConfig } from '../../../config';
|
||||
|
||||
const {
|
||||
validation: { blogPostTitleMaxLength }
|
||||
} = getAppConfig();
|
||||
|
||||
export class UpdateBlogPostDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@MaxLength(blogPostTitleMaxLength)
|
||||
title: string;
|
||||
|
||||
@IsString()
|
||||
contentHtml: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsBoolean()
|
||||
isDraft: boolean;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity('blog_posts')
|
||||
export class BlogPost {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
title: string;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
thumbnailStorageKey: string | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
bannerStorageKey: string | null;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
contentHtml: string;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
isDraft: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import type { Repository } from 'typeorm';
|
||||
import { getBlogImagePublicUrl, resolveBlogImagePath } from '../../../config/uploadPaths';
|
||||
import { removeFileFromDisk } from '../../../utils/removeFileFromDisk';
|
||||
import { BlogPost } from '../entities/BlogPost';
|
||||
import { BlogPostsService } from './BlogPostsService';
|
||||
|
||||
jest.mock('../../../utils/removeFileFromDisk', () => ({
|
||||
removeFileFromDisk: jest.fn().mockResolvedValue(undefined)
|
||||
}));
|
||||
|
||||
describe('BlogPostsService', () => {
|
||||
let service: BlogPostsService;
|
||||
let blogPostRepo: {
|
||||
findAndCount: jest.Mock;
|
||||
findOne: jest.Mock;
|
||||
create: jest.Mock;
|
||||
insert: jest.Mock;
|
||||
update: jest.Mock;
|
||||
exists: jest.Mock;
|
||||
delete: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(removeFileFromDisk).mockClear();
|
||||
|
||||
blogPostRepo = {
|
||||
findAndCount: jest.fn().mockResolvedValue([[], 0]),
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn(data => ({ id: 'post-1', ...data })),
|
||||
insert: jest.fn().mockResolvedValue(undefined),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
exists: jest.fn().mockResolvedValue(false),
|
||||
delete: jest.fn().mockResolvedValue(undefined)
|
||||
};
|
||||
|
||||
service = new BlogPostsService(blogPostRepo as unknown as Repository<BlogPost>);
|
||||
});
|
||||
|
||||
it('creates a draft post with empty title and content', async () => {
|
||||
blogPostRepo.findOne.mockResolvedValue({
|
||||
id: 'post-1',
|
||||
title: '',
|
||||
contentHtml: '',
|
||||
isDraft: true
|
||||
});
|
||||
|
||||
const created = await service.createDraft();
|
||||
|
||||
expect(blogPostRepo.create).toHaveBeenCalledWith({
|
||||
title: '',
|
||||
contentHtml: '',
|
||||
isDraft: true
|
||||
});
|
||||
expect(blogPostRepo.insert).toHaveBeenCalled();
|
||||
expect(created).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'post-1',
|
||||
title: '',
|
||||
contentHtml: '',
|
||||
isDraft: true,
|
||||
thumbnailUrl: null,
|
||||
bannerUrl: null
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('maps image storage keys to public urls', async () => {
|
||||
blogPostRepo.findOne.mockResolvedValue({
|
||||
id: 'post-1',
|
||||
title: 'Hello',
|
||||
thumbnailStorageKey: 'thumb.webp',
|
||||
bannerStorageKey: 'banner.webp',
|
||||
contentHtml: '<p>Body</p>',
|
||||
isDraft: false
|
||||
});
|
||||
|
||||
await expect(service.findOne('post-1')).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
title: 'Hello',
|
||||
thumbnailUrl: getBlogImagePublicUrl('thumb.webp'),
|
||||
bannerUrl: getBlogImagePublicUrl('banner.webp')
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('lists posts ordered by createdAt descending', async () => {
|
||||
await service.findAll({ page: 2, limit: 10 });
|
||||
|
||||
expect(blogPostRepo.findAndCount).toHaveBeenCalledWith({
|
||||
order: { createdAt: 'DESC' },
|
||||
skip: 10,
|
||||
take: 10
|
||||
});
|
||||
});
|
||||
|
||||
it('trims title on update', async () => {
|
||||
blogPostRepo.exists.mockResolvedValueOnce(true);
|
||||
blogPostRepo.findOne.mockResolvedValueOnce({
|
||||
id: 'post-1',
|
||||
title: 'Hello',
|
||||
contentHtml: '<p>Body</p>',
|
||||
isDraft: false
|
||||
});
|
||||
|
||||
await service.update('post-1', {
|
||||
title: ' Hello ',
|
||||
contentHtml: '<p>Body</p>',
|
||||
isDraft: false
|
||||
});
|
||||
|
||||
expect(blogPostRepo.update).toHaveBeenCalledWith('post-1', {
|
||||
title: 'Hello',
|
||||
contentHtml: '<p>Body</p>',
|
||||
isDraft: false
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads a thumbnail and removes the previous file', async () => {
|
||||
blogPostRepo.findOne
|
||||
.mockResolvedValueOnce({ id: 'post-1', thumbnailStorageKey: 'old.webp' })
|
||||
.mockResolvedValueOnce({ id: 'post-1', thumbnailStorageKey: 'new.webp' });
|
||||
|
||||
await service.uploadThumbnail('post-1', 'new.webp');
|
||||
|
||||
expect(blogPostRepo.update).toHaveBeenCalledWith('post-1', { thumbnailStorageKey: 'new.webp' });
|
||||
expect(removeFileFromDisk).toHaveBeenCalledWith(resolveBlogImagePath('old.webp'), BlogPostsService.name);
|
||||
});
|
||||
|
||||
it('uploads a banner and removes the previous file', async () => {
|
||||
blogPostRepo.findOne
|
||||
.mockResolvedValueOnce({ id: 'post-1', bannerStorageKey: 'old-banner.webp' })
|
||||
.mockResolvedValueOnce({ id: 'post-1', bannerStorageKey: 'new-banner.webp' });
|
||||
|
||||
await service.uploadBanner('post-1', 'new-banner.webp');
|
||||
|
||||
expect(blogPostRepo.update).toHaveBeenCalledWith('post-1', { bannerStorageKey: 'new-banner.webp' });
|
||||
expect(removeFileFromDisk).toHaveBeenCalledWith(
|
||||
resolveBlogImagePath('old-banner.webp'),
|
||||
BlogPostsService.name
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when loading a missing post', async () => {
|
||||
await expect(service.findOne('missing-id')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('throws when updating a missing post', async () => {
|
||||
blogPostRepo.exists.mockResolvedValueOnce(false);
|
||||
|
||||
await expect(
|
||||
service.update('missing-id', {
|
||||
title: 'Hello',
|
||||
contentHtml: '',
|
||||
isDraft: true
|
||||
})
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('deletes an existing post and its image files', async () => {
|
||||
blogPostRepo.findOne.mockResolvedValueOnce({
|
||||
id: 'post-1',
|
||||
thumbnailStorageKey: 'thumb.webp',
|
||||
bannerStorageKey: 'banner.webp'
|
||||
});
|
||||
|
||||
await service.remove('post-1');
|
||||
|
||||
expect(blogPostRepo.delete).toHaveBeenCalledWith('post-1');
|
||||
expect(removeFileFromDisk).toHaveBeenCalledWith(resolveBlogImagePath('thumb.webp'), BlogPostsService.name);
|
||||
expect(removeFileFromDisk).toHaveBeenCalledWith(
|
||||
resolveBlogImagePath('banner.webp'),
|
||||
BlogPostsService.name
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when deleting a missing post', async () => {
|
||||
await expect(service.remove('missing-id')).rejects.toThrow(NotFoundException);
|
||||
expect(blogPostRepo.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { getBlogImagePublicUrl, resolveBlogImagePath } from '../../../config/uploadPaths';
|
||||
import type { PaginatedResponse } from '../../../types/PaginatedResponse';
|
||||
import { removeFileFromDisk } from '../../../utils/removeFileFromDisk';
|
||||
import type { ListBlogPostsQueryDto } from '../dto/ListBlogPostsQueryDto';
|
||||
import { UpdateBlogPostDto } from '../dto/UpdateBlogPostDto';
|
||||
import { BlogPost } from '../entities/BlogPost';
|
||||
import type { BlogPostExtended } from '../types/BlogPostExtended';
|
||||
|
||||
@Injectable()
|
||||
export class BlogPostsService {
|
||||
constructor(
|
||||
@InjectRepository(BlogPost)
|
||||
private readonly blogPostRepo: Repository<BlogPost>
|
||||
) {}
|
||||
|
||||
async findAll({ page = 1, limit = 20 }: ListBlogPostsQueryDto): Promise<PaginatedResponse<BlogPostExtended>> {
|
||||
const [items, total] = await this.blogPostRepo.findAndCount({
|
||||
order: { createdAt: 'DESC' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit
|
||||
});
|
||||
|
||||
const itemsExtended = items.map(post => this.toPostExtended(post));
|
||||
|
||||
return { items: itemsExtended, total, page, limit };
|
||||
}
|
||||
|
||||
async createDraft(): Promise<BlogPostExtended> {
|
||||
const entity = this.blogPostRepo.create({
|
||||
title: '',
|
||||
contentHtml: '',
|
||||
isDraft: true
|
||||
});
|
||||
|
||||
await this.blogPostRepo.insert(entity);
|
||||
|
||||
return this.findOne(entity.id);
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<BlogPostExtended> {
|
||||
const entity = await this.findEntity(id);
|
||||
|
||||
return this.toPostExtended(entity);
|
||||
}
|
||||
|
||||
async update(id: string, { title, contentHtml, isDraft }: UpdateBlogPostDto): Promise<BlogPostExtended> {
|
||||
const exists = await this.blogPostRepo.exists({ where: { id } });
|
||||
|
||||
if (!exists) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await this.blogPostRepo.update(id, {
|
||||
title: title.trim(),
|
||||
contentHtml,
|
||||
isDraft
|
||||
});
|
||||
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
async uploadThumbnail(id: string, filename: string): Promise<BlogPostExtended> {
|
||||
const post = await this.findEntity(id);
|
||||
|
||||
const previousStorageKey = post.thumbnailStorageKey;
|
||||
|
||||
await this.blogPostRepo.update(id, { thumbnailStorageKey: filename });
|
||||
await this.removeBlogImageFile(previousStorageKey);
|
||||
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
async uploadBanner(id: string, filename: string): Promise<BlogPostExtended> {
|
||||
const post = await this.findEntity(id);
|
||||
|
||||
const previousStorageKey = post.bannerStorageKey;
|
||||
|
||||
await this.blogPostRepo.update(id, { bannerStorageKey: filename });
|
||||
await this.removeBlogImageFile(previousStorageKey);
|
||||
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const post = await this.findEntity(id);
|
||||
|
||||
await this.blogPostRepo.delete(id);
|
||||
|
||||
await Promise.all([
|
||||
this.removeBlogImageFile(post.thumbnailStorageKey),
|
||||
this.removeBlogImageFile(post.bannerStorageKey)
|
||||
]);
|
||||
}
|
||||
|
||||
private async findEntity(id: string): Promise<BlogPost> {
|
||||
const entity = await this.blogPostRepo.findOne({ where: { id } });
|
||||
|
||||
if (!entity) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
private toPostExtended(post: BlogPost): BlogPostExtended {
|
||||
return {
|
||||
...post,
|
||||
thumbnailUrl: post.thumbnailStorageKey ? getBlogImagePublicUrl(post.thumbnailStorageKey) : null,
|
||||
bannerUrl: post.bannerStorageKey ? getBlogImagePublicUrl(post.bannerStorageKey) : null
|
||||
};
|
||||
}
|
||||
|
||||
private async removeBlogImageFile(storageKey: string | null): Promise<void> {
|
||||
if (!storageKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = resolveBlogImagePath(storageKey);
|
||||
|
||||
await removeFileFromDisk(path, BlogPostsService.name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { BlogPost } from '../entities/BlogPost';
|
||||
|
||||
export type BlogPostExtended = BlogPost & {
|
||||
thumbnailUrl: string | null;
|
||||
bannerUrl: string | null;
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import { JwtGuard } from '../../../guards/JwtGuard';
|
||||
import { shopFaviconFilePipe, shopFaviconUploadOptions } from '../config/shopFaviconUpload';
|
||||
import { shopLogoFilePipe, shopLogoUploadOptions } from '../config/shopLogoUpload';
|
||||
import { ConnectSimplexNotificationsDto } from '../dto/ConnectSimplexNotificationsDto';
|
||||
import { UpdateBlogEnabledDto } from '../dto/UpdateBlogEnabledDto';
|
||||
import { UpdateNotificationsDto } from '../dto/UpdateNotificationsDto';
|
||||
import { UpdateShippingNoteDto } from '../dto/UpdateShippingNoteDto';
|
||||
import { UpdateSimplexLinkDto } from '../dto/UpdateSimplexLinkDto';
|
||||
@@ -43,6 +44,11 @@ export class ShopSettingsController {
|
||||
return this.shopSettingsService.updateNotifications(dto);
|
||||
}
|
||||
|
||||
@Patch('/blog-enabled')
|
||||
updateBlogEnabled(@Body() dto: UpdateBlogEnabledDto) {
|
||||
return this.shopSettingsService.updateBlogEnabled(dto);
|
||||
}
|
||||
|
||||
@Post('/simplex-connect')
|
||||
connectSimplexNotifications(@Body() dto: ConnectSimplexNotificationsDto) {
|
||||
return this.shopSettingsService.connectSimplexNotifications(dto.simplexNotificationLink);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsBoolean, IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class UpdateBlogEnabledDto {
|
||||
@IsNotEmpty()
|
||||
@IsBoolean()
|
||||
blogEnabled: boolean;
|
||||
}
|
||||
@@ -32,6 +32,9 @@ export class ShopSettings {
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
simplexNotificationContactId: number | null;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
blogEnabled: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ describe('ShopSettingsService', () => {
|
||||
notifyOnNewOrder: false,
|
||||
notifyOnOrderMessage: false,
|
||||
simplexNotificationContactId: null,
|
||||
blogEnabled: false,
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00.000Z')
|
||||
} as ShopSettings;
|
||||
@@ -67,10 +68,27 @@ describe('ShopSettingsService', () => {
|
||||
logoUrl: '/uploads/public/shop/logo.png',
|
||||
faviconUrl: null,
|
||||
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 () => {
|
||||
await service.updateSimplexLink({ simplexLink: ' https://simplex.example/new ' });
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getShopBrandingPublicUrl, resolveShopBrandingPath } from '../../../conf
|
||||
import { Config } from '../../../types/Config';
|
||||
import { removeFileFromDisk } from '../../../utils/removeFileFromDisk';
|
||||
import { SimplexChatClient } from '../../simplex/services/SimplexChatClient';
|
||||
import { UpdateBlogEnabledDto } from '../dto/UpdateBlogEnabledDto';
|
||||
import { UpdateNotificationsDto } from '../dto/UpdateNotificationsDto';
|
||||
import { UpdateShippingNoteDto } from '../dto/UpdateShippingNoteDto';
|
||||
import { UpdateSimplexLinkDto } from '../dto/UpdateSimplexLinkDto';
|
||||
@@ -43,7 +44,8 @@ export class ShopSettingsService {
|
||||
logoUrl,
|
||||
faviconUrl,
|
||||
simplexLink: settings.simplexLink,
|
||||
shippingNote: settings.shippingNote
|
||||
shippingNote: settings.shippingNote,
|
||||
blogEnabled: settings.blogEnabled
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,6 +95,14 @@ export class ShopSettingsService {
|
||||
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> {
|
||||
const settings = await this.findSettings();
|
||||
|
||||
@@ -191,6 +201,7 @@ export class ShopSettingsService {
|
||||
notifyOnNewOrder,
|
||||
notifyOnOrderMessage,
|
||||
simplexNotificationContactId,
|
||||
blogEnabled,
|
||||
createdAt,
|
||||
updatedAt
|
||||
}: ShopSettings): ShopSettingsView {
|
||||
@@ -220,6 +231,7 @@ export class ShopSettingsService {
|
||||
notifyOnNewOrder,
|
||||
notifyOnOrderMessage,
|
||||
simplexNotificationConnected: simplexNotificationContactId !== null,
|
||||
blogEnabled,
|
||||
isSetupComplete,
|
||||
setupChecklist,
|
||||
createdAt,
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface ShopSettingsView {
|
||||
notifyOnNewOrder: boolean;
|
||||
notifyOnOrderMessage: boolean;
|
||||
simplexNotificationConnected: boolean;
|
||||
blogEnabled: boolean;
|
||||
isSetupComplete: boolean;
|
||||
setupChecklist: SetupChecklist;
|
||||
createdAt: Date | null;
|
||||
|
||||
@@ -3,4 +3,5 @@ export interface StorefrontBranding {
|
||||
faviconUrl: string | null;
|
||||
simplexLink: 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 { Throttle } from '@nestjs/throttler';
|
||||
import type { Request, Response } from 'express';
|
||||
@@ -89,6 +89,10 @@ export class StorefrontCartController {
|
||||
@Res() res: Response,
|
||||
@Body() { variantId, qty }: AddToCartDto
|
||||
): 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 cartMutation = await this.cartService.addToCart(cart, variantId, qty);
|
||||
@@ -109,6 +113,10 @@ export class StorefrontCartController {
|
||||
@Res() res: Response,
|
||||
@Body() { variantId, qty }: UpdateCartQtyDto
|
||||
): 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 cartMutation = await this.cartService.updateCartQty(cart, variantId, qty);
|
||||
@@ -125,6 +133,10 @@ export class StorefrontCartController {
|
||||
|
||||
@Post('shop/cart/product/remove')
|
||||
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 cartMutation = this.cartService.removeFromCart(cart, variantId);
|
||||
@@ -143,6 +155,10 @@ export class StorefrontCartController {
|
||||
@Res() res: Response,
|
||||
@Body() { code }: ApplyDiscountCodeDto
|
||||
): 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 discountCodes = this.discountCookieService.getDiscountCodes(req, res);
|
||||
|
||||
@@ -160,6 +176,10 @@ export class StorefrontCartController {
|
||||
|
||||
@Post('shop/cart/discount/remove')
|
||||
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 discountMutation = this.discountService.removeDiscountCode(discountCodes, code);
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@
|
||||
--sf-split-sidebar: minmax(220px, 320px);
|
||||
--sf-product-card-min: 11rem;
|
||||
--sf-product-card-max: 22rem;
|
||||
--sf-blog-card-min: 17rem;
|
||||
--sf-blog-card-max: 28rem;
|
||||
--sf-sticky-header-scroll-padding: 9rem;
|
||||
}
|
||||
|
||||
@@ -1598,3 +1600,75 @@ a:hover {
|
||||
font-size: 2rem;
|
||||
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,
|
||||
faviconUrl: null,
|
||||
simplexLink: null,
|
||||
shippingNote: null
|
||||
shippingNote: null,
|
||||
blogEnabled: false
|
||||
},
|
||||
shopSettings = {
|
||||
shopName: 'Demo Shop',
|
||||
@@ -136,7 +137,8 @@ describe('StorefrontShopViewService', () => {
|
||||
logoUrl: '/uploads/logo.png',
|
||||
faviconUrl: '/uploads/favicon.ico',
|
||||
simplexLink: 'https://simplex.example',
|
||||
shippingNote: 'Ships in 3 days'
|
||||
shippingNote: 'Ships in 3 days',
|
||||
blogEnabled: true
|
||||
},
|
||||
fiatPerXmr: 200,
|
||||
fiatPerBtc: 80_000
|
||||
@@ -157,6 +159,7 @@ describe('StorefrontShopViewService', () => {
|
||||
faviconUrl: '/uploads/favicon.ico',
|
||||
simplexLink: 'https://simplex.example',
|
||||
shippingNote: 'Ships in 3 days',
|
||||
blogEnabled: true,
|
||||
themePreference: 'dark'
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ export class StorefrontShopViewService {
|
||||
|
||||
const cryptoRates = this.buildCryptoRates(enabledPaymentMethods);
|
||||
|
||||
const { logoUrl, faviconUrl, simplexLink, shippingNote } =
|
||||
const { logoUrl, faviconUrl, simplexLink, shippingNote, blogEnabled } =
|
||||
await this.shopSettingsService.getStorefrontBranding();
|
||||
|
||||
const authorizedOrders = this.buildAuthorizedOrderNavItems(req, res);
|
||||
@@ -75,6 +75,7 @@ export class StorefrontShopViewService {
|
||||
faviconUrl,
|
||||
simplexLink,
|
||||
shippingNote,
|
||||
blogEnabled,
|
||||
themePreference,
|
||||
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;
|
||||
simplexLink: string | null;
|
||||
shippingNote: string | null;
|
||||
blogEnabled: boolean;
|
||||
themePreference: StorefrontThemePreference;
|
||||
pageMeta: StorefrontPageMeta;
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ export type StorefrontShopViewServiceOverrides = {
|
||||
faviconUrl: string | null;
|
||||
simplexLink: string | null;
|
||||
shippingNote: string | null;
|
||||
blogEnabled: boolean;
|
||||
};
|
||||
shopSettings?: { shopName: string; shopFiatCurrency: string; enabledPaymentMethods?: PaymentMethod[] };
|
||||
fiatPerXmr?: number;
|
||||
|
||||
@@ -33,6 +33,7 @@ const STOREFRONT_PARTIALS = [
|
||||
{ name: 'html-theme-attributes', file: 'html-theme-attributes.hbs' },
|
||||
{ name: 'page-back-link', file: 'page-back-link.hbs' },
|
||||
{ name: 'product-card', file: 'product-card.hbs' },
|
||||
{ name: 'blog-card', file: 'blog-card.hbs' },
|
||||
{ name: 'order-data-retention-notice', file: 'order-data-retention-notice.hbs' },
|
||||
{ name: 'category-nav', file: 'category-nav.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', () => {
|
||||
expect(resolveShopNavActive('/shop/theme')).toEqual({ activeShopNav: null, activeOrderId: null });
|
||||
});
|
||||
|
||||
@@ -23,6 +23,10 @@ export const resolveShopNavActive = (path: string): ShopNavActive => {
|
||||
return { activeShopNav: null, activeOrderId: third };
|
||||
}
|
||||
|
||||
if (first === 'shop' && second === 'blog') {
|
||||
return { activeShopNav: 'blog', activeOrderId: null };
|
||||
}
|
||||
|
||||
if (first === 'shop' && (second === 'categories' || second === 'products')) {
|
||||
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}}
|
||||
{{> storefront-image
|
||||
src=thumbnailUrl
|
||||
alt=''
|
||||
width='1'
|
||||
height='1'
|
||||
loading='lazy'
|
||||
|
||||
@@ -86,7 +86,6 @@
|
||||
{{> storefront-image
|
||||
class='sf-crypto-icon'
|
||||
src=iconUrl
|
||||
alt=''
|
||||
width='20'
|
||||
height='20'
|
||||
}}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
{{#if thumbnailUrl}}
|
||||
{{> storefront-image
|
||||
src=thumbnailUrl
|
||||
alt=''
|
||||
width='1'
|
||||
height='1'
|
||||
loading='lazy'
|
||||
@@ -19,7 +18,6 @@
|
||||
{{#if thumbnailUrl}}
|
||||
{{> storefront-image
|
||||
src=thumbnailUrl
|
||||
alt=''
|
||||
width='1'
|
||||
height='1'
|
||||
loading='lazy'
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
{{#if thumbnailUrl}}
|
||||
{{> storefront-image
|
||||
src=thumbnailUrl
|
||||
alt=''
|
||||
width='1'
|
||||
height='1'
|
||||
loading='lazy'
|
||||
@@ -19,7 +18,6 @@
|
||||
{{#if thumbnailUrl}}
|
||||
{{> storefront-image
|
||||
src=thumbnailUrl
|
||||
alt=''
|
||||
width='1'
|
||||
height='1'
|
||||
loading='lazy'
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
{{> storefront-image
|
||||
class='sf-product-card__image'
|
||||
src=thumbnailUrl
|
||||
alt=''
|
||||
width='1'
|
||||
height='1'
|
||||
loading='lazy'
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
|
||||
<nav class='sf-nav' aria-label='Shop navigation'>
|
||||
{{> 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}}
|
||||
{{> nav-link active=shopNavActive.activeShopNav key='checkout' href='/shop/checkout' label='Checkout' count=cartTotalQty}}
|
||||
{{else}}
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
>
|
||||
{{> storefront-image
|
||||
src=url
|
||||
alt=''
|
||||
width='1'
|
||||
height='1'
|
||||
loading='lazy'
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
{{#if thumbnailUrl}}
|
||||
{{> storefront-image
|
||||
src=thumbnailUrl
|
||||
alt=''
|
||||
width='1'
|
||||
height='1'
|
||||
loading='lazy'
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface AppConfig {
|
||||
signedCookie: SignedCookieConfig;
|
||||
validation: {
|
||||
productTitleMaxLength: number;
|
||||
blogPostTitleMaxLength: number;
|
||||
categoryNameMaxLength: number;
|
||||
discountCodeMaxLength: number;
|
||||
variantImagesMax: number;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:22.23.4-alpine
|
||||
|
||||
WORKDIR /cms
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<el-menu-item index="/products">Products</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="/blog">Blog</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="/settings">Settings</el-menu-item>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
{{ row.productTitle }}
|
||||
{{ getProductTitle(row.productTitle) }}
|
||||
</router-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -113,6 +113,7 @@ import type { OrderExtended } from '@/types/order/OrderExtended';
|
||||
import { DeliveryMode } from '@/types/product/DeliveryMode';
|
||||
import { formatDeliveryMode } from '@/utils/product/formatDeliveryMode';
|
||||
import { formatFiatPrice } from '@/utils/formatFiatPrice';
|
||||
import { getProductTitle } from '@/utils/product/getProductTitle';
|
||||
import { resolveAxiosErrorMessage } from '@/utils/resolveAxiosErrorMessage';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -143,7 +144,7 @@ const openAutoDelivery = (line: OrderLine): void => {
|
||||
const markAsFulfilled = async (line: OrderLine): Promise<void> => {
|
||||
try {
|
||||
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',
|
||||
{
|
||||
type: 'warning',
|
||||
|
||||
@@ -70,6 +70,7 @@ import type { OrderLine } from '@/types/order/OrderLine';
|
||||
import type { OrderLineAutoFulfillmentItem } from '@/types/order/OrderLineAutoFulfillmentItem';
|
||||
import type { OrderLineAutoFulfillmentItemAttachment } from '@/types/order/OrderLineAutoFulfillmentItemAttachment';
|
||||
import { formatFileSize } from '@/utils/formatFileSize';
|
||||
import { getProductTitle } from '@/utils/product/getProductTitle';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -99,7 +100,7 @@ const lineSummary = computed((): string => {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `${line.productTitle} · ${line.variantTitle}`;
|
||||
return `${getProductTitle(line.productTitle)} · ${line.variantTitle}`;
|
||||
});
|
||||
|
||||
const downloadAttachment = async (
|
||||
|
||||
@@ -317,7 +317,7 @@ const stockRemoving = ref(false);
|
||||
const stockListLoading = ref(false);
|
||||
|
||||
const stockContentRules: FormRules = {
|
||||
content: [{ required: true, message: 'Required', trigger: 'blur' }]
|
||||
content: [{ required: true, message: 'Content is required', trigger: 'blur' }]
|
||||
};
|
||||
|
||||
const stockEditVisible = ref(false);
|
||||
|
||||
@@ -19,6 +19,11 @@ export const config = {
|
||||
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: {
|
||||
accept: env('VITE_SHOP_LOGO_ALLOWED_MIMES'),
|
||||
maxFileBytes: parseInt(env('VITE_SHOP_LOGO_MAX_FILE_BYTES'), 10)
|
||||
@@ -36,6 +41,7 @@ export const config = {
|
||||
|
||||
validation: {
|
||||
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),
|
||||
discountCodeMaxLength: parseInt(env('VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH'), 10),
|
||||
variantImagesMax: parseInt(env('VITE_VALIDATION_VARIANT_IMAGES_MAX'), 10),
|
||||
|
||||
@@ -5,6 +5,8 @@ export const ROUTE_NAMES = {
|
||||
Products: 'Products',
|
||||
Categories: 'Categories',
|
||||
DiscountCodes: 'DiscountCodes',
|
||||
BlogPosts: 'BlogPosts',
|
||||
BlogPostDetail: 'BlogPostDetail',
|
||||
ShopSettings: 'ShopSettings',
|
||||
Notifications: 'Notifications',
|
||||
MoneroWallet: 'MoneroWallet',
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const UNTITLED_BLOG_POST_TITLE = '(untitled)';
|
||||
@@ -41,6 +41,18 @@ const router = createRouter({
|
||||
component: () => import('../views/CmsDiscountCodesView.vue'),
|
||||
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',
|
||||
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>(() => ({
|
||||
title: [
|
||||
{ required: true, message: 'Required', trigger: 'blur' },
|
||||
{ required: true, message: 'Title is required', trigger: 'blur' },
|
||||
{
|
||||
max: validationProductTitleMaxLength,
|
||||
message: `At most ${validationProductTitleMaxLength} characters`,
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
price: [{ required: true, message: 'Required', trigger: 'change' }],
|
||||
price: [{ required: true, message: 'Price is required', trigger: 'change' }],
|
||||
...(currentProductIsManual.value
|
||||
? {
|
||||
stockQuantity: [
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ConnectSimplexNotificationsPayload } from '@/types/shopSettings/Co
|
||||
import type { ShopSettings } from '@/types/shopSettings/ShopSettings';
|
||||
import type { UpdateNotificationsPayload } from '@/types/shopSettings/UpdateNotificationsPayload';
|
||||
import type { UpdateShippingNotePayload } from '@/types/shopSettings/UpdateShippingNotePayload';
|
||||
import type { UpdateBlogEnabledPayload } from '@/types/shopSettings/UpdateBlogEnabledPayload';
|
||||
import type { UpdateSimplexLinkPayload } from '@/types/shopSettings/UpdateSimplexLinkPayload';
|
||||
|
||||
export const useShopSettingsStore = defineStore('shopSettings', () => {
|
||||
@@ -42,6 +43,14 @@ export const useShopSettingsStore = defineStore('shopSettings', () => {
|
||||
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 { data } = await api.post<ShopSettings>('/shop-settings/simplex-connect', payload);
|
||||
|
||||
@@ -80,6 +89,7 @@ export const useShopSettingsStore = defineStore('shopSettings', () => {
|
||||
updateSimplexLink,
|
||||
updateShippingNote,
|
||||
updateNotifications,
|
||||
updateBlogEnabled,
|
||||
connectSimplexNotifications,
|
||||
uploadShopLogo,
|
||||
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;
|
||||
notifyOnOrderMessage: boolean;
|
||||
simplexNotificationConnected: boolean;
|
||||
blogEnabled: boolean;
|
||||
isSetupComplete: boolean;
|
||||
setupChecklist: SetupChecklist;
|
||||
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/pdf': 'PDF',
|
||||
'application/zip': 'ZIP',
|
||||
'application/x-zip-compressed': 'ZIP',
|
||||
'image/gif': 'GIF',
|
||||
'image/jpeg': '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 type { ProductVariantExtended } from '@/types/product/ProductVariantExtended';
|
||||
import { formatFiatPrice } from '@/utils/formatFiatPrice';
|
||||
import { resolveAxiosErrorMessage } from '@/utils/resolveAxiosErrorMessage';
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, onBeforeMount, reactive, ref } from 'vue';
|
||||
@@ -168,7 +169,7 @@ const deleteSaving = ref(false);
|
||||
|
||||
const productRules: FormRules = {
|
||||
title: [
|
||||
{ required: true, message: 'Required', trigger: 'blur' },
|
||||
{ required: true, message: 'Title is required', trigger: 'blur' },
|
||||
{
|
||||
max: validationProductTitleMaxLength,
|
||||
message: `At most ${validationProductTitleMaxLength} characters`,
|
||||
@@ -200,8 +201,6 @@ onBeforeMount(async () => {
|
||||
productForm.descriptionHtml = currentProduct.value.descriptionHtml;
|
||||
productForm.isDraft = currentProduct.value.isDraft;
|
||||
selectedCategoryIds.value = (currentProduct.value.categories ?? []).map(c => c.id);
|
||||
|
||||
productFormRef.value?.clearValidate();
|
||||
}
|
||||
} catch {
|
||||
loadError.value = true;
|
||||
@@ -241,8 +240,10 @@ const submitProduct = async (): Promise<void> => {
|
||||
});
|
||||
|
||||
ElMessage.success('Product saved');
|
||||
} catch {
|
||||
ElMessage.error('Could not save product');
|
||||
} catch (e) {
|
||||
const message = resolveAxiosErrorMessage(e, 'Could not save product');
|
||||
|
||||
ElMessage.error(message);
|
||||
} finally {
|
||||
productSaving.value = false;
|
||||
}
|
||||
@@ -288,8 +289,10 @@ const confirmDeleteProduct = async (): Promise<void> => {
|
||||
await productsStore.deleteProduct(pid);
|
||||
ElMessage.success('Product deleted');
|
||||
router.push({ name: ROUTE_NAMES.Products });
|
||||
} catch {
|
||||
ElMessage.error('Could not delete product');
|
||||
} catch (e) {
|
||||
const message = resolveAxiosErrorMessage(e, 'Could not delete product');
|
||||
|
||||
ElMessage.error(message);
|
||||
} finally {
|
||||
deleteSaving.value = false;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="products"
|
||||
row-key="id"
|
||||
stripe
|
||||
empty-text="No products yet"
|
||||
class="clickable-table"
|
||||
@@ -22,8 +23,9 @@
|
||||
|
||||
<el-table-column label="Status" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.isDraft" type="info" size="small">Draft</el-tag>
|
||||
<el-tag v-else type="success" size="small">Live</el-tag>
|
||||
<el-tag :type="row.isDraft ? 'info' : 'success'" size="small">
|
||||
{{ row.isDraft ? 'Draft' : 'Live' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
@@ -89,7 +91,6 @@ import { useProductsStore } from '@/stores/products';
|
||||
import type { Category } from '@/types/product/Category';
|
||||
import type { ProductWithVariantsExtended } from '@/types/product/ProductWithVariantsExtended';
|
||||
import { DeliveryMode } from '@/types/product/DeliveryMode';
|
||||
import { HttpStatusCode, isAxiosError } from 'axios';
|
||||
import { formatDate } from '@/utils/formatDate';
|
||||
import { formatDeliveryMode } from '@/utils/product/formatDeliveryMode';
|
||||
import { getProductTitle } from '@/utils/product/getProductTitle';
|
||||
@@ -127,10 +128,8 @@ const loadProducts = async (): Promise<void> => {
|
||||
total.value = data.total;
|
||||
page.value = data.page;
|
||||
limit.value = data.limit;
|
||||
} catch (e) {
|
||||
if (!isAxiosError(e) || e.response?.status !== HttpStatusCode.Unauthorized) {
|
||||
ElMessage.error('Failed to load products');
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('Failed to load products');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
Vendored
+3
@@ -15,6 +15,8 @@ interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL?: string;
|
||||
readonly VITE_PRODUCT_THUMB_ALLOWED_MIMES?: 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_MAX_FILE_BYTES?: 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_MAX_FILE_BYTES?: 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_DISCOUNT_CODE_MAX_LENGTH?: 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.
|
||||
4. Connect SimpleX notifications in shop settings.
|
||||
|
||||
## 10. Updates
|
||||
## Updates
|
||||
|
||||
This pulls the latest code and rebuilds the stack (`deploy.sh`).
|
||||
|
||||
```bash
|
||||
./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_PRODUCT_THUMB_ALLOWED_MIMES: ${VITE_PRODUCT_THUMB_ALLOWED_MIMES}
|
||||
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_MAX_FILE_BYTES: ${VITE_SHOP_LOGO_MAX_FILE_BYTES}
|
||||
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_MAX_FILE_BYTES: ${VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES}
|
||||
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_DISCOUNT_CODE_MAX_LENGTH: ${VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH}
|
||||
VITE_VALIDATION_VARIANT_IMAGES_MAX: ${VITE_VALIDATION_VARIANT_IMAGES_MAX}
|
||||
|
||||
@@ -4,10 +4,17 @@ set -euo pipefail
|
||||
NETWORK_FLAG="--${ELECTRUM_NETWORK}"
|
||||
WALLET_PATH="/electrum/wallet/${ELECTRUM_WALLET_NAME}"
|
||||
DATA_DIR="/electrum/data"
|
||||
LOAD_WALLET_MAX_ATTEMPTS=90
|
||||
DAEMON_STARTUP_WAIT_SECONDS=5
|
||||
|
||||
run_electrum() { electrum "${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}"
|
||||
chmod 700 /electrum/wallet
|
||||
|
||||
@@ -16,19 +23,42 @@ if [[ ! -f "${WALLET_PATH}" ]]; then
|
||||
|
||||
run_electrum_offline create -w "${WALLET_PATH}" --password "${ELECTRUM_WALLET_PASSWORD}"
|
||||
chmod 600 "${WALLET_PATH}" 2>/dev/null || true
|
||||
|
||||
|
||||
echo "Wallet created at ${WALLET_PATH}"
|
||||
fi
|
||||
|
||||
maybe_stop_electrum
|
||||
|
||||
run_electrum_offline setconfig rpcuser "${ELECTRUM_DAEMON_RPC_USER}"
|
||||
run_electrum_offline setconfig rpcpassword "${ELECTRUM_DAEMON_RPC_PASSWORD}"
|
||||
run_electrum_offline setconfig rpchost 0.0.0.0
|
||||
run_electrum_offline setconfig rpcport "${ELECTRUM_DAEMON_PORT}"
|
||||
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
|
||||
run_electrum load_wallet -w "${WALLET_PATH}" --password "${ELECTRUM_WALLET_PASSWORD}"
|
||||
echo "Starting Electrum daemon..."
|
||||
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
|
||||
|
||||
@@ -11,6 +11,8 @@ ARG VITE_API_BASE_URL
|
||||
ARG VITE_SHOP_FIAT_CURRENCY
|
||||
ARG VITE_PRODUCT_THUMB_ALLOWED_MIMES
|
||||
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_MAX_FILE_BYTES
|
||||
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_MAX_FILE_BYTES
|
||||
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_DISCOUNT_CODE_MAX_LENGTH
|
||||
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_PRODUCT_THUMB_ALLOWED_MIMES=$VITE_PRODUCT_THUMB_ALLOWED_MIMES \
|
||||
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_MAX_FILE_BYTES=$VITE_SHOP_LOGO_MAX_FILE_BYTES \
|
||||
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_MAX_FILE_BYTES=$VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES \
|
||||
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_DISCOUNT_CODE_MAX_LENGTH=$VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH \
|
||||
VITE_VALIDATION_VARIANT_IMAGES_MAX=$VITE_VALIDATION_VARIANT_IMAGES_MAX \
|
||||
|
||||
@@ -20,6 +20,7 @@ location = /api/shop-settings/simplex-connect {
|
||||
}
|
||||
|
||||
location /api {
|
||||
client_max_body_size 20m;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
Reference in New Issue
Block a user