Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6419439a3d | ||
|
|
762af5c4f3 | ||
|
|
17a26b60ed | ||
|
|
9cd7059928 | ||
|
|
9c7b8470d3 | ||
|
|
e5eb773404 | ||
|
|
0cd7158f7d | ||
|
|
09ad236cb9 | ||
|
|
a3d1842fb3 | ||
|
|
73f7ffed00 |
@@ -37,6 +37,9 @@ THROTTLE_LIMIT=120
|
|||||||
MULTER_PRODUCT_THUMB_ALLOWED_MIMES=image/jpeg,image/png
|
MULTER_PRODUCT_THUMB_ALLOWED_MIMES=image/jpeg,image/png
|
||||||
MULTER_PRODUCT_THUMB_MAX_FILE_BYTES=1048576 # 1MB
|
MULTER_PRODUCT_THUMB_MAX_FILE_BYTES=1048576 # 1MB
|
||||||
|
|
||||||
|
MULTER_BLOG_IMAGE_ALLOWED_MIMES=image/jpeg,image/png
|
||||||
|
MULTER_BLOG_IMAGE_MAX_FILE_BYTES=1048576 # 1MB
|
||||||
|
|
||||||
MULTER_SHOP_LOGO_ALLOWED_MIMES=image/jpeg,image/png
|
MULTER_SHOP_LOGO_ALLOWED_MIMES=image/jpeg,image/png
|
||||||
MULTER_SHOP_LOGO_MAX_FILE_BYTES=1048576 # 1MB
|
MULTER_SHOP_LOGO_MAX_FILE_BYTES=1048576 # 1MB
|
||||||
|
|
||||||
@@ -135,6 +138,8 @@ VITE_API_BASE_URL=http://localhost:3000/api
|
|||||||
VITE_SHOP_FIAT_CURRENCY=USD
|
VITE_SHOP_FIAT_CURRENCY=USD
|
||||||
VITE_PRODUCT_THUMB_ALLOWED_MIMES=image/jpeg,image/png
|
VITE_PRODUCT_THUMB_ALLOWED_MIMES=image/jpeg,image/png
|
||||||
VITE_PRODUCT_THUMB_MAX_FILE_BYTES=1048576 # 1MB
|
VITE_PRODUCT_THUMB_MAX_FILE_BYTES=1048576 # 1MB
|
||||||
|
VITE_BLOG_IMAGE_ALLOWED_MIMES=image/jpeg,image/png
|
||||||
|
VITE_BLOG_IMAGE_MAX_FILE_BYTES=1048576 # 1MB
|
||||||
VITE_SHOP_LOGO_ALLOWED_MIMES=image/jpeg,image/png
|
VITE_SHOP_LOGO_ALLOWED_MIMES=image/jpeg,image/png
|
||||||
VITE_SHOP_LOGO_MAX_FILE_BYTES=1048576 # 1MB
|
VITE_SHOP_LOGO_MAX_FILE_BYTES=1048576 # 1MB
|
||||||
VITE_SHOP_FAVICON_ALLOWED_MIMES=image/png,image/x-icon,image/vnd.microsoft.icon
|
VITE_SHOP_FAVICON_ALLOWED_MIMES=image/png,image/x-icon,image/vnd.microsoft.icon
|
||||||
@@ -142,6 +147,7 @@ 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,image/jpeg,image/png
|
||||||
VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES=5242880 # 5MB
|
VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES=5242880 # 5MB
|
||||||
VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH=100
|
VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH=100
|
||||||
|
VITE_VALIDATION_BLOG_POST_TITLE_MAX_LENGTH=200
|
||||||
VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH=50
|
VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH=50
|
||||||
VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH=32
|
VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH=32
|
||||||
VITE_VALIDATION_VARIANT_IMAGES_MAX=10
|
VITE_VALIDATION_VARIANT_IMAGES_MAX=10
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { StorefrontCheckoutModule } from './modules/storefrontCheckout/Storefron
|
|||||||
import { StorefrontOrderModule } from './modules/storefrontOrder/StorefrontOrderModule';
|
import { StorefrontOrderModule } from './modules/storefrontOrder/StorefrontOrderModule';
|
||||||
import { StorefrontCoreModule } from './modules/storefrontCore/StorefrontCoreModule';
|
import { StorefrontCoreModule } from './modules/storefrontCore/StorefrontCoreModule';
|
||||||
import { StorefrontProductModule } from './modules/storefrontProduct/StorefrontProductModule';
|
import { StorefrontProductModule } from './modules/storefrontProduct/StorefrontProductModule';
|
||||||
|
import { StorefrontBlogModule } from './modules/storefrontBlog/StorefrontBlogModule';
|
||||||
import { ExchangeRateModule } from './modules/exchangeRate/ExchangeRateModule';
|
import { ExchangeRateModule } from './modules/exchangeRate/ExchangeRateModule';
|
||||||
import { Config } from './types/Config';
|
import { Config } from './types/Config';
|
||||||
|
|
||||||
@@ -101,7 +102,8 @@ import { Config } from './types/Config';
|
|||||||
StorefrontProductModule,
|
StorefrontProductModule,
|
||||||
StorefrontCartModule,
|
StorefrontCartModule,
|
||||||
StorefrontCheckoutModule,
|
StorefrontCheckoutModule,
|
||||||
StorefrontOrderModule
|
StorefrontOrderModule,
|
||||||
|
StorefrontBlogModule
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -128,6 +128,17 @@ export const getProductThumbMulterConfig = (): MulterConfig => {
|
|||||||
return { allowedMimes, maxFileBytes };
|
return { allowedMimes, maxFileBytes };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getBlogImageMulterConfig = (): MulterConfig => {
|
||||||
|
const allowedMimes = env('MULTER_BLOG_IMAGE_ALLOWED_MIMES')
|
||||||
|
.split(',')
|
||||||
|
.map(v => v.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const maxFileBytes = envInt('MULTER_BLOG_IMAGE_MAX_FILE_BYTES');
|
||||||
|
|
||||||
|
return { allowedMimes, maxFileBytes };
|
||||||
|
};
|
||||||
|
|
||||||
export const getShopLogoMulterConfig = (): MulterConfig => {
|
export const getShopLogoMulterConfig = (): MulterConfig => {
|
||||||
const allowedMimes = env('MULTER_SHOP_LOGO_ALLOWED_MIMES')
|
const allowedMimes = env('MULTER_SHOP_LOGO_ALLOWED_MIMES')
|
||||||
.split(',')
|
.split(',')
|
||||||
|
|||||||
@@ -75,6 +75,16 @@ class EnvironmentVariables {
|
|||||||
@Max(10 * 1024 * 1024) // 10MB
|
@Max(10 * 1024 * 1024) // 10MB
|
||||||
MULTER_PRODUCT_THUMB_MAX_FILE_BYTES: number;
|
MULTER_PRODUCT_THUMB_MAX_FILE_BYTES: number;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
MULTER_BLOG_IMAGE_ALLOWED_MIMES: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Max(10 * 1024 * 1024) // 10MB
|
||||||
|
MULTER_BLOG_IMAGE_MAX_FILE_BYTES: number;
|
||||||
|
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsString()
|
@IsString()
|
||||||
MULTER_SHOP_LOGO_ALLOWED_MIMES: string;
|
MULTER_SHOP_LOGO_ALLOWED_MIMES: string;
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ async function bootstrap() {
|
|||||||
{ path: '/', method: RequestMethod.GET },
|
{ path: '/', method: RequestMethod.GET },
|
||||||
{ path: 'shop/categories/:id', method: RequestMethod.GET },
|
{ path: 'shop/categories/:id', method: RequestMethod.GET },
|
||||||
{ path: 'shop/products/:id/variants/:variantId', method: RequestMethod.GET },
|
{ path: 'shop/products/:id/variants/:variantId', method: RequestMethod.GET },
|
||||||
|
{ path: 'shop/blog', method: RequestMethod.GET },
|
||||||
|
{ path: 'shop/blog/:id', method: RequestMethod.GET },
|
||||||
{ path: 'shop/assets/{*path}', method: RequestMethod.GET },
|
{ path: 'shop/assets/{*path}', method: RequestMethod.GET },
|
||||||
{ path: 'shop/preferences/theme', method: RequestMethod.POST },
|
{ path: 'shop/preferences/theme', method: RequestMethod.POST },
|
||||||
{ path: 'shop/error', method: RequestMethod.GET },
|
{ path: 'shop/error', method: RequestMethod.GET },
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { BlogPostsService } from './services/BlogPostsService';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([BlogPost])],
|
imports: [TypeOrmModule.forFeature([BlogPost])],
|
||||||
controllers: [BlogPostsController],
|
controllers: [BlogPostsController],
|
||||||
providers: [BlogPostsService]
|
providers: [BlogPostsService],
|
||||||
|
exports: [TypeOrmModule.forFeature([BlogPost])]
|
||||||
})
|
})
|
||||||
export class BlogModule {}
|
export class BlogModule {}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getProductThumbMulterConfig } from '../../../config';
|
import { getBlogImageMulterConfig } from '../../../config';
|
||||||
import { getBlogImagesDir } from '../../../config/uploadPaths';
|
import { getBlogImagesDir } from '../../../config/uploadPaths';
|
||||||
import { createDiskStorageUploadOptions } from '../../../utils/createDiskStorageUploadOptions';
|
import { createDiskStorageUploadOptions } from '../../../utils/createDiskStorageUploadOptions';
|
||||||
import { createUploadFilePipe } from '../../../utils/createUploadFilePipe';
|
import { createUploadFilePipe } from '../../../utils/createUploadFilePipe';
|
||||||
@@ -7,6 +7,6 @@ const uploadDir = getBlogImagesDir();
|
|||||||
|
|
||||||
export const blogImageUploadOptions = createDiskStorageUploadOptions(uploadDir);
|
export const blogImageUploadOptions = createDiskStorageUploadOptions(uploadDir);
|
||||||
|
|
||||||
const { allowedMimes, maxFileBytes } = getProductThumbMulterConfig();
|
const { allowedMimes, maxFileBytes } = getBlogImageMulterConfig();
|
||||||
|
|
||||||
export const blogImageFilePipe = createUploadFilePipe(allowedMimes, maxFileBytes);
|
export const blogImageFilePipe = createUploadFilePipe(allowedMimes, maxFileBytes);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NotFoundException } from '@nestjs/common';
|
import { NotFoundException } from '@nestjs/common';
|
||||||
import type { Repository } from 'typeorm';
|
import type { Repository } from 'typeorm';
|
||||||
import { resolveBlogImagePath } from '../../../config/uploadPaths';
|
import { getBlogImagePublicUrl, resolveBlogImagePath } from '../../../config/uploadPaths';
|
||||||
import { removeFileFromDisk } from '../../../utils/removeFileFromDisk';
|
import { removeFileFromDisk } from '../../../utils/removeFileFromDisk';
|
||||||
import { BlogPost } from '../entities/BlogPost';
|
import { BlogPost } from '../entities/BlogPost';
|
||||||
import { BlogPostsService } from './BlogPostsService';
|
import { BlogPostsService } from './BlogPostsService';
|
||||||
@@ -58,7 +58,28 @@ describe('BlogPostsService', () => {
|
|||||||
id: 'post-1',
|
id: 'post-1',
|
||||||
title: '',
|
title: '',
|
||||||
contentHtml: '',
|
contentHtml: '',
|
||||||
isDraft: true
|
isDraft: true,
|
||||||
|
thumbnailUrl: null,
|
||||||
|
bannerUrl: null
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps image storage keys to public urls', async () => {
|
||||||
|
blogPostRepo.findOne.mockResolvedValue({
|
||||||
|
id: 'post-1',
|
||||||
|
title: 'Hello',
|
||||||
|
thumbnailStorageKey: 'thumb.webp',
|
||||||
|
bannerStorageKey: 'banner.webp',
|
||||||
|
contentHtml: '<p>Body</p>',
|
||||||
|
isDraft: false
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.findOne('post-1')).resolves.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
title: 'Hello',
|
||||||
|
thumbnailUrl: getBlogImagePublicUrl('thumb.webp'),
|
||||||
|
bannerUrl: getBlogImagePublicUrl('banner.webp')
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { resolveBlogImagePath } from '../../../config/uploadPaths';
|
import { getBlogImagePublicUrl, resolveBlogImagePath } from '../../../config/uploadPaths';
|
||||||
import type { PaginatedResponse } from '../../../types/PaginatedResponse';
|
import type { PaginatedResponse } from '../../../types/PaginatedResponse';
|
||||||
import { removeFileFromDisk } from '../../../utils/removeFileFromDisk';
|
import { removeFileFromDisk } from '../../../utils/removeFileFromDisk';
|
||||||
import type { ListBlogPostsQueryDto } from '../dto/ListBlogPostsQueryDto';
|
import type { ListBlogPostsQueryDto } from '../dto/ListBlogPostsQueryDto';
|
||||||
import { UpdateBlogPostDto } from '../dto/UpdateBlogPostDto';
|
import { UpdateBlogPostDto } from '../dto/UpdateBlogPostDto';
|
||||||
import { BlogPost } from '../entities/BlogPost';
|
import { BlogPost } from '../entities/BlogPost';
|
||||||
|
import type { BlogPostExtended } from '../types/BlogPostExtended';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BlogPostsService {
|
export class BlogPostsService {
|
||||||
@@ -15,17 +16,19 @@ export class BlogPostsService {
|
|||||||
private readonly blogPostRepo: Repository<BlogPost>
|
private readonly blogPostRepo: Repository<BlogPost>
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll({ page = 1, limit = 20 }: ListBlogPostsQueryDto): Promise<PaginatedResponse<BlogPost>> {
|
async findAll({ page = 1, limit = 20 }: ListBlogPostsQueryDto): Promise<PaginatedResponse<BlogPostExtended>> {
|
||||||
const [items, total] = await this.blogPostRepo.findAndCount({
|
const [items, total] = await this.blogPostRepo.findAndCount({
|
||||||
order: { createdAt: 'DESC' },
|
order: { createdAt: 'DESC' },
|
||||||
skip: (page - 1) * limit,
|
skip: (page - 1) * limit,
|
||||||
take: limit
|
take: limit
|
||||||
});
|
});
|
||||||
|
|
||||||
return { items, total, page, limit };
|
const itemsExtended = items.map(post => this.toPostExtended(post));
|
||||||
|
|
||||||
|
return { items: itemsExtended, total, page, limit };
|
||||||
}
|
}
|
||||||
|
|
||||||
async createDraft(): Promise<BlogPost> {
|
async createDraft(): Promise<BlogPostExtended> {
|
||||||
const entity = this.blogPostRepo.create({
|
const entity = this.blogPostRepo.create({
|
||||||
title: '',
|
title: '',
|
||||||
contentHtml: '',
|
contentHtml: '',
|
||||||
@@ -37,17 +40,13 @@ export class BlogPostsService {
|
|||||||
return this.findOne(entity.id);
|
return this.findOne(entity.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: string): Promise<BlogPost> {
|
async findOne(id: string): Promise<BlogPostExtended> {
|
||||||
const entity = await this.blogPostRepo.findOne({ where: { id } });
|
const entity = await this.findEntity(id);
|
||||||
|
|
||||||
if (!entity) {
|
return this.toPostExtended(entity);
|
||||||
throw new NotFoundException();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return entity;
|
async update(id: string, { title, contentHtml, isDraft }: UpdateBlogPostDto): Promise<BlogPostExtended> {
|
||||||
}
|
|
||||||
|
|
||||||
async update(id: string, { title, contentHtml, isDraft }: UpdateBlogPostDto): Promise<BlogPost> {
|
|
||||||
const exists = await this.blogPostRepo.exists({ where: { id } });
|
const exists = await this.blogPostRepo.exists({ where: { id } });
|
||||||
|
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
@@ -63,8 +62,8 @@ export class BlogPostsService {
|
|||||||
return this.findOne(id);
|
return this.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadThumbnail(id: string, filename: string): Promise<BlogPost> {
|
async uploadThumbnail(id: string, filename: string): Promise<BlogPostExtended> {
|
||||||
const post = await this.findOne(id);
|
const post = await this.findEntity(id);
|
||||||
|
|
||||||
const previousStorageKey = post.thumbnailStorageKey;
|
const previousStorageKey = post.thumbnailStorageKey;
|
||||||
|
|
||||||
@@ -74,8 +73,8 @@ export class BlogPostsService {
|
|||||||
return this.findOne(id);
|
return this.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadBanner(id: string, filename: string): Promise<BlogPost> {
|
async uploadBanner(id: string, filename: string): Promise<BlogPostExtended> {
|
||||||
const post = await this.findOne(id);
|
const post = await this.findEntity(id);
|
||||||
|
|
||||||
const previousStorageKey = post.bannerStorageKey;
|
const previousStorageKey = post.bannerStorageKey;
|
||||||
|
|
||||||
@@ -86,7 +85,7 @@ export class BlogPostsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: string): Promise<void> {
|
async remove(id: string): Promise<void> {
|
||||||
const post = await this.findOne(id);
|
const post = await this.findEntity(id);
|
||||||
|
|
||||||
await this.blogPostRepo.delete(id);
|
await this.blogPostRepo.delete(id);
|
||||||
|
|
||||||
@@ -96,6 +95,24 @@ export class BlogPostsService {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async findEntity(id: string): Promise<BlogPost> {
|
||||||
|
const entity = await this.blogPostRepo.findOne({ where: { id } });
|
||||||
|
|
||||||
|
if (!entity) {
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toPostExtended(post: BlogPost): BlogPostExtended {
|
||||||
|
return {
|
||||||
|
...post,
|
||||||
|
thumbnailUrl: post.thumbnailStorageKey ? getBlogImagePublicUrl(post.thumbnailStorageKey) : null,
|
||||||
|
bannerUrl: post.bannerStorageKey ? getBlogImagePublicUrl(post.bannerStorageKey) : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async removeBlogImageFile(storageKey: string | null): Promise<void> {
|
private async removeBlogImageFile(storageKey: string | null): Promise<void> {
|
||||||
if (!storageKey) {
|
if (!storageKey) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { BlogPost } from '../entities/BlogPost';
|
||||||
|
|
||||||
|
export type BlogPostExtended = BlogPost & {
|
||||||
|
thumbnailUrl: string | null;
|
||||||
|
bannerUrl: string | null;
|
||||||
|
};
|
||||||
@@ -13,6 +13,7 @@ import { JwtGuard } from '../../../guards/JwtGuard';
|
|||||||
import { shopFaviconFilePipe, shopFaviconUploadOptions } from '../config/shopFaviconUpload';
|
import { shopFaviconFilePipe, shopFaviconUploadOptions } from '../config/shopFaviconUpload';
|
||||||
import { shopLogoFilePipe, shopLogoUploadOptions } from '../config/shopLogoUpload';
|
import { shopLogoFilePipe, shopLogoUploadOptions } from '../config/shopLogoUpload';
|
||||||
import { ConnectSimplexNotificationsDto } from '../dto/ConnectSimplexNotificationsDto';
|
import { ConnectSimplexNotificationsDto } from '../dto/ConnectSimplexNotificationsDto';
|
||||||
|
import { UpdateBlogEnabledDto } from '../dto/UpdateBlogEnabledDto';
|
||||||
import { UpdateNotificationsDto } from '../dto/UpdateNotificationsDto';
|
import { UpdateNotificationsDto } from '../dto/UpdateNotificationsDto';
|
||||||
import { UpdateShippingNoteDto } from '../dto/UpdateShippingNoteDto';
|
import { UpdateShippingNoteDto } from '../dto/UpdateShippingNoteDto';
|
||||||
import { UpdateSimplexLinkDto } from '../dto/UpdateSimplexLinkDto';
|
import { UpdateSimplexLinkDto } from '../dto/UpdateSimplexLinkDto';
|
||||||
@@ -43,6 +44,11 @@ export class ShopSettingsController {
|
|||||||
return this.shopSettingsService.updateNotifications(dto);
|
return this.shopSettingsService.updateNotifications(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch('/blog-enabled')
|
||||||
|
updateBlogEnabled(@Body() dto: UpdateBlogEnabledDto) {
|
||||||
|
return this.shopSettingsService.updateBlogEnabled(dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('/simplex-connect')
|
@Post('/simplex-connect')
|
||||||
connectSimplexNotifications(@Body() dto: ConnectSimplexNotificationsDto) {
|
connectSimplexNotifications(@Body() dto: ConnectSimplexNotificationsDto) {
|
||||||
return this.shopSettingsService.connectSimplexNotifications(dto.simplexNotificationLink);
|
return this.shopSettingsService.connectSimplexNotifications(dto.simplexNotificationLink);
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsBoolean, IsNotEmpty } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateBlogEnabledDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsBoolean()
|
||||||
|
blogEnabled: boolean;
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ describe('ShopSettingsService', () => {
|
|||||||
notifyOnNewOrder: false,
|
notifyOnNewOrder: false,
|
||||||
notifyOnOrderMessage: false,
|
notifyOnOrderMessage: false,
|
||||||
simplexNotificationContactId: null,
|
simplexNotificationContactId: null,
|
||||||
|
blogEnabled: false,
|
||||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
updatedAt: new Date('2026-01-01T00:00:00.000Z')
|
updatedAt: new Date('2026-01-01T00:00:00.000Z')
|
||||||
} as ShopSettings;
|
} as ShopSettings;
|
||||||
@@ -67,10 +68,27 @@ describe('ShopSettingsService', () => {
|
|||||||
logoUrl: '/uploads/public/shop/logo.png',
|
logoUrl: '/uploads/public/shop/logo.png',
|
||||||
faviconUrl: null,
|
faviconUrl: null,
|
||||||
simplexLink: 'https://simplex.example',
|
simplexLink: 'https://simplex.example',
|
||||||
shippingNote: 'Ships in 3 days'
|
shippingNote: 'Ships in 3 days',
|
||||||
|
blogEnabled: false
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('updates blog enabled flag', async () => {
|
||||||
|
await service.updateBlogEnabled({ blogEnabled: true });
|
||||||
|
|
||||||
|
expect(shopSettingsRepo.update).toHaveBeenCalledWith('settings-1', { blogEnabled: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes blog enabled in shop settings view', async () => {
|
||||||
|
shopSettingsRepo.find.mockResolvedValue([{ ...settings, blogEnabled: true }]);
|
||||||
|
|
||||||
|
await expect(service.getView()).resolves.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
blogEnabled: true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('trims simplex links on update', async () => {
|
it('trims simplex links on update', async () => {
|
||||||
await service.updateSimplexLink({ simplexLink: ' https://simplex.example/new ' });
|
await service.updateSimplexLink({ simplexLink: ' https://simplex.example/new ' });
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getShopBrandingPublicUrl, resolveShopBrandingPath } from '../../../conf
|
|||||||
import { Config } from '../../../types/Config';
|
import { Config } from '../../../types/Config';
|
||||||
import { removeFileFromDisk } from '../../../utils/removeFileFromDisk';
|
import { removeFileFromDisk } from '../../../utils/removeFileFromDisk';
|
||||||
import { SimplexChatClient } from '../../simplex/services/SimplexChatClient';
|
import { SimplexChatClient } from '../../simplex/services/SimplexChatClient';
|
||||||
|
import { UpdateBlogEnabledDto } from '../dto/UpdateBlogEnabledDto';
|
||||||
import { UpdateNotificationsDto } from '../dto/UpdateNotificationsDto';
|
import { UpdateNotificationsDto } from '../dto/UpdateNotificationsDto';
|
||||||
import { UpdateShippingNoteDto } from '../dto/UpdateShippingNoteDto';
|
import { UpdateShippingNoteDto } from '../dto/UpdateShippingNoteDto';
|
||||||
import { UpdateSimplexLinkDto } from '../dto/UpdateSimplexLinkDto';
|
import { UpdateSimplexLinkDto } from '../dto/UpdateSimplexLinkDto';
|
||||||
@@ -43,7 +44,8 @@ export class ShopSettingsService {
|
|||||||
logoUrl,
|
logoUrl,
|
||||||
faviconUrl,
|
faviconUrl,
|
||||||
simplexLink: settings.simplexLink,
|
simplexLink: settings.simplexLink,
|
||||||
shippingNote: settings.shippingNote
|
shippingNote: settings.shippingNote,
|
||||||
|
blogEnabled: settings.blogEnabled
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +95,14 @@ export class ShopSettingsService {
|
|||||||
return this.getView();
|
return this.getView();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateBlogEnabled({ blogEnabled }: UpdateBlogEnabledDto): Promise<ShopSettingsView> {
|
||||||
|
const settings = await this.findSettings();
|
||||||
|
|
||||||
|
await this.shopSettingsRepo.update(settings.id, { blogEnabled });
|
||||||
|
|
||||||
|
return this.getView();
|
||||||
|
}
|
||||||
|
|
||||||
async uploadLogo(filename: string): Promise<ShopSettingsView> {
|
async uploadLogo(filename: string): Promise<ShopSettingsView> {
|
||||||
const settings = await this.findSettings();
|
const settings = await this.findSettings();
|
||||||
|
|
||||||
@@ -191,6 +201,7 @@ export class ShopSettingsService {
|
|||||||
notifyOnNewOrder,
|
notifyOnNewOrder,
|
||||||
notifyOnOrderMessage,
|
notifyOnOrderMessage,
|
||||||
simplexNotificationContactId,
|
simplexNotificationContactId,
|
||||||
|
blogEnabled,
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt
|
updatedAt
|
||||||
}: ShopSettings): ShopSettingsView {
|
}: ShopSettings): ShopSettingsView {
|
||||||
@@ -220,6 +231,7 @@ export class ShopSettingsService {
|
|||||||
notifyOnNewOrder,
|
notifyOnNewOrder,
|
||||||
notifyOnOrderMessage,
|
notifyOnOrderMessage,
|
||||||
simplexNotificationConnected: simplexNotificationContactId !== null,
|
simplexNotificationConnected: simplexNotificationContactId !== null,
|
||||||
|
blogEnabled,
|
||||||
isSetupComplete,
|
isSetupComplete,
|
||||||
setupChecklist,
|
setupChecklist,
|
||||||
createdAt,
|
createdAt,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface ShopSettingsView {
|
|||||||
notifyOnNewOrder: boolean;
|
notifyOnNewOrder: boolean;
|
||||||
notifyOnOrderMessage: boolean;
|
notifyOnOrderMessage: boolean;
|
||||||
simplexNotificationConnected: boolean;
|
simplexNotificationConnected: boolean;
|
||||||
|
blogEnabled: boolean;
|
||||||
isSetupComplete: boolean;
|
isSetupComplete: boolean;
|
||||||
setupChecklist: SetupChecklist;
|
setupChecklist: SetupChecklist;
|
||||||
createdAt: Date | null;
|
createdAt: Date | null;
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ export interface StorefrontBranding {
|
|||||||
faviconUrl: string | null;
|
faviconUrl: string | null;
|
||||||
simplexLink: string | null;
|
simplexLink: string | null;
|
||||||
shippingNote: string | null;
|
shippingNote: string | null;
|
||||||
|
blogEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { BlogModule } from '../blog/BlogModule';
|
||||||
|
import { ShopSettingsModule } from '../shopSettings/ShopSettingsModule';
|
||||||
|
import { StorefrontCoreModule } from '../storefrontCore/StorefrontCoreModule';
|
||||||
|
import { StorefrontBlogController } from './controllers/StorefrontBlogController';
|
||||||
|
import { StorefrontBlogPostsService } from './services/StorefrontBlogPostsService';
|
||||||
|
import { StorefrontBlogViewService } from './services/StorefrontBlogViewService';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [BlogModule, ShopSettingsModule, StorefrontCoreModule],
|
||||||
|
controllers: [StorefrontBlogController],
|
||||||
|
providers: [StorefrontBlogPostsService, StorefrontBlogViewService]
|
||||||
|
})
|
||||||
|
export class StorefrontBlogModule {}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Controller, Get, NotFoundException, Param, ParseUUIDPipe, Req, Res, UseFilters } from '@nestjs/common';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { ShopSettingsService } from '../../shopSettings/services/ShopSettingsService';
|
||||||
|
import { StorefrontExceptionFilter } from '../../storefrontCore/filters/StorefrontExceptionFilter';
|
||||||
|
import { StorefrontShopViewService } from '../../storefrontCore/services/StorefrontShopViewService';
|
||||||
|
import { StorefrontBlogPostsService } from '../services/StorefrontBlogPostsService';
|
||||||
|
import { StorefrontBlogViewService } from '../services/StorefrontBlogViewService';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
@UseFilters(StorefrontExceptionFilter)
|
||||||
|
export class StorefrontBlogController {
|
||||||
|
constructor(
|
||||||
|
private readonly blogPostsService: StorefrontBlogPostsService,
|
||||||
|
private readonly blogViewService: StorefrontBlogViewService,
|
||||||
|
private readonly shopViewService: StorefrontShopViewService,
|
||||||
|
private readonly shopSettingsService: ShopSettingsService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('shop/blog')
|
||||||
|
async blogIndex(@Req() req: Request, @Res() res: Response) {
|
||||||
|
const { blogEnabled } = await this.shopSettingsService.getStorefrontBranding();
|
||||||
|
|
||||||
|
if (!blogEnabled) {
|
||||||
|
throw new NotFoundException('We could not find that page.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [posts, shopLocals] = await Promise.all([
|
||||||
|
this.blogPostsService.listLivePosts(),
|
||||||
|
this.shopViewService.buildShopRenderLocals(req, res, {
|
||||||
|
title: 'Blog',
|
||||||
|
metaDescription: 'News and updates from {shopName}.'
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const postsView = this.blogViewService.toPostsIndexView(posts);
|
||||||
|
|
||||||
|
return res.render('blog-index', {
|
||||||
|
posts: postsView,
|
||||||
|
...shopLocals
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('shop/blog/:id')
|
||||||
|
async blogDetail(@Param('id', ParseUUIDPipe) id: string, @Req() req: Request, @Res() res: Response) {
|
||||||
|
const { blogEnabled } = await this.shopSettingsService.getStorefrontBranding();
|
||||||
|
|
||||||
|
if (!blogEnabled) {
|
||||||
|
throw new NotFoundException('We could not find that page.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = await this.blogPostsService.getLivePost(id);
|
||||||
|
|
||||||
|
const postView = this.blogViewService.toPostDetailView(post);
|
||||||
|
|
||||||
|
const shopLocals = await this.shopViewService.buildShopRenderLocals(req, res, {
|
||||||
|
title: post.title,
|
||||||
|
metaDescription: `Read ${post.title} at {shopName}.`,
|
||||||
|
ogType: 'article',
|
||||||
|
ogImageUrl: postView.bannerUrl ?? postView.thumbnailUrl
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.render('blog-detail', {
|
||||||
|
post: postView,
|
||||||
|
backHref: '/shop/blog',
|
||||||
|
backLabel: 'Blog',
|
||||||
|
...shopLocals
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { NotFoundException } from '@nestjs/common';
|
||||||
|
import type { Repository } from 'typeorm';
|
||||||
|
import { BlogPost } from '../../blog/entities/BlogPost';
|
||||||
|
import { StorefrontBlogPostsService } from './StorefrontBlogPostsService';
|
||||||
|
|
||||||
|
describe('StorefrontBlogPostsService', () => {
|
||||||
|
let service: StorefrontBlogPostsService;
|
||||||
|
let blogPostRepo: {
|
||||||
|
find: jest.Mock;
|
||||||
|
findOne: jest.Mock;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
blogPostRepo = {
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
findOne: jest.fn().mockResolvedValue(null)
|
||||||
|
};
|
||||||
|
|
||||||
|
service = new StorefrontBlogPostsService(blogPostRepo as unknown as Repository<BlogPost>);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists live posts ordered by createdAt descending', async () => {
|
||||||
|
await service.listLivePosts();
|
||||||
|
|
||||||
|
expect(blogPostRepo.find).toHaveBeenCalledWith({
|
||||||
|
where: { isDraft: false },
|
||||||
|
order: { createdAt: 'DESC' }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a live post by id', async () => {
|
||||||
|
const post = { id: 'post-1', title: 'Hello', isDraft: false } as BlogPost;
|
||||||
|
|
||||||
|
blogPostRepo.findOne.mockResolvedValue(post);
|
||||||
|
|
||||||
|
await expect(service.getLivePost('post-1')).resolves.toBe(post);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when a draft or missing post is requested', async () => {
|
||||||
|
await expect(service.getLivePost('missing-id')).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { BlogPost } from '../../blog/entities/BlogPost';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StorefrontBlogPostsService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(BlogPost)
|
||||||
|
private readonly blogPostRepo: Repository<BlogPost>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async listLivePosts(): Promise<BlogPost[]> {
|
||||||
|
return this.blogPostRepo.find({
|
||||||
|
where: { isDraft: false },
|
||||||
|
order: { createdAt: 'DESC' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLivePost(id: string): Promise<BlogPost> {
|
||||||
|
const post = await this.blogPostRepo.findOne({
|
||||||
|
where: { id, isDraft: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
throw new NotFoundException('We could not find that blog post.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return post;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { BlogPost } from '../../blog/entities/BlogPost';
|
||||||
|
import { StorefrontBlogViewService } from './StorefrontBlogViewService';
|
||||||
|
|
||||||
|
describe('StorefrontBlogViewService', () => {
|
||||||
|
let service: StorefrontBlogViewService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new StorefrontBlogViewService();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps posts to card views with image urls', () => {
|
||||||
|
const views = service.toPostsIndexView([
|
||||||
|
{
|
||||||
|
id: 'post-1',
|
||||||
|
title: 'Hello',
|
||||||
|
thumbnailStorageKey: 'thumb.webp',
|
||||||
|
bannerStorageKey: null,
|
||||||
|
contentHtml: '',
|
||||||
|
isDraft: false,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
} as BlogPost
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(views).toEqual([
|
||||||
|
{
|
||||||
|
id: 'post-1',
|
||||||
|
title: 'Hello',
|
||||||
|
thumbnailUrl: '/uploads/public/blog/images/thumb.webp',
|
||||||
|
detailHref: '/shop/blog/post-1'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { getBlogImagePublicUrl } from '../../../config/uploadPaths';
|
||||||
|
import { BlogPost } from '../../blog/entities/BlogPost';
|
||||||
|
import type { StorefrontBlogPostCardView } from '../types/StorefrontBlogPostCardView';
|
||||||
|
import type { StorefrontBlogPostDetailView } from '../types/StorefrontBlogPostDetailView';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StorefrontBlogViewService {
|
||||||
|
toPostsIndexView(posts: BlogPost[]): StorefrontBlogPostCardView[] {
|
||||||
|
return posts.map(post => ({
|
||||||
|
id: post.id,
|
||||||
|
title: post.title,
|
||||||
|
thumbnailUrl: post.thumbnailStorageKey ? getBlogImagePublicUrl(post.thumbnailStorageKey) : null,
|
||||||
|
detailHref: `/shop/blog/${post.id}`
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
toPostDetailView(post: BlogPost): StorefrontBlogPostDetailView {
|
||||||
|
return {
|
||||||
|
id: post.id,
|
||||||
|
title: post.title,
|
||||||
|
bannerUrl: post.bannerStorageKey ? getBlogImagePublicUrl(post.bannerStorageKey) : null,
|
||||||
|
thumbnailUrl: post.thumbnailStorageKey ? getBlogImagePublicUrl(post.thumbnailStorageKey) : null,
|
||||||
|
contentHtml: post.contentHtml
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export interface StorefrontBlogPostCardView {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
thumbnailUrl: string | null;
|
||||||
|
detailHref: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export interface StorefrontBlogPostDetailView {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
bannerUrl: string | null;
|
||||||
|
thumbnailUrl: string | null;
|
||||||
|
contentHtml: string;
|
||||||
|
}
|
||||||
@@ -49,6 +49,8 @@
|
|||||||
--sf-split-sidebar: minmax(220px, 320px);
|
--sf-split-sidebar: minmax(220px, 320px);
|
||||||
--sf-product-card-min: 11rem;
|
--sf-product-card-min: 11rem;
|
||||||
--sf-product-card-max: 22rem;
|
--sf-product-card-max: 22rem;
|
||||||
|
--sf-blog-card-min: 17rem;
|
||||||
|
--sf-blog-card-max: 28rem;
|
||||||
--sf-sticky-header-scroll-padding: 9rem;
|
--sf-sticky-header-scroll-padding: 9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1598,3 +1600,75 @@ a:hover {
|
|||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
margin: 0 0 var(--sf-space-2);
|
margin: 0 0 var(--sf-space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sf-blog-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--sf-space-3);
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 36rem) {
|
||||||
|
.sf-blog-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--sf-blog-card-min)), 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-blog-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: var(--sf-blog-card-max);
|
||||||
|
min-width: 0;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-blog-card__link {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sf-space-2);
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-blog-card__link:hover .sf-blog-card__title {
|
||||||
|
color: var(--sf-link-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-blog-card__media {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
background: var(--sf-surface-muted);
|
||||||
|
border-radius: var(--sf-radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-blog-card__image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-blog-card__title {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-blog-detail__banner {
|
||||||
|
margin-bottom: var(--sf-space-4);
|
||||||
|
background: var(--sf-surface-muted);
|
||||||
|
border-radius: var(--sf-radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-blog-detail__banner-image {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 24rem;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-blog-detail__title {
|
||||||
|
margin-bottom: var(--sf-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-blog-detail__content {
|
||||||
|
max-width: 42rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ describe('StorefrontShopViewService', () => {
|
|||||||
logoUrl: null,
|
logoUrl: null,
|
||||||
faviconUrl: null,
|
faviconUrl: null,
|
||||||
simplexLink: null,
|
simplexLink: null,
|
||||||
shippingNote: null
|
shippingNote: null,
|
||||||
|
blogEnabled: false
|
||||||
},
|
},
|
||||||
shopSettings = {
|
shopSettings = {
|
||||||
shopName: 'Demo Shop',
|
shopName: 'Demo Shop',
|
||||||
@@ -136,7 +137,8 @@ describe('StorefrontShopViewService', () => {
|
|||||||
logoUrl: '/uploads/logo.png',
|
logoUrl: '/uploads/logo.png',
|
||||||
faviconUrl: '/uploads/favicon.ico',
|
faviconUrl: '/uploads/favicon.ico',
|
||||||
simplexLink: 'https://simplex.example',
|
simplexLink: 'https://simplex.example',
|
||||||
shippingNote: 'Ships in 3 days'
|
shippingNote: 'Ships in 3 days',
|
||||||
|
blogEnabled: true
|
||||||
},
|
},
|
||||||
fiatPerXmr: 200,
|
fiatPerXmr: 200,
|
||||||
fiatPerBtc: 80_000
|
fiatPerBtc: 80_000
|
||||||
@@ -157,6 +159,7 @@ describe('StorefrontShopViewService', () => {
|
|||||||
faviconUrl: '/uploads/favicon.ico',
|
faviconUrl: '/uploads/favicon.ico',
|
||||||
simplexLink: 'https://simplex.example',
|
simplexLink: 'https://simplex.example',
|
||||||
shippingNote: 'Ships in 3 days',
|
shippingNote: 'Ships in 3 days',
|
||||||
|
blogEnabled: true,
|
||||||
themePreference: 'dark'
|
themePreference: 'dark'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export class StorefrontShopViewService {
|
|||||||
|
|
||||||
const cryptoRates = this.buildCryptoRates(enabledPaymentMethods);
|
const cryptoRates = this.buildCryptoRates(enabledPaymentMethods);
|
||||||
|
|
||||||
const { logoUrl, faviconUrl, simplexLink, shippingNote } =
|
const { logoUrl, faviconUrl, simplexLink, shippingNote, blogEnabled } =
|
||||||
await this.shopSettingsService.getStorefrontBranding();
|
await this.shopSettingsService.getStorefrontBranding();
|
||||||
|
|
||||||
const authorizedOrders = this.buildAuthorizedOrderNavItems(req, res);
|
const authorizedOrders = this.buildAuthorizedOrderNavItems(req, res);
|
||||||
@@ -75,6 +75,7 @@ export class StorefrontShopViewService {
|
|||||||
faviconUrl,
|
faviconUrl,
|
||||||
simplexLink,
|
simplexLink,
|
||||||
shippingNote,
|
shippingNote,
|
||||||
|
blogEnabled,
|
||||||
themePreference,
|
themePreference,
|
||||||
pageMeta: this.buildPageMeta(siteOrigin, shopName, shopFiatCurrency, currentPath, page)
|
pageMeta: this.buildPageMeta(siteOrigin, shopName, shopFiatCurrency, currentPath, page)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export type ShopNavKey = 'shop' | 'cart' | 'checkout' | 'check-order';
|
export type ShopNavKey = 'shop' | 'blog' | 'cart' | 'checkout' | 'check-order';
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export type ShopRenderLocals = {
|
|||||||
faviconUrl: string | null;
|
faviconUrl: string | null;
|
||||||
simplexLink: string | null;
|
simplexLink: string | null;
|
||||||
shippingNote: string | null;
|
shippingNote: string | null;
|
||||||
|
blogEnabled: boolean;
|
||||||
themePreference: StorefrontThemePreference;
|
themePreference: StorefrontThemePreference;
|
||||||
pageMeta: StorefrontPageMeta;
|
pageMeta: StorefrontPageMeta;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type StorefrontShopViewServiceOverrides = {
|
|||||||
faviconUrl: string | null;
|
faviconUrl: string | null;
|
||||||
simplexLink: string | null;
|
simplexLink: string | null;
|
||||||
shippingNote: string | null;
|
shippingNote: string | null;
|
||||||
|
blogEnabled: boolean;
|
||||||
};
|
};
|
||||||
shopSettings?: { shopName: string; shopFiatCurrency: string; enabledPaymentMethods?: PaymentMethod[] };
|
shopSettings?: { shopName: string; shopFiatCurrency: string; enabledPaymentMethods?: PaymentMethod[] };
|
||||||
fiatPerXmr?: number;
|
fiatPerXmr?: number;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const STOREFRONT_PARTIALS = [
|
|||||||
{ name: 'html-theme-attributes', file: 'html-theme-attributes.hbs' },
|
{ name: 'html-theme-attributes', file: 'html-theme-attributes.hbs' },
|
||||||
{ name: 'page-back-link', file: 'page-back-link.hbs' },
|
{ name: 'page-back-link', file: 'page-back-link.hbs' },
|
||||||
{ name: 'product-card', file: 'product-card.hbs' },
|
{ name: 'product-card', file: 'product-card.hbs' },
|
||||||
|
{ name: 'blog-card', file: 'blog-card.hbs' },
|
||||||
{ name: 'order-data-retention-notice', file: 'order-data-retention-notice.hbs' },
|
{ name: 'order-data-retention-notice', file: 'order-data-retention-notice.hbs' },
|
||||||
{ name: 'category-nav', file: 'category-nav.hbs' },
|
{ name: 'category-nav', file: 'category-nav.hbs' },
|
||||||
{ name: 'product-delivery-note', file: 'product-delivery-note.hbs' },
|
{ name: 'product-delivery-note', file: 'product-delivery-note.hbs' },
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ describe('resolveShopNavActive', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('highlights blog pages', () => {
|
||||||
|
expect(resolveShopNavActive('/shop/blog')).toEqual({ activeShopNav: 'blog', activeOrderId: null });
|
||||||
|
expect(resolveShopNavActive('/shop/blog/post-1')).toEqual({ activeShopNav: 'blog', activeOrderId: null });
|
||||||
|
});
|
||||||
|
|
||||||
it('does not highlight nav items on unrelated pages', () => {
|
it('does not highlight nav items on unrelated pages', () => {
|
||||||
expect(resolveShopNavActive('/shop/theme')).toEqual({ activeShopNav: null, activeOrderId: null });
|
expect(resolveShopNavActive('/shop/theme')).toEqual({ activeShopNav: null, activeOrderId: null });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ export const resolveShopNavActive = (path: string): ShopNavActive => {
|
|||||||
return { activeShopNav: null, activeOrderId: third };
|
return { activeShopNav: null, activeOrderId: third };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (first === 'shop' && second === 'blog') {
|
||||||
|
return { activeShopNav: 'blog', activeOrderId: null };
|
||||||
|
}
|
||||||
|
|
||||||
if (first === 'shop' && (second === 'categories' || second === 'products')) {
|
if (first === 'shop' && (second === 'categories' || second === 'products')) {
|
||||||
return { activeShopNav: 'shop', activeOrderId: null };
|
return { activeShopNav: 'shop', activeOrderId: null };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{{> page-back-link href=backHref label=backLabel}}
|
||||||
|
|
||||||
|
{{#if post.bannerUrl}}
|
||||||
|
<div class='sf-blog-detail__banner'>
|
||||||
|
{{> storefront-image
|
||||||
|
class='sf-blog-detail__banner-image'
|
||||||
|
src=post.bannerUrl
|
||||||
|
width='1600'
|
||||||
|
height='600'
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<h1 class='sf-page-title sf-blog-detail__title'>{{post.title}}</h1>
|
||||||
|
|
||||||
|
<div class='sf-prose sf-blog-detail__content'>{{{post.contentHtml}}}</div>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<h1 class='sf-page-title'>{{title}}</h1>
|
||||||
|
|
||||||
|
{{#if posts.length}}
|
||||||
|
<ul class='sf-list-reset sf-blog-grid'>
|
||||||
|
{{#each posts}}
|
||||||
|
{{> blog-card}}
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
{{else}}
|
||||||
|
<p class='sf-text-muted'>No posts yet.</p>
|
||||||
|
{{/if}}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<li class='sf-card sf-blog-card'>
|
||||||
|
<a class='sf-blog-card__link' href='{{detailHref}}'>
|
||||||
|
{{#if thumbnailUrl}}
|
||||||
|
<div class='sf-blog-card__media'>
|
||||||
|
{{> storefront-image
|
||||||
|
class='sf-blog-card__image'
|
||||||
|
src=thumbnailUrl
|
||||||
|
width='1'
|
||||||
|
height='1'
|
||||||
|
loading='lazy'
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
<strong class='sf-blog-card__title'>{{title}}</strong>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
@@ -5,7 +5,6 @@
|
|||||||
{{#if thumbnailUrl}}
|
{{#if thumbnailUrl}}
|
||||||
{{> storefront-image
|
{{> storefront-image
|
||||||
src=thumbnailUrl
|
src=thumbnailUrl
|
||||||
alt=''
|
|
||||||
width='1'
|
width='1'
|
||||||
height='1'
|
height='1'
|
||||||
loading='lazy'
|
loading='lazy'
|
||||||
|
|||||||
@@ -86,7 +86,6 @@
|
|||||||
{{> storefront-image
|
{{> storefront-image
|
||||||
class='sf-crypto-icon'
|
class='sf-crypto-icon'
|
||||||
src=iconUrl
|
src=iconUrl
|
||||||
alt=''
|
|
||||||
width='20'
|
width='20'
|
||||||
height='20'
|
height='20'
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
{{#if thumbnailUrl}}
|
{{#if thumbnailUrl}}
|
||||||
{{> storefront-image
|
{{> storefront-image
|
||||||
src=thumbnailUrl
|
src=thumbnailUrl
|
||||||
alt=''
|
|
||||||
width='1'
|
width='1'
|
||||||
height='1'
|
height='1'
|
||||||
loading='lazy'
|
loading='lazy'
|
||||||
@@ -19,7 +18,6 @@
|
|||||||
{{#if thumbnailUrl}}
|
{{#if thumbnailUrl}}
|
||||||
{{> storefront-image
|
{{> storefront-image
|
||||||
src=thumbnailUrl
|
src=thumbnailUrl
|
||||||
alt=''
|
|
||||||
width='1'
|
width='1'
|
||||||
height='1'
|
height='1'
|
||||||
loading='lazy'
|
loading='lazy'
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
{{#if thumbnailUrl}}
|
{{#if thumbnailUrl}}
|
||||||
{{> storefront-image
|
{{> storefront-image
|
||||||
src=thumbnailUrl
|
src=thumbnailUrl
|
||||||
alt=''
|
|
||||||
width='1'
|
width='1'
|
||||||
height='1'
|
height='1'
|
||||||
loading='lazy'
|
loading='lazy'
|
||||||
@@ -19,7 +18,6 @@
|
|||||||
{{#if thumbnailUrl}}
|
{{#if thumbnailUrl}}
|
||||||
{{> storefront-image
|
{{> storefront-image
|
||||||
src=thumbnailUrl
|
src=thumbnailUrl
|
||||||
alt=''
|
|
||||||
width='1'
|
width='1'
|
||||||
height='1'
|
height='1'
|
||||||
loading='lazy'
|
loading='lazy'
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
{{> storefront-image
|
{{> storefront-image
|
||||||
class='sf-product-card__image'
|
class='sf-product-card__image'
|
||||||
src=thumbnailUrl
|
src=thumbnailUrl
|
||||||
alt=''
|
|
||||||
width='1'
|
width='1'
|
||||||
height='1'
|
height='1'
|
||||||
loading='lazy'
|
loading='lazy'
|
||||||
|
|||||||
@@ -17,6 +17,9 @@
|
|||||||
|
|
||||||
<nav class='sf-nav' aria-label='Shop navigation'>
|
<nav class='sf-nav' aria-label='Shop navigation'>
|
||||||
{{> nav-link active=shopNavActive.activeShopNav key='shop' href='/' label='Shop'}}
|
{{> nav-link active=shopNavActive.activeShopNav key='shop' href='/' label='Shop'}}
|
||||||
|
{{#if blogEnabled}}
|
||||||
|
{{> nav-link active=shopNavActive.activeShopNav key='blog' href='/shop/blog' label='Blog'}}
|
||||||
|
{{/if}}
|
||||||
{{#if hasActiveCheckout}}
|
{{#if hasActiveCheckout}}
|
||||||
{{> nav-link active=shopNavActive.activeShopNav key='checkout' href='/shop/checkout' label='Checkout' count=cartTotalQty}}
|
{{> nav-link active=shopNavActive.activeShopNav key='checkout' href='/shop/checkout' label='Checkout' count=cartTotalQty}}
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@
|
|||||||
>
|
>
|
||||||
{{> storefront-image
|
{{> storefront-image
|
||||||
src=url
|
src=url
|
||||||
alt=''
|
|
||||||
width='1'
|
width='1'
|
||||||
height='1'
|
height='1'
|
||||||
loading='lazy'
|
loading='lazy'
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
{{#if thumbnailUrl}}
|
{{#if thumbnailUrl}}
|
||||||
{{> storefront-image
|
{{> storefront-image
|
||||||
src=thumbnailUrl
|
src=thumbnailUrl
|
||||||
alt=''
|
|
||||||
width='1'
|
width='1'
|
||||||
height='1'
|
height='1'
|
||||||
loading='lazy'
|
loading='lazy'
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
<el-menu-item index="/products">Products</el-menu-item>
|
<el-menu-item index="/products">Products</el-menu-item>
|
||||||
<el-menu-item index="/categories">Categories</el-menu-item>
|
<el-menu-item index="/categories">Categories</el-menu-item>
|
||||||
<el-menu-item index="/discount-codes">Discount codes</el-menu-item>
|
<el-menu-item index="/discount-codes">Discount codes</el-menu-item>
|
||||||
|
<el-menu-item index="/blog">Blog</el-menu-item>
|
||||||
<el-menu-item index="/orders">Orders</el-menu-item>
|
<el-menu-item index="/orders">Orders</el-menu-item>
|
||||||
<el-menu-item index="/wallet">Wallet</el-menu-item>
|
<el-menu-item index="/wallet">Wallet</el-menu-item>
|
||||||
<el-menu-item index="/settings">Settings</el-menu-item>
|
<el-menu-item index="/settings">Settings</el-menu-item>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
>
|
>
|
||||||
{{ row.productTitle }}
|
{{ getProductTitle(row.productTitle) }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -113,6 +113,7 @@ import type { OrderExtended } from '@/types/order/OrderExtended';
|
|||||||
import { DeliveryMode } from '@/types/product/DeliveryMode';
|
import { DeliveryMode } from '@/types/product/DeliveryMode';
|
||||||
import { formatDeliveryMode } from '@/utils/product/formatDeliveryMode';
|
import { formatDeliveryMode } from '@/utils/product/formatDeliveryMode';
|
||||||
import { formatFiatPrice } from '@/utils/formatFiatPrice';
|
import { formatFiatPrice } from '@/utils/formatFiatPrice';
|
||||||
|
import { getProductTitle } from '@/utils/product/getProductTitle';
|
||||||
import { resolveAxiosErrorMessage } from '@/utils/resolveAxiosErrorMessage';
|
import { resolveAxiosErrorMessage } from '@/utils/resolveAxiosErrorMessage';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -143,7 +144,7 @@ const openAutoDelivery = (line: OrderLine): void => {
|
|||||||
const markAsFulfilled = async (line: OrderLine): Promise<void> => {
|
const markAsFulfilled = async (line: OrderLine): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
`Mark "${line.productTitle}" (${line.variantTitle}) as fulfilled? This cannot be undone.`,
|
`Mark "${getProductTitle(line.productTitle)}" (${line.variantTitle}) as fulfilled? This cannot be undone.`,
|
||||||
'Mark as fulfilled',
|
'Mark as fulfilled',
|
||||||
{
|
{
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ import type { OrderLine } from '@/types/order/OrderLine';
|
|||||||
import type { OrderLineAutoFulfillmentItem } from '@/types/order/OrderLineAutoFulfillmentItem';
|
import type { OrderLineAutoFulfillmentItem } from '@/types/order/OrderLineAutoFulfillmentItem';
|
||||||
import type { OrderLineAutoFulfillmentItemAttachment } from '@/types/order/OrderLineAutoFulfillmentItemAttachment';
|
import type { OrderLineAutoFulfillmentItemAttachment } from '@/types/order/OrderLineAutoFulfillmentItemAttachment';
|
||||||
import { formatFileSize } from '@/utils/formatFileSize';
|
import { formatFileSize } from '@/utils/formatFileSize';
|
||||||
|
import { getProductTitle } from '@/utils/product/getProductTitle';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
@@ -99,7 +100,7 @@ const lineSummary = computed((): string => {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${line.productTitle} · ${line.variantTitle}`;
|
return `${getProductTitle(line.productTitle)} · ${line.variantTitle}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const downloadAttachment = async (
|
const downloadAttachment = async (
|
||||||
|
|||||||
@@ -317,7 +317,7 @@ const stockRemoving = ref(false);
|
|||||||
const stockListLoading = ref(false);
|
const stockListLoading = ref(false);
|
||||||
|
|
||||||
const stockContentRules: FormRules = {
|
const stockContentRules: FormRules = {
|
||||||
content: [{ required: true, message: 'Required', trigger: 'blur' }]
|
content: [{ required: true, message: 'Content is required', trigger: 'blur' }]
|
||||||
};
|
};
|
||||||
|
|
||||||
const stockEditVisible = ref(false);
|
const stockEditVisible = ref(false);
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ export const config = {
|
|||||||
maxFileBytes: parseInt(env('VITE_PRODUCT_THUMB_MAX_FILE_BYTES'), 10)
|
maxFileBytes: parseInt(env('VITE_PRODUCT_THUMB_MAX_FILE_BYTES'), 10)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
blogImage: {
|
||||||
|
accept: env('VITE_BLOG_IMAGE_ALLOWED_MIMES'),
|
||||||
|
maxFileBytes: parseInt(env('VITE_BLOG_IMAGE_MAX_FILE_BYTES'), 10)
|
||||||
|
},
|
||||||
|
|
||||||
shopLogo: {
|
shopLogo: {
|
||||||
accept: env('VITE_SHOP_LOGO_ALLOWED_MIMES'),
|
accept: env('VITE_SHOP_LOGO_ALLOWED_MIMES'),
|
||||||
maxFileBytes: parseInt(env('VITE_SHOP_LOGO_MAX_FILE_BYTES'), 10)
|
maxFileBytes: parseInt(env('VITE_SHOP_LOGO_MAX_FILE_BYTES'), 10)
|
||||||
@@ -36,6 +41,7 @@ export const config = {
|
|||||||
|
|
||||||
validation: {
|
validation: {
|
||||||
productTitleMaxLength: parseInt(env('VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH'), 10),
|
productTitleMaxLength: parseInt(env('VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH'), 10),
|
||||||
|
blogPostTitleMaxLength: parseInt(env('VITE_VALIDATION_BLOG_POST_TITLE_MAX_LENGTH'), 10),
|
||||||
categoryNameMaxLength: parseInt(env('VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH'), 10),
|
categoryNameMaxLength: parseInt(env('VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH'), 10),
|
||||||
discountCodeMaxLength: parseInt(env('VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH'), 10),
|
discountCodeMaxLength: parseInt(env('VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH'), 10),
|
||||||
variantImagesMax: parseInt(env('VITE_VALIDATION_VARIANT_IMAGES_MAX'), 10),
|
variantImagesMax: parseInt(env('VITE_VALIDATION_VARIANT_IMAGES_MAX'), 10),
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export const ROUTE_NAMES = {
|
|||||||
Products: 'Products',
|
Products: 'Products',
|
||||||
Categories: 'Categories',
|
Categories: 'Categories',
|
||||||
DiscountCodes: 'DiscountCodes',
|
DiscountCodes: 'DiscountCodes',
|
||||||
|
BlogPosts: 'BlogPosts',
|
||||||
|
BlogPostDetail: 'BlogPostDetail',
|
||||||
ShopSettings: 'ShopSettings',
|
ShopSettings: 'ShopSettings',
|
||||||
Notifications: 'Notifications',
|
Notifications: 'Notifications',
|
||||||
MoneroWallet: 'MoneroWallet',
|
MoneroWallet: 'MoneroWallet',
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export const UNTITLED_BLOG_POST_TITLE = '(untitled)';
|
||||||
@@ -41,6 +41,18 @@ const router = createRouter({
|
|||||||
component: () => import('../views/CmsDiscountCodesView.vue'),
|
component: () => import('../views/CmsDiscountCodesView.vue'),
|
||||||
meta: { requiresAuth: true, title: 'CMS - Discount codes' }
|
meta: { requiresAuth: true, title: 'CMS - Discount codes' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/blog/:id',
|
||||||
|
name: ROUTE_NAMES.BlogPostDetail,
|
||||||
|
component: () => import('../views/CmsBlogPostDetailView.vue'),
|
||||||
|
meta: { requiresAuth: true, title: 'CMS - Blog post', activeMenu: '/blog' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/blog',
|
||||||
|
name: ROUTE_NAMES.BlogPosts,
|
||||||
|
component: () => import('../views/CmsBlogPostsView.vue'),
|
||||||
|
meta: { requiresAuth: true, title: 'CMS - Blog' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/orders/:id',
|
path: '/orders/:id',
|
||||||
name: ROUTE_NAMES.OrderDetail,
|
name: ROUTE_NAMES.OrderDetail,
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { ROUTE_NAMES } from '@/consts/routeNames';
|
||||||
|
import { api } from '@/plugins/axios';
|
||||||
|
import type { BlogPost } from '@/types/blog/BlogPost';
|
||||||
|
import type { UpdateBlogPostPayload } from '@/types/blog/UpdateBlogPostPayload';
|
||||||
|
import type { PaginatedResponse } from '@/types/PaginatedResponse';
|
||||||
|
import { getBlogPostTitle } from '@/utils/blog/getBlogPostTitle';
|
||||||
|
|
||||||
|
export const useBlogPostsStore = defineStore('blogPosts', () => {
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const blogPosts = ref<BlogPost[]>([]);
|
||||||
|
|
||||||
|
const currentBlogPostId = computed<string | null>(() => {
|
||||||
|
if (route.name !== ROUTE_NAMES.BlogPostDetail) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = route.params.id;
|
||||||
|
|
||||||
|
return typeof id === 'string' && id ? id : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentBlogPost = computed<BlogPost | null>(() => {
|
||||||
|
if (!currentBlogPostId.value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return blogPosts.value.find(post => post.id === currentBlogPostId.value) ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentBlogPostDisplayTitle = computed(() =>
|
||||||
|
currentBlogPost.value ? getBlogPostTitle(currentBlogPost.value.title) : null
|
||||||
|
);
|
||||||
|
|
||||||
|
const upsertBlogPost = (data: BlogPost): void => {
|
||||||
|
const idx = blogPosts.value.findIndex(post => post.id === data.id);
|
||||||
|
|
||||||
|
if (idx !== -1) {
|
||||||
|
blogPosts.value[idx] = data;
|
||||||
|
} else {
|
||||||
|
blogPosts.value.unshift(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchBlogPosts = async ({
|
||||||
|
page,
|
||||||
|
limit
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
}): Promise<PaginatedResponse<BlogPost>> => {
|
||||||
|
const { data } = await api.get<PaginatedResponse<BlogPost>>('/blog-posts', {
|
||||||
|
params: { page, limit }
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchBlogPostById = async (id: string): Promise<BlogPost> => {
|
||||||
|
const { data } = await api.get<BlogPost>(`/blog-posts/${id}`);
|
||||||
|
|
||||||
|
upsertBlogPost(data);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createDraftBlogPost = async (): Promise<BlogPost> => {
|
||||||
|
const { data } = await api.post<BlogPost>('/blog-posts');
|
||||||
|
|
||||||
|
upsertBlogPost(data);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateBlogPost = async (id: string, payload: UpdateBlogPostPayload): Promise<BlogPost> => {
|
||||||
|
const { data } = await api.patch<BlogPost>(`/blog-posts/${id}`, payload);
|
||||||
|
|
||||||
|
upsertBlogPost(data);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteBlogPost = async (id: string): Promise<void> => {
|
||||||
|
await api.delete(`/blog-posts/${id}`);
|
||||||
|
|
||||||
|
blogPosts.value = blogPosts.value.filter(post => post.id !== id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadThumbnail = async (id: string, file: File): Promise<BlogPost> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const { data } = await api.post<BlogPost>(`/blog-posts/${id}/thumbnail`, formData);
|
||||||
|
|
||||||
|
upsertBlogPost(data);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadBanner = async (id: string, file: File): Promise<BlogPost> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const { data } = await api.post<BlogPost>(`/blog-posts/${id}/banner`, formData);
|
||||||
|
|
||||||
|
upsertBlogPost(data);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
blogPosts,
|
||||||
|
currentBlogPost,
|
||||||
|
currentBlogPostDisplayTitle,
|
||||||
|
currentBlogPostId,
|
||||||
|
fetchBlogPosts,
|
||||||
|
fetchBlogPostById,
|
||||||
|
createDraftBlogPost,
|
||||||
|
updateBlogPost,
|
||||||
|
deleteBlogPost,
|
||||||
|
uploadThumbnail,
|
||||||
|
uploadBanner
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -86,14 +86,14 @@ export const useProductsStore = defineStore('products', () => {
|
|||||||
|
|
||||||
const createOrEditCurrentProductVariantFormRules = computed<FormRules>(() => ({
|
const createOrEditCurrentProductVariantFormRules = computed<FormRules>(() => ({
|
||||||
title: [
|
title: [
|
||||||
{ required: true, message: 'Required', trigger: 'blur' },
|
{ required: true, message: 'Title is required', trigger: 'blur' },
|
||||||
{
|
{
|
||||||
max: validationProductTitleMaxLength,
|
max: validationProductTitleMaxLength,
|
||||||
message: `At most ${validationProductTitleMaxLength} characters`,
|
message: `At most ${validationProductTitleMaxLength} characters`,
|
||||||
trigger: 'blur'
|
trigger: 'blur'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
price: [{ required: true, message: 'Required', trigger: 'change' }],
|
price: [{ required: true, message: 'Price is required', trigger: 'change' }],
|
||||||
...(currentProductIsManual.value
|
...(currentProductIsManual.value
|
||||||
? {
|
? {
|
||||||
stockQuantity: [
|
stockQuantity: [
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { ConnectSimplexNotificationsPayload } from '@/types/shopSettings/Co
|
|||||||
import type { ShopSettings } from '@/types/shopSettings/ShopSettings';
|
import type { ShopSettings } from '@/types/shopSettings/ShopSettings';
|
||||||
import type { UpdateNotificationsPayload } from '@/types/shopSettings/UpdateNotificationsPayload';
|
import type { UpdateNotificationsPayload } from '@/types/shopSettings/UpdateNotificationsPayload';
|
||||||
import type { UpdateShippingNotePayload } from '@/types/shopSettings/UpdateShippingNotePayload';
|
import type { UpdateShippingNotePayload } from '@/types/shopSettings/UpdateShippingNotePayload';
|
||||||
|
import type { UpdateBlogEnabledPayload } from '@/types/shopSettings/UpdateBlogEnabledPayload';
|
||||||
import type { UpdateSimplexLinkPayload } from '@/types/shopSettings/UpdateSimplexLinkPayload';
|
import type { UpdateSimplexLinkPayload } from '@/types/shopSettings/UpdateSimplexLinkPayload';
|
||||||
|
|
||||||
export const useShopSettingsStore = defineStore('shopSettings', () => {
|
export const useShopSettingsStore = defineStore('shopSettings', () => {
|
||||||
@@ -42,6 +43,14 @@ export const useShopSettingsStore = defineStore('shopSettings', () => {
|
|||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateBlogEnabled = async (payload: UpdateBlogEnabledPayload): Promise<ShopSettings> => {
|
||||||
|
const { data } = await api.patch<ShopSettings>('/shop-settings/blog-enabled', payload);
|
||||||
|
|
||||||
|
settings.value = data;
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
const connectSimplexNotifications = async (payload: ConnectSimplexNotificationsPayload): Promise<ShopSettings> => {
|
const connectSimplexNotifications = async (payload: ConnectSimplexNotificationsPayload): Promise<ShopSettings> => {
|
||||||
const { data } = await api.post<ShopSettings>('/shop-settings/simplex-connect', payload);
|
const { data } = await api.post<ShopSettings>('/shop-settings/simplex-connect', payload);
|
||||||
|
|
||||||
@@ -80,6 +89,7 @@ export const useShopSettingsStore = defineStore('shopSettings', () => {
|
|||||||
updateSimplexLink,
|
updateSimplexLink,
|
||||||
updateShippingNote,
|
updateShippingNote,
|
||||||
updateNotifications,
|
updateNotifications,
|
||||||
|
updateBlogEnabled,
|
||||||
connectSimplexNotifications,
|
connectSimplexNotifications,
|
||||||
uploadShopLogo,
|
uploadShopLogo,
|
||||||
uploadShopFavicon
|
uploadShopFavicon
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export interface BlogPost {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
thumbnailUrl: string | null;
|
||||||
|
bannerUrl: string | null;
|
||||||
|
contentHtml: string;
|
||||||
|
isDraft: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export interface UpdateBlogPostPayload {
|
||||||
|
title: string;
|
||||||
|
contentHtml: string;
|
||||||
|
isDraft: boolean;
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ export interface ShopSettings {
|
|||||||
notifyOnNewOrder: boolean;
|
notifyOnNewOrder: boolean;
|
||||||
notifyOnOrderMessage: boolean;
|
notifyOnOrderMessage: boolean;
|
||||||
simplexNotificationConnected: boolean;
|
simplexNotificationConnected: boolean;
|
||||||
|
blogEnabled: boolean;
|
||||||
isSetupComplete: boolean;
|
isSetupComplete: boolean;
|
||||||
setupChecklist: SetupChecklist;
|
setupChecklist: SetupChecklist;
|
||||||
createdAt: string | null;
|
createdAt: string | null;
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export interface UpdateBlogEnabledPayload {
|
||||||
|
blogEnabled: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { UNTITLED_BLOG_POST_TITLE } from '@/consts/untitledBlogPostTitle';
|
||||||
|
|
||||||
|
export const getBlogPostTitle = (title: string | undefined | null): string => title || UNTITLED_BLOG_POST_TITLE;
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center mb-16">
|
||||||
|
<el-button link type="primary" @click="router.push({ name: ROUTE_NAMES.BlogPosts })"> ← Blog </el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-loading="loading" class="detail-loading-host" element-loading-text="Loading blog post…">
|
||||||
|
<el-empty v-if="!loading && loadError" description="Blog post not found" />
|
||||||
|
|
||||||
|
<template v-if="!loading && !loadError && currentBlogPost">
|
||||||
|
<div v-loading="deleteSaving" element-loading-text="Deleting blog post…">
|
||||||
|
<h3 class="m-0 mb-16">Edit blog post</h3>
|
||||||
|
|
||||||
|
<div v-if="currentBlogPost?.isDraft" class="mb-16">
|
||||||
|
<el-alert
|
||||||
|
type="info"
|
||||||
|
title="Draft"
|
||||||
|
description="This post is hidden from the storefront until you publish it."
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card class="mb-24" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<span>Details</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-form ref="postFormRef" label-position="top" :model="postForm" :rules="postRules">
|
||||||
|
<el-form-item label="Live" class="mb-4">
|
||||||
|
<el-switch v-model="publishSwitch" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="Title" prop="title">
|
||||||
|
<el-input
|
||||||
|
v-model="postForm.title"
|
||||||
|
:maxlength="validationBlogPostTitleMaxLength"
|
||||||
|
show-word-limit
|
||||||
|
@input="postFormRef?.clearValidate('title')"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="Content">
|
||||||
|
<rich-text-editor
|
||||||
|
v-model="postForm.contentHtml"
|
||||||
|
placeholder="Write your post…"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-button type="primary" :loading="postSaving" @click="submitPost"> Save post </el-button>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card class="mb-24" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<span>Thumbnail</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="mb-16 flex flex-col items-start gap-4">
|
||||||
|
<el-text tag="p" size="small" class="secondary-text w-full">
|
||||||
|
{{ blogImageHint }}
|
||||||
|
</el-text>
|
||||||
|
|
||||||
|
<el-text tag="p" size="small" class="secondary-text w-full">
|
||||||
|
Shown on the blog index. Recommended: 16:9 aspect ratio.
|
||||||
|
</el-text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="thumbnailPreviewUrl" class="mb-16">
|
||||||
|
<div class="blog-image-preview-wrap blog-image-preview-wrap--thumb">
|
||||||
|
<img :src="thumbnailPreviewUrl" alt="" class="blog-image-preview" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-12 min-w-0">
|
||||||
|
<el-upload
|
||||||
|
ref="thumbnailUploadRef"
|
||||||
|
:show-file-list="false"
|
||||||
|
:auto-upload="false"
|
||||||
|
:limit="1"
|
||||||
|
:accept="blogImageAccept"
|
||||||
|
:disabled="thumbnailUploadSaving"
|
||||||
|
:on-change="onThumbnailChange"
|
||||||
|
:on-remove="onThumbnailRemove"
|
||||||
|
>
|
||||||
|
<el-button type="default">Select image</el-button>
|
||||||
|
</el-upload>
|
||||||
|
|
||||||
|
<template v-if="thumbnailPendingFile">
|
||||||
|
<el-text size="small" class="flex-ellipsis" :title="thumbnailPendingFile.name">
|
||||||
|
{{ thumbnailPendingFile.name }}
|
||||||
|
</el-text>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="thumbnailUploadSaving"
|
||||||
|
@click="submitThumbnailUpload"
|
||||||
|
>
|
||||||
|
Upload
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card class="mb-24" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<span>Banner</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="mb-16 flex flex-col items-start gap-4">
|
||||||
|
<el-text tag="p" size="small" class="secondary-text w-full">
|
||||||
|
{{ blogImageHint }}
|
||||||
|
</el-text>
|
||||||
|
|
||||||
|
<el-text tag="p" size="small" class="secondary-text w-full">
|
||||||
|
Shown at the top of the post detail page. Recommended: 8:3 aspect ratio (e.g.
|
||||||
|
1600×600).
|
||||||
|
</el-text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="bannerPreviewUrl" class="mb-16">
|
||||||
|
<div class="blog-image-preview-wrap blog-image-preview-wrap--banner">
|
||||||
|
<img :src="bannerPreviewUrl" alt="" class="blog-image-preview" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-12 min-w-0">
|
||||||
|
<el-upload
|
||||||
|
ref="bannerUploadRef"
|
||||||
|
:show-file-list="false"
|
||||||
|
:auto-upload="false"
|
||||||
|
:limit="1"
|
||||||
|
:accept="blogImageAccept"
|
||||||
|
:disabled="bannerUploadSaving"
|
||||||
|
:on-change="onBannerChange"
|
||||||
|
:on-remove="onBannerRemove"
|
||||||
|
>
|
||||||
|
<el-button type="default">Select image</el-button>
|
||||||
|
</el-upload>
|
||||||
|
|
||||||
|
<template v-if="bannerPendingFile">
|
||||||
|
<el-text size="small" class="flex-ellipsis" :title="bannerPendingFile.name">
|
||||||
|
{{ bannerPendingFile.name }}
|
||||||
|
</el-text>
|
||||||
|
<el-button type="primary" :loading="bannerUploadSaving" @click="submitBannerUpload">
|
||||||
|
Upload
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-button type="danger" plain :loading="deleteSaving" @click="confirmDeletePost">
|
||||||
|
Delete post
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { config } from '@/config';
|
||||||
|
import { ROUTE_NAMES } from '@/consts/routeNames';
|
||||||
|
import { useBlogPostsStore } from '@/stores/blogPosts';
|
||||||
|
import { buildUploadHint } from '@/utils/upload/buildUploadHint';
|
||||||
|
import { resolveAxiosErrorMessage } from '@/utils/resolveAxiosErrorMessage';
|
||||||
|
import { resolveUploadPublicUrl } from '@/utils/upload/resolveUploadPublicUrl';
|
||||||
|
import { validateUpload } from '@/utils/upload/validateUpload';
|
||||||
|
import {
|
||||||
|
ElMessage,
|
||||||
|
ElMessageBox,
|
||||||
|
type FormInstance,
|
||||||
|
type FormRules,
|
||||||
|
type UploadInstance,
|
||||||
|
type UploadProps
|
||||||
|
} from 'element-plus';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { computed, onBeforeMount, reactive, ref } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const {
|
||||||
|
blogImage: { accept: blogImageAccept, maxFileBytes: blogImageMaxFileBytes },
|
||||||
|
validation: { blogPostTitleMaxLength: validationBlogPostTitleMaxLength }
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const blogPostsStore = useBlogPostsStore();
|
||||||
|
|
||||||
|
const { currentBlogPost, currentBlogPostDisplayTitle } = storeToRefs(blogPostsStore);
|
||||||
|
const { fetchBlogPostById, updateBlogPost, deleteBlogPost, uploadThumbnail, uploadBanner } = blogPostsStore;
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const loadError = ref(false);
|
||||||
|
const postFormRef = ref<FormInstance>();
|
||||||
|
const postForm = reactive({
|
||||||
|
title: '',
|
||||||
|
contentHtml: '',
|
||||||
|
isDraft: true
|
||||||
|
});
|
||||||
|
const postSaving = ref(false);
|
||||||
|
const deleteSaving = ref(false);
|
||||||
|
const thumbnailUploadSaving = ref(false);
|
||||||
|
const bannerUploadSaving = ref(false);
|
||||||
|
const thumbnailPendingFile = ref<File | null>(null);
|
||||||
|
const bannerPendingFile = ref<File | null>(null);
|
||||||
|
const thumbnailUploadRef = ref<UploadInstance>();
|
||||||
|
const bannerUploadRef = ref<UploadInstance>();
|
||||||
|
|
||||||
|
const postRules: FormRules = {
|
||||||
|
title: [
|
||||||
|
{ required: true, message: 'Title is required', trigger: 'blur' },
|
||||||
|
{
|
||||||
|
max: validationBlogPostTitleMaxLength,
|
||||||
|
message: `At most ${validationBlogPostTitleMaxLength} characters`,
|
||||||
|
trigger: 'blur'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
onBeforeMount(async () => {
|
||||||
|
loadError.value = false;
|
||||||
|
|
||||||
|
const id = route.params.id as string;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
loadError.value = true;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchBlogPostById(id);
|
||||||
|
|
||||||
|
if (currentBlogPost.value) {
|
||||||
|
postForm.title = currentBlogPost.value.title;
|
||||||
|
postForm.contentHtml = currentBlogPost.value.contentHtml;
|
||||||
|
postForm.isDraft = currentBlogPost.value.isDraft;
|
||||||
|
} else {
|
||||||
|
loadError.value = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
loadError.value = true;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const blogImageHint = computed(() =>
|
||||||
|
buildUploadHint({
|
||||||
|
allowedMimesCsv: blogImageAccept,
|
||||||
|
maxFileBytes: blogImageMaxFileBytes
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const thumbnailPreviewUrl = computed(() => {
|
||||||
|
const thumbnailUrl = currentBlogPost.value?.thumbnailUrl;
|
||||||
|
|
||||||
|
return thumbnailUrl ? resolveUploadPublicUrl(thumbnailUrl) : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const bannerPreviewUrl = computed(() => {
|
||||||
|
const bannerUrl = currentBlogPost.value?.bannerUrl;
|
||||||
|
|
||||||
|
return bannerUrl ? resolveUploadPublicUrl(bannerUrl) : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const publishSwitch = computed<boolean>({
|
||||||
|
get: () => !postForm.isDraft,
|
||||||
|
set: (published: boolean) => {
|
||||||
|
postForm.isDraft = !published;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const submitPost = async (): Promise<void> => {
|
||||||
|
const form = postFormRef.value;
|
||||||
|
|
||||||
|
if (!form) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await form.validate();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
postSaving.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateBlogPost(route.params.id as string, {
|
||||||
|
title: postForm.title.trim(),
|
||||||
|
contentHtml: postForm.contentHtml,
|
||||||
|
isDraft: postForm.isDraft
|
||||||
|
});
|
||||||
|
|
||||||
|
ElMessage.success('Blog post saved');
|
||||||
|
} catch (e) {
|
||||||
|
const message = resolveAxiosErrorMessage(e, 'Could not save blog post');
|
||||||
|
|
||||||
|
ElMessage.error(message);
|
||||||
|
} finally {
|
||||||
|
postSaving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onThumbnailChange: UploadProps['onChange'] = uploadFile => {
|
||||||
|
const file = uploadFile.raw;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationError = validateUpload(file, {
|
||||||
|
allowedMimesCsv: blogImageAccept,
|
||||||
|
maxFileBytes: blogImageMaxFileBytes
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validationError) {
|
||||||
|
ElMessage.error(validationError);
|
||||||
|
thumbnailUploadRef.value?.clearFiles();
|
||||||
|
thumbnailPendingFile.value = null;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
thumbnailPendingFile.value = file;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onThumbnailRemove: UploadProps['onRemove'] = () => {
|
||||||
|
thumbnailPendingFile.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitThumbnailUpload = async (): Promise<void> => {
|
||||||
|
if (!thumbnailPendingFile.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
thumbnailUploadSaving.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await uploadThumbnail(route.params.id as string, thumbnailPendingFile.value);
|
||||||
|
thumbnailPendingFile.value = null;
|
||||||
|
thumbnailUploadRef.value?.clearFiles();
|
||||||
|
ElMessage.success('Thumbnail uploaded');
|
||||||
|
} catch (e) {
|
||||||
|
const message = resolveAxiosErrorMessage(e, 'Could not upload thumbnail');
|
||||||
|
|
||||||
|
ElMessage.error(message);
|
||||||
|
} finally {
|
||||||
|
thumbnailUploadSaving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBannerChange: UploadProps['onChange'] = uploadFile => {
|
||||||
|
const file = uploadFile.raw;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationError = validateUpload(file, {
|
||||||
|
allowedMimesCsv: blogImageAccept,
|
||||||
|
maxFileBytes: blogImageMaxFileBytes
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validationError) {
|
||||||
|
ElMessage.error(validationError);
|
||||||
|
bannerUploadRef.value?.clearFiles();
|
||||||
|
bannerPendingFile.value = null;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bannerPendingFile.value = file;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBannerRemove: UploadProps['onRemove'] = () => {
|
||||||
|
bannerPendingFile.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitBannerUpload = async (): Promise<void> => {
|
||||||
|
if (!bannerPendingFile.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bannerUploadSaving.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await uploadBanner(route.params.id as string, bannerPendingFile.value);
|
||||||
|
bannerPendingFile.value = null;
|
||||||
|
bannerUploadRef.value?.clearFiles();
|
||||||
|
ElMessage.success('Banner uploaded');
|
||||||
|
} catch (e) {
|
||||||
|
const message = resolveAxiosErrorMessage(e, 'Could not upload banner');
|
||||||
|
|
||||||
|
ElMessage.error(message);
|
||||||
|
} finally {
|
||||||
|
bannerUploadSaving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDeletePost = async (): Promise<void> => {
|
||||||
|
const postId = currentBlogPost.value?.id;
|
||||||
|
|
||||||
|
if (!postId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`Delete «${currentBlogPostDisplayTitle.value}»? This cannot be undone.`,
|
||||||
|
'Delete blog post',
|
||||||
|
{
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: 'Delete',
|
||||||
|
cancelButtonText: 'Cancel'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSaving.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteBlogPost(postId);
|
||||||
|
ElMessage.success('Blog post deleted');
|
||||||
|
router.push({ name: ROUTE_NAMES.BlogPosts });
|
||||||
|
} catch (e) {
|
||||||
|
const message = resolveAxiosErrorMessage(e, 'Could not delete blog post');
|
||||||
|
|
||||||
|
ElMessage.error(message);
|
||||||
|
} finally {
|
||||||
|
deleteSaving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.blog-image-preview-wrap {
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: var(--el-border-radius-base);
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-image-preview-wrap--thumb {
|
||||||
|
width: 22rem;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-image-preview-wrap--banner {
|
||||||
|
width: min(100%, 40rem);
|
||||||
|
aspect-ratio: 8 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-image-preview {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="cms-page-header mb-24">
|
||||||
|
<h3 class="m-0">Blog</h3>
|
||||||
|
<div class="flex items-center gap-16">
|
||||||
|
<div class="flex items-center gap-8">
|
||||||
|
<span>Blog enabled</span>
|
||||||
|
<el-text v-if="settingsLoading" size="small" class="secondary-text">Loading…</el-text>
|
||||||
|
<el-switch
|
||||||
|
v-else
|
||||||
|
:model-value="settings?.blogEnabled ?? false"
|
||||||
|
:loading="blogEnabledSaving"
|
||||||
|
:disabled="!settings"
|
||||||
|
@change="onBlogEnabledChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<el-button type="primary" :loading="createDraftLoading" @click="handleNewPostDraft">
|
||||||
|
New post
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cms-table-scroll">
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="blogPosts"
|
||||||
|
row-key="id"
|
||||||
|
stripe
|
||||||
|
empty-text="No blog posts yet"
|
||||||
|
class="clickable-table"
|
||||||
|
@row-click="row => router.push({ name: ROUTE_NAMES.BlogPostDetail, params: { id: row.id } })"
|
||||||
|
>
|
||||||
|
<el-table-column prop="title" label="Title" min-width="200" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ getBlogPostTitle(row.title) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="Status" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.isDraft ? 'info' : 'success'" size="small">
|
||||||
|
{{ row.isDraft ? 'Draft' : 'Live' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="Updated" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDate(row.updatedAt) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column width="100" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
@click.stop="router.push({ name: ROUTE_NAMES.BlogPostDetail, params: { id: row.id } })"
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<cms-list-pagination v-model:page="page" v-model:limit="limit" :total="total" @change="loadBlogPosts" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ROUTE_NAMES } from '@/consts/routeNames';
|
||||||
|
import { useBlogPostsStore } from '@/stores/blogPosts';
|
||||||
|
import { useShopSettingsStore } from '@/stores/shopSettings';
|
||||||
|
import { getBlogPostTitle } from '@/utils/blog/getBlogPostTitle';
|
||||||
|
import { formatDate } from '@/utils/formatDate';
|
||||||
|
import { resolveAxiosErrorMessage } from '@/utils/resolveAxiosErrorMessage';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { onBeforeMount, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const blogPostsStore = useBlogPostsStore();
|
||||||
|
const shopSettingsStore = useShopSettingsStore();
|
||||||
|
|
||||||
|
const { blogPosts } = storeToRefs(blogPostsStore);
|
||||||
|
const { fetchBlogPosts, createDraftBlogPost } = blogPostsStore;
|
||||||
|
|
||||||
|
const { settings } = storeToRefs(shopSettingsStore);
|
||||||
|
const { fetchShopSettings, updateBlogEnabled } = shopSettingsStore;
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const settingsLoading = ref(false);
|
||||||
|
const createDraftLoading = ref(false);
|
||||||
|
const blogEnabledSaving = ref(false);
|
||||||
|
const page = ref(1);
|
||||||
|
const limit = ref(20);
|
||||||
|
const total = ref(0);
|
||||||
|
|
||||||
|
onBeforeMount(async () => {
|
||||||
|
await Promise.all([loadBlogPosts(), loadShopSettings()]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadShopSettings = async (): Promise<void> => {
|
||||||
|
settingsLoading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchShopSettings();
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('Failed to load shop settings');
|
||||||
|
} finally {
|
||||||
|
settingsLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBlogEnabledChange = async (value: string | number | boolean): Promise<void> => {
|
||||||
|
const enabled = Boolean(value);
|
||||||
|
|
||||||
|
blogEnabledSaving.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateBlogEnabled({ blogEnabled: enabled });
|
||||||
|
} catch (e) {
|
||||||
|
const message = resolveAxiosErrorMessage(e, 'Failed to update blog setting');
|
||||||
|
|
||||||
|
ElMessage.error(message);
|
||||||
|
} finally {
|
||||||
|
blogEnabledSaving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadBlogPosts = async (): Promise<void> => {
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchBlogPosts({
|
||||||
|
page: page.value,
|
||||||
|
limit: limit.value
|
||||||
|
});
|
||||||
|
|
||||||
|
blogPosts.value = data.items;
|
||||||
|
total.value = data.total;
|
||||||
|
page.value = data.page;
|
||||||
|
limit.value = data.limit;
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('Failed to load blog posts');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNewPostDraft = async (): Promise<void> => {
|
||||||
|
createDraftLoading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await createDraftBlogPost();
|
||||||
|
ElMessage.success('Draft created — add details and publish when ready');
|
||||||
|
router.push({ name: ROUTE_NAMES.BlogPostDetail, params: { id: created.id } });
|
||||||
|
} catch (e) {
|
||||||
|
const message = resolveAxiosErrorMessage(e, 'Could not create draft post');
|
||||||
|
|
||||||
|
ElMessage.error(message);
|
||||||
|
} finally {
|
||||||
|
createDraftLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -131,6 +131,7 @@ import { useCategoriesStore } from '@/stores/categories';
|
|||||||
import { useProductsStore } from '@/stores/products';
|
import { useProductsStore } from '@/stores/products';
|
||||||
import type { ProductVariantExtended } from '@/types/product/ProductVariantExtended';
|
import type { ProductVariantExtended } from '@/types/product/ProductVariantExtended';
|
||||||
import { formatFiatPrice } from '@/utils/formatFiatPrice';
|
import { formatFiatPrice } from '@/utils/formatFiatPrice';
|
||||||
|
import { resolveAxiosErrorMessage } from '@/utils/resolveAxiosErrorMessage';
|
||||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { computed, onBeforeMount, reactive, ref } from 'vue';
|
import { computed, onBeforeMount, reactive, ref } from 'vue';
|
||||||
@@ -168,7 +169,7 @@ const deleteSaving = ref(false);
|
|||||||
|
|
||||||
const productRules: FormRules = {
|
const productRules: FormRules = {
|
||||||
title: [
|
title: [
|
||||||
{ required: true, message: 'Required', trigger: 'blur' },
|
{ required: true, message: 'Title is required', trigger: 'blur' },
|
||||||
{
|
{
|
||||||
max: validationProductTitleMaxLength,
|
max: validationProductTitleMaxLength,
|
||||||
message: `At most ${validationProductTitleMaxLength} characters`,
|
message: `At most ${validationProductTitleMaxLength} characters`,
|
||||||
@@ -200,8 +201,6 @@ onBeforeMount(async () => {
|
|||||||
productForm.descriptionHtml = currentProduct.value.descriptionHtml;
|
productForm.descriptionHtml = currentProduct.value.descriptionHtml;
|
||||||
productForm.isDraft = currentProduct.value.isDraft;
|
productForm.isDraft = currentProduct.value.isDraft;
|
||||||
selectedCategoryIds.value = (currentProduct.value.categories ?? []).map(c => c.id);
|
selectedCategoryIds.value = (currentProduct.value.categories ?? []).map(c => c.id);
|
||||||
|
|
||||||
productFormRef.value?.clearValidate();
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
loadError.value = true;
|
loadError.value = true;
|
||||||
@@ -241,8 +240,10 @@ const submitProduct = async (): Promise<void> => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ElMessage.success('Product saved');
|
ElMessage.success('Product saved');
|
||||||
} catch {
|
} catch (e) {
|
||||||
ElMessage.error('Could not save product');
|
const message = resolveAxiosErrorMessage(e, 'Could not save product');
|
||||||
|
|
||||||
|
ElMessage.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
productSaving.value = false;
|
productSaving.value = false;
|
||||||
}
|
}
|
||||||
@@ -288,8 +289,10 @@ const confirmDeleteProduct = async (): Promise<void> => {
|
|||||||
await productsStore.deleteProduct(pid);
|
await productsStore.deleteProduct(pid);
|
||||||
ElMessage.success('Product deleted');
|
ElMessage.success('Product deleted');
|
||||||
router.push({ name: ROUTE_NAMES.Products });
|
router.push({ name: ROUTE_NAMES.Products });
|
||||||
} catch {
|
} catch (e) {
|
||||||
ElMessage.error('Could not delete product');
|
const message = resolveAxiosErrorMessage(e, 'Could not delete product');
|
||||||
|
|
||||||
|
ElMessage.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
deleteSaving.value = false;
|
deleteSaving.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<el-table
|
<el-table
|
||||||
v-loading="loading"
|
v-loading="loading"
|
||||||
:data="products"
|
:data="products"
|
||||||
|
row-key="id"
|
||||||
stripe
|
stripe
|
||||||
empty-text="No products yet"
|
empty-text="No products yet"
|
||||||
class="clickable-table"
|
class="clickable-table"
|
||||||
@@ -22,8 +23,9 @@
|
|||||||
|
|
||||||
<el-table-column label="Status" width="100">
|
<el-table-column label="Status" width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag v-if="row.isDraft" type="info" size="small">Draft</el-tag>
|
<el-tag :type="row.isDraft ? 'info' : 'success'" size="small">
|
||||||
<el-tag v-else type="success" size="small">Live</el-tag>
|
{{ row.isDraft ? 'Draft' : 'Live' }}
|
||||||
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
@@ -89,7 +91,6 @@ import { useProductsStore } from '@/stores/products';
|
|||||||
import type { Category } from '@/types/product/Category';
|
import type { Category } from '@/types/product/Category';
|
||||||
import type { ProductWithVariantsExtended } from '@/types/product/ProductWithVariantsExtended';
|
import type { ProductWithVariantsExtended } from '@/types/product/ProductWithVariantsExtended';
|
||||||
import { DeliveryMode } from '@/types/product/DeliveryMode';
|
import { DeliveryMode } from '@/types/product/DeliveryMode';
|
||||||
import { HttpStatusCode, isAxiosError } from 'axios';
|
|
||||||
import { formatDate } from '@/utils/formatDate';
|
import { formatDate } from '@/utils/formatDate';
|
||||||
import { formatDeliveryMode } from '@/utils/product/formatDeliveryMode';
|
import { formatDeliveryMode } from '@/utils/product/formatDeliveryMode';
|
||||||
import { getProductTitle } from '@/utils/product/getProductTitle';
|
import { getProductTitle } from '@/utils/product/getProductTitle';
|
||||||
@@ -127,10 +128,8 @@ const loadProducts = async (): Promise<void> => {
|
|||||||
total.value = data.total;
|
total.value = data.total;
|
||||||
page.value = data.page;
|
page.value = data.page;
|
||||||
limit.value = data.limit;
|
limit.value = data.limit;
|
||||||
} catch (e) {
|
} catch {
|
||||||
if (!isAxiosError(e) || e.response?.status !== HttpStatusCode.Unauthorized) {
|
|
||||||
ElMessage.error('Failed to load products');
|
ElMessage.error('Failed to load products');
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+3
@@ -15,6 +15,8 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_API_BASE_URL?: string;
|
readonly VITE_API_BASE_URL?: string;
|
||||||
readonly VITE_PRODUCT_THUMB_ALLOWED_MIMES?: string;
|
readonly VITE_PRODUCT_THUMB_ALLOWED_MIMES?: string;
|
||||||
readonly VITE_PRODUCT_THUMB_MAX_FILE_BYTES?: string;
|
readonly VITE_PRODUCT_THUMB_MAX_FILE_BYTES?: string;
|
||||||
|
readonly VITE_BLOG_IMAGE_ALLOWED_MIMES?: string;
|
||||||
|
readonly VITE_BLOG_IMAGE_MAX_FILE_BYTES?: string;
|
||||||
readonly VITE_SHOP_LOGO_ALLOWED_MIMES?: string;
|
readonly VITE_SHOP_LOGO_ALLOWED_MIMES?: string;
|
||||||
readonly VITE_SHOP_LOGO_MAX_FILE_BYTES?: string;
|
readonly VITE_SHOP_LOGO_MAX_FILE_BYTES?: string;
|
||||||
readonly VITE_SHOP_FAVICON_ALLOWED_MIMES?: string;
|
readonly VITE_SHOP_FAVICON_ALLOWED_MIMES?: string;
|
||||||
@@ -22,6 +24,7 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES?: string;
|
readonly VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES?: string;
|
||||||
readonly VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES?: string;
|
readonly VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES?: string;
|
||||||
readonly VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH?: string;
|
readonly VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH?: string;
|
||||||
|
readonly VITE_VALIDATION_BLOG_POST_TITLE_MAX_LENGTH?: string;
|
||||||
readonly VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH?: string;
|
readonly VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH?: string;
|
||||||
readonly VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH?: string;
|
readonly VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH?: string;
|
||||||
readonly VITE_VALIDATION_VARIANT_IMAGES_MAX?: string;
|
readonly VITE_VALIDATION_VARIANT_IMAGES_MAX?: string;
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ services:
|
|||||||
VITE_SHOP_FIAT_CURRENCY: ${VITE_SHOP_FIAT_CURRENCY}
|
VITE_SHOP_FIAT_CURRENCY: ${VITE_SHOP_FIAT_CURRENCY}
|
||||||
VITE_PRODUCT_THUMB_ALLOWED_MIMES: ${VITE_PRODUCT_THUMB_ALLOWED_MIMES}
|
VITE_PRODUCT_THUMB_ALLOWED_MIMES: ${VITE_PRODUCT_THUMB_ALLOWED_MIMES}
|
||||||
VITE_PRODUCT_THUMB_MAX_FILE_BYTES: ${VITE_PRODUCT_THUMB_MAX_FILE_BYTES}
|
VITE_PRODUCT_THUMB_MAX_FILE_BYTES: ${VITE_PRODUCT_THUMB_MAX_FILE_BYTES}
|
||||||
|
VITE_BLOG_IMAGE_ALLOWED_MIMES: ${VITE_BLOG_IMAGE_ALLOWED_MIMES}
|
||||||
|
VITE_BLOG_IMAGE_MAX_FILE_BYTES: ${VITE_BLOG_IMAGE_MAX_FILE_BYTES}
|
||||||
VITE_SHOP_LOGO_ALLOWED_MIMES: ${VITE_SHOP_LOGO_ALLOWED_MIMES}
|
VITE_SHOP_LOGO_ALLOWED_MIMES: ${VITE_SHOP_LOGO_ALLOWED_MIMES}
|
||||||
VITE_SHOP_LOGO_MAX_FILE_BYTES: ${VITE_SHOP_LOGO_MAX_FILE_BYTES}
|
VITE_SHOP_LOGO_MAX_FILE_BYTES: ${VITE_SHOP_LOGO_MAX_FILE_BYTES}
|
||||||
VITE_SHOP_FAVICON_ALLOWED_MIMES: ${VITE_SHOP_FAVICON_ALLOWED_MIMES}
|
VITE_SHOP_FAVICON_ALLOWED_MIMES: ${VITE_SHOP_FAVICON_ALLOWED_MIMES}
|
||||||
@@ -133,6 +135,7 @@ services:
|
|||||||
VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES: ${VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES}
|
VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES: ${VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES}
|
||||||
VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES: ${VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES}
|
VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES: ${VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES}
|
||||||
VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH: ${VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH}
|
VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH: ${VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH}
|
||||||
|
VITE_VALIDATION_BLOG_POST_TITLE_MAX_LENGTH: ${VITE_VALIDATION_BLOG_POST_TITLE_MAX_LENGTH}
|
||||||
VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH: ${VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH}
|
VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH: ${VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH}
|
||||||
VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH: ${VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH}
|
VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH: ${VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH}
|
||||||
VITE_VALIDATION_VARIANT_IMAGES_MAX: ${VITE_VALIDATION_VARIANT_IMAGES_MAX}
|
VITE_VALIDATION_VARIANT_IMAGES_MAX: ${VITE_VALIDATION_VARIANT_IMAGES_MAX}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ ARG VITE_API_BASE_URL
|
|||||||
ARG VITE_SHOP_FIAT_CURRENCY
|
ARG VITE_SHOP_FIAT_CURRENCY
|
||||||
ARG VITE_PRODUCT_THUMB_ALLOWED_MIMES
|
ARG VITE_PRODUCT_THUMB_ALLOWED_MIMES
|
||||||
ARG VITE_PRODUCT_THUMB_MAX_FILE_BYTES
|
ARG VITE_PRODUCT_THUMB_MAX_FILE_BYTES
|
||||||
|
ARG VITE_BLOG_IMAGE_ALLOWED_MIMES
|
||||||
|
ARG VITE_BLOG_IMAGE_MAX_FILE_BYTES
|
||||||
ARG VITE_SHOP_LOGO_ALLOWED_MIMES
|
ARG VITE_SHOP_LOGO_ALLOWED_MIMES
|
||||||
ARG VITE_SHOP_LOGO_MAX_FILE_BYTES
|
ARG VITE_SHOP_LOGO_MAX_FILE_BYTES
|
||||||
ARG VITE_SHOP_FAVICON_ALLOWED_MIMES
|
ARG VITE_SHOP_FAVICON_ALLOWED_MIMES
|
||||||
@@ -18,6 +20,7 @@ ARG VITE_SHOP_FAVICON_MAX_FILE_BYTES
|
|||||||
ARG VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES
|
ARG VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES
|
||||||
ARG VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES
|
ARG VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES
|
||||||
ARG VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH
|
ARG VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH
|
||||||
|
ARG VITE_VALIDATION_BLOG_POST_TITLE_MAX_LENGTH
|
||||||
ARG VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH
|
ARG VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH
|
||||||
ARG VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH
|
ARG VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH
|
||||||
ARG VITE_VALIDATION_VARIANT_IMAGES_MAX
|
ARG VITE_VALIDATION_VARIANT_IMAGES_MAX
|
||||||
@@ -32,6 +35,8 @@ ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \
|
|||||||
VITE_SHOP_FIAT_CURRENCY=$VITE_SHOP_FIAT_CURRENCY \
|
VITE_SHOP_FIAT_CURRENCY=$VITE_SHOP_FIAT_CURRENCY \
|
||||||
VITE_PRODUCT_THUMB_ALLOWED_MIMES=$VITE_PRODUCT_THUMB_ALLOWED_MIMES \
|
VITE_PRODUCT_THUMB_ALLOWED_MIMES=$VITE_PRODUCT_THUMB_ALLOWED_MIMES \
|
||||||
VITE_PRODUCT_THUMB_MAX_FILE_BYTES=$VITE_PRODUCT_THUMB_MAX_FILE_BYTES \
|
VITE_PRODUCT_THUMB_MAX_FILE_BYTES=$VITE_PRODUCT_THUMB_MAX_FILE_BYTES \
|
||||||
|
VITE_BLOG_IMAGE_ALLOWED_MIMES=$VITE_BLOG_IMAGE_ALLOWED_MIMES \
|
||||||
|
VITE_BLOG_IMAGE_MAX_FILE_BYTES=$VITE_BLOG_IMAGE_MAX_FILE_BYTES \
|
||||||
VITE_SHOP_LOGO_ALLOWED_MIMES=$VITE_SHOP_LOGO_ALLOWED_MIMES \
|
VITE_SHOP_LOGO_ALLOWED_MIMES=$VITE_SHOP_LOGO_ALLOWED_MIMES \
|
||||||
VITE_SHOP_LOGO_MAX_FILE_BYTES=$VITE_SHOP_LOGO_MAX_FILE_BYTES \
|
VITE_SHOP_LOGO_MAX_FILE_BYTES=$VITE_SHOP_LOGO_MAX_FILE_BYTES \
|
||||||
VITE_SHOP_FAVICON_ALLOWED_MIMES=$VITE_SHOP_FAVICON_ALLOWED_MIMES \
|
VITE_SHOP_FAVICON_ALLOWED_MIMES=$VITE_SHOP_FAVICON_ALLOWED_MIMES \
|
||||||
@@ -39,6 +44,7 @@ ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \
|
|||||||
VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES=$VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES \
|
VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES=$VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES \
|
||||||
VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES=$VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES \
|
VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES=$VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES \
|
||||||
VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH=$VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH \
|
VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH=$VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH \
|
||||||
|
VITE_VALIDATION_BLOG_POST_TITLE_MAX_LENGTH=$VITE_VALIDATION_BLOG_POST_TITLE_MAX_LENGTH \
|
||||||
VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH=$VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH \
|
VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH=$VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH \
|
||||||
VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH=$VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH \
|
VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH=$VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH \
|
||||||
VITE_VALIDATION_VARIANT_IMAGES_MAX=$VITE_VALIDATION_VARIANT_IMAGES_MAX \
|
VITE_VALIDATION_VARIANT_IMAGES_MAX=$VITE_VALIDATION_VARIANT_IMAGES_MAX \
|
||||||
|
|||||||
Reference in New Issue
Block a user