Compare commits
13
Commits
abe6208e4e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55630e33b4 | ||
|
|
e057b29cc7 | ||
|
|
226a31b937 | ||
|
|
6419439a3d | ||
|
|
762af5c4f3 | ||
|
|
17a26b60ed | ||
|
|
9cd7059928 | ||
|
|
9c7b8470d3 | ||
|
|
e5eb773404 | ||
|
|
0cd7158f7d | ||
|
|
09ad236cb9 | ||
|
|
a3d1842fb3 | ||
|
|
73f7ffed00 |
+8
-2
@@ -37,13 +37,16 @@ THROTTLE_LIMIT=120
|
||||
MULTER_PRODUCT_THUMB_ALLOWED_MIMES=image/jpeg,image/png
|
||||
MULTER_PRODUCT_THUMB_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
|
||||
@@ -135,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
|
||||
|
||||
@@ -38,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';
|
||||
|
||||
@@ -101,7 +102,8 @@ import { Config } from './types/Config';
|
||||
StorefrontProductModule,
|
||||
StorefrontCartModule,
|
||||
StorefrontCheckoutModule,
|
||||
StorefrontOrderModule
|
||||
StorefrontOrderModule,
|
||||
StorefrontBlogModule
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
|
||||
@@ -128,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(',')
|
||||
|
||||
@@ -75,6 +75,16 @@ class EnvironmentVariables {
|
||||
@Max(10 * 1024 * 1024) // 10MB
|
||||
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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -7,6 +7,7 @@ import { BlogPostsService } from './services/BlogPostsService';
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([BlogPost])],
|
||||
controllers: [BlogPostsController],
|
||||
providers: [BlogPostsService]
|
||||
providers: [BlogPostsService],
|
||||
exports: [TypeOrmModule.forFeature([BlogPost])]
|
||||
})
|
||||
export class BlogModule {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getProductThumbMulterConfig } from '../../../config';
|
||||
import { getBlogImageMulterConfig } from '../../../config';
|
||||
import { getBlogImagesDir } from '../../../config/uploadPaths';
|
||||
import { createDiskStorageUploadOptions } from '../../../utils/createDiskStorageUploadOptions';
|
||||
import { createUploadFilePipe } from '../../../utils/createUploadFilePipe';
|
||||
@@ -7,6 +7,6 @@ const uploadDir = getBlogImagesDir();
|
||||
|
||||
export const blogImageUploadOptions = createDiskStorageUploadOptions(uploadDir);
|
||||
|
||||
const { allowedMimes, maxFileBytes } = getProductThumbMulterConfig();
|
||||
const { allowedMimes, maxFileBytes } = getBlogImageMulterConfig();
|
||||
|
||||
export const blogImageFilePipe = createUploadFilePipe(allowedMimes, maxFileBytes);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import type { Repository } from 'typeorm';
|
||||
import { resolveBlogImagePath } from '../../../config/uploadPaths';
|
||||
import { getBlogImagePublicUrl, resolveBlogImagePath } from '../../../config/uploadPaths';
|
||||
import { removeFileFromDisk } from '../../../utils/removeFileFromDisk';
|
||||
import { BlogPost } from '../entities/BlogPost';
|
||||
import { BlogPostsService } from './BlogPostsService';
|
||||
@@ -58,7 +58,28 @@ describe('BlogPostsService', () => {
|
||||
id: 'post-1',
|
||||
title: '',
|
||||
contentHtml: '',
|
||||
isDraft: true
|
||||
isDraft: true,
|
||||
thumbnailUrl: null,
|
||||
bannerUrl: null
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('maps image storage keys to public urls', async () => {
|
||||
blogPostRepo.findOne.mockResolvedValue({
|
||||
id: 'post-1',
|
||||
title: 'Hello',
|
||||
thumbnailStorageKey: 'thumb.webp',
|
||||
bannerStorageKey: 'banner.webp',
|
||||
contentHtml: '<p>Body</p>',
|
||||
isDraft: false
|
||||
});
|
||||
|
||||
await expect(service.findOne('post-1')).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
title: 'Hello',
|
||||
thumbnailUrl: getBlogImagePublicUrl('thumb.webp'),
|
||||
bannerUrl: getBlogImagePublicUrl('banner.webp')
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { resolveBlogImagePath } from '../../../config/uploadPaths';
|
||||
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 {
|
||||
@@ -15,17 +16,19 @@ export class BlogPostsService {
|
||||
private readonly blogPostRepo: Repository<BlogPost>
|
||||
) {}
|
||||
|
||||
async findAll({ page = 1, limit = 20 }: ListBlogPostsQueryDto): Promise<PaginatedResponse<BlogPost>> {
|
||||
async findAll({ page = 1, limit = 20 }: ListBlogPostsQueryDto): Promise<PaginatedResponse<BlogPostExtended>> {
|
||||
const [items, total] = await this.blogPostRepo.findAndCount({
|
||||
order: { createdAt: 'DESC' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit
|
||||
});
|
||||
|
||||
return { items, total, page, limit };
|
||||
const itemsExtended = items.map(post => this.toPostExtended(post));
|
||||
|
||||
return { items: itemsExtended, total, page, limit };
|
||||
}
|
||||
|
||||
async createDraft(): Promise<BlogPost> {
|
||||
async createDraft(): Promise<BlogPostExtended> {
|
||||
const entity = this.blogPostRepo.create({
|
||||
title: '',
|
||||
contentHtml: '',
|
||||
@@ -37,17 +40,13 @@ export class BlogPostsService {
|
||||
return this.findOne(entity.id);
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<BlogPost> {
|
||||
const entity = await this.blogPostRepo.findOne({ where: { id } });
|
||||
async findOne(id: string): Promise<BlogPostExtended> {
|
||||
const entity = await this.findEntity(id);
|
||||
|
||||
if (!entity) {
|
||||
throw new NotFoundException();
|
||||
return this.toPostExtended(entity);
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
async update(id: string, { title, contentHtml, isDraft }: UpdateBlogPostDto): Promise<BlogPost> {
|
||||
async update(id: string, { title, contentHtml, isDraft }: UpdateBlogPostDto): Promise<BlogPostExtended> {
|
||||
const exists = await this.blogPostRepo.exists({ where: { id } });
|
||||
|
||||
if (!exists) {
|
||||
@@ -63,8 +62,8 @@ export class BlogPostsService {
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
async uploadThumbnail(id: string, filename: string): Promise<BlogPost> {
|
||||
const post = await this.findOne(id);
|
||||
async uploadThumbnail(id: string, filename: string): Promise<BlogPostExtended> {
|
||||
const post = await this.findEntity(id);
|
||||
|
||||
const previousStorageKey = post.thumbnailStorageKey;
|
||||
|
||||
@@ -74,8 +73,8 @@ export class BlogPostsService {
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
async uploadBanner(id: string, filename: string): Promise<BlogPost> {
|
||||
const post = await this.findOne(id);
|
||||
async uploadBanner(id: string, filename: string): Promise<BlogPostExtended> {
|
||||
const post = await this.findEntity(id);
|
||||
|
||||
const previousStorageKey = post.bannerStorageKey;
|
||||
|
||||
@@ -86,7 +85,7 @@ export class BlogPostsService {
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const post = await this.findOne(id);
|
||||
const post = await this.findEntity(id);
|
||||
|
||||
await this.blogPostRepo.delete(id);
|
||||
|
||||
@@ -96,6 +95,24 @@ export class BlogPostsService {
|
||||
]);
|
||||
}
|
||||
|
||||
private async findEntity(id: string): Promise<BlogPost> {
|
||||
const entity = await this.blogPostRepo.findOne({ where: { id } });
|
||||
|
||||
if (!entity) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
private toPostExtended(post: BlogPost): BlogPostExtended {
|
||||
return {
|
||||
...post,
|
||||
thumbnailUrl: post.thumbnailStorageKey ? getBlogImagePublicUrl(post.thumbnailStorageKey) : null,
|
||||
bannerUrl: post.bannerStorageKey ? getBlogImagePublicUrl(post.bannerStorageKey) : null
|
||||
};
|
||||
}
|
||||
|
||||
private async removeBlogImageFile(storageKey: string | null): Promise<void> {
|
||||
if (!storageKey) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { BlogPost } from '../entities/BlogPost';
|
||||
|
||||
export type BlogPostExtended = BlogPost & {
|
||||
thumbnailUrl: string | null;
|
||||
bannerUrl: string | null;
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import { JwtGuard } from '../../../guards/JwtGuard';
|
||||
import { shopFaviconFilePipe, shopFaviconUploadOptions } from '../config/shopFaviconUpload';
|
||||
import { 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;
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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) {
|
||||
} 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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 \
|
||||
|
||||
Reference in New Issue
Block a user