diff --git a/.env.example b/.env.example index 3d78ee4..9e3fb26 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,9 @@ THROTTLE_LIMIT=120 MULTER_PRODUCT_THUMB_ALLOWED_MIMES=image/jpeg,image/png MULTER_PRODUCT_THUMB_MAX_FILE_BYTES=1048576 # 1MB +MULTER_BLOG_IMAGE_ALLOWED_MIMES=image/jpeg,image/png +MULTER_BLOG_IMAGE_MAX_FILE_BYTES=1048576 # 1MB + MULTER_SHOP_LOGO_ALLOWED_MIMES=image/jpeg,image/png MULTER_SHOP_LOGO_MAX_FILE_BYTES=1048576 # 1MB @@ -47,6 +50,7 @@ MULTER_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES=application/pdf,application/zip,im MULTER_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES=5242880 # 5MB VALIDATION_PRODUCT_TITLE_MAX_LENGTH=100 +VALIDATION_BLOG_POST_TITLE_MAX_LENGTH=200 VALIDATION_CATEGORY_NAME_MAX_LENGTH=50 VALIDATION_DISCOUNT_CODE_MAX_LENGTH=32 VALIDATION_VARIANT_IMAGES_MAX=10 @@ -134,6 +138,8 @@ VITE_API_BASE_URL=http://localhost:3000/api VITE_SHOP_FIAT_CURRENCY=USD VITE_PRODUCT_THUMB_ALLOWED_MIMES=image/jpeg,image/png VITE_PRODUCT_THUMB_MAX_FILE_BYTES=1048576 # 1MB +VITE_BLOG_IMAGE_ALLOWED_MIMES=image/jpeg,image/png +VITE_BLOG_IMAGE_MAX_FILE_BYTES=1048576 # 1MB VITE_SHOP_LOGO_ALLOWED_MIMES=image/jpeg,image/png VITE_SHOP_LOGO_MAX_FILE_BYTES=1048576 # 1MB VITE_SHOP_FAVICON_ALLOWED_MIMES=image/png,image/x-icon,image/vnd.microsoft.icon @@ -141,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_MAX_FILE_BYTES=5242880 # 5MB VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH=100 +VITE_VALIDATION_BLOG_POST_TITLE_MAX_LENGTH=200 VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH=50 VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH=32 VITE_VALIDATION_VARIANT_IMAGES_MAX=10 diff --git a/backend/src/AppModule.ts b/backend/src/AppModule.ts index 3e38a2e..2933a85 100644 --- a/backend/src/AppModule.ts +++ b/backend/src/AppModule.ts @@ -20,6 +20,7 @@ import { } from './config'; import { validate } from './config/validate'; import { AuthModule } from './modules/auth/AuthModule'; +import { BlogModule } from './modules/blog/BlogModule'; import { EncryptionModule } from './modules/encryption/EncryptionModule'; import { HealthCheckModule } from './modules/healthCheck/HealthCheckModule'; import { MoneroWalletModule } from './modules/moneroWallet/MoneroWalletModule'; @@ -37,6 +38,7 @@ import { StorefrontCheckoutModule } from './modules/storefrontCheckout/Storefron import { StorefrontOrderModule } from './modules/storefrontOrder/StorefrontOrderModule'; import { StorefrontCoreModule } from './modules/storefrontCore/StorefrontCoreModule'; import { StorefrontProductModule } from './modules/storefrontProduct/StorefrontProductModule'; +import { StorefrontBlogModule } from './modules/storefrontBlog/StorefrontBlogModule'; import { ExchangeRateModule } from './modules/exchangeRate/ExchangeRateModule'; import { Config } from './types/Config'; @@ -85,6 +87,7 @@ import { Config } from './types/Config'; HealthCheckModule, EncryptionModule, AuthModule, + BlogModule, ProductsModule, ShopSettingsModule, SimplexModule, @@ -99,7 +102,8 @@ import { Config } from './types/Config'; StorefrontProductModule, StorefrontCartModule, StorefrontCheckoutModule, - StorefrontOrderModule + StorefrontOrderModule, + StorefrontBlogModule ], providers: [ { diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index ae81352..8235476 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -97,6 +97,7 @@ export const getAppConfig = (): AppConfig => { }, validation: { productTitleMaxLength: envInt('VALIDATION_PRODUCT_TITLE_MAX_LENGTH'), + blogPostTitleMaxLength: envInt('VALIDATION_BLOG_POST_TITLE_MAX_LENGTH'), categoryNameMaxLength: envInt('VALIDATION_CATEGORY_NAME_MAX_LENGTH'), discountCodeMaxLength: envInt('VALIDATION_DISCOUNT_CODE_MAX_LENGTH'), variantImagesMax: envInt('VALIDATION_VARIANT_IMAGES_MAX'), @@ -127,6 +128,17 @@ export const getProductThumbMulterConfig = (): MulterConfig => { return { allowedMimes, maxFileBytes }; }; +export const getBlogImageMulterConfig = (): MulterConfig => { + const allowedMimes = env('MULTER_BLOG_IMAGE_ALLOWED_MIMES') + .split(',') + .map(v => v.trim()) + .filter(Boolean); + + const maxFileBytes = envInt('MULTER_BLOG_IMAGE_MAX_FILE_BYTES'); + + return { allowedMimes, maxFileBytes }; +}; + export const getShopLogoMulterConfig = (): MulterConfig => { const allowedMimes = env('MULTER_SHOP_LOGO_ALLOWED_MIMES') .split(',') diff --git a/backend/src/config/uploadPaths.ts b/backend/src/config/uploadPaths.ts index df70f11..d783f8e 100644 --- a/backend/src/config/uploadPaths.ts +++ b/backend/src/config/uploadPaths.ts @@ -31,6 +31,20 @@ export const variantImagesUrlPrefix = '/uploads/public/variants/images/'; export const getVariantImagePublicUrl = (filename: string): string => `${variantImagesUrlPrefix}${filename}`; +export const getBlogImagesDir = (): string => join(getPublicUploadsDir(), 'blog', 'images'); + +export const blogImagesUrlPrefix = '/uploads/public/blog/images/'; + +export const getBlogImagePublicUrl = (filename: string): string => `${blogImagesUrlPrefix}${filename}`; + +export const resolveBlogImagePath = (storageKey: string): string => { + const baseDir = getBlogImagesDir(); + + const absolutePath = resolvePathWithinDir(baseDir, storageKey); + + return absolutePath; +}; + export const getShopBrandingDir = (): string => join(getPublicUploadsDir(), 'shop'); export const shopBrandingUrlPrefix = '/uploads/public/shop/'; diff --git a/backend/src/config/validate.ts b/backend/src/config/validate.ts index 87ae559..39b6901 100644 --- a/backend/src/config/validate.ts +++ b/backend/src/config/validate.ts @@ -75,6 +75,16 @@ class EnvironmentVariables { @Max(10 * 1024 * 1024) // 10MB MULTER_PRODUCT_THUMB_MAX_FILE_BYTES: number; + @IsNotEmpty() + @IsString() + MULTER_BLOG_IMAGE_ALLOWED_MIMES: string; + + @IsNotEmpty() + @IsNumber() + @Min(1) + @Max(10 * 1024 * 1024) // 10MB + MULTER_BLOG_IMAGE_MAX_FILE_BYTES: number; + @IsNotEmpty() @IsString() MULTER_SHOP_LOGO_ALLOWED_MIMES: string; @@ -101,6 +111,12 @@ class EnvironmentVariables { @Max(256) VALIDATION_PRODUCT_TITLE_MAX_LENGTH: number; + @IsNotEmpty() + @IsNumber() + @Min(1) + @Max(256) + VALIDATION_BLOG_POST_TITLE_MAX_LENGTH: number; + @IsNotEmpty() @IsNumber() @Min(1) diff --git a/backend/src/database/migrations/1784900000000-add-blog.ts b/backend/src/database/migrations/1784900000000-add-blog.ts new file mode 100644 index 0000000..d7dd4ed --- /dev/null +++ b/backend/src/database/migrations/1784900000000-add-blog.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBlog1784900000000 implements MigrationInterface { + name = 'AddBlog1784900000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "blog_posts" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "title" character varying NOT NULL, "thumbnailStorageKey" character varying, "bannerStorageKey" character varying, "contentHtml" text NOT NULL, "isDraft" boolean NOT NULL DEFAULT true, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_blog_posts_id" PRIMARY KEY ("id"))` + ); + await queryRunner.query(`ALTER TABLE "shop_settings" ADD "blogEnabled" boolean NOT NULL DEFAULT false`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "shop_settings" DROP COLUMN "blogEnabled"`); + await queryRunner.query(`DROP TABLE "blog_posts"`); + } +} diff --git a/backend/src/main.ts b/backend/src/main.ts index 2f3ab93..3b3f0e7 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -77,6 +77,8 @@ async function bootstrap() { { path: '/', method: RequestMethod.GET }, { path: 'shop/categories/:id', method: RequestMethod.GET }, { path: 'shop/products/:id/variants/:variantId', method: RequestMethod.GET }, + { path: 'shop/blog', method: RequestMethod.GET }, + { path: 'shop/blog/:id', method: RequestMethod.GET }, { path: 'shop/assets/{*path}', method: RequestMethod.GET }, { path: 'shop/preferences/theme', method: RequestMethod.POST }, { path: 'shop/error', method: RequestMethod.GET }, diff --git a/backend/src/modules/blog/BlogModule.ts b/backend/src/modules/blog/BlogModule.ts new file mode 100644 index 0000000..9af786d --- /dev/null +++ b/backend/src/modules/blog/BlogModule.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { BlogPostsController } from './controllers/BlogPostsController'; +import { BlogPost } from './entities/BlogPost'; +import { BlogPostsService } from './services/BlogPostsService'; + +@Module({ + imports: [TypeOrmModule.forFeature([BlogPost])], + controllers: [BlogPostsController], + providers: [BlogPostsService], + exports: [TypeOrmModule.forFeature([BlogPost])] +}) +export class BlogModule {} diff --git a/backend/src/modules/blog/config/blogImageUpload.ts b/backend/src/modules/blog/config/blogImageUpload.ts new file mode 100644 index 0000000..7ed9361 --- /dev/null +++ b/backend/src/modules/blog/config/blogImageUpload.ts @@ -0,0 +1,12 @@ +import { getBlogImageMulterConfig } from '../../../config'; +import { getBlogImagesDir } from '../../../config/uploadPaths'; +import { createDiskStorageUploadOptions } from '../../../utils/createDiskStorageUploadOptions'; +import { createUploadFilePipe } from '../../../utils/createUploadFilePipe'; + +const uploadDir = getBlogImagesDir(); + +export const blogImageUploadOptions = createDiskStorageUploadOptions(uploadDir); + +const { allowedMimes, maxFileBytes } = getBlogImageMulterConfig(); + +export const blogImageFilePipe = createUploadFilePipe(allowedMimes, maxFileBytes); diff --git a/backend/src/modules/blog/controllers/BlogPostsController.ts b/backend/src/modules/blog/controllers/BlogPostsController.ts new file mode 100644 index 0000000..66b05ae --- /dev/null +++ b/backend/src/modules/blog/controllers/BlogPostsController.ts @@ -0,0 +1,72 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + UploadedFile, + UseGuards, + UseInterceptors +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { JwtGuard } from '../../../guards/JwtGuard'; +import { blogImageFilePipe, blogImageUploadOptions } from '../config/blogImageUpload'; +import { ListBlogPostsQueryDto } from '../dto/ListBlogPostsQueryDto'; +import { UpdateBlogPostDto } from '../dto/UpdateBlogPostDto'; +import { BlogPostsService } from '../services/BlogPostsService'; + +@Controller('blog-posts') +@UseGuards(JwtGuard) +export class BlogPostsController { + constructor(private readonly blogPostsService: BlogPostsService) {} + + @Post('/') + createDraft() { + return this.blogPostsService.createDraft(); + } + + @Get('/') + findAll(@Query() query: ListBlogPostsQueryDto) { + return this.blogPostsService.findAll(query); + } + + @Get('/:id') + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.blogPostsService.findOne(id); + } + + @Patch('/:id') + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateBlogPostDto) { + return this.blogPostsService.update(id, dto); + } + + @Post('/:id/thumbnail') + @UseInterceptors(FileInterceptor('file', blogImageUploadOptions)) + uploadThumbnail( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile(blogImageFilePipe) file: Express.Multer.File + ) { + return this.blogPostsService.uploadThumbnail(id, file.filename); + } + + @Post('/:id/banner') + @UseInterceptors(FileInterceptor('file', blogImageUploadOptions)) + uploadBanner( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile(blogImageFilePipe) file: Express.Multer.File + ) { + return this.blogPostsService.uploadBanner(id, file.filename); + } + + @Delete('/:id') + @HttpCode(HttpStatus.NO_CONTENT) + async remove(@Param('id', ParseUUIDPipe) id: string) { + await this.blogPostsService.remove(id); + } +} diff --git a/backend/src/modules/blog/dto/ListBlogPostsQueryDto.ts b/backend/src/modules/blog/dto/ListBlogPostsQueryDto.ts new file mode 100644 index 0000000..ccf74f1 --- /dev/null +++ b/backend/src/modules/blog/dto/ListBlogPostsQueryDto.ts @@ -0,0 +1,17 @@ +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, Max, Min } from 'class-validator'; + +export class ListBlogPostsQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number; +} diff --git a/backend/src/modules/blog/dto/UpdateBlogPostDto.ts b/backend/src/modules/blog/dto/UpdateBlogPostDto.ts new file mode 100644 index 0000000..4eff67a --- /dev/null +++ b/backend/src/modules/blog/dto/UpdateBlogPostDto.ts @@ -0,0 +1,21 @@ +import { IsBoolean, IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +import { getAppConfig } from '../../../config'; + +const { + validation: { blogPostTitleMaxLength } +} = getAppConfig(); + +export class UpdateBlogPostDto { + @IsNotEmpty() + @IsString() + @MaxLength(blogPostTitleMaxLength) + title: string; + + @IsString() + contentHtml: string; + + @IsNotEmpty() + @IsBoolean() + isDraft: boolean; +} diff --git a/backend/src/modules/blog/entities/BlogPost.ts b/backend/src/modules/blog/entities/BlogPost.ts new file mode 100644 index 0000000..c2b3689 --- /dev/null +++ b/backend/src/modules/blog/entities/BlogPost.ts @@ -0,0 +1,28 @@ +import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; + +@Entity('blog_posts') +export class BlogPost { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + title: string; + + @Column({ type: 'varchar', nullable: true }) + thumbnailStorageKey: string | null; + + @Column({ type: 'varchar', nullable: true }) + bannerStorageKey: string | null; + + @Column({ type: 'text' }) + contentHtml: string; + + @Column({ type: 'boolean', default: true }) + isDraft: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/modules/blog/services/BlogPostsService.spec.ts b/backend/src/modules/blog/services/BlogPostsService.spec.ts new file mode 100644 index 0000000..ad16f29 --- /dev/null +++ b/backend/src/modules/blog/services/BlogPostsService.spec.ts @@ -0,0 +1,181 @@ +import { NotFoundException } from '@nestjs/common'; +import type { Repository } from 'typeorm'; +import { getBlogImagePublicUrl, resolveBlogImagePath } from '../../../config/uploadPaths'; +import { removeFileFromDisk } from '../../../utils/removeFileFromDisk'; +import { BlogPost } from '../entities/BlogPost'; +import { BlogPostsService } from './BlogPostsService'; + +jest.mock('../../../utils/removeFileFromDisk', () => ({ + removeFileFromDisk: jest.fn().mockResolvedValue(undefined) +})); + +describe('BlogPostsService', () => { + let service: BlogPostsService; + let blogPostRepo: { + findAndCount: jest.Mock; + findOne: jest.Mock; + create: jest.Mock; + insert: jest.Mock; + update: jest.Mock; + exists: jest.Mock; + delete: jest.Mock; + }; + + beforeEach(() => { + jest.mocked(removeFileFromDisk).mockClear(); + + blogPostRepo = { + findAndCount: jest.fn().mockResolvedValue([[], 0]), + findOne: jest.fn().mockResolvedValue(null), + create: jest.fn(data => ({ id: 'post-1', ...data })), + insert: jest.fn().mockResolvedValue(undefined), + update: jest.fn().mockResolvedValue(undefined), + exists: jest.fn().mockResolvedValue(false), + delete: jest.fn().mockResolvedValue(undefined) + }; + + service = new BlogPostsService(blogPostRepo as unknown as Repository); + }); + + it('creates a draft post with empty title and content', async () => { + blogPostRepo.findOne.mockResolvedValue({ + id: 'post-1', + title: '', + contentHtml: '', + isDraft: true + }); + + const created = await service.createDraft(); + + expect(blogPostRepo.create).toHaveBeenCalledWith({ + title: '', + contentHtml: '', + isDraft: true + }); + expect(blogPostRepo.insert).toHaveBeenCalled(); + expect(created).toEqual( + expect.objectContaining({ + id: 'post-1', + title: '', + contentHtml: '', + isDraft: true, + thumbnailUrl: null, + bannerUrl: null + }) + ); + }); + + it('maps image storage keys to public urls', async () => { + blogPostRepo.findOne.mockResolvedValue({ + id: 'post-1', + title: 'Hello', + thumbnailStorageKey: 'thumb.webp', + bannerStorageKey: 'banner.webp', + contentHtml: '

Body

', + isDraft: false + }); + + await expect(service.findOne('post-1')).resolves.toEqual( + expect.objectContaining({ + title: 'Hello', + thumbnailUrl: getBlogImagePublicUrl('thumb.webp'), + bannerUrl: getBlogImagePublicUrl('banner.webp') + }) + ); + }); + + it('lists posts ordered by createdAt descending', async () => { + await service.findAll({ page: 2, limit: 10 }); + + expect(blogPostRepo.findAndCount).toHaveBeenCalledWith({ + order: { createdAt: 'DESC' }, + skip: 10, + take: 10 + }); + }); + + it('trims title on update', async () => { + blogPostRepo.exists.mockResolvedValueOnce(true); + blogPostRepo.findOne.mockResolvedValueOnce({ + id: 'post-1', + title: 'Hello', + contentHtml: '

Body

', + isDraft: false + }); + + await service.update('post-1', { + title: ' Hello ', + contentHtml: '

Body

', + isDraft: false + }); + + expect(blogPostRepo.update).toHaveBeenCalledWith('post-1', { + title: 'Hello', + contentHtml: '

Body

', + isDraft: false + }); + }); + + it('uploads a thumbnail and removes the previous file', async () => { + blogPostRepo.findOne + .mockResolvedValueOnce({ id: 'post-1', thumbnailStorageKey: 'old.webp' }) + .mockResolvedValueOnce({ id: 'post-1', thumbnailStorageKey: 'new.webp' }); + + await service.uploadThumbnail('post-1', 'new.webp'); + + expect(blogPostRepo.update).toHaveBeenCalledWith('post-1', { thumbnailStorageKey: 'new.webp' }); + expect(removeFileFromDisk).toHaveBeenCalledWith(resolveBlogImagePath('old.webp'), BlogPostsService.name); + }); + + it('uploads a banner and removes the previous file', async () => { + blogPostRepo.findOne + .mockResolvedValueOnce({ id: 'post-1', bannerStorageKey: 'old-banner.webp' }) + .mockResolvedValueOnce({ id: 'post-1', bannerStorageKey: 'new-banner.webp' }); + + await service.uploadBanner('post-1', 'new-banner.webp'); + + expect(blogPostRepo.update).toHaveBeenCalledWith('post-1', { bannerStorageKey: 'new-banner.webp' }); + expect(removeFileFromDisk).toHaveBeenCalledWith( + resolveBlogImagePath('old-banner.webp'), + BlogPostsService.name + ); + }); + + it('throws when loading a missing post', async () => { + await expect(service.findOne('missing-id')).rejects.toThrow(NotFoundException); + }); + + it('throws when updating a missing post', async () => { + blogPostRepo.exists.mockResolvedValueOnce(false); + + await expect( + service.update('missing-id', { + title: 'Hello', + contentHtml: '', + isDraft: true + }) + ).rejects.toThrow(NotFoundException); + }); + + it('deletes an existing post and its image files', async () => { + blogPostRepo.findOne.mockResolvedValueOnce({ + id: 'post-1', + thumbnailStorageKey: 'thumb.webp', + bannerStorageKey: 'banner.webp' + }); + + await service.remove('post-1'); + + expect(blogPostRepo.delete).toHaveBeenCalledWith('post-1'); + expect(removeFileFromDisk).toHaveBeenCalledWith(resolveBlogImagePath('thumb.webp'), BlogPostsService.name); + expect(removeFileFromDisk).toHaveBeenCalledWith( + resolveBlogImagePath('banner.webp'), + BlogPostsService.name + ); + }); + + it('throws when deleting a missing post', async () => { + await expect(service.remove('missing-id')).rejects.toThrow(NotFoundException); + expect(blogPostRepo.delete).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/modules/blog/services/BlogPostsService.ts b/backend/src/modules/blog/services/BlogPostsService.ts new file mode 100644 index 0000000..042d6b7 --- /dev/null +++ b/backend/src/modules/blog/services/BlogPostsService.ts @@ -0,0 +1,125 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { getBlogImagePublicUrl, resolveBlogImagePath } from '../../../config/uploadPaths'; +import type { PaginatedResponse } from '../../../types/PaginatedResponse'; +import { removeFileFromDisk } from '../../../utils/removeFileFromDisk'; +import type { ListBlogPostsQueryDto } from '../dto/ListBlogPostsQueryDto'; +import { UpdateBlogPostDto } from '../dto/UpdateBlogPostDto'; +import { BlogPost } from '../entities/BlogPost'; +import type { BlogPostExtended } from '../types/BlogPostExtended'; + +@Injectable() +export class BlogPostsService { + constructor( + @InjectRepository(BlogPost) + private readonly blogPostRepo: Repository + ) {} + + async findAll({ page = 1, limit = 20 }: ListBlogPostsQueryDto): Promise> { + const [items, total] = await this.blogPostRepo.findAndCount({ + order: { createdAt: 'DESC' }, + skip: (page - 1) * limit, + take: limit + }); + + const itemsExtended = items.map(post => this.toPostExtended(post)); + + return { items: itemsExtended, total, page, limit }; + } + + async createDraft(): Promise { + const entity = this.blogPostRepo.create({ + title: '', + contentHtml: '', + isDraft: true + }); + + await this.blogPostRepo.insert(entity); + + return this.findOne(entity.id); + } + + async findOne(id: string): Promise { + const entity = await this.findEntity(id); + + return this.toPostExtended(entity); + } + + async update(id: string, { title, contentHtml, isDraft }: UpdateBlogPostDto): Promise { + const exists = await this.blogPostRepo.exists({ where: { id } }); + + if (!exists) { + throw new NotFoundException(); + } + + await this.blogPostRepo.update(id, { + title: title.trim(), + contentHtml, + isDraft + }); + + return this.findOne(id); + } + + async uploadThumbnail(id: string, filename: string): Promise { + const post = await this.findEntity(id); + + const previousStorageKey = post.thumbnailStorageKey; + + await this.blogPostRepo.update(id, { thumbnailStorageKey: filename }); + await this.removeBlogImageFile(previousStorageKey); + + return this.findOne(id); + } + + async uploadBanner(id: string, filename: string): Promise { + const post = await this.findEntity(id); + + const previousStorageKey = post.bannerStorageKey; + + await this.blogPostRepo.update(id, { bannerStorageKey: filename }); + await this.removeBlogImageFile(previousStorageKey); + + return this.findOne(id); + } + + async remove(id: string): Promise { + const post = await this.findEntity(id); + + await this.blogPostRepo.delete(id); + + await Promise.all([ + this.removeBlogImageFile(post.thumbnailStorageKey), + this.removeBlogImageFile(post.bannerStorageKey) + ]); + } + + private async findEntity(id: string): Promise { + 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 { + if (!storageKey) { + return; + } + + const path = resolveBlogImagePath(storageKey); + + await removeFileFromDisk(path, BlogPostsService.name); + } +} diff --git a/backend/src/modules/blog/types/BlogPostExtended.ts b/backend/src/modules/blog/types/BlogPostExtended.ts new file mode 100644 index 0000000..1d765bb --- /dev/null +++ b/backend/src/modules/blog/types/BlogPostExtended.ts @@ -0,0 +1,6 @@ +import type { BlogPost } from '../entities/BlogPost'; + +export type BlogPostExtended = BlogPost & { + thumbnailUrl: string | null; + bannerUrl: string | null; +}; diff --git a/backend/src/modules/shopSettings/controllers/ShopSettingsController.ts b/backend/src/modules/shopSettings/controllers/ShopSettingsController.ts index 6480a70..639fe4c 100644 --- a/backend/src/modules/shopSettings/controllers/ShopSettingsController.ts +++ b/backend/src/modules/shopSettings/controllers/ShopSettingsController.ts @@ -13,6 +13,7 @@ import { JwtGuard } from '../../../guards/JwtGuard'; import { shopFaviconFilePipe, shopFaviconUploadOptions } from '../config/shopFaviconUpload'; import { shopLogoFilePipe, shopLogoUploadOptions } from '../config/shopLogoUpload'; import { ConnectSimplexNotificationsDto } from '../dto/ConnectSimplexNotificationsDto'; +import { UpdateBlogEnabledDto } from '../dto/UpdateBlogEnabledDto'; import { UpdateNotificationsDto } from '../dto/UpdateNotificationsDto'; import { UpdateShippingNoteDto } from '../dto/UpdateShippingNoteDto'; import { UpdateSimplexLinkDto } from '../dto/UpdateSimplexLinkDto'; @@ -43,6 +44,11 @@ export class ShopSettingsController { return this.shopSettingsService.updateNotifications(dto); } + @Patch('/blog-enabled') + updateBlogEnabled(@Body() dto: UpdateBlogEnabledDto) { + return this.shopSettingsService.updateBlogEnabled(dto); + } + @Post('/simplex-connect') connectSimplexNotifications(@Body() dto: ConnectSimplexNotificationsDto) { return this.shopSettingsService.connectSimplexNotifications(dto.simplexNotificationLink); diff --git a/backend/src/modules/shopSettings/dto/UpdateBlogEnabledDto.ts b/backend/src/modules/shopSettings/dto/UpdateBlogEnabledDto.ts new file mode 100644 index 0000000..0e40ce4 --- /dev/null +++ b/backend/src/modules/shopSettings/dto/UpdateBlogEnabledDto.ts @@ -0,0 +1,7 @@ +import { IsBoolean, IsNotEmpty } from 'class-validator'; + +export class UpdateBlogEnabledDto { + @IsNotEmpty() + @IsBoolean() + blogEnabled: boolean; +} diff --git a/backend/src/modules/shopSettings/entities/ShopSettings.ts b/backend/src/modules/shopSettings/entities/ShopSettings.ts index 839bfa0..66eec16 100644 --- a/backend/src/modules/shopSettings/entities/ShopSettings.ts +++ b/backend/src/modules/shopSettings/entities/ShopSettings.ts @@ -32,6 +32,9 @@ export class ShopSettings { @Column({ type: 'integer', nullable: true }) simplexNotificationContactId: number | null; + @Column({ type: 'boolean', default: false }) + blogEnabled: boolean; + @CreateDateColumn() createdAt: Date; diff --git a/backend/src/modules/shopSettings/services/ShopSettingsService.spec.ts b/backend/src/modules/shopSettings/services/ShopSettingsService.spec.ts index 2b4b964..a03818a 100644 --- a/backend/src/modules/shopSettings/services/ShopSettingsService.spec.ts +++ b/backend/src/modules/shopSettings/services/ShopSettingsService.spec.ts @@ -25,6 +25,7 @@ describe('ShopSettingsService', () => { notifyOnNewOrder: false, notifyOnOrderMessage: false, simplexNotificationContactId: null, + blogEnabled: false, createdAt: new Date('2026-01-01T00:00:00.000Z'), updatedAt: new Date('2026-01-01T00:00:00.000Z') } as ShopSettings; @@ -67,10 +68,27 @@ describe('ShopSettingsService', () => { logoUrl: '/uploads/public/shop/logo.png', faviconUrl: null, simplexLink: 'https://simplex.example', - shippingNote: 'Ships in 3 days' + shippingNote: 'Ships in 3 days', + blogEnabled: false }); }); + it('updates blog enabled flag', async () => { + await service.updateBlogEnabled({ blogEnabled: true }); + + expect(shopSettingsRepo.update).toHaveBeenCalledWith('settings-1', { blogEnabled: true }); + }); + + it('includes blog enabled in shop settings view', async () => { + shopSettingsRepo.find.mockResolvedValue([{ ...settings, blogEnabled: true }]); + + await expect(service.getView()).resolves.toEqual( + expect.objectContaining({ + blogEnabled: true + }) + ); + }); + it('trims simplex links on update', async () => { await service.updateSimplexLink({ simplexLink: ' https://simplex.example/new ' }); diff --git a/backend/src/modules/shopSettings/services/ShopSettingsService.ts b/backend/src/modules/shopSettings/services/ShopSettingsService.ts index 293e154..f53b3cd 100644 --- a/backend/src/modules/shopSettings/services/ShopSettingsService.ts +++ b/backend/src/modules/shopSettings/services/ShopSettingsService.ts @@ -6,6 +6,7 @@ import { getShopBrandingPublicUrl, resolveShopBrandingPath } from '../../../conf import { Config } from '../../../types/Config'; import { removeFileFromDisk } from '../../../utils/removeFileFromDisk'; import { SimplexChatClient } from '../../simplex/services/SimplexChatClient'; +import { UpdateBlogEnabledDto } from '../dto/UpdateBlogEnabledDto'; import { UpdateNotificationsDto } from '../dto/UpdateNotificationsDto'; import { UpdateShippingNoteDto } from '../dto/UpdateShippingNoteDto'; import { UpdateSimplexLinkDto } from '../dto/UpdateSimplexLinkDto'; @@ -43,7 +44,8 @@ export class ShopSettingsService { logoUrl, faviconUrl, simplexLink: settings.simplexLink, - shippingNote: settings.shippingNote + shippingNote: settings.shippingNote, + blogEnabled: settings.blogEnabled }; } @@ -93,6 +95,14 @@ export class ShopSettingsService { return this.getView(); } + async updateBlogEnabled({ blogEnabled }: UpdateBlogEnabledDto): Promise { + const settings = await this.findSettings(); + + await this.shopSettingsRepo.update(settings.id, { blogEnabled }); + + return this.getView(); + } + async uploadLogo(filename: string): Promise { const settings = await this.findSettings(); @@ -191,6 +201,7 @@ export class ShopSettingsService { notifyOnNewOrder, notifyOnOrderMessage, simplexNotificationContactId, + blogEnabled, createdAt, updatedAt }: ShopSettings): ShopSettingsView { @@ -220,6 +231,7 @@ export class ShopSettingsService { notifyOnNewOrder, notifyOnOrderMessage, simplexNotificationConnected: simplexNotificationContactId !== null, + blogEnabled, isSetupComplete, setupChecklist, createdAt, diff --git a/backend/src/modules/shopSettings/types/ShopSettingsView.ts b/backend/src/modules/shopSettings/types/ShopSettingsView.ts index 9c889de..cd7b6fa 100644 --- a/backend/src/modules/shopSettings/types/ShopSettingsView.ts +++ b/backend/src/modules/shopSettings/types/ShopSettingsView.ts @@ -18,6 +18,7 @@ export interface ShopSettingsView { notifyOnNewOrder: boolean; notifyOnOrderMessage: boolean; simplexNotificationConnected: boolean; + blogEnabled: boolean; isSetupComplete: boolean; setupChecklist: SetupChecklist; createdAt: Date | null; diff --git a/backend/src/modules/shopSettings/types/StorefrontBranding.ts b/backend/src/modules/shopSettings/types/StorefrontBranding.ts index 39d5da5..1eaca45 100644 --- a/backend/src/modules/shopSettings/types/StorefrontBranding.ts +++ b/backend/src/modules/shopSettings/types/StorefrontBranding.ts @@ -3,4 +3,5 @@ export interface StorefrontBranding { faviconUrl: string | null; simplexLink: string | null; shippingNote: string | null; + blogEnabled: boolean; } diff --git a/backend/src/modules/storefrontBlog/StorefrontBlogModule.ts b/backend/src/modules/storefrontBlog/StorefrontBlogModule.ts new file mode 100644 index 0000000..eaff28c --- /dev/null +++ b/backend/src/modules/storefrontBlog/StorefrontBlogModule.ts @@ -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 {} diff --git a/backend/src/modules/storefrontBlog/controllers/StorefrontBlogController.ts b/backend/src/modules/storefrontBlog/controllers/StorefrontBlogController.ts new file mode 100644 index 0000000..ef1264d --- /dev/null +++ b/backend/src/modules/storefrontBlog/controllers/StorefrontBlogController.ts @@ -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 + }); + } +} diff --git a/backend/src/modules/storefrontBlog/services/StorefrontBlogPostsService.spec.ts b/backend/src/modules/storefrontBlog/services/StorefrontBlogPostsService.spec.ts new file mode 100644 index 0000000..99f4dfc --- /dev/null +++ b/backend/src/modules/storefrontBlog/services/StorefrontBlogPostsService.spec.ts @@ -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); + }); + + 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); + }); +}); diff --git a/backend/src/modules/storefrontBlog/services/StorefrontBlogPostsService.ts b/backend/src/modules/storefrontBlog/services/StorefrontBlogPostsService.ts new file mode 100644 index 0000000..ca34768 --- /dev/null +++ b/backend/src/modules/storefrontBlog/services/StorefrontBlogPostsService.ts @@ -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 + ) {} + + async listLivePosts(): Promise { + return this.blogPostRepo.find({ + where: { isDraft: false }, + order: { createdAt: 'DESC' } + }); + } + + async getLivePost(id: string): Promise { + const post = await this.blogPostRepo.findOne({ + where: { id, isDraft: false } + }); + + if (!post) { + throw new NotFoundException('We could not find that blog post.'); + } + + return post; + } +} diff --git a/backend/src/modules/storefrontBlog/services/StorefrontBlogViewService.spec.ts b/backend/src/modules/storefrontBlog/services/StorefrontBlogViewService.spec.ts new file mode 100644 index 0000000..5be0b0f --- /dev/null +++ b/backend/src/modules/storefrontBlog/services/StorefrontBlogViewService.spec.ts @@ -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' + } + ]); + }); +}); diff --git a/backend/src/modules/storefrontBlog/services/StorefrontBlogViewService.ts b/backend/src/modules/storefrontBlog/services/StorefrontBlogViewService.ts new file mode 100644 index 0000000..84019e8 --- /dev/null +++ b/backend/src/modules/storefrontBlog/services/StorefrontBlogViewService.ts @@ -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 + }; + } +} diff --git a/backend/src/modules/storefrontBlog/types/StorefrontBlogPostCardView.ts b/backend/src/modules/storefrontBlog/types/StorefrontBlogPostCardView.ts new file mode 100644 index 0000000..95b940d --- /dev/null +++ b/backend/src/modules/storefrontBlog/types/StorefrontBlogPostCardView.ts @@ -0,0 +1,6 @@ +export interface StorefrontBlogPostCardView { + id: string; + title: string; + thumbnailUrl: string | null; + detailHref: string; +} diff --git a/backend/src/modules/storefrontBlog/types/StorefrontBlogPostDetailView.ts b/backend/src/modules/storefrontBlog/types/StorefrontBlogPostDetailView.ts new file mode 100644 index 0000000..5b00505 --- /dev/null +++ b/backend/src/modules/storefrontBlog/types/StorefrontBlogPostDetailView.ts @@ -0,0 +1,7 @@ +export interface StorefrontBlogPostDetailView { + id: string; + title: string; + bannerUrl: string | null; + thumbnailUrl: string | null; + contentHtml: string; +} diff --git a/backend/src/modules/storefrontCore/public/css/storefront.css b/backend/src/modules/storefrontCore/public/css/storefront.css index 874c72b..645cd3d 100644 --- a/backend/src/modules/storefrontCore/public/css/storefront.css +++ b/backend/src/modules/storefrontCore/public/css/storefront.css @@ -49,6 +49,8 @@ --sf-split-sidebar: minmax(220px, 320px); --sf-product-card-min: 11rem; --sf-product-card-max: 22rem; + --sf-blog-card-min: 17rem; + --sf-blog-card-max: 28rem; --sf-sticky-header-scroll-padding: 9rem; } @@ -1598,3 +1600,75 @@ a:hover { font-size: 2rem; margin: 0 0 var(--sf-space-2); } + +.sf-blog-grid { + display: grid; + gap: var(--sf-space-3); + grid-template-columns: minmax(0, 1fr); +} + +@media (min-width: 36rem) { + .sf-blog-grid { + grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--sf-blog-card-min)), 1fr)); + } +} + +.sf-blog-card { + width: 100%; + max-width: var(--sf-blog-card-max); + min-width: 0; + justify-self: start; +} + +.sf-blog-card__link { + display: flex; + flex-direction: column; + gap: var(--sf-space-2); + color: inherit; + text-decoration: none; +} + +.sf-blog-card__link:hover .sf-blog-card__title { + color: var(--sf-link-hover); +} + +.sf-blog-card__media { + aspect-ratio: 16 / 9; + background: var(--sf-surface-muted); + border-radius: var(--sf-radius-sm); + overflow: hidden; +} + +.sf-blog-card__image { + width: 100%; + height: 100%; + object-fit: cover; +} + +.sf-blog-card__title { + font-size: 0.9375rem; + line-height: 1.4; + font-weight: 600; +} + +.sf-blog-detail__banner { + margin-bottom: var(--sf-space-4); + background: var(--sf-surface-muted); + border-radius: var(--sf-radius-sm); + overflow: hidden; +} + +.sf-blog-detail__banner-image { + display: block; + width: 100%; + max-height: 24rem; + object-fit: cover; +} + +.sf-blog-detail__title { + margin-bottom: var(--sf-space-4); +} + +.sf-blog-detail__content { + max-width: 42rem; +} diff --git a/backend/src/modules/storefrontCore/services/StorefrontShopViewService.spec.ts b/backend/src/modules/storefrontCore/services/StorefrontShopViewService.spec.ts index ca6dda6..62b119a 100644 --- a/backend/src/modules/storefrontCore/services/StorefrontShopViewService.spec.ts +++ b/backend/src/modules/storefrontCore/services/StorefrontShopViewService.spec.ts @@ -29,7 +29,8 @@ describe('StorefrontShopViewService', () => { logoUrl: null, faviconUrl: null, simplexLink: null, - shippingNote: null + shippingNote: null, + blogEnabled: false }, shopSettings = { shopName: 'Demo Shop', @@ -136,7 +137,8 @@ describe('StorefrontShopViewService', () => { logoUrl: '/uploads/logo.png', faviconUrl: '/uploads/favicon.ico', simplexLink: 'https://simplex.example', - shippingNote: 'Ships in 3 days' + shippingNote: 'Ships in 3 days', + blogEnabled: true }, fiatPerXmr: 200, fiatPerBtc: 80_000 @@ -157,6 +159,7 @@ describe('StorefrontShopViewService', () => { faviconUrl: '/uploads/favicon.ico', simplexLink: 'https://simplex.example', shippingNote: 'Ships in 3 days', + blogEnabled: true, themePreference: 'dark' }); }); diff --git a/backend/src/modules/storefrontCore/services/StorefrontShopViewService.ts b/backend/src/modules/storefrontCore/services/StorefrontShopViewService.ts index f236966..56ae5ba 100644 --- a/backend/src/modules/storefrontCore/services/StorefrontShopViewService.ts +++ b/backend/src/modules/storefrontCore/services/StorefrontShopViewService.ts @@ -51,7 +51,7 @@ export class StorefrontShopViewService { const cryptoRates = this.buildCryptoRates(enabledPaymentMethods); - const { logoUrl, faviconUrl, simplexLink, shippingNote } = + const { logoUrl, faviconUrl, simplexLink, shippingNote, blogEnabled } = await this.shopSettingsService.getStorefrontBranding(); const authorizedOrders = this.buildAuthorizedOrderNavItems(req, res); @@ -75,6 +75,7 @@ export class StorefrontShopViewService { faviconUrl, simplexLink, shippingNote, + blogEnabled, themePreference, pageMeta: this.buildPageMeta(siteOrigin, shopName, shopFiatCurrency, currentPath, page) }; diff --git a/backend/src/modules/storefrontCore/types/ShopNavKey.ts b/backend/src/modules/storefrontCore/types/ShopNavKey.ts index ce92918..89e11ad 100644 --- a/backend/src/modules/storefrontCore/types/ShopNavKey.ts +++ b/backend/src/modules/storefrontCore/types/ShopNavKey.ts @@ -1 +1 @@ -export type ShopNavKey = 'shop' | 'cart' | 'checkout' | 'check-order'; +export type ShopNavKey = 'shop' | 'blog' | 'cart' | 'checkout' | 'check-order'; diff --git a/backend/src/modules/storefrontCore/types/ShopRenderLocals.ts b/backend/src/modules/storefrontCore/types/ShopRenderLocals.ts index d04e7bb..8c1c952 100644 --- a/backend/src/modules/storefrontCore/types/ShopRenderLocals.ts +++ b/backend/src/modules/storefrontCore/types/ShopRenderLocals.ts @@ -20,6 +20,7 @@ export type ShopRenderLocals = { faviconUrl: string | null; simplexLink: string | null; shippingNote: string | null; + blogEnabled: boolean; themePreference: StorefrontThemePreference; pageMeta: StorefrontPageMeta; }; diff --git a/backend/src/modules/storefrontCore/types/StorefrontShopViewServiceTestTypes.ts b/backend/src/modules/storefrontCore/types/StorefrontShopViewServiceTestTypes.ts index 4b68795..03847da 100644 --- a/backend/src/modules/storefrontCore/types/StorefrontShopViewServiceTestTypes.ts +++ b/backend/src/modules/storefrontCore/types/StorefrontShopViewServiceTestTypes.ts @@ -13,6 +13,7 @@ export type StorefrontShopViewServiceOverrides = { faviconUrl: string | null; simplexLink: string | null; shippingNote: string | null; + blogEnabled: boolean; }; shopSettings?: { shopName: string; shopFiatCurrency: string; enabledPaymentMethods?: PaymentMethod[] }; fiatPerXmr?: number; diff --git a/backend/src/modules/storefrontCore/utils/registerStorefrontPartials.ts b/backend/src/modules/storefrontCore/utils/registerStorefrontPartials.ts index 5c7eae1..1ee01b3 100644 --- a/backend/src/modules/storefrontCore/utils/registerStorefrontPartials.ts +++ b/backend/src/modules/storefrontCore/utils/registerStorefrontPartials.ts @@ -33,6 +33,7 @@ const STOREFRONT_PARTIALS = [ { name: 'html-theme-attributes', file: 'html-theme-attributes.hbs' }, { name: 'page-back-link', file: 'page-back-link.hbs' }, { name: 'product-card', file: 'product-card.hbs' }, + { name: 'blog-card', file: 'blog-card.hbs' }, { name: 'order-data-retention-notice', file: 'order-data-retention-notice.hbs' }, { name: 'category-nav', file: 'category-nav.hbs' }, { name: 'product-delivery-note', file: 'product-delivery-note.hbs' }, diff --git a/backend/src/modules/storefrontCore/utils/resolveShopNavActive.spec.ts b/backend/src/modules/storefrontCore/utils/resolveShopNavActive.spec.ts index 7182ed6..bc0f5e3 100644 --- a/backend/src/modules/storefrontCore/utils/resolveShopNavActive.spec.ts +++ b/backend/src/modules/storefrontCore/utils/resolveShopNavActive.spec.ts @@ -29,6 +29,11 @@ describe('resolveShopNavActive', () => { }); }); + it('highlights blog pages', () => { + expect(resolveShopNavActive('/shop/blog')).toEqual({ activeShopNav: 'blog', activeOrderId: null }); + expect(resolveShopNavActive('/shop/blog/post-1')).toEqual({ activeShopNav: 'blog', activeOrderId: null }); + }); + it('does not highlight nav items on unrelated pages', () => { expect(resolveShopNavActive('/shop/theme')).toEqual({ activeShopNav: null, activeOrderId: null }); }); diff --git a/backend/src/modules/storefrontCore/utils/resolveShopNavActive.ts b/backend/src/modules/storefrontCore/utils/resolveShopNavActive.ts index c24859c..026b01e 100644 --- a/backend/src/modules/storefrontCore/utils/resolveShopNavActive.ts +++ b/backend/src/modules/storefrontCore/utils/resolveShopNavActive.ts @@ -23,6 +23,10 @@ export const resolveShopNavActive = (path: string): ShopNavActive => { return { activeShopNav: null, activeOrderId: third }; } + if (first === 'shop' && second === 'blog') { + return { activeShopNav: 'blog', activeOrderId: null }; + } + if (first === 'shop' && (second === 'categories' || second === 'products')) { return { activeShopNav: 'shop', activeOrderId: null }; } diff --git a/backend/src/modules/storefrontCore/views/blog-detail.hbs b/backend/src/modules/storefrontCore/views/blog-detail.hbs new file mode 100644 index 0000000..86ed60b --- /dev/null +++ b/backend/src/modules/storefrontCore/views/blog-detail.hbs @@ -0,0 +1,16 @@ +{{> page-back-link href=backHref label=backLabel}} + +{{#if post.bannerUrl}} +
+ {{> storefront-image + class='sf-blog-detail__banner-image' + src=post.bannerUrl + width='1600' + height='600' + }} +
+{{/if}} + +

{{post.title}}

+ +
{{{post.contentHtml}}}
diff --git a/backend/src/modules/storefrontCore/views/blog-index.hbs b/backend/src/modules/storefrontCore/views/blog-index.hbs new file mode 100644 index 0000000..e2f93d4 --- /dev/null +++ b/backend/src/modules/storefrontCore/views/blog-index.hbs @@ -0,0 +1,11 @@ +

{{title}}

+ +{{#if posts.length}} +
    + {{#each posts}} + {{> blog-card}} + {{/each}} +
+{{else}} +

No posts yet.

+{{/if}} diff --git a/backend/src/modules/storefrontCore/views/partials/blog-card.hbs b/backend/src/modules/storefrontCore/views/partials/blog-card.hbs new file mode 100644 index 0000000..205c41c --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/blog-card.hbs @@ -0,0 +1,16 @@ +
  • + + {{#if thumbnailUrl}} +
    + {{> storefront-image + class='sf-blog-card__image' + src=thumbnailUrl + width='1' + height='1' + loading='lazy' + }} +
    + {{/if}} + {{title}} +
    +
  • diff --git a/backend/src/modules/storefrontCore/views/partials/cart-line-item.hbs b/backend/src/modules/storefrontCore/views/partials/cart-line-item.hbs index a3e137e..38e255b 100644 --- a/backend/src/modules/storefrontCore/views/partials/cart-line-item.hbs +++ b/backend/src/modules/storefrontCore/views/partials/cart-line-item.hbs @@ -5,7 +5,6 @@ {{#if thumbnailUrl}} {{> storefront-image src=thumbnailUrl - alt='' width='1' height='1' loading='lazy' diff --git a/backend/src/modules/storefrontCore/views/partials/cart-totals-panel.hbs b/backend/src/modules/storefrontCore/views/partials/cart-totals-panel.hbs index 7afda16..1857377 100644 --- a/backend/src/modules/storefrontCore/views/partials/cart-totals-panel.hbs +++ b/backend/src/modules/storefrontCore/views/partials/cart-totals-panel.hbs @@ -86,7 +86,6 @@ {{> storefront-image class='sf-crypto-icon' src=iconUrl - alt='' width='20' height='20' }} diff --git a/backend/src/modules/storefrontCore/views/partials/checkout-line-item.hbs b/backend/src/modules/storefrontCore/views/partials/checkout-line-item.hbs index 106ba67..037b935 100644 --- a/backend/src/modules/storefrontCore/views/partials/checkout-line-item.hbs +++ b/backend/src/modules/storefrontCore/views/partials/checkout-line-item.hbs @@ -6,7 +6,6 @@ {{#if thumbnailUrl}} {{> storefront-image src=thumbnailUrl - alt='' width='1' height='1' loading='lazy' @@ -19,7 +18,6 @@ {{#if thumbnailUrl}} {{> storefront-image src=thumbnailUrl - alt='' width='1' height='1' loading='lazy' diff --git a/backend/src/modules/storefrontCore/views/partials/order-line-item.hbs b/backend/src/modules/storefrontCore/views/partials/order-line-item.hbs index 31daf4a..72f92d1 100644 --- a/backend/src/modules/storefrontCore/views/partials/order-line-item.hbs +++ b/backend/src/modules/storefrontCore/views/partials/order-line-item.hbs @@ -6,7 +6,6 @@ {{#if thumbnailUrl}} {{> storefront-image src=thumbnailUrl - alt='' width='1' height='1' loading='lazy' @@ -19,7 +18,6 @@ {{#if thumbnailUrl}} {{> storefront-image src=thumbnailUrl - alt='' width='1' height='1' loading='lazy' diff --git a/backend/src/modules/storefrontCore/views/partials/product-card.hbs b/backend/src/modules/storefrontCore/views/partials/product-card.hbs index 0d5cfd3..aa94321 100644 --- a/backend/src/modules/storefrontCore/views/partials/product-card.hbs +++ b/backend/src/modules/storefrontCore/views/partials/product-card.hbs @@ -5,7 +5,6 @@ {{> storefront-image class='sf-product-card__image' src=thumbnailUrl - alt='' width='1' height='1' loading='lazy' diff --git a/backend/src/modules/storefrontCore/views/partials/shop-nav.hbs b/backend/src/modules/storefrontCore/views/partials/shop-nav.hbs index 7e67148..edfa52b 100644 --- a/backend/src/modules/storefrontCore/views/partials/shop-nav.hbs +++ b/backend/src/modules/storefrontCore/views/partials/shop-nav.hbs @@ -17,6 +17,9 @@