Add storefront blog pages with nav, templates, and SEO meta.

Wire blogEnabled into shop render locals and expose public blog index and detail routes when the shop toggle is on.
This commit is contained in:
2026-09-16 13:55:12 +02:00
parent 73f7ffed00
commit a3d1842fb3
23 changed files with 359 additions and 5 deletions
+3 -1
View File
@@ -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: [
{ {
+2
View File
@@ -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 },
+2 -1
View File
@@ -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 {}
@@ -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;
}
@@ -1598,3 +1598,64 @@ 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: 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;
}
@@ -137,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
@@ -158,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;
}; };
@@ -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,14 @@
<li class='sf-card sf-blog-card'>
<a class='sf-blog-card__link' href='{{detailHref}}'>
{{#if thumbnailUrl}}
{{> storefront-image
class='sf-blog-card__image'
src=thumbnailUrl
width='1'
height='1'
loading='lazy'
}}
{{/if}}
<strong class='sf-blog-card__title'>{{title}}</strong>
</a>
</li>
@@ -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}}