diff --git a/backend/src/AppModule.ts b/backend/src/AppModule.ts index 90f2e7e..2933a85 100644 --- a/backend/src/AppModule.ts +++ b/backend/src/AppModule.ts @@ -38,6 +38,7 @@ import { StorefrontCheckoutModule } from './modules/storefrontCheckout/Storefron import { StorefrontOrderModule } from './modules/storefrontOrder/StorefrontOrderModule'; import { StorefrontCoreModule } from './modules/storefrontCore/StorefrontCoreModule'; import { StorefrontProductModule } from './modules/storefrontProduct/StorefrontProductModule'; +import { StorefrontBlogModule } from './modules/storefrontBlog/StorefrontBlogModule'; import { ExchangeRateModule } from './modules/exchangeRate/ExchangeRateModule'; import { Config } from './types/Config'; @@ -101,7 +102,8 @@ import { Config } from './types/Config'; StorefrontProductModule, StorefrontCartModule, StorefrontCheckoutModule, - StorefrontOrderModule + StorefrontOrderModule, + StorefrontBlogModule ], providers: [ { 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 index 30b0433..9af786d 100644 --- a/backend/src/modules/blog/BlogModule.ts +++ b/backend/src/modules/blog/BlogModule.ts @@ -7,6 +7,7 @@ import { BlogPostsService } from './services/BlogPostsService'; @Module({ imports: [TypeOrmModule.forFeature([BlogPost])], controllers: [BlogPostsController], - providers: [BlogPostsService] + providers: [BlogPostsService], + exports: [TypeOrmModule.forFeature([BlogPost])] }) export class BlogModule {} 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..07b86b1 100644 --- a/backend/src/modules/storefrontCore/public/css/storefront.css +++ b/backend/src/modules/storefrontCore/public/css/storefront.css @@ -1598,3 +1598,64 @@ 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: repeat(2, minmax(0, 1fr)); +} + +@media (min-width: 36rem) { + .sf-blog-grid { + grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--sf-product-card-min)), 1fr)); + } +} + +.sf-blog-card { + width: 100%; + max-width: var(--sf-product-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__image { + width: 100%; + aspect-ratio: 16 / 9; + object-fit: cover; + border-radius: var(--sf-radius-sm); + background: var(--sf-surface-muted); +} + +.sf-blog-card__title { + font-weight: 600; +} + +.sf-blog-detail__banner { + width: 100vw; + margin-left: calc(50% - 50vw); + margin-bottom: var(--sf-space-4); + background: var(--sf-surface-muted); +} + +.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 80b877c..62b119a 100644 --- a/backend/src/modules/storefrontCore/services/StorefrontShopViewService.spec.ts +++ b/backend/src/modules/storefrontCore/services/StorefrontShopViewService.spec.ts @@ -137,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 @@ -158,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/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..dfad2ea --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/blog-card.hbs @@ -0,0 +1,14 @@ +
  • + + {{#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/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 @@