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:
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -77,6 +77,8 @@ async function bootstrap() {
|
||||
{ path: '/', method: RequestMethod.GET },
|
||||
{ path: 'shop/categories/:id', method: RequestMethod.GET },
|
||||
{ path: 'shop/products/:id/variants/:variantId', method: RequestMethod.GET },
|
||||
{ path: 'shop/blog', method: RequestMethod.GET },
|
||||
{ path: 'shop/blog/:id', method: RequestMethod.GET },
|
||||
{ path: 'shop/assets/{*path}', method: RequestMethod.GET },
|
||||
{ path: 'shop/preferences/theme', method: RequestMethod.POST },
|
||||
{ path: 'shop/error', method: RequestMethod.GET },
|
||||
|
||||
@@ -7,6 +7,7 @@ import { BlogPostsService } from './services/BlogPostsService';
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([BlogPost])],
|
||||
controllers: [BlogPostsController],
|
||||
providers: [BlogPostsService]
|
||||
providers: [BlogPostsService],
|
||||
exports: [TypeOrmModule.forFeature([BlogPost])]
|
||||
})
|
||||
export class BlogModule {}
|
||||
|
||||
@@ -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;
|
||||
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',
|
||||
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'
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ export class StorefrontShopViewService {
|
||||
|
||||
const cryptoRates = this.buildCryptoRates(enabledPaymentMethods);
|
||||
|
||||
const { logoUrl, faviconUrl, simplexLink, shippingNote } =
|
||||
const { logoUrl, faviconUrl, simplexLink, shippingNote, blogEnabled } =
|
||||
await this.shopSettingsService.getStorefrontBranding();
|
||||
|
||||
const authorizedOrders = this.buildAuthorizedOrderNavItems(req, res);
|
||||
@@ -75,6 +75,7 @@ export class StorefrontShopViewService {
|
||||
faviconUrl,
|
||||
simplexLink,
|
||||
shippingNote,
|
||||
blogEnabled,
|
||||
themePreference,
|
||||
pageMeta: this.buildPageMeta(siteOrigin, shopName, shopFiatCurrency, currentPath, page)
|
||||
};
|
||||
|
||||
@@ -1 +1 @@
|
||||
export type ShopNavKey = 'shop' | 'cart' | 'checkout' | 'check-order';
|
||||
export type ShopNavKey = 'shop' | 'blog' | 'cart' | 'checkout' | 'check-order';
|
||||
|
||||
@@ -20,6 +20,7 @@ export type ShopRenderLocals = {
|
||||
faviconUrl: string | null;
|
||||
simplexLink: string | null;
|
||||
shippingNote: string | null;
|
||||
blogEnabled: boolean;
|
||||
themePreference: StorefrontThemePreference;
|
||||
pageMeta: StorefrontPageMeta;
|
||||
};
|
||||
|
||||
@@ -33,6 +33,7 @@ const STOREFRONT_PARTIALS = [
|
||||
{ name: 'html-theme-attributes', file: 'html-theme-attributes.hbs' },
|
||||
{ name: 'page-back-link', file: 'page-back-link.hbs' },
|
||||
{ name: 'product-card', file: 'product-card.hbs' },
|
||||
{ name: 'blog-card', file: 'blog-card.hbs' },
|
||||
{ name: 'order-data-retention-notice', file: 'order-data-retention-notice.hbs' },
|
||||
{ name: 'category-nav', file: 'category-nav.hbs' },
|
||||
{ name: 'product-delivery-note', file: 'product-delivery-note.hbs' },
|
||||
|
||||
@@ -29,6 +29,11 @@ describe('resolveShopNavActive', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('highlights blog pages', () => {
|
||||
expect(resolveShopNavActive('/shop/blog')).toEqual({ activeShopNav: 'blog', activeOrderId: null });
|
||||
expect(resolveShopNavActive('/shop/blog/post-1')).toEqual({ activeShopNav: 'blog', activeOrderId: null });
|
||||
});
|
||||
|
||||
it('does not highlight nav items on unrelated pages', () => {
|
||||
expect(resolveShopNavActive('/shop/theme')).toEqual({ activeShopNav: null, activeOrderId: null });
|
||||
});
|
||||
|
||||
@@ -23,6 +23,10 @@ export const resolveShopNavActive = (path: string): ShopNavActive => {
|
||||
return { activeShopNav: null, activeOrderId: third };
|
||||
}
|
||||
|
||||
if (first === 'shop' && second === 'blog') {
|
||||
return { activeShopNav: 'blog', activeOrderId: null };
|
||||
}
|
||||
|
||||
if (first === 'shop' && (second === 'categories' || second === 'products')) {
|
||||
return { activeShopNav: 'shop', activeOrderId: null };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{{> page-back-link href=backHref label=backLabel}}
|
||||
|
||||
{{#if post.bannerUrl}}
|
||||
<div class='sf-blog-detail__banner'>
|
||||
{{> storefront-image
|
||||
class='sf-blog-detail__banner-image'
|
||||
src=post.bannerUrl
|
||||
width='1600'
|
||||
height='600'
|
||||
}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<h1 class='sf-page-title sf-blog-detail__title'>{{post.title}}</h1>
|
||||
|
||||
<div class='sf-prose sf-blog-detail__content'>{{{post.contentHtml}}}</div>
|
||||
@@ -0,0 +1,11 @@
|
||||
<h1 class='sf-page-title'>{{title}}</h1>
|
||||
|
||||
{{#if posts.length}}
|
||||
<ul class='sf-list-reset sf-blog-grid'>
|
||||
{{#each posts}}
|
||||
{{> blog-card}}
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class='sf-text-muted'>No posts yet.</p>
|
||||
{{/if}}
|
||||
@@ -0,0 +1,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-link active=shopNavActive.activeShopNav key='shop' href='/' label='Shop'}}
|
||||
{{#if blogEnabled}}
|
||||
{{> nav-link active=shopNavActive.activeShopNav key='blog' href='/shop/blog' label='Blog'}}
|
||||
{{/if}}
|
||||
{{#if hasActiveCheckout}}
|
||||
{{> nav-link active=shopNavActive.activeShopNav key='checkout' href='/shop/checkout' label='Checkout' count=cartTotalQty}}
|
||||
{{else}}
|
||||
|
||||
Reference in New Issue
Block a user