Slave/blog #9

Merged
nobswebdev merged 14 commits from slave/blog into master 2026-09-16 20:15:08 +00:00
23 changed files with 359 additions and 5 deletions
Showing only changes of commit a3d1842fb3 - Show all commits
+3 -1
View File
@@ -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: [
{
+2
View File
@@ -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 },
+2 -1
View File
@@ -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}}