From 762af5c4f3a7d9f1d5e53ab1b0da5da2bb7657bf Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Wed, 16 Sep 2026 20:58:22 +0200 Subject: [PATCH] Add CMS blog admin for managing posts and storefront visibility. Wire list and detail views with image uploads, draft/live editing, and a blog-enabled toggle backed by the shop settings API. --- cms/src/App.vue | 1 + cms/src/consts/routeNames.ts | 2 + cms/src/router/index.ts | 12 + cms/src/stores/blogPosts.ts | 129 +++++ cms/src/stores/shopSettings.ts | 10 + cms/src/types/blog/BlogPost.ts | 10 + cms/src/types/blog/UpdateBlogPostPayload.ts | 5 + cms/src/types/shopSettings/ShopSettings.ts | 1 + .../shopSettings/UpdateBlogEnabledPayload.ts | 3 + cms/src/views/CmsBlogPostDetailView.vue | 467 ++++++++++++++++++ cms/src/views/CmsBlogPostsView.vue | 169 +++++++ 11 files changed, 809 insertions(+) create mode 100644 cms/src/stores/blogPosts.ts create mode 100644 cms/src/types/blog/BlogPost.ts create mode 100644 cms/src/types/blog/UpdateBlogPostPayload.ts create mode 100644 cms/src/types/shopSettings/UpdateBlogEnabledPayload.ts create mode 100644 cms/src/views/CmsBlogPostDetailView.vue create mode 100644 cms/src/views/CmsBlogPostsView.vue diff --git a/cms/src/App.vue b/cms/src/App.vue index e4a205a..3005dc8 100644 --- a/cms/src/App.vue +++ b/cms/src/App.vue @@ -16,6 +16,7 @@ Products Categories Discount codes + Blog Orders Wallet Settings diff --git a/cms/src/consts/routeNames.ts b/cms/src/consts/routeNames.ts index ba183f0..b939c89 100644 --- a/cms/src/consts/routeNames.ts +++ b/cms/src/consts/routeNames.ts @@ -5,6 +5,8 @@ export const ROUTE_NAMES = { Products: 'Products', Categories: 'Categories', DiscountCodes: 'DiscountCodes', + BlogPosts: 'BlogPosts', + BlogPostDetail: 'BlogPostDetail', ShopSettings: 'ShopSettings', Notifications: 'Notifications', MoneroWallet: 'MoneroWallet', diff --git a/cms/src/router/index.ts b/cms/src/router/index.ts index 173b02d..813aba1 100644 --- a/cms/src/router/index.ts +++ b/cms/src/router/index.ts @@ -41,6 +41,18 @@ const router = createRouter({ component: () => import('../views/CmsDiscountCodesView.vue'), meta: { requiresAuth: true, title: 'CMS - Discount codes' } }, + { + path: '/blog/:id', + name: ROUTE_NAMES.BlogPostDetail, + component: () => import('../views/CmsBlogPostDetailView.vue'), + meta: { requiresAuth: true, title: 'CMS - Blog post', activeMenu: '/blog' } + }, + { + path: '/blog', + name: ROUTE_NAMES.BlogPosts, + component: () => import('../views/CmsBlogPostsView.vue'), + meta: { requiresAuth: true, title: 'CMS - Blog' } + }, { path: '/orders/:id', name: ROUTE_NAMES.OrderDetail, diff --git a/cms/src/stores/blogPosts.ts b/cms/src/stores/blogPosts.ts new file mode 100644 index 0000000..be4a1f1 --- /dev/null +++ b/cms/src/stores/blogPosts.ts @@ -0,0 +1,129 @@ +import { defineStore } from 'pinia'; +import { computed, ref } from 'vue'; +import { useRoute } from 'vue-router'; +import { ROUTE_NAMES } from '@/consts/routeNames'; +import { api } from '@/plugins/axios'; +import type { BlogPost } from '@/types/blog/BlogPost'; +import type { UpdateBlogPostPayload } from '@/types/blog/UpdateBlogPostPayload'; +import type { PaginatedResponse } from '@/types/PaginatedResponse'; +import { getBlogPostTitle } from '@/utils/blog/getBlogPostTitle'; + +export const useBlogPostsStore = defineStore('blogPosts', () => { + const route = useRoute(); + + const blogPosts = ref([]); + + const currentBlogPostId = computed(() => { + if (route.name !== ROUTE_NAMES.BlogPostDetail) { + return null; + } + + const id = route.params.id; + + return typeof id === 'string' && id ? id : null; + }); + + const currentBlogPost = computed(() => { + if (!currentBlogPostId.value) { + return null; + } + + return blogPosts.value.find(post => post.id === currentBlogPostId.value) ?? null; + }); + + const currentBlogPostDisplayTitle = computed(() => + currentBlogPost.value ? getBlogPostTitle(currentBlogPost.value.title) : null + ); + + const upsertBlogPost = (data: BlogPost): void => { + const idx = blogPosts.value.findIndex(post => post.id === data.id); + + if (idx !== -1) { + blogPosts.value[idx] = data; + } else { + blogPosts.value.unshift(data); + } + }; + + const fetchBlogPosts = async ({ + page, + limit + }: { + page: number; + limit: number; + }): Promise> => { + const { data } = await api.get>('/blog-posts', { + params: { page, limit } + }); + + return data; + }; + + const fetchBlogPostById = async (id: string): Promise => { + const { data } = await api.get(`/blog-posts/${id}`); + + upsertBlogPost(data); + + return data; + }; + + const createDraftBlogPost = async (): Promise => { + const { data } = await api.post('/blog-posts'); + + upsertBlogPost(data); + + return data; + }; + + const updateBlogPost = async (id: string, payload: UpdateBlogPostPayload): Promise => { + const { data } = await api.patch(`/blog-posts/${id}`, payload); + + upsertBlogPost(data); + + return data; + }; + + const deleteBlogPost = async (id: string): Promise => { + await api.delete(`/blog-posts/${id}`); + + blogPosts.value = blogPosts.value.filter(post => post.id !== id); + }; + + const uploadThumbnail = async (id: string, file: File): Promise => { + const formData = new FormData(); + + formData.append('file', file); + + const { data } = await api.post(`/blog-posts/${id}/thumbnail`, formData); + + upsertBlogPost(data); + + return data; + }; + + const uploadBanner = async (id: string, file: File): Promise => { + const formData = new FormData(); + + formData.append('file', file); + + const { data } = await api.post(`/blog-posts/${id}/banner`, formData); + + upsertBlogPost(data); + + return data; + }; + + return { + blogPosts, + currentBlogPost, + currentBlogPostDisplayTitle, + currentBlogPostId, + fetchBlogPosts, + fetchBlogPostById, + createDraftBlogPost, + updateBlogPost, + deleteBlogPost, + uploadThumbnail, + uploadBanner + }; +}); diff --git a/cms/src/stores/shopSettings.ts b/cms/src/stores/shopSettings.ts index ccc9629..483476a 100644 --- a/cms/src/stores/shopSettings.ts +++ b/cms/src/stores/shopSettings.ts @@ -5,6 +5,7 @@ import type { ConnectSimplexNotificationsPayload } from '@/types/shopSettings/Co import type { ShopSettings } from '@/types/shopSettings/ShopSettings'; import type { UpdateNotificationsPayload } from '@/types/shopSettings/UpdateNotificationsPayload'; import type { UpdateShippingNotePayload } from '@/types/shopSettings/UpdateShippingNotePayload'; +import type { UpdateBlogEnabledPayload } from '@/types/shopSettings/UpdateBlogEnabledPayload'; import type { UpdateSimplexLinkPayload } from '@/types/shopSettings/UpdateSimplexLinkPayload'; export const useShopSettingsStore = defineStore('shopSettings', () => { @@ -42,6 +43,14 @@ export const useShopSettingsStore = defineStore('shopSettings', () => { return data; }; + const updateBlogEnabled = async (payload: UpdateBlogEnabledPayload): Promise => { + const { data } = await api.patch('/shop-settings/blog-enabled', payload); + + settings.value = data; + + return data; + }; + const connectSimplexNotifications = async (payload: ConnectSimplexNotificationsPayload): Promise => { const { data } = await api.post('/shop-settings/simplex-connect', payload); @@ -80,6 +89,7 @@ export const useShopSettingsStore = defineStore('shopSettings', () => { updateSimplexLink, updateShippingNote, updateNotifications, + updateBlogEnabled, connectSimplexNotifications, uploadShopLogo, uploadShopFavicon diff --git a/cms/src/types/blog/BlogPost.ts b/cms/src/types/blog/BlogPost.ts new file mode 100644 index 0000000..1a8bb98 --- /dev/null +++ b/cms/src/types/blog/BlogPost.ts @@ -0,0 +1,10 @@ +export interface BlogPost { + id: string; + title: string; + thumbnailUrl: string | null; + bannerUrl: string | null; + contentHtml: string; + isDraft: boolean; + createdAt: string; + updatedAt: string; +} diff --git a/cms/src/types/blog/UpdateBlogPostPayload.ts b/cms/src/types/blog/UpdateBlogPostPayload.ts new file mode 100644 index 0000000..ac7a4b1 --- /dev/null +++ b/cms/src/types/blog/UpdateBlogPostPayload.ts @@ -0,0 +1,5 @@ +export interface UpdateBlogPostPayload { + title: string; + contentHtml: string; + isDraft: boolean; +} diff --git a/cms/src/types/shopSettings/ShopSettings.ts b/cms/src/types/shopSettings/ShopSettings.ts index c68dc5b..40e4581 100644 --- a/cms/src/types/shopSettings/ShopSettings.ts +++ b/cms/src/types/shopSettings/ShopSettings.ts @@ -17,6 +17,7 @@ export interface ShopSettings { notifyOnNewOrder: boolean; notifyOnOrderMessage: boolean; simplexNotificationConnected: boolean; + blogEnabled: boolean; isSetupComplete: boolean; setupChecklist: SetupChecklist; createdAt: string | null; diff --git a/cms/src/types/shopSettings/UpdateBlogEnabledPayload.ts b/cms/src/types/shopSettings/UpdateBlogEnabledPayload.ts new file mode 100644 index 0000000..138bda6 --- /dev/null +++ b/cms/src/types/shopSettings/UpdateBlogEnabledPayload.ts @@ -0,0 +1,3 @@ +export interface UpdateBlogEnabledPayload { + blogEnabled: boolean; +} diff --git a/cms/src/views/CmsBlogPostDetailView.vue b/cms/src/views/CmsBlogPostDetailView.vue new file mode 100644 index 0000000..3520c97 --- /dev/null +++ b/cms/src/views/CmsBlogPostDetailView.vue @@ -0,0 +1,467 @@ + + + + + diff --git a/cms/src/views/CmsBlogPostsView.vue b/cms/src/views/CmsBlogPostsView.vue new file mode 100644 index 0000000..24a6863 --- /dev/null +++ b/cms/src/views/CmsBlogPostsView.vue @@ -0,0 +1,169 @@ + + +