domma-cms 0.112.1 → 0.114.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/admin/js/app.js CHANGED
@@ -1,7 +1,7 @@
1
- import{views as q}from"./views/index.js";import{withPluginChrome as B}from"./lib/plugin-chrome.js";import{api as r,getUser as O,isAuthenticated as u,logout as G,sendToLogin as z}from"./api.js";import{isAuthScreen as C,loginHashFor as K}from"./lib/login-arrange.js";import{installHttpInterceptor as J}from"./http-interceptor.js";import{installResizableSlideovers as Y}from"./lib/slideover-resizable.js";import{installActivityThrobber as Q}from"./lib/activity-throbber.js";import{isPathAllowed as X,redirectFor as Z}from"./lib/admin-scope.js";import{deliverDesktop as tt,desktopActive as et,notificationSettings as it}from"./lib/desktop-notify.js";import{avatarUrl as ot}from"./lib/avatar.js";import{installCalculator as at}from"./lib/calculator.js";$(()=>{J(),at(),Q(),Y(),(async()=>{const{applyAdminTheme:t,refreshAdminTheme:e}=await import("./lib/admin-theme.js");u()?await e(r,{init:!0}):t({dommaTheme:"charcoal-dark"},{init:!0})})();let c=null,l="/",h=!1,w=null;function D(t){if(y[t])return E.toast(`${y[t]} is switched off on this site.`,{type:"info"}),"/";const e=b[t]||Z(c,t);return e||((t.split("?")[0]||"/")==="/"&&l&&l!=="/"?b[l]||l:null)}const b={},y={};R.use(async(t,e,i)=>{if(C(t.path))return i();if(!u()){z();return}const o=D(t.path);if(o){R.navigate(o);return}i()});async function g(){try{const t=await r.get("/auth/permissions");c=t.adminScope||null,l=t.adminHome||"/";const e=t.permissions||[];return h=X(c,"/settings")&&e.some(i=>i==="settings"||i.startsWith("settings.")),w=e.some(i=>i==="notifications"||i.startsWith("notifications.")),e}catch{return c=null,l="/",h=!1,w=null,[]}}document.addEventListener("dm:admin-home-changed",()=>{g()});let P=null,N=!1;document.addEventListener("dm:sidebar-rebuild",()=>{if(P){N=!0;return}P=(async()=>{do{N=!1;try{await j(await g())}catch{}}while(N)})().finally(()=>{P=null})});async function j(t){const{renderAdminSidebar:e}=await import("./lib/sidebar-renderer.js");await e({mount:"#admin-sidebar",permissions:t,adminScope:c})}M.subscribe("router:afterChange",({to:t})=>{C(t.path)&&(d&&(clearInterval(d),d=null),c=null,l="/",h=!1,w=null)});const F=[{path:"/",view:"dashboard",title:"Dashboard - Domma CMS"},{path:"/pages",view:"pages",title:"Pages - Domma CMS"},{path:"/pages/new",view:"pageEditor",title:"New Page - Domma CMS"},{path:"/pages/edit/*",view:"pageEditor",title:"Edit Page - Domma CMS"},{path:"/media",view:"media",title:"Media - Domma CMS"},{path:"/menus",view:"menus",title:"Menus - Domma CMS"},{path:"/context-menus",view:"contextMenus",title:"Context Menus - Domma CMS"},{path:"/context-menus/new",view:"contextMenuEditor",title:"New Context Menu - Domma CMS"},{path:"/context-menus/edit/:slug",view:"contextMenuEditor",title:"Edit Context Menu - Domma CMS"},{path:"/menus/new",view:"menuEditor",title:"New Menu - Domma CMS"},{path:"/menus/edit/:slug",view:"menuEditor",title:"Edit Menu - Domma CMS"},{path:"/menu-locations",view:"menuLocations",title:"Menu Locations - Domma CMS"},{path:"/navigation",view:"menusRedirect",title:"Menus - Domma CMS"},{path:"/layouts",view:"layouts",title:"Layouts - Domma CMS"},{path:"/settings",view:"settings",title:"Settings - Domma CMS"},{path:"/theme",view:"theme",title:"Theme - Domma CMS"},{path:"/users",view:"users",title:"Users - Domma CMS"},{path:"/users/new",view:"userEditor",title:"New User - Domma CMS"},{path:"/users/edit/:id",view:"userEditor",title:"Edit User - Domma CMS"},{path:"/plugins",view:"plugins",title:"Plugins - Domma CMS"},{path:"/plugins/code/:name",view:"pluginCode",title:"Plugin Code - Domma CMS"},{path:"/documentation",view:"documentation",title:"Usage - Domma CMS"},{path:"/tutorials",view:"tutorials",title:"Tutorials - Domma CMS"},{path:"/tutorials/crud",view:"tutorialCrud",title:"Building a CRUD App - Domma CMS"},{path:"/tutorials/plugin",view:"tutorialPlugin",title:"Writing a Plugin - Domma CMS"},{path:"/tutorials/forms",view:"tutorialForms",title:"Form Follow-Up - Domma CMS"},{path:"/docs/components",view:"componentsReference",title:"Components Reference - Domma CMS"},{path:"/docs/components-howto",view:"componentsHowto",title:"Components How-To - Domma CMS"},{path:"/docs/components-walkthrough",view:"componentsWalkthrough",title:"Components Walkthrough - Domma CMS"},{path:"/docs/components-rules",view:"componentsRules",title:"Components Rules - Domma CMS"},{path:"/docs/usage/pages",view:"usagePages",title:"Pages - Usage - Domma CMS"},{path:"/docs/usage/media",view:"usageMedia",title:"Media - Usage - Domma CMS"},{path:"/docs/usage/navigation",view:"usageNavigation",title:"Navigation - Usage - Domma CMS"},{path:"/docs/usage/dconfig",view:"usageDconfig",title:"DConfig - Usage - Domma CMS"},{path:"/docs/usage/shortcodes",view:"usageShortcodes",title:"Shortcodes - Usage - Domma CMS"},{path:"/docs/usage/site-settings",view:"usageSiteSettings",title:"Site Settings - Usage - Domma CMS"},{path:"/docs/usage/plugins",view:"usagePlugins",title:"Plugins - Usage - Domma CMS"},{path:"/docs/usage/users-roles",view:"usageUsersRoles",title:"Users & Roles - Usage - Domma CMS"},{path:"/docs/usage/views",view:"usageViews",title:"Views - Usage - Domma CMS"},{path:"/docs/usage/actions",view:"usageActions",title:"Actions - Usage - Domma CMS"},{path:"/docs/usage/cta-shortcode",view:"usageCtaShortcode",title:"CTA Shortcode - Usage - Domma CMS"},{path:"/docs/usage/editions",view:"usageEditions",title:"Editions & Licences - Usage - Domma CMS"},{path:"/docs/usage/collections",view:"usageCollections",title:"Collections & Forms - Usage - Domma CMS"},{path:"/docs/usage/tools",view:"usageTools",title:"Built-in Tools - Usage - Domma CMS"},{path:"/docs/plugins",view:"pluginGuides",title:"Plugin Guides - Domma CMS"},{path:"/docs/plugins/*",view:"pluginGuide",title:"Plugin Guide - Domma CMS"},{path:"/docs/api/authentication",view:"apiRefAuthentication",title:"Authentication - API - Domma CMS"},{path:"/docs/api/pages",view:"apiRefPages",title:"Pages - API - Domma CMS"},{path:"/docs/api/settings",view:"apiRefSettings",title:"Settings - API - Domma CMS"},{path:"/docs/api/layouts",view:"apiRefLayouts",title:"Layouts - API - Domma CMS"},{path:"/docs/api/navigation",view:"apiRefNavigation",title:"Navigation - API - Domma CMS"},{path:"/docs/api/media",view:"apiRefMedia",title:"Media - API - Domma CMS"},{path:"/docs/api/users",view:"apiRefUsers",title:"Users - API - Domma CMS"},{path:"/docs/api/plugins",view:"apiRefPlugins",title:"Plugins - API - Domma CMS"},{path:"/docs/api/collections",view:"apiRefCollections",title:"Collections - API - Domma CMS"},{path:"/docs/api/views",view:"apiRefViews",title:"Views API - Domma CMS"},{path:"/docs/api/actions",view:"apiRefActions",title:"Actions API - Domma CMS"},{path:"/docs/api/forms",view:"apiRefForms",title:"Forms API - Domma CMS"},{path:"/docs/api/scaffold",view:"apiRefScaffold",title:"Scaffold API - Domma CMS"},{path:"/docs/api/external",view:"apiRefExternal",title:"External API & Tokens - Domma CMS"},{path:"/docs/api/builder",view:"apiRefBuilder",title:"API Builder - Domma CMS"},{path:"/api-reference",view:"apiReference",title:"API Reference - Domma CMS"},{path:"/collections",view:"collections",title:"Collections - Domma CMS"},{path:"/collections/new",view:"collectionEditor",title:"New Collection - Domma CMS"},{path:"/collections/edit/:slug",view:"collectionEditor",title:"Edit Collection - Domma CMS"},{path:"/collections/:slug/entries",view:"collectionEntries",title:"Entries - Domma CMS"},{path:"/forms",view:"forms",title:"Forms - Domma CMS"},{path:"/forms/new",view:"formEditor",title:"New Form - Domma CMS"},{path:"/forms/edit/:slug",view:"formEditor",title:"Edit Form - Domma CMS"},{path:"/forms/:slug/submissions",view:"formSubmissions",title:"Submissions - Domma CMS"},{path:"/views",view:"viewsList",title:"Views - Domma CMS"},{path:"/views/new",view:"viewEditor",title:"New View - Domma CMS"},{path:"/views/edit/:slug",view:"viewEditor",title:"Edit View - Domma CMS"},{path:"/views/:slug/preview",view:"viewPreview",title:"View Preview - Domma CMS"},{path:"/actions",view:"actionsList",title:"Actions - Domma CMS"},{path:"/actions/new",view:"actionEditor",title:"New Action - Domma CMS"},{path:"/actions/edit/:slug",view:"actionEditor",title:"Edit Action - Domma CMS"},{path:"/pro/docs",view:"proDocs",title:"Pro Documentation - Domma CMS"},{path:"/blocks",view:"blocks",title:"Blocks - Domma CMS"},{path:"/blocks/new",view:"blockEditor",title:"New Block - Domma CMS"},{path:"/blocks/edit/:name",view:"blockEditor",title:"Edit Block - Domma CMS"},{path:"/components",view:"components",title:"Components - Domma CMS"},{path:"/components/new",view:"componentEditor",title:"New Component - Domma CMS"},{path:"/components/edit/:name",view:"componentEditor",title:"Edit Component - Domma CMS"},{path:"/my-profile",view:"myProfile",title:"My Profile - Domma CMS"},{path:"/roles",view:"roles",title:"Roles & Permissions - Domma CMS"},{path:"/roles/edit/:id",view:"roleEditor",title:"Edit Role - Domma CMS"},{path:"/effects",view:"effects",title:"Effects - Domma CMS"},{path:"/search",view:"search",title:"Search - Domma CMS"},{path:"/notes",view:"notes",title:"Notes - Domma CMS"},{path:"/todo",view:"todo",title:"Todo - Domma CMS"},{path:"/calculator",view:"calculator",title:"Calculator - Domma CMS"},{path:"/analytics",view:"analytics",title:"Analytics - Domma CMS"},{path:"/seo",view:"seo",title:"SEO - Domma CMS"},{path:"/seo/:tab",view:"seo",title:"SEO - Domma CMS"},{path:"/contacts",view:"contacts",title:"Contacts - Domma CMS"},{path:"/calls/new",view:"callNew",title:"Call now - Domma CMS"},{path:"/system/notifications",view:"notifications",title:"Notifications - Domma CMS"},{path:"/api-tokens",view:"apiTokens",title:"API Tokens - Domma CMS"},{path:"/api-endpoints",view:"apiEndpoints",title:"API Builder - Domma CMS"},{path:"/api-endpoints/new",view:"apiEndpointEditor",title:"New API Endpoint - Domma CMS"},{path:"/api-endpoints/edit/:id",view:"apiEndpointEditor",title:"Edit API Endpoint - Domma CMS"},{path:"/projects",view:"projects",title:"Projects - Domma CMS"},{path:"/projects/new",view:"projects",title:"New Project - Domma CMS"},{path:"/projects/edit/:slug",view:"projects",title:"Edit Project - Domma CMS"},{path:"/projects/:slug/settings",view:"projectSettings",title:"Project settings - Domma CMS"},{path:"/projects/:slug/pages",view:"pages",title:"Project pages - Domma CMS"},{path:"/projects/:slug/collections",view:"collections",title:"Project collections - Domma CMS"},{path:"/projects/:slug/forms",view:"forms",title:"Project forms - Domma CMS"},{path:"/projects/:slug/actions",view:"actionsList",title:"Project actions - Domma CMS"},{path:"/projects/:slug/menus",view:"menus",title:"Project menus - Domma CMS"},{path:"/projects/:slug/blocks",view:"blocks",title:"Project blocks - Domma CMS"},{path:"/projects/:slug/components",view:"components",title:"Project components - Domma CMS"},{path:"/projects/:slug/views",view:"viewsList",title:"Project views - Domma CMS"},{path:"/projects/:slug/roles",view:"roles",title:"Project roles - Domma CMS"},{path:"/projects/:slug/users",view:"users",title:"Project users - Domma CMS"},{path:"/projects/:slug/apis",view:"apiEndpoints",title:"Project APIs - Domma CMS"},{path:"/projects/:slug",view:"projectDetail",title:"Project - Domma CMS"},{path:"/login",view:"login",title:"Sign in - Domma CMS",onEnter:()=>{$("#admin-sidebar").hide(),$("#admin-topbar").hide()}},{path:"/reset-password",view:"login",title:"Reset Password - Domma CMS",onEnter:()=>{$("#admin-sidebar").hide(),$("#admin-topbar").hide()}}];M.subscribe("router:afterChange",async({to:t,from:e})=>{if(!C(t.path)){if($("#admin-sidebar").show(),$("#admin-topbar").show(),C(e?.path)){$("#topbar-user-name").remove(),import("./lib/admin-theme.js").then(a=>a.refreshAdminTheme(r)),await x();const i=await g(),o=D(t.path);if(o&&R.navigate(o),j(i),it.read().signInToasts)try{const a=await r.system.notifications.list().catch(()=>[]),n=(Array.isArray(a)?a:[]).filter(s=>s.unread&&["warning","critical"].includes(s.data?.severity)).slice(0,3);for(const s of n){const f=p(s.data?.title||""),k=p((s.data?.body||"").slice(0,120));E.toast(`${f} - ${k}`,{type:s.data?.severity==="critical"?"error":"warning",duration:0})}}catch{}}T()}}),M.subscribe("router:afterChange",()=>{setTimeout(()=>{$(".btn-primary, .btn-danger").length&&Domma.effects.reveal(".btn-primary, .btn-danger",{animation:"fade",stagger:40,duration:300})},50)});const V=["#field-project","#menu-project","#collection-project","#endpoint-project","#action-project","#view-project","#block-project","#component-project","#role-project",'[name="project"]','[name="ownedByProject"]'];function W(t){return/^\/(pages|menus|context-menus|collections|forms|actions|views|blocks|components|users|roles|api-endpoints)\/(new|edit\/)/.test(t)}M.subscribe("router:afterChange",({to:t})=>{const e=t?String(t.path).split("?")[0]:"";if(!t||!W(e))return;const o=new URLSearchParams(location.hash.split("?")[1]||"").get("project");if(!o||!/\/new$/.test(e))return;let a=0;const n=60,s=1500,f=()=>{a+=n;for(const k of V){const v=document.querySelector(k);if(!v)continue;if(Array.from(v.options||[]).find(H=>H.value===o)){v.value=o,v.dispatchEvent(new Event("change",{bubbles:!0}));return}}a<s&&setTimeout(f,n)};setTimeout(f,n)});const I="cms_card_states",A=S.get(I)||{};M.subscribe("router:afterChange",()=>{setTimeout(()=>{$("#view-container .card-collapsible").each(function(){const t=$(this).find(".card-header h2, .card-header h3").first().text().trim();t&&A[t]==="collapsed"&&$(this).addClass("card-collapsed")})},200)}),$("#view-container").on("click",".card-collapsible .card-header",function(t){if($(t.target).closest("button, a").length)return;const e=$(this).closest(".card");e.toggleClass("card-collapsed");const i=$(this).find("h2, h3").first().text().trim();i&&(A[i]=e.hasClass("card-collapsed")?"collapsed":"open",S.set(I,A))}),document.addEventListener("keydown",t=>{if(!(t.ctrlKey||t.metaKey)||t.key!=="s"||window.location.hash.startsWith("#/login")||window.location.hash.startsWith("#/reset-password"))return;const e=document.querySelector("#view-container .view-header button.btn-primary");e&&(t.preventDefault(),e.click())});const m={...q},_={notes:{displayName:"Notes",icon:"edit-3"},todo:{displayName:"Todo",icon:"check-square"},calculator:{displayName:"Calculator",icon:"calculator"},analytics:{displayName:"Analytics",icon:"chart-bar"},seo:{displayName:"SEO",icon:"search"},contacts:{displayName:"Contacts",icon:"users"},projects:{displayName:"Projects",icon:"folder"},pages:{displayName:"Pages",icon:"file-text"},media:{displayName:"Media",icon:"image"},menus:{displayName:"Menus",icon:"menu"},contextMenus:{displayName:"Context Menus",icon:"menu"},theme:{displayName:"Theme",icon:"droplet"},collections:{displayName:"Collections",icon:"database"},forms:{displayName:"Forms",icon:"layout"},viewsList:{displayName:"Views",icon:"eye"},actionsList:{displayName:"Actions",icon:"zap"},apiEndpoints:{displayName:"API Builder",icon:"code"},blocks:{displayName:"Blocks",icon:"box"},components:{displayName:"Components",icon:"component"},notifications:{displayName:"Notifications",icon:"bell"},roles:{displayName:"Roles",icon:"shield"},users:{displayName:"Users",icon:"users"},settings:{displayName:"Site Settings",icon:"settings"},effects:{displayName:"Effects",icon:"sparkles"},layouts:{displayName:"Layouts",icon:"layout"},plugins:{displayName:"Marketplace",icon:"store"},myProfile:{displayName:"My Profile",icon:"user"},apiTokens:{displayName:"API Tokens",icon:"key"},search:{displayName:"Search",icon:"search"},dashboard:{displayName:"Dashboard",icon:"home"},pageEditor:{displayName:"Pages",section:"Edit",icon:"file-text"},menuEditor:{displayName:"Menus",section:"Edit",icon:"menu"},menuLocations:{displayName:"Menus",section:"Locations",icon:"map-pin"},contextMenuEditor:{displayName:"Context Menus",section:"Edit",icon:"menu"},collectionEditor:{displayName:"Collections",section:"Edit",icon:"database"},collectionEntries:{displayName:"Collections",section:"Entries",icon:"database"},formEditor:{displayName:"Forms",section:"Edit",icon:"layout"},formSubmissions:{displayName:"Forms",section:"Submissions",icon:"inbox"},viewEditor:{displayName:"Views",section:"Edit",icon:"eye"},viewPreview:{displayName:"Views",section:"Preview",icon:"eye"},actionEditor:{displayName:"Actions",section:"Edit",icon:"zap"},apiEndpointEditor:{displayName:"API Builder",section:"Edit",icon:"code"},blockEditor:{displayName:"Blocks",section:"Edit",icon:"box"},componentEditor:{displayName:"Components",section:"Edit",icon:"component"},roleEditor:{displayName:"Roles",section:"Edit",icon:"shield"},userEditor:{displayName:"Users",section:"Edit",icon:"users"},projectDetail:{displayName:"Projects",section:"Overview",icon:"folder"},projectSettings:{displayName:"Projects",section:"Settings",icon:"folder"},pluginCode:{displayName:"Marketplace",section:"Code",icon:"code"}};for(const[t,e]of Object.entries(_))m[t]&&(m[t]=B(m[t],{plugin:t,licence:"Built in",...e}));const U=[...F];async function x(){if(u())try{const t=await r.plugins.adminConfig();t.routes?.length&&U.push(...t.routes);for(const[e,i]of Object.entries(t.views||{}))try{const o=await import(`/plugins/${i.entry}`);m[e]=B(o[i.exportName],(t.meta||{})[e])}catch{}for(const[e,{route:i}]of Object.entries(t.takeovers||{}))i&&(b[`/${e}`]=i);for(const[e,{enabled:i,displayName:o}]of Object.entries(t.tools||{}))i===!1&&(y[`/${e}`]=o);for(const{id:e,href:i}of t.css||[])if(!document.getElementById(e)){const o=document.createElement("link");o.id=e,o.rel="stylesheet",o.href=i,document.head.appendChild(o)}}catch{}}function T(){const t=O();if(!t||$("#topbar-user-name").length)return;const i={"super-admin":"Super Admin",admin:"Admin",user:"User"}[t.role]||t.role,o=ot(t);$("#topbar-user").html(`
2
- ${o?`<img id="topbar-avatar" class="topbar-avatar" src="${p(o)}" alt="">`:""}
3
- <span id="topbar-user-name" class="topbar-user-name">${p(t.name)}</span>
4
- <span class="topbar-role-badge topbar-role-badge--${p(t.role)}">${i}</span>
1
+ import{views as q}from"./views/index.js";import{withPluginChrome as B}from"./lib/plugin-chrome.js";import{api as r,getUser as F,isAuthenticated as c,logout as G,sendToLogin as z}from"./api.js";import{isAuthScreen as C,loginHashFor as K}from"./lib/login-arrange.js";import{installHttpInterceptor as J}from"./http-interceptor.js";import{installResizableSlideovers as Y}from"./lib/slideover-resizable.js";import{installActivityThrobber as Q}from"./lib/activity-throbber.js";import{isPathAllowed as X,redirectFor as Z}from"./lib/admin-scope.js";import{deliverDesktop as tt,desktopActive as et,notificationSettings as it}from"./lib/desktop-notify.js";import{avatarUrl as ot}from"./lib/avatar.js";import{installCalculator as at}from"./lib/calculator.js";$(()=>{J(),at(),Q(),Y(),(async()=>{const{applyAdminTheme:t,refreshAdminTheme:i}=await import("./lib/admin-theme.js");c()?await i(r,{init:!0}):t({dommaTheme:"charcoal-dark"},{init:!0})})();let m=null,l="/",h=!1,w=null;function D(t){if(y[t])return E.toast(`${y[t]} is switched off on this site.`,{type:"info"}),"/";const i=b[t]||Z(m,t);return i||((t.split("?")[0]||"/")==="/"&&l&&l!=="/"?b[l]||l:null)}const b={},y={};R.use(async(t,i,e)=>{if(C(t.path))return e();if(!c()){z();return}const o=D(t.path);if(o){R.navigate(o);return}e()});async function g(){try{const t=await r.get("/auth/permissions");m=t.adminScope||null,l=t.adminHome||"/";const i=t.permissions||[];return h=X(m,"/settings")&&i.some(e=>e==="settings"||e.startsWith("settings.")),w=i.some(e=>e==="notifications"||e.startsWith("notifications.")),i}catch{return m=null,l="/",h=!1,w=null,[]}}document.addEventListener("dm:admin-home-changed",()=>{g()});let N=null,P=!1;document.addEventListener("dm:sidebar-rebuild",()=>{if(N){P=!0;return}N=(async()=>{do{P=!1;try{await j(await g())}catch{}}while(P)})().finally(()=>{N=null})});async function j(t){const{renderAdminSidebar:i}=await import("./lib/sidebar-renderer.js");await i({mount:"#admin-sidebar",permissions:t,adminScope:m})}M.subscribe("router:afterChange",({to:t})=>{C(t.path)&&(u&&(clearInterval(u),u=null),m=null,l="/",h=!1,w=null)});const O=[{path:"/",view:"dashboard",title:"Dashboard - Domma CMS"},{path:"/pages",view:"pages",title:"Pages - Domma CMS"},{path:"/pages/new",view:"pageEditor",title:"New Page - Domma CMS"},{path:"/pages/edit/*",view:"pageEditor",title:"Edit Page - Domma CMS"},{path:"/media",view:"media",title:"Media - Domma CMS"},{path:"/menus",view:"menus",title:"Menus - Domma CMS"},{path:"/context-menus",view:"contextMenus",title:"Context Menus - Domma CMS"},{path:"/context-menus/new",view:"contextMenuEditor",title:"New Context Menu - Domma CMS"},{path:"/context-menus/edit/:slug",view:"contextMenuEditor",title:"Edit Context Menu - Domma CMS"},{path:"/menus/new",view:"menuEditor",title:"New Menu - Domma CMS"},{path:"/menus/edit/:slug",view:"menuEditor",title:"Edit Menu - Domma CMS"},{path:"/menu-locations",view:"menuLocations",title:"Menu Locations - Domma CMS"},{path:"/navigation",view:"menusRedirect",title:"Menus - Domma CMS"},{path:"/layouts",view:"layouts",title:"Layouts - Domma CMS"},{path:"/settings",view:"settings",title:"Settings - Domma CMS"},{path:"/theme",view:"theme",title:"Theme - Domma CMS"},{path:"/users",view:"users",title:"Users - Domma CMS"},{path:"/users/new",view:"userEditor",title:"New User - Domma CMS"},{path:"/users/edit/:id",view:"userEditor",title:"Edit User - Domma CMS"},{path:"/plugins",view:"plugins",title:"Plugins - Domma CMS"},{path:"/plugins/code/:name",view:"pluginCode",title:"Plugin Code - Domma CMS"},{path:"/documentation",view:"documentation",title:"Usage - Domma CMS"},{path:"/tutorials",view:"tutorials",title:"Tutorials - Domma CMS"},{path:"/tutorials/crud",view:"tutorialCrud",title:"Building a CRUD App - Domma CMS"},{path:"/tutorials/plugin",view:"tutorialPlugin",title:"Writing a Plugin - Domma CMS"},{path:"/tutorials/forms",view:"tutorialForms",title:"Form Follow-Up - Domma CMS"},{path:"/docs/components",view:"componentsReference",title:"Components Reference - Domma CMS"},{path:"/docs/components-howto",view:"componentsHowto",title:"Components How-To - Domma CMS"},{path:"/docs/components-walkthrough",view:"componentsWalkthrough",title:"Components Walkthrough - Domma CMS"},{path:"/docs/components-rules",view:"componentsRules",title:"Components Rules - Domma CMS"},{path:"/docs/usage/pages",view:"usagePages",title:"Pages - Usage - Domma CMS"},{path:"/docs/usage/media",view:"usageMedia",title:"Media - Usage - Domma CMS"},{path:"/docs/usage/navigation",view:"usageNavigation",title:"Navigation - Usage - Domma CMS"},{path:"/docs/usage/dconfig",view:"usageDconfig",title:"DConfig - Usage - Domma CMS"},{path:"/docs/usage/shortcodes",view:"usageShortcodes",title:"Shortcodes - Usage - Domma CMS"},{path:"/docs/usage/site-settings",view:"usageSiteSettings",title:"Site Settings - Usage - Domma CMS"},{path:"/docs/usage/plugins",view:"usagePlugins",title:"Plugins - Usage - Domma CMS"},{path:"/docs/usage/users-roles",view:"usageUsersRoles",title:"Users & Roles - Usage - Domma CMS"},{path:"/docs/usage/views",view:"usageViews",title:"Views - Usage - Domma CMS"},{path:"/docs/usage/actions",view:"usageActions",title:"Actions - Usage - Domma CMS"},{path:"/docs/usage/cta-shortcode",view:"usageCtaShortcode",title:"CTA Shortcode - Usage - Domma CMS"},{path:"/docs/usage/editions",view:"usageEditions",title:"Editions & Licences - Usage - Domma CMS"},{path:"/docs/usage/collections",view:"usageCollections",title:"Collections & Forms - Usage - Domma CMS"},{path:"/docs/usage/tools",view:"usageTools",title:"Built-in Tools - Usage - Domma CMS"},{path:"/docs/plugins",view:"pluginGuides",title:"Plugin Guides - Domma CMS"},{path:"/docs/plugins/*",view:"pluginGuide",title:"Plugin Guide - Domma CMS"},{path:"/docs/api/authentication",view:"apiRefAuthentication",title:"Authentication - API - Domma CMS"},{path:"/docs/api/pages",view:"apiRefPages",title:"Pages - API - Domma CMS"},{path:"/docs/api/settings",view:"apiRefSettings",title:"Settings - API - Domma CMS"},{path:"/docs/api/layouts",view:"apiRefLayouts",title:"Layouts - API - Domma CMS"},{path:"/docs/api/navigation",view:"apiRefNavigation",title:"Navigation - API - Domma CMS"},{path:"/docs/api/media",view:"apiRefMedia",title:"Media - API - Domma CMS"},{path:"/docs/api/users",view:"apiRefUsers",title:"Users - API - Domma CMS"},{path:"/docs/api/plugins",view:"apiRefPlugins",title:"Plugins - API - Domma CMS"},{path:"/docs/api/collections",view:"apiRefCollections",title:"Collections - API - Domma CMS"},{path:"/docs/api/views",view:"apiRefViews",title:"Views API - Domma CMS"},{path:"/docs/api/actions",view:"apiRefActions",title:"Actions API - Domma CMS"},{path:"/docs/api/forms",view:"apiRefForms",title:"Forms API - Domma CMS"},{path:"/docs/api/scaffold",view:"apiRefScaffold",title:"Scaffold API - Domma CMS"},{path:"/docs/api/external",view:"apiRefExternal",title:"External API & Tokens - Domma CMS"},{path:"/docs/api/builder",view:"apiRefBuilder",title:"API Builder - Domma CMS"},{path:"/api-reference",view:"apiReference",title:"API Reference - Domma CMS"},{path:"/collections",view:"collections",title:"Collections - Domma CMS"},{path:"/collections/new",view:"collectionEditor",title:"New Collection - Domma CMS"},{path:"/collections/edit/:slug",view:"collectionEditor",title:"Edit Collection - Domma CMS"},{path:"/collections/:slug/entries",view:"collectionEntries",title:"Entries - Domma CMS"},{path:"/forms",view:"forms",title:"Forms - Domma CMS"},{path:"/forms/new",view:"formEditor",title:"New Form - Domma CMS"},{path:"/forms/edit/:slug",view:"formEditor",title:"Edit Form - Domma CMS"},{path:"/forms/:slug/submissions",view:"formSubmissions",title:"Submissions - Domma CMS"},{path:"/views",view:"viewsList",title:"Views - Domma CMS"},{path:"/views/new",view:"viewEditor",title:"New View - Domma CMS"},{path:"/views/edit/:slug",view:"viewEditor",title:"Edit View - Domma CMS"},{path:"/views/:slug/preview",view:"viewPreview",title:"View Preview - Domma CMS"},{path:"/actions",view:"actionsList",title:"Actions - Domma CMS"},{path:"/actions/new",view:"actionEditor",title:"New Action - Domma CMS"},{path:"/actions/edit/:slug",view:"actionEditor",title:"Edit Action - Domma CMS"},{path:"/pro/docs",view:"proDocs",title:"Pro Documentation - Domma CMS"},{path:"/blocks",view:"blocks",title:"Blocks - Domma CMS"},{path:"/blocks/new",view:"blockEditor",title:"New Block - Domma CMS"},{path:"/blocks/edit/:name",view:"blockEditor",title:"Edit Block - Domma CMS"},{path:"/components",view:"components",title:"Components - Domma CMS"},{path:"/components/new",view:"componentEditor",title:"New Component - Domma CMS"},{path:"/components/edit/:name",view:"componentEditor",title:"Edit Component - Domma CMS"},{path:"/my-profile",view:"myProfile",title:"My Profile - Domma CMS"},{path:"/roles",view:"roles",title:"Roles & Permissions - Domma CMS"},{path:"/roles/edit/:id",view:"roleEditor",title:"Edit Role - Domma CMS"},{path:"/effects",view:"effects",title:"Effects - Domma CMS"},{path:"/search",view:"search",title:"Search - Domma CMS"},{path:"/notes",view:"notes",title:"Notes - Domma CMS"},{path:"/todo",view:"todo",title:"Todo - Domma CMS"},{path:"/calculator",view:"calculator",title:"Calculator - Domma CMS"},{path:"/analytics",view:"analytics",title:"Analytics - Domma CMS"},{path:"/seo",view:"seo",title:"SEO - Domma CMS"},{path:"/seo/:tab",view:"seo",title:"SEO - Domma CMS"},{path:"/contacts",view:"contacts",title:"Contacts - Domma CMS"},{path:"/calls/new",view:"callNew",title:"Call now - Domma CMS"},{path:"/system/notifications",view:"notifications",title:"Notifications - Domma CMS"},{path:"/api-tokens",view:"apiTokens",title:"API Tokens - Domma CMS"},{path:"/api-endpoints",view:"apiEndpoints",title:"API Builder - Domma CMS"},{path:"/api-endpoints/new",view:"apiEndpointEditor",title:"New API Endpoint - Domma CMS"},{path:"/api-endpoints/edit/:id",view:"apiEndpointEditor",title:"Edit API Endpoint - Domma CMS"},{path:"/projects",view:"projects",title:"Projects - Domma CMS"},{path:"/projects/new",view:"projects",title:"New Project - Domma CMS"},{path:"/projects/edit/:slug",view:"projects",title:"Edit Project - Domma CMS"},{path:"/projects/:slug/settings",view:"projectSettings",title:"Project settings - Domma CMS"},{path:"/projects/:slug/pages",view:"pages",title:"Project pages - Domma CMS"},{path:"/projects/:slug/collections",view:"collections",title:"Project collections - Domma CMS"},{path:"/projects/:slug/forms",view:"forms",title:"Project forms - Domma CMS"},{path:"/projects/:slug/actions",view:"actionsList",title:"Project actions - Domma CMS"},{path:"/projects/:slug/menus",view:"menus",title:"Project menus - Domma CMS"},{path:"/projects/:slug/blocks",view:"blocks",title:"Project blocks - Domma CMS"},{path:"/projects/:slug/components",view:"components",title:"Project components - Domma CMS"},{path:"/projects/:slug/views",view:"viewsList",title:"Project views - Domma CMS"},{path:"/projects/:slug/roles",view:"roles",title:"Project roles - Domma CMS"},{path:"/projects/:slug/users",view:"users",title:"Project users - Domma CMS"},{path:"/projects/:slug/apis",view:"apiEndpoints",title:"Project APIs - Domma CMS"},{path:"/projects/:slug",view:"projectDetail",title:"Project - Domma CMS"},{path:"/login",view:"login",title:"Sign in - Domma CMS",onEnter:()=>{$("#admin-sidebar").hide(),$("#admin-topbar").hide()}},{path:"/reset-password",view:"login",title:"Reset Password - Domma CMS",onEnter:()=>{$("#admin-sidebar").hide(),$("#admin-topbar").hide()}}];M.subscribe("router:afterChange",async({to:t,from:i})=>{if(!C(t.path)){if($("#admin-sidebar").show(),$("#admin-topbar").show(),C(i?.path)){$("#topbar-user-name").remove(),import("./lib/admin-theme.js").then(a=>a.refreshAdminTheme(r)),await I();const e=await g(),o=D(t.path);if(o&&R.navigate(o),j(e),it.read().signInToasts)try{const a=await r.system.notifications.list().catch(()=>[]),n=(Array.isArray(a)?a:[]).filter(s=>s.unread&&["warning","critical"].includes(s.data?.severity)).slice(0,3);for(const s of n){const f=d(s.data?.title||""),T=d((s.data?.body||"").slice(0,120));E.toast(`${f} - ${T}`,{type:s.data?.severity==="critical"?"error":"warning",duration:0})}}catch{}}x()}}),M.subscribe("router:afterChange",()=>{setTimeout(()=>{$(".btn-primary, .btn-danger").length&&Domma.effects.reveal(".btn-primary, .btn-danger",{animation:"fade",stagger:40,duration:300})},50)});const _=["#field-project","#menu-project","#collection-project","#endpoint-project","#action-project","#view-project","#block-project","#component-project","#role-project",'[name="project"]','[name="ownedByProject"]'];function V(t){return/^\/(pages|menus|context-menus|collections|forms|actions|views|blocks|components|users|roles|api-endpoints)\/(new|edit\/)/.test(t)}M.subscribe("router:afterChange",({to:t})=>{const i=t?String(t.path).split("?")[0]:"";if(!t||!V(i))return;const o=new URLSearchParams(location.hash.split("?")[1]||"").get("project");if(!o||!/\/new$/.test(i))return;let a=0;const n=60,s=1500,f=()=>{a+=n;for(const T of _){const v=document.querySelector(T);if(!v)continue;if(Array.from(v.options||[]).find(H=>H.value===o)){v.value=o,v.dispatchEvent(new Event("change",{bubbles:!0}));return}}a<s&&setTimeout(f,n)};setTimeout(f,n)});const U="cms_card_states",A=S.get(U)||{};M.subscribe("router:afterChange",()=>{setTimeout(()=>{$("#view-container .card-collapsible").each(function(){const t=$(this).find(".card-header h2, .card-header h3").first().text().trim();t&&A[t]==="collapsed"&&$(this).addClass("card-collapsed")})},200)}),$("#view-container").on("click",".card-collapsible .card-header",function(t){if($(t.target).closest("button, a").length)return;const i=$(this).closest(".card");i.toggleClass("card-collapsed");const e=$(this).find("h2, h3").first().text().trim();e&&(A[e]=i.hasClass("card-collapsed")?"collapsed":"open",S.set(U,A))}),document.addEventListener("keydown",t=>{if(!(t.ctrlKey||t.metaKey)||t.key!=="s"||window.location.hash.startsWith("#/login")||window.location.hash.startsWith("#/reset-password"))return;const i=document.querySelector("#view-container .view-header button.btn-primary");i&&(t.preventDefault(),i.click())});const p={...q},W={notes:{displayName:"Notes",icon:"edit-3"},todo:{displayName:"Todo",icon:"check-square"},calculator:{displayName:"Calculator",icon:"calculator"},analytics:{displayName:"Analytics",icon:"chart-bar"},seo:{displayName:"SEO",icon:"search"},contacts:{displayName:"Contacts",icon:"users"},projects:{displayName:"Projects",icon:"folder"},pages:{displayName:"Pages",icon:"file-text"},media:{displayName:"Media",icon:"image"},menus:{displayName:"Menus",icon:"menu"},contextMenus:{displayName:"Context Menus",icon:"menu"},theme:{displayName:"Theme",icon:"droplet"},collections:{displayName:"Collections",icon:"database"},forms:{displayName:"Forms",icon:"layout"},viewsList:{displayName:"Views",icon:"eye"},actionsList:{displayName:"Actions",icon:"zap"},apiEndpoints:{displayName:"API Builder",icon:"code"},blocks:{displayName:"Blocks",icon:"box"},components:{displayName:"Components",icon:"component"},notifications:{displayName:"Notifications",icon:"bell"},roles:{displayName:"Roles",icon:"shield"},users:{displayName:"Users",icon:"users"},settings:{displayName:"Site Settings",icon:"settings"},effects:{displayName:"Effects",icon:"sparkles"},layouts:{displayName:"Layouts",icon:"layout"},plugins:{displayName:"Marketplace",icon:"store"},myProfile:{displayName:"My Profile",icon:"user"},apiTokens:{displayName:"API Tokens",icon:"key"},search:{displayName:"Search",icon:"search"},dashboard:{displayName:"Dashboard",icon:"home"},pageEditor:{displayName:"Pages",section:"Edit",icon:"file-text"},menuEditor:{displayName:"Menus",section:"Edit",icon:"menu"},menuLocations:{displayName:"Menus",section:"Locations",icon:"map-pin"},contextMenuEditor:{displayName:"Context Menus",section:"Edit",icon:"menu"},collectionEditor:{displayName:"Collections",section:"Edit",icon:"database"},collectionEntries:{displayName:"Collections",section:"Entries",icon:"database"},formEditor:{displayName:"Forms",section:"Edit",icon:"layout"},formSubmissions:{displayName:"Forms",section:"Submissions",icon:"inbox"},viewEditor:{displayName:"Views",section:"Edit",icon:"eye"},viewPreview:{displayName:"Views",section:"Preview",icon:"eye"},actionEditor:{displayName:"Actions",section:"Edit",icon:"zap"},apiEndpointEditor:{displayName:"API Builder",section:"Edit",icon:"code"},blockEditor:{displayName:"Blocks",section:"Edit",icon:"box"},componentEditor:{displayName:"Components",section:"Edit",icon:"component"},roleEditor:{displayName:"Roles",section:"Edit",icon:"shield"},userEditor:{displayName:"Users",section:"Edit",icon:"users"},projectDetail:{displayName:"Projects",section:"Overview",icon:"folder"},projectSettings:{displayName:"Projects",section:"Settings",icon:"folder"},pluginCode:{displayName:"Marketplace",section:"Code",icon:"code"}};for(const[t,i]of Object.entries(W))p[t]&&(p[t]=B(p[t],{plugin:t,licence:"Built in",...i}));const k=[...O];async function I(){if(c())try{const t=await r.plugins.adminConfig(),i=new Set(k.map(e=>e.path));for(const e of t.routes||[])i.has(e.path)||k.push(e);for(const[e,o]of Object.entries(t.views||{}))try{const a=await import(`/plugins/${o.entry}`);p[e]=B(a[o.exportName],(t.meta||{})[e])}catch{}for(const[e,{route:o}]of Object.entries(t.takeovers||{}))o&&(b[`/${e}`]=o);for(const[e,{enabled:o,displayName:a}]of Object.entries(t.tools||{}))o===!1&&(y[`/${e}`]=a);for(const{id:e,href:o}of t.css||[])if(!document.getElementById(e)){const a=document.createElement("link");a.id=e,a.rel="stylesheet",a.href=o,document.head.appendChild(a)}}catch{}}function x(){const t=F();if(!t||$("#topbar-user-name").length)return;const e={"super-admin":"Super Admin",admin:"Admin",user:"User"}[t.role]||t.role,o=ot(t);$("#topbar-user").html(`
2
+ ${o?`<img id="topbar-avatar" class="topbar-avatar" src="${d(o)}" alt="">`:""}
3
+ <span id="topbar-user-name" class="topbar-user-name">${d(t.name)}</span>
4
+ <span class="topbar-role-badge topbar-role-badge--${d(t.role)}">${e}</span>
5
5
  `),$("#topbar-actions").html(`
6
6
  <a href="#/system/notifications" id="topbar-bell" class="topbar-action-link" data-tooltip="Notifications" data-tooltip-placement="bottom" hidden>
7
7
  <span data-icon="bell"></span>
@@ -19,4 +19,4 @@ import{views as q}from"./views/index.js";import{withPluginChrome as B}from"./lib
19
19
  <span data-icon="log-out"></span>
20
20
  <span>Sign out</span>
21
21
  </a>
22
- `);const a=$("#topbar-bell").get(0);a&&(a.hidden=!0);const n=$("#topbar-settings").get(0);n&&(n.hidden=!h),$("#topbar-logout-btn").on("click",s=>{s.preventDefault(),G()}),Domma.icons.scan("#admin-topbar"),E.tooltip("#topbar-actions [data-tooltip]",{placement:"bottom"}),L(),d||(d=setInterval(L,6e4))}function p(t){return String(t).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#039;")}let d=null;async function L(){if(!(!u()||w===!1))try{const{count:t}=await r.system.notifications.unreadCount();$("#topbar-bell").removeAttr("hidden");const e=$("#topbar-bell-badge");t>0?e.text(t>99?"99+":String(t)).removeAttr("hidden"):e.attr("hidden","").text(""),t>0&&et()&&tt(await r.system.notifications.list().catch(()=>[]))}catch{}}(async()=>{if(!u()&&!window.location.hash.startsWith("#/reset-password"))window.location.hash.startsWith("#/login")||(window.location.hash=K(window.location.hash));else if(await x(),O()){const i=await g(),o=(window.location.hash||"#/").slice(1)||"/",a=D(o);a&&(window.location.hash=`#${a}`),await j(i),T()}const t=R._extractParams.bind(R);R._extractParams=function(e,i){const o=i.indexOf("?");if(o!==-1&&(i=i.slice(0,o)),e.endsWith("/*")){const a=e.slice(0,-2);return i.startsWith(a+"/")?{}:null}return t(e,i)},R.init({container:"#view-container",routes:U,views:m,default:"/",transitions:{enter:"fadeIn",leave:"fadeOut",duration:150}})})()});
22
+ `);const a=$("#topbar-bell").get(0);a&&(a.hidden=!0);const n=$("#topbar-settings").get(0);n&&(n.hidden=!h),$("#topbar-logout-btn").on("click",s=>{s.preventDefault(),G()}),Domma.icons.scan("#admin-topbar"),E.tooltip("#topbar-actions [data-tooltip]",{placement:"bottom"}),L(),u||(u=setInterval(L,6e4))}function d(t){return String(t).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#039;")}let u=null;async function L(){if(!(!c()||w===!1))try{const{count:t}=await r.system.notifications.unreadCount();$("#topbar-bell").removeAttr("hidden");const i=$("#topbar-bell-badge");t>0?i.text(t>99?"99+":String(t)).removeAttr("hidden"):i.attr("hidden","").text(""),t>0&&et()&&tt(await r.system.notifications.list().catch(()=>[]))}catch{}}(async()=>{if(!c()&&!window.location.hash.startsWith("#/reset-password"))window.location.hash.startsWith("#/login")||(window.location.hash=K(window.location.hash));else if(await I(),F()){const a=await g(),n=(window.location.hash||"#/").slice(1)||"/",s=D(n);s&&(window.location.hash=`#${s}`),await j(a),x()}const t=R._extractParams.bind(R);R._extractParams=function(o,a){const n=a.indexOf("?");if(n!==-1&&(a=a.slice(0,n)),o.endsWith("/*")){const s=o.slice(0,-2);return a.startsWith(s+"/")?{}:null}return t(o,a)};const i=R._renderNotFound.bind(R);let e="";R._renderNotFound=async function(o){return c()&&e!==o&&(e=o,await I(),R._matchRoute(o).route)?(e="",R._handleRouteChange()):(e="",c()&&$("#admin-sidebar").get(0)?.style.display==="none"?(E.toast("That link goes nowhere - here is your home page.",{type:"warning"}),R.navigate("/")):i(o))},R.init({container:"#view-container",routes:k,views:p,default:"/",transitions:{enter:"fadeIn",leave:"fadeOut",duration:150}})})()});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.112.1",
3
+ "version": "0.114.0",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "note": "Written by `make sync-free-tier` in dcms-marketplace. Do not edit these plugins here - edit them there and sync.",
3
- "source": "dcms-marketplace@b7cee0e",
3
+ "source": "dcms-marketplace@26a9062",
4
4
  "plugins": {
5
5
  "blog": {
6
6
  "version": "1.9.4",
@@ -175,9 +175,9 @@
175
175
  }
176
176
  },
177
177
  "shopping-cart": {
178
- "version": "1.0.4",
178
+ "version": "1.1.0",
179
179
  "files": {
180
- "CLAUDE.md": "7ca2d1b447923d48600b261907f2ecd421150fd9927f4b402d9f913124a7d408",
180
+ "CLAUDE.md": "70765cf061e97d0a57890d7731e0624b224e36267df42741e763e3efcd076d05",
181
181
  "admin/css/index.css": "a6bab1d0072e364ee1d94c2d7d7b99c3660ba91c5eba537e269a579f5b17f924",
182
182
  "admin/lib/kit.js": "c2111d1b10c6c8b18bc061f9f55c83ee8baecc2bcff66f74eb38298f39886d86",
183
183
  "admin/templates/shop.html": "c5bb03f1f7cbabfbd8202f3533783eb1848103755b1ba25fdcf1fd8e9478cb8e",
@@ -193,18 +193,18 @@
193
193
  "inject/body.html": "de20f92c83229545d20e5186fd37ef0046a8820881bff34e7df5391cd7a2c75d",
194
194
  "lib/extensions.js": "2cba90efef5b437ace557c7265fe7363ac7923d155681087a63e31e1168f6601",
195
195
  "lib/payments.js": "84ad14813688de1f38448b4935180a786cda8af2f7f4b8890a8f1d426982d1aa",
196
- "lib/pricing.js": "94953075062e9b36cdbd6ea149662d013d97ee12359a9ff7459669beff99ab99",
196
+ "lib/pricing.js": "3ae965e042f5af0acdcecc66d27145ca5f608c656717288832b881a2a181dd2e",
197
197
  "lib/render.js": "310d528f69cd0160db3bd3d3c88f16221e43004c93856587175c0bb149fe3a3d",
198
198
  "lib/samples.js": "912b3d5bd543dffa5f870caf3c2b873f642fd55e3ebf3f97e1b31eeaaac77d3b",
199
- "lib/shop.js": "36e1cea342e23f1a53b9fac63b993f860afc1bcb74b25bef9f7bf8eaf07d2d85",
199
+ "lib/shop.js": "4399931dd46ab4dc9bd28e8613c992420ab822bd694b1495ef2b4ad6483b5153",
200
200
  "lib/summary.js": "5697ffa45c9cc5338bbcb6eada08671c3e8376c49c629d2ae99b022397523f71",
201
- "plugin.js": "16f2c8d93c83cc8a002d3e8629d5f969b2fa03c7e7bb6677b7ac89e43aa2d3d9",
202
- "plugin.json": "e17bc5158cd94f0110b0131179c346c2324e2bd4d0bacb6715cd0746e6657ba7",
203
- "plugin.public.js": "ad901378eb28758d18b9f257e4a76d5a453f89fb984ed02a10f62f1a13cd2a62",
201
+ "plugin.js": "1f8477188b4054d172b37d953a439b4d5d4a532c6d26daaf9808346f332ddfcf",
202
+ "plugin.json": "eccf7d9bf2f7fdde26d78c82b944897031387b42ab26daa04c59d7a5cac3ea5f",
203
+ "plugin.public.js": "98fa32b6c69db4a967577f3536fc521c926053319dd20d2bae8bc7e94830e3e8",
204
204
  "public/cart.js": "fcc706773a36e4bfd02ea235bcdad629137e996218e0a0e23892e33c689f2a6e",
205
205
  "public/lib/money.js": "d22b2b6f80dde6f7887b69e24188c05ea93c02217aba639b5331aa3bd616f4fc",
206
206
  "public/lib/orders.js": "09478e20d238adc8e387b3d9efcb13c441898fab18387ce6a1a8a92304ac4c5b",
207
- "public/lib/products.js": "c698dcb509dfc09a135e4039ec085dda8a3606e7f9f9586ea0fcf068f9147438",
207
+ "public/lib/products.js": "65a033b4b03c1b6d9cd7035e6be4c3fe47c7ad309dc14c4d417b6ac0fc10e2f4",
208
208
  "public/samples/candle-2.svg": "aca5691b9168467caf6feb7622ab154ee92504be8b887ac6c818db22476e7bed",
209
209
  "public/samples/candle.svg": "ef75e95a53ce13e88928004c6f1290c45a1094765e2ba741ae1fc55d404b881f",
210
210
  "public/samples/mug-2.svg": "5f37d23e90b7e79e42d38009a56b3ea26adae03586830a0cd418565491bd4532",
@@ -219,9 +219,31 @@
219
219
  "public/samples/tote.svg": "01de817a32c8d3d8ea47ff491049f439ca3e4d4fce943dbdc62a4187139e016c",
220
220
  "public/shop.css": "277904382e491f0ecc76aad916126970b87e161ef06ac6deeff193358b4e4c31",
221
221
  "templates/shop.html": "4649e57d23458833a157799dc80141824eb0f658d96734bd1859c68e0ba9a8b9",
222
- "tests/api.test.js": "6c12db3d3da4887b4334d199d521555fd61cfce1b4d80c0427a92e98bf3cb867",
222
+ "tests/api.test.js": "04105c5d4f7bf4fd7b9e4dea5e8b8de4a4ebbba0a8795b47c166b0a6798fd089",
223
223
  "tests/pure.test.js": "f00f27ee8c941d6268eb780520007b7413b0872e2590487f1b460ce5ea09dbb1"
224
224
  }
225
+ },
226
+ "support": {
227
+ "version": "1.0.0",
228
+ "files": {
229
+ "CLAUDE.md": "a54f3af90bc273622e67d0da0502da39a9e0962633d43d82eebf1228cf996f79",
230
+ "admin/css/index.css": "43a792dbc08acafc9f79757ac96c3675a49abb39aab05a553c6871f6f2050a1e",
231
+ "admin/lib/kit.js": "bc52d8dfe031b470b49c51e7170d46689a6b2892871e53ec0d6a44f37a91afc1",
232
+ "admin/templates/support.html": "2bb69d0443a367a402e6160fe68f4af3e8983b578cfff9575b4df9a534fcb480",
233
+ "admin/views/desk.js": "f0770c750dd340b83438c8f41b222b814c0937081201cb06b851a3fdea2952fd",
234
+ "admin/views/inbox.js": "fac961363a827ce5139689464a6b9a65c685752b0771f4bf956fb1dcd8d95ccb",
235
+ "admin/views/support.js": "448595a7087f7cff633bf5844755a931d1ca461af1c418f275dadb97673502dc",
236
+ "collections/support-tickets.json": "aa1e6b4814fdfbbcd46d097edd93dfb838cc49f5232a86ba76b54c9b29d82ac5",
237
+ "config.js": "07a72a5c7900d5eb3e2464f7f58b316fd2217f41f57fdad43ce917fe2da80b82",
238
+ "lib/mail.js": "4365511b2c3ab93bbe6e2635e4e2ebe39eb841f968a836507331b426ab6f4c1c",
239
+ "lib/receiver.js": "ef958be2a8fadcec10169ff76f10404d395f9c6b401a51642ede9218547d6262",
240
+ "lib/sender.js": "71e9146729e443df401a1b84e9778e9243d057012462e48a05e32a73f7309aa2",
241
+ "lib/shape.js": "7bef95c823ee4c6d4041152a58fdb2279dbe6797710a89e1339f8aca60cd0f89",
242
+ "plugin.js": "50db3f804ad987490d322c265be0d00e11202af265c5067b20816defa6ff990d",
243
+ "plugin.json": "75d1eaf11416b855e5255ae565781f2f7c144b42384362cd0f00f7da85f046ab",
244
+ "tests/api.test.js": "57a00bd19ba3569246f0841d8fb8f3d488fcf420c4c0556bfe07331576d74cf5",
245
+ "tests/shape.test.js": "cd7c44c8e2f676734c90044f25acc0583ab6074c1661cce05103fd0cbe307796"
246
+ }
225
247
  }
226
248
  }
227
249
  }
@@ -97,3 +97,18 @@ A cached page can show a stale stock flag until it is re-rendered; checkout re-p
97
97
  - A test site on a non-standard port needs `site.baseUrl` for Stripe/PayPal return addresses behind a proxy.
98
98
  - Admin views: item templates live in JS (a templateUrl goes through _.render); checkboxes are set after render
99
99
  and listened to; slideovers via kit.openPanel (disposed and removed on close).
100
+
101
+ ## 1.1.0: extensions sell their own things
102
+
103
+ - **`services.startCheckout`** (`{lines, customer, provider, ref, returnTo, base}`) prices and orders exactly as the
104
+ storefront's checkout. Both go through `openPayment()`.
105
+ - The order keeps the extension's `ref`, which comes back in `on.paid(order)`, and its `returnTo` page.
106
+ - Stripe success, cancel and the PayPal return go to that page instead of the receipt.
107
+ - It only sells things that are not posted.
108
+ - **`services.checkOrder(id)`** asks Stripe about a pending order. An extension's page calls it on the return,
109
+ as the receipt does, so a missing webhook does not leave it pending.
110
+ - **Unlisted products** (`unlisted: true`, `owner: '<plugin>'`) are an extension's: never on the storefront, refused
111
+ by its checkout, and bought only through `startCheckout`.
112
+ - The product form can never set or clear the two fields (POST strips them, PUT keeps the stored ones).
113
+ - A copy is the shop's own.
114
+ - `services.saveProduct` and `services.removeProduct` are how the extension manages them.
@@ -121,7 +121,8 @@ export async function priceBasket({lines, products, settings, input = {}, handle
121
121
  for (const l of lines) {
122
122
  const entry = products.get(l.productId);
123
123
  const p = entry?.data;
124
- if (!p || p.status !== 'active') {
124
+ // An unlisted product (an extension's) is sold only through that extension's checkout.
125
+ if (!p || p.status !== 'active' || (p.unlisted && !input.allowUnlisted)) {
125
126
  problems.push({productId: l.productId, variantId: l.variantId, message: 'No longer on sale - taken out.'});
126
127
  continue;
127
128
  }
@@ -230,7 +230,7 @@ async function nextNumber() {
230
230
  *
231
231
  * @returns {Promise<{id, data}>}
232
232
  */
233
- export function createOrder({quote, checkout, provider, delivery, code = ''}) {
233
+ export function createOrder({quote, checkout, provider, delivery, code = '', ref = '', returnTo = ''}) {
234
234
  return withLock(async () => {
235
235
  const now = new Date().toISOString();
236
236
  const data = {
@@ -257,6 +257,10 @@ export function createOrder({quote, checkout, provider, delivery, code = ''}) {
257
257
  address: checkout.address,
258
258
  note: checkout.note,
259
259
  code,
260
+ // An extension's own reference (services.startCheckout) - it comes back in on.paid(order).
261
+ ref: String(ref || '').slice(0, 64),
262
+ // Where an extension's shopper goes back to after paying, instead of the Shop's receipt.
263
+ returnTo: String(returnTo || '').slice(0, 500),
260
264
  provider,
261
265
  providerRef: '',
262
266
  paymentRef: '',
@@ -58,7 +58,7 @@ const receiptPath = (s, order) => `${s.shopPath}/order/${order.id}?t=${order.dat
58
58
  const fail = (reply, status, error, extra = {}) => reply.code(status).send({error, ...extra});
59
59
 
60
60
  /** The quote, from a request body, at today's prices. */
61
- async function quoteFor(body, settings) {
61
+ async function quoteFor(body, settings, {allowUnlisted = false} = {}) {
62
62
  const lines = cleanLines(body?.lines);
63
63
  const ids = new Set(lines.map(l => l.productId));
64
64
  const products = new Map();
@@ -70,7 +70,8 @@ async function quoteFor(body, settings) {
70
70
  postcode: String(body?.address?.postcode || '').slice(0, 20),
71
71
  delivery: body?.delivery === 'collect' ? 'collect' : 'ship',
72
72
  service: String(body?.service || '').slice(0, 40),
73
- email: String(body?.customer?.email || '').slice(0, 200)
73
+ email: String(body?.customer?.email || '').slice(0, 200),
74
+ allowUnlisted
74
75
  };
75
76
  const quote = await priceBasket({lines, products, settings, input, handlers: cartRegistry().handlers()});
76
77
  return {quote, input};
@@ -95,7 +96,7 @@ function shopperQuote(q, settings) {
95
96
  */
96
97
  function registerShortcodes(hooks) {
97
98
  if (typeof hooks.registerShortcode !== 'function') return;
98
- const onSale = () => shelfNow().filter(e => e.data?.status === 'active').sort(byShelf);
99
+ const onSale = () => shelfNow().filter(e => e.data?.status === 'active' && !e.data?.unlisted).sort(byShelf);
99
100
 
100
101
  hooks.registerShortcode('shop-products', (attrStr, _body, ctx) => {
101
102
  const a = ctx.parseShortcodeAttrs(attrStr || '');
@@ -210,14 +211,19 @@ export default async function shoppingCart(fastify, options) {
210
211
 
211
212
  fastify.post('/products', can('manage'), async (request, reply) => {
212
213
  const s = await liveSettings();
213
- const r = await saveProduct(request.body || {}, {currency: s.currency, by: request.user?.id});
214
+ // unlisted and owner are an extension's to set (services.saveProduct), never the product form's.
215
+ const {unlisted, owner, ...body} = request.body || {};
216
+ const r = await saveProduct(body, {currency: s.currency, by: request.user?.id});
214
217
  if (r.errors) return fail(reply, 400, Object.values(r.errors)[0], {errors: r.errors});
215
218
  return reply.code(201).send(r.entry);
216
219
  });
217
220
 
218
221
  fastify.put('/products/:id', can('manage'), async (request, reply) => {
219
222
  const s = await liveSettings();
220
- const r = await saveProduct(request.body || {}, {id: request.params.id, currency: s.currency});
223
+ // An edit in the product form keeps an extension's product unlisted and its own: the form never sends them.
224
+ const had = await getProduct(request.params.id);
225
+ const {unlisted, owner, ...body} = request.body || {};
226
+ const r = await saveProduct({...body, unlisted: had?.data?.unlisted === true, owner: had?.data?.owner || ''}, {id: request.params.id, currency: s.currency});
221
227
  if (r.notFound) return fail(reply, 404, 'That product is gone.');
222
228
  if (r.errors) return fail(reply, 400, Object.values(r.errors)[0], {errors: r.errors});
223
229
  return r.entry;
@@ -240,7 +246,8 @@ export default async function shoppingCart(fastify, options) {
240
246
  const had = await getProduct(request.params.id);
241
247
  if (!had) return fail(reply, 404, 'That product is gone.');
242
248
  const s = await liveSettings();
243
- const r = await saveProduct({...had.data, name: `${had.data.name} (copy)`, slug: '', status: 'draft',
249
+ // A copy is the shop's own product, even of an extension's: listed, and nobody else's to change.
250
+ const r = await saveProduct({...had.data, name: `${had.data.name} (copy)`, slug: '', status: 'draft', unlisted: false, owner: '',
244
251
  variants: (had.data.variants || []).map(v => ({...v, id: ''}))}, {currency: s.currency, by: request.user?.id});
245
252
  if (r.errors) return fail(reply, 400, Object.values(r.errors)[0]);
246
253
  return reply.code(201).send(r.entry);
@@ -390,7 +397,7 @@ export default async function shoppingCart(fastify, options) {
390
397
 
391
398
  fastify.get('/catalogue', async (request) => {
392
399
  const s = await liveSettings();
393
- const products = (await allProducts()).filter(e => e.data?.status === 'active').sort(byShelf).map(publicProduct);
400
+ const products = (await allProducts()).filter(e => e.data?.status === 'active' && !e.data?.unlisted).sort(byShelf).map(publicProduct);
394
401
  return {currency: s.currency, shopPath: s.shopPath, products};
395
402
  });
396
403
 
@@ -424,46 +431,9 @@ export default async function shoppingCart(fastify, options) {
424
431
 
425
432
  const order = await createOrder({quote, checkout, provider, delivery: collecting ? 'collect' : (quote.requiresShipping ? 'ship' : 'none'), code: input.code});
426
433
  registry.emit('checkout', order);
427
- const base = siteBase(request);
428
- const receipt = `${base}${receiptPath(s, order)}`;
429
-
430
434
  try {
431
- if (provider === 'free') {
432
- await markPaid(order.id, {by: 'free', note: 'Nothing to pay'}).then(r => r.changed && announcePaid(r.order));
433
- return {redirect: receipt, orderId: order.id};
434
- }
435
- if (provider === 'offline') {
436
- const {order: moved} = await moveOrder(order.id, 'awaiting', {by: 'shopper', note: s.offlineLabel});
437
- registry.emit('status', moved, 'pending', 'awaiting');
438
- try {
439
- hooks.notify?.({title: `Order ${orderLabel(moved.data)} awaiting payment - ${formatMoney(moved.data.total, moved.data.currency)}`,
440
- body: `${moved.data.customer?.name}: ${s.offlineLabel}`, severity: 'info', source: 'orders',
441
- link: `#/plugins/shopping-cart/orders/${order.id}`, dedupeKey: `order-awaiting:${order.id}`});
442
- } catch { /* never mind */ }
443
- return {redirect: receipt, orderId: order.id};
444
- }
445
- if (provider === 'stripe') {
446
- const session = await pay.stripeCreateSession({
447
- secretKey: s.stripeSecretKey,
448
- order: {id: order.id, ...order.data},
449
- successUrl: `${receipt}&session_id={CHECKOUT_SESSION_ID}`,
450
- cancelUrl: `${base}${s.shopPath}/cart?cancelled=${order.id}`,
451
- shopName: s.shopTitle
452
- });
453
- await setProviderRef(order.id, session.id);
454
- return {redirect: session.url, orderId: order.id};
455
- }
456
- if (provider === 'paypal') {
457
- const pp = await pay.paypalCreateOrder({
458
- creds: {clientId: s.paypalClientId, secret: s.paypalSecret, mode: s.paypalMode},
459
- order: {id: order.id, ...order.data},
460
- returnUrl: `${base}/api/plugins/shopping-cart/paypal/return?order=${order.id}&t=${order.data.token}`,
461
- cancelUrl: `${base}${s.shopPath}/cart?cancelled=${order.id}`,
462
- shopName: s.shopTitle
463
- });
464
- await setProviderRef(order.id, pp.id);
465
- return {redirect: pp.url, orderId: order.id};
466
- }
435
+ const opened = await openPayment(order, s, provider, siteBase(request));
436
+ if (opened) return opened;
467
437
  } catch (err) {
468
438
  request.log.warn(`[shopping-cart] checkout ${order.data.number}: ${err.message}`);
469
439
  return fail(reply, 502, `The payment page could not be opened: ${err.message.replace(/^(Stripe|PayPal): /, '')}. Nothing has been charged.`);
@@ -471,6 +441,59 @@ export default async function shoppingCart(fastify, options) {
471
441
  return fail(reply, 400, 'Choose how to pay.');
472
442
  });
473
443
 
444
+ /** Where a shopper goes after paying: an extension's own page when it asked for one, else the receipt. */
445
+ const afterPay = (base, s, order, extra = '') => {
446
+ const to = order.data.returnTo;
447
+ if (to) return `${to}${to.includes('?') ? '&' : '?'}order=${order.id}${extra ? `&${extra}` : ''}`;
448
+ return `${base}${receiptPath(s, order)}${extra ? `&${extra}` : ''}`;
449
+ };
450
+
451
+ /**
452
+ * Open the way to pay for an order: the provider's page, or straight to the receipt (free, offline).
453
+ * The storefront and services.startCheckout both come through here. Null when the method is unknown.
454
+ */
455
+ async function openPayment(order, s, provider, base) {
456
+ const receipt = afterPay(base, s, order);
457
+ const cancel = order.data.returnTo ? afterPay(base, s, order, 'cancelled=1') : `${base}${s.shopPath}/cart?cancelled=${order.id}`;
458
+ if (provider === 'free') {
459
+ await markPaid(order.id, {by: 'free', note: 'Nothing to pay'}).then(r => r.changed && announcePaid(r.order));
460
+ return {redirect: receipt, orderId: order.id};
461
+ }
462
+ if (provider === 'offline') {
463
+ const {order: moved} = await moveOrder(order.id, 'awaiting', {by: 'shopper', note: s.offlineLabel});
464
+ registry.emit('status', moved, 'pending', 'awaiting');
465
+ try {
466
+ hooks.notify?.({title: `Order ${orderLabel(moved.data)} awaiting payment - ${formatMoney(moved.data.total, moved.data.currency)}`,
467
+ body: `${moved.data.customer?.name}: ${s.offlineLabel}`, severity: 'info', source: 'orders',
468
+ link: `#/plugins/shopping-cart/orders/${order.id}`, dedupeKey: `order-awaiting:${order.id}`});
469
+ } catch { /* never mind */ }
470
+ return {redirect: receipt, orderId: order.id};
471
+ }
472
+ if (provider === 'stripe') {
473
+ const session = await pay.stripeCreateSession({
474
+ secretKey: s.stripeSecretKey,
475
+ order: {id: order.id, ...order.data},
476
+ successUrl: `${receipt}&session_id={CHECKOUT_SESSION_ID}`,
477
+ cancelUrl: cancel,
478
+ shopName: s.shopTitle
479
+ });
480
+ await setProviderRef(order.id, session.id);
481
+ return {redirect: session.url, orderId: order.id};
482
+ }
483
+ if (provider === 'paypal') {
484
+ const pp = await pay.paypalCreateOrder({
485
+ creds: {clientId: s.paypalClientId, secret: s.paypalSecret, mode: s.paypalMode},
486
+ order: {id: order.id, ...order.data},
487
+ returnUrl: `${base}/api/plugins/shopping-cart/paypal/return?order=${order.id}&t=${order.data.token}`,
488
+ cancelUrl: cancel,
489
+ shopName: s.shopTitle
490
+ });
491
+ await setProviderRef(order.id, pp.id);
492
+ return {redirect: pp.url, orderId: order.id};
493
+ }
494
+ return null;
495
+ }
496
+
474
497
  /**
475
498
  * An order's receipt, for the page and for its polling. A pending Stripe
476
499
  * order is checked with Stripe on the way: the shopper's return is the
@@ -503,12 +526,59 @@ export default async function shoppingCart(fastify, options) {
503
526
  }
504
527
  registry.services.confirmStripe = confirmStripe;
505
528
 
529
+ /**
530
+ * A checkout started by an extension (Cards Pro selling credits) for things that are not posted:
531
+ * priced, ordered and paid exactly as the storefront's, with the extension's own `ref` on the order
532
+ * (it comes back in on.paid) and its own page to come back to. Throws {status, message}.
533
+ *
534
+ * @param {object} p
535
+ * @param {{productId: string, qty: number}[]} p.lines
536
+ * @param {{name: string, email: string}} p.customer
537
+ * @param {string} [p.provider] - one of the Shop's methods; the first when left out
538
+ * @param {string} [p.ref] - the extension's reference, 64 characters at most
539
+ * @param {string} [p.returnTo] - a path on this site ('/admin/#/...'), where the shopper comes back to
540
+ * @param {string} [p.base] - this site's origin, from the extension's request
541
+ */
542
+ async function startCheckout({lines, customer, provider = '', ref = '', returnTo = '', base = ''} = {}) {
543
+ const bad = (status, message) => Object.assign(new Error(message), {status});
544
+ const s = await liveSettings();
545
+ const {quote} = await quoteFor({lines, customer}, s, {allowUnlisted: true});
546
+ if (!quote.lines.length) throw bad(400, 'Nothing to buy.');
547
+ if (quote.problems.length) throw bad(409, quote.problems.map(p => p.message).join(' '));
548
+ if (quote.requiresShipping) throw bad(400, 'This checkout is only for things that are not posted.');
549
+ const checkout = cleanCheckout({customer}, {needsAddress: false});
550
+ if (Object.keys(checkout.errors).length) throw bad(400, Object.values(checkout.errors)[0]);
551
+ const methods = methodsOf(s).map(m => m.id);
552
+ const via = quote.total === 0 ? 'free' : (provider || methods[0] || '');
553
+ if (via !== 'free' && !methods.includes(via)) throw bad(409, 'The Shop has no way to take payment set up yet.');
554
+ const origin = /^https?:\/\/[^/\s]+$/i.test(base) ? base.replace(/\/+$/, '') : siteBase(null);
555
+ const back = /^\/(?!\/)/.test(String(returnTo)) ? `${origin}${returnTo}` : '';
556
+ const order = await createOrder({quote, checkout, provider: via, delivery: 'none', ref, returnTo: back});
557
+ registry.emit('checkout', order);
558
+ const opened = await openPayment(order, s, via, origin);
559
+ if (!opened) throw bad(400, 'Choose how to pay.');
560
+ return {...opened, provider: via, total: quote.total, currency: quote.currency};
561
+ }
562
+
563
+ /** An order, with a pending Stripe payment asked about on the way (the shopper's return is how we usually learn). */
564
+ async function checkOrder(id) {
565
+ const o = await getOrder(String(id || ''));
566
+ return o ? confirmStripe(o, '', fastify.log) : null;
567
+ }
568
+
569
+ registry.services.startCheckout = startCheckout;
570
+ registry.services.checkOrder = checkOrder;
571
+ registry.services.paymentMethods = async () => methodsOf(await liveSettings()).map(m => ({id: m.id, label: m.label}));
572
+ // An extension looks after its own (unlisted) products: made, changed and removed through these.
573
+ registry.services.saveProduct = async (input, {id = null} = {}) => saveProduct(input, {id, currency: (await liveSettings()).currency});
574
+ registry.services.removeProduct = removeProduct;
575
+
506
576
  fastify.get('/paypal/return', async (request, reply) => {
507
577
  const {order: id, t, token} = request.query || {};
508
578
  const o = await getOrder(String(id || ''));
509
579
  const s = await liveSettings();
510
580
  if (!o || !tokenOk(o, t)) return reply.redirect(`${s.shopPath}/cart`);
511
- const receipt = receiptPath(s, o);
581
+ const receipt = afterPay('', s, o);
512
582
  if (o.data.status !== 'pending' || o.data.provider !== 'paypal') return reply.redirect(receipt);
513
583
  if (token !== o.data.providerRef) return reply.redirect(`${receipt}&problem=paypal`);
514
584
  try {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "shopping-cart",
3
3
  "displayName": "Shopping Cart",
4
- "version": "1.0.4",
4
+ "version": "1.1.0",
5
5
  "tier": "free",
6
6
  "description": "Sell from your site: products with options, stock and pictures, a storefront and a cart that follows shoppers from page to page, and checkout with Stripe, PayPal or payment offline. Orders to fulfil in the sidebar, refunds from the order.",
7
7
  "author": "Domma CMS",
@@ -60,7 +60,7 @@ export default async function shopPublic(fastify) {
60
60
 
61
61
  const pages = {
62
62
  async index(request, reply, s) {
63
- const entries = (await allProducts()).filter(e => e.data?.status === 'active').sort(byShelf);
63
+ const entries = (await allProducts()).filter(e => e.data?.status === 'active' && !e.data?.unlisted).sort(byShelf);
64
64
  const category = String(request.query?.category || '').slice(0, 60);
65
65
  const query = String(request.query?.q || '').slice(0, 80);
66
66
  return send(reply, request, s, {
@@ -79,7 +79,7 @@ export default async function shopPublic(fastify) {
79
79
  },
80
80
 
81
81
  async product(request, reply, s, {slug}) {
82
- const all = (await allProducts()).filter(e => e.data?.status === 'active');
82
+ const all = (await allProducts()).filter(e => e.data?.status === 'active' && !e.data?.unlisted);
83
83
  const entry = all.find(e => e.data.slug === slug);
84
84
  if (!entry) return null; // fall through to the CMS's own 404 (or a page at that address)
85
85
  let descriptionHtml = '';
@@ -142,7 +142,12 @@ export function cleanProduct(input = {}, {currency = 'GBP', minor = true} = {})
142
142
  variants,
143
143
  shippable: input.shippable === undefined ? true : Boolean(input.shippable),
144
144
  weight: Number.isInteger(weight) ? weight : null,
145
- taxClass: TAX_CLASSES.includes(input.taxClass) ? input.taxClass : 'standard'
145
+ taxClass: TAX_CLASSES.includes(input.taxClass) ? input.taxClass : 'standard',
146
+ // Sold only through an extension's own checkout (Cards Pro's credit packs): never on the
147
+ // storefront, where we would know nothing of who bought it but an email address.
148
+ unlisted: input.unlisted === true,
149
+ // The plugin that looks after it (it may change or remove it); '' for the shop's own products.
150
+ owner: /^[a-z0-9-]{0,60}$/.test(String(input.owner ?? '')) ? String(input.owner ?? '') : ''
146
151
  };
147
152
  return {data, errors};
148
153
  }
@@ -146,3 +146,45 @@ test('the Stripe webhook refuses an unsigned body', async () => {
146
146
  headers: {'content-type': 'application/json', 'stripe-signature': 't=1,v1=00'}});
147
147
  assert.equal(res.statusCode, 400);
148
148
  });
149
+
150
+ test('an extension sells its own unlisted product through services.startCheckout, and gets its ref back when paid', async () => {
151
+ const {cartRegistry} = await import('../lib/extensions.js');
152
+ const reg = cartRegistry();
153
+ const paid = [];
154
+ reg.register('test-credits', {on: {paid: (order) => paid.push(order)}});
155
+ try {
156
+ await call('PUT', '/settings', {offlineEnabled: true, countries: ['GB']});
157
+ const made = await reg.services.saveProduct({name: '5 card credits', price: 100, status: 'active', shippable: false, unlisted: true, owner: 'test-credits'});
158
+ const pack = made.entry;
159
+ assert.equal(pack.data.unlisted, true);
160
+ // Not on the storefront, and not buyable there.
161
+ assert.ok(!(await call('GET', '/catalogue')).body.products.some(p => p.name === '5 card credits'));
162
+ const q = await call('POST', '/quote', {lines: [{productId: pack.id, qty: 1}]});
163
+ assert.equal(q.body.lines.length, 0, 'the storefront checkout refuses it');
164
+ // The product form cannot list it by accident.
165
+ const edited = await call('PUT', `/products/${pack.id}`, {...pack.data, name: '5 credits', unlisted: false, owner: ''});
166
+ assert.equal(edited.body.data.unlisted, true);
167
+ assert.equal(edited.body.data.owner, 'test-credits');
168
+ // The extension's checkout: priced as the shop prices, the ref kept, back to its own page.
169
+ const out = await reg.services.startCheckout({lines: [{productId: pack.id, qty: 1}], customer: {name: 'Pat', email: 'pat@x.test'},
170
+ provider: 'offline', ref: 'purchase-123', returnTo: '/admin/#/plugins/test-credits?purchase=123', base: 'https://site.test'});
171
+ assert.equal(out.total, 100);
172
+ assert.match(out.redirect, /^https:\/\/site\.test\/admin\/#\/plugins\/test-credits\?purchase=123&order=/);
173
+ const order = await reg.services.checkOrder(out.orderId);
174
+ assert.equal(order.data.ref, 'purchase-123');
175
+ await assert.rejects(reg.services.startCheckout({lines: [{productId: pack.id, qty: 1}], customer: {name: '', email: 'x'}, provider: 'offline'}), /name/i);
176
+ await assert.rejects(reg.services.startCheckout({lines: [{productId: pack.id, qty: 1}], customer: {name: 'Pat', email: 'pat@x.test'}, provider: 'bitcoin'}), /payment/i);
177
+ // Paid (here by hand, as an offline order is): the extension hears, with its ref.
178
+ const r = await call('PUT', `/orders/${out.orderId}/status`, {status: 'paid'});
179
+ assert.ok([200, 201].includes(r.status), JSON.stringify(r.body));
180
+ await new Promise(res => setTimeout(res, 20));
181
+ assert.equal(paid.at(-1)?.data?.ref, 'purchase-123');
182
+ // A free one is paid at once.
183
+ const freebie = (await reg.services.saveProduct({name: 'Free credit', price: 0, status: 'active', shippable: false, unlisted: true, owner: 'test-credits'})).entry;
184
+ const f = await reg.services.startCheckout({lines: [{productId: freebie.id, qty: 1}], customer: {name: 'Pat', email: 'pat@x.test'}, ref: 'gift-1', base: 'https://site.test'});
185
+ assert.equal(f.provider, 'free');
186
+ assert.equal((await reg.services.checkOrder(f.orderId)).data.status, 'paid');
187
+ } finally {
188
+ reg.unregister('test-credits');
189
+ }
190
+ });