Slave/blog #9
@@ -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