domma-cms 1.6.31 → 1.6.33

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.
Files changed (43) hide show
  1. package/admin/js/lib/effect-defs.js +1 -1
  2. package/admin/js/lib/effects-builder.js +2 -2
  3. package/admin/js/lib/favicon-generator.js +1 -0
  4. package/admin/js/lib/markdown-toolbar.js +9 -9
  5. package/admin/js/lib/page-comments.js +9 -0
  6. package/admin/js/lib/page-editor-reference.js +1 -1
  7. package/admin/js/lib/scribe-composer.js +1 -1
  8. package/admin/js/lib/scribe-model.js +1 -1
  9. package/admin/js/lib/shortcode-preview.js +1 -1
  10. package/admin/js/lib/site-settings-arrange.js +1 -1
  11. package/admin/js/lib/syntax-highlight.js +1 -1
  12. package/admin/js/templates/settings.html +25 -7
  13. package/admin/js/views/settings.js +4 -4
  14. package/package.json +1 -1
  15. package/plugins/free-tier.lock.json +19 -19
  16. package/plugins/shopping-cart/CLAUDE.md +25 -0
  17. package/plugins/shopping-cart/admin/css/index.css +2 -0
  18. package/plugins/shopping-cart/admin/lib/kit.js +41 -0
  19. package/plugins/shopping-cart/admin/views/orders.js +11 -2
  20. package/plugins/shopping-cart/admin/views/products.js +19 -4
  21. package/plugins/shopping-cart/admin/views/shop.js +1 -1
  22. package/plugins/shopping-cart/docs/guide.md +2 -0
  23. package/plugins/shopping-cart/inject/body.html +1 -1
  24. package/plugins/shopping-cart/lib/extensions.js +71 -0
  25. package/plugins/shopping-cart/lib/render.js +6 -2
  26. package/plugins/shopping-cart/lib/shop.js +36 -12
  27. package/plugins/shopping-cart/plugin.js +19 -5
  28. package/plugins/shopping-cart/plugin.json +3 -3
  29. package/plugins/shopping-cart/plugin.public.js +2 -1
  30. package/plugins/shopping-cart/public/shop.css +12 -0
  31. package/plugins/shopping-cart/tests/api.test.js +51 -0
  32. package/plugins/shopping-cart/tests/pure.test.js +49 -0
  33. package/public/js/effects.js +1 -1
  34. package/public/js/scribe-style.mjs +1 -0
  35. package/public/js/site.js +1 -1
  36. package/scripts/run-tests.mjs +4 -0
  37. package/server/routes/api/pages.js +3 -3
  38. package/server/routes/api/settings.js +20 -1
  39. package/server/routes/api/versions.js +4 -3
  40. package/server/services/favicon.js +33 -0
  41. package/server/services/markdown.js +17 -5
  42. package/server/services/versions.js +10 -0
  43. package/server/templates/page.html +3 -3
@@ -17,6 +17,7 @@
17
17
  <div class="tab-list">
18
18
  <button type="button" class="tab-item active">General <i class="stx-dot" data-bind-hidden="!tabs.value.general" title="Changed"></i></button>
19
19
  <button type="button" class="tab-item">Branding <i class="stx-dot" data-bind-hidden="!tabs.value.branding" title="Changed"></i></button>
20
+ <button type="button" class="tab-item">Favicon <i class="stx-dot" data-bind-hidden="!tabs.value.favicon" title="Changed"></i></button>
20
21
  <button type="button" class="tab-item">SEO <i class="stx-dot" data-bind-hidden="!tabs.value.seo" title="Changed"></i></button>
21
22
  <button type="button" class="tab-item">Footer <i class="stx-dot" data-bind-hidden="!tabs.value.footer" title="Changed"></i></button>
22
23
  <button type="button" class="tab-item">Email <i class="stx-dot" data-bind-hidden="!tabs.value.email" title="Changed"></i><i class="stx-dot is-bad" data-bind-hidden="!noSmtp.value || tabs.value.email" title="No SMTP host"></i></button>
@@ -102,13 +103,8 @@
102
103
  <div><label class="form-label" for="stx-brandIcon">Icon</label>
103
104
  <div data-brand-icon="brandIcon"></div>
104
105
  </div>
105
- <div><label class="form-label" for="stx-favicon">Browser icon (favicon)</label>
106
- <input id="stx-favicon" type="text" class="form-input" placeholder="/media/favicon.png" data-model="favicon.value">
107
- <button type="button" class="btn btn-sm btn-ghost" data-on-click="pickBrandImage('favicon')">Choose or upload image</button>
108
- <button type="button" class="btn btn-sm btn-ghost" data-on-click="clearBrandImage('favicon')">Remove</button>
109
- <img data-bind-src="favicon.value" data-bind-hidden="!favicon.value" alt="Browser icon (favicon) preview" style="display:block;max-width:180px;max-height:80px;object-fit:contain;margin-top:.75rem;">
110
- </div>
111
- </div><p class="text-muted">The logo takes priority over the icon. Leave the browser icon empty to use the public logo.</p></div>
106
+
107
+ </div><p class="text-muted">The logo takes priority over the icon. Set the browser icon in the Favicon tab.</p></div>
112
108
  <div class="stx-card"><div class="stx-card-title">Admin sidebar</div><div class="stx-grid">
113
109
  <div><label class="form-label" for="stx-adminBrandTitle">Admin name</label>
114
110
  <input id="stx-adminBrandTitle" type="text" class="form-input" placeholder="Uses the site title when empty" data-model="adminBrandTitle.value">
@@ -125,6 +121,28 @@
125
121
  </div><p class="text-muted">The logo takes priority over the icon. Leave both empty to use the default icon.</p></div>
126
122
  </div>
127
123
 
124
+ <!-- Favicon -->
125
+ <div class="tab-panel">
126
+ <div class="stx-card"><div class="stx-card-title">Generate favicon.ico</div>
127
+ <p>Choose or upload an image. Generate a single ICO file with 16, 32, 48 and 256 pixel icons. Images keep their proportions with transparent padding.</p>
128
+ <label class="form-label" for="stx-favicon-source">Source image</label>
129
+ <input id="stx-favicon-source" class="form-input" type="text" placeholder="/media/logo.png" data-model="faviconSource.value">
130
+ <button type="button" class="btn btn-secondary" data-on-click="chooseFaviconSource" data-bind-disabled="faviconGenerating.value">Choose or upload image</button>
131
+ <button type="button" class="btn btn-primary" data-on-click="generateFavicon" data-bind-disabled="faviconGenerating.value || !faviconSource.value">Generate favicon.ico</button>
132
+ <p class="text-danger" data-bind-text="faviconError.value" data-bind-hidden="!faviconError.value"></p>
133
+ <p data-bind-hidden="!faviconGenerated.value">Generated. Save Site Settings to apply it. <a data-bind-href="faviconGenerated.value" download="favicon.ico">Download favicon.ico</a></p>
134
+ </div>
135
+ <div class="stx-card"><div class="stx-card-title">Browser icon</div>
136
+ <div><label class="form-label" for="stx-favicon">Browser icon (favicon)</label>
137
+ <input id="stx-favicon" type="text" class="form-input" placeholder="/media/favicon.png" data-model="favicon.value">
138
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="pickBrandImage('favicon')">Choose or upload image</button>
139
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="clearBrandImage('favicon')">Remove</button>
140
+ <img data-bind-src="favicon.value" data-bind-hidden="!favicon.value" alt="Browser icon (favicon) preview" style="display:block;max-width:180px;max-height:80px;object-fit:contain;margin-top:.75rem;">
141
+ </div>
142
+ <p class="text-muted">Save to apply the browser icon. Leave it empty to use the public logo.</p>
143
+ </div>
144
+ </div>
145
+
128
146
  <!-- SEO -->
129
147
  <div class="tab-panel">
130
148
  <div class="stx-card">
@@ -1,13 +1,13 @@
1
- import{api as G,apiRequest as w}from"../api.js";import{placeTools as Ie}from"../lib/sidebar-grouping.js";import{addBannerAction as he}from"../lib/plugin-chrome.js";import{helpPopovers as $e}from"../lib/help-popover.js";import{openMenuAt as Q,sidebarCount as ve}from"../lib/tool-kit.js";import{changedTabs as Ae,describeCacheKey as X,draftOf as Pe,expiresIn as Be,formFromSettings as De,homeValue as He,payloadFromForm as je,siteUrlError as be,settingsProblems as Ke,snapshot as ge,TAB_FIELDS as _e}from"../lib/site-settings-arrange.js";import{pickMedia as qe}from"../lib/media-picker.js";import{makeIconInput as Ue}from"../lib/shortcode-modal.js";import{fmtDateTime as Fe}from"../lib/dates.js";const Z=/^\/settings(\?.*)?$/,ye="#/settings",T="settings.draft",ze=`
1
+ import{api as J,apiRequest as w}from"../api.js";import{placeTools as $e}from"../lib/sidebar-grouping.js";import{addBannerAction as ve}from"../lib/plugin-chrome.js";import{helpPopovers as Ae}from"../lib/help-popover.js";import{openMenuAt as Q,sidebarCount as be}from"../lib/tool-kit.js";import{changedTabs as Pe,describeCacheKey as X,draftOf as Be,expiresIn as De,formFromSettings as He,homeValue as je,payloadFromForm as Ke,siteUrlError as ge,settingsProblems as _e,snapshot as ye,TAB_FIELDS as qe}from"../lib/site-settings-arrange.js";import{pickMedia as Ue}from"../lib/media-picker.js";import{faviconGenerator as Fe}from"../lib/favicon-generator.js";import{makeIconInput as ze}from"../lib/shortcode-modal.js";import{fmtDateTime as Ne}from"../lib/dates.js";const Z=/^\/settings(\?.*)?$/,ke="#/settings",T="settings.draft",Ve=`
2
2
  <div class="stx-problem">
3
3
  <span class="stx-chip" data-bind-class="tone === 'danger' ? 'is-bad' : 'is-warn'">{{label}}</span>
4
4
  <span>{{text}}</span>
5
5
  <button type="button" class="btn btn-sm btn-ghost" data-go="{{tab}}">Go there</button>
6
- </div>`,Ne=`
6
+ </div>`,We=`
7
7
  <div class="stx-row" data-id="{{id}}" tabindex="0">
8
8
  <span class="stx-main"><strong>{{path}}</strong><small>{{meta}}</small></span>
9
9
  <span class="stx-chip">{{expires}}</span>
10
10
  <button type="button" class="stx-kebab" aria-label="Actions for {{path}}" title="Actions">\u22EE</button>
11
- </div>`,Ve=["general","branding","seo","footer","email","backToTop","cookies","breadcrumbs","css","cache"];async function We(l,p){const[r,L]=await Promise.all([H.get("/api/sidebar/menu").catch(()=>({items:[]})),H.get("/api/sidebar/registered-items").catch(()=>[])]),a=new Map([["/","Dashboard (built in)"]]),c=(C,m)=>{for(const i of C||[]){if(!i||i.hidden||i.restricted)continue;const f=typeof i.url=="string"?i.url:"";f.startsWith("#/")&&f.length>2&&!a.has(f.slice(1))&&a.set(f.slice(1),`${m?`${m} \u203A `:""}${i.text||f}`),c(i.items,i.url?m:i.text||m)}};c(Ie(r?.items||[],L||[]),""),a.has(p)||a.set(p,`${p} (not in the sidebar)`),l.replaceChildren();for(const[C,m]of a){const i=document.createElement("option");i.value=C,i.textContent=m,l.appendChild(i)}}let g=null,ke=!1;function Ye(){ke||!window.R?.use||(ke=!0,R.use((l,p,r)=>!(Z.test(p?.path||"")&&!Z.test(l?.path||""))||!g?.dirty()?r():E.confirm("You have unsaved settings. Leave anyway? They are kept as a draft for your next visit (not the SMTP password).").then(a=>{a?r():history.replaceState(null,"","#/settings")})),M.subscribe("router:afterChange",({to:l}={})=>{if(g&&!Z.test(l?.path||"")){const p=g;g=null,p.dispose()}}))}export const settingsView={templateUrl:"/admin/js/templates/settings.html",async onMount(l){if(Ye(),g){const e=g;g=null,e.dispose()}const p=[],r=e=>(p.push(e),e);let L=!1;const a=l.get(0).querySelector(".stx")||l.get(0),c=()=>!L&&a.isConnected,C=E.loader(l.get(0),{type:"dots"});let m,i;try{[m,i]=await Promise.all([G.settings.get(),w("/settings/custom-css").then(e=>e?.css||"").catch(()=>null)])}catch(e){C.destroy(),E.toast(`Could not load the settings: ${e.message||e}`,{type:"error"});return}if(C.destroy(),!a.isConnected)return;const f=i!==null,ee=M.observable(m||{}),te=De(m||{},i||""),n={};for(const[e,t]of Object.entries(te))n[e]=M.observable(t);const K=()=>Object.fromEntries(Object.keys(n).map(e=>[e,n[e].value])),se=()=>Object.fromEntries(Object.keys(n).map(e=>[e,n[e].peek()])),y=M.observable(te),_=M.computed(()=>ge(y.value)),O=M.computed(()=>ge(K())!==_.value),$=M.observable(!1),A=M.observable(""),q=M.observable(!1),ne=M.computed(()=>{const e=new Set(Ae(K(),y.value));return Object.fromEntries(Object.keys(_e).map(t=>[t,e.has(t)]))}),U=M.observable([]);r(M.effect(()=>{U.value=Ke(K()).map(e=>({...e,label:e.tone==="danger"?"Mail":"Check"}))}));const ae=M.observable(""),P=M.observable(!1),B=M.observable(""),D=M.observable(""),v=M.observable(null),F=M.observable([]),z=M.observable(""),N=M.observable(!0),V=M.observable(""),oe=M.observable([]);r(M.effect(()=>{const e=z.value.trim().toLowerCase(),t=Date.now();oe.value=[...F.value].reverse().filter(s=>!e||String(s.key).toLowerCase().includes(e)||(s.tags||[]).some(o=>String(o).toLowerCase().includes(e))).map(s=>{const o=X(s.key);return{id:s.key,path:o.path,meta:[o.page?`as ${o.role}`:"not a page",(s.tags||[]).join(", ")].filter(Boolean).join(" \xB7 "),expires:Be(s.expiresAt,t)}})}));const we={...n,saving:$,dirty:O,tabs:ne,problems:U,testTo:ae,testing:P,testResult:B,testTone:D,cacheQuery:z,cacheLoading:N,cacheError:V,cacheRows:oe,errorText:A,problemsText:M.computed(()=>U.value.length?"yes":""),siteUrlError:M.computed(()=>be(n.siteUrl.value)),noSmtp:M.computed(()=>!String(n.smtpHost.value||"").trim()),dirtyEmail:M.computed(()=>ne.value.email),seoLength:M.computed(()=>{const e=String(n.seoDescription.value||"").trim().length;return e?`${e} characters${e>160?" - search engines usually cut it off after about 150":""}`:""}),cssSize:M.computed(()=>{if(!f)return"Could not load content/custom.css - reload to try again. It will not be saved until it loads.";const e=new Blob([String(n.css.value||"")]).size/1024;return`${e.toFixed(1)} KB of 100 KB${e>100?" - too big to save":""}`}),cacheOn:M.computed(()=>v.value?.enabled===!0),cacheState:M.computed(()=>{const e=v.value;return e?e.enabled?`On (${e.driver})`:"Off":"Status unknown"}),cacheSize:M.computed(()=>{const e=v.value;return e?.enabled&&e.size!==null&&e.size!==void 0?`${e.size}${e.maxItems?` of ${e.maxItems}`:""} entries`:""}),cacheHits:M.computed(()=>{const e=v.value?.hitRate;return e==null?"":`${Math.round(e*100)}% served from the cache`}),cacheToggleText:M.computed(()=>v.value?.enabled?"Turn off":"Turn on"),cacheEmpty:M.computed(()=>z.value.trim()?"Nothing matches.":v.value?.enabled?"The cache is empty - pages are cached as visitors open them.":"The cache is off.")};async function W(){if($.peek())return;const e=se(),t=y.peek(),s=String(e.css)!==String(t.css);if(s&&!f){E.toast("The custom CSS did not load, so it cannot be saved - reload the screen first.",{type:"error"});return}const o=be(e.siteUrl);if(o){E.toast(`Site URL: ${o}`,{type:"error"});return}$.value=!0,A.value="";const h=String(e.title).trim()!==String(t.title).trim();try{await G.settings.save(je(e,ee.peek(),{adminHomeReady:q.peek()})),s&&await w("/settings/custom-css",{method:"PUT",body:JSON.stringify({css:String(e.css)})});const d=await G.settings.get().catch(()=>null);if(!c())return;d&&(ee.value=d);const J={...e,smtpPass:""};n.smtpPass.value="",y.value=J;try{S.remove?S.remove(T):S.set(T,null)}catch{}document.dispatchEvent(new CustomEvent("dm:admin-home-changed")),(h||["adminBrandTitle","adminBrandLogo","adminBrandIcon"].some(fe=>e[fe]!==t[fe]))&&document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild")),ve(ye),E.toast("Settings saved.",{type:"success"})}catch(d){A.value=d.message||"Could not save the settings.",E.toast("Settings not saved - see the message at the top.",{type:"error"})}finally{$.value=!1}}async function Ee(){await E.confirm("Throw away your unsaved settings?")&&(re(y.peek()),A.value="")}function re(e){for(const[t,s]of Object.entries(e))n[t]&&(n[t].value=s);u&&u.getValue()!==String(n.css.peek())?u.setValue(String(n.css.peek())):!u&&b&&(b.value=String(n.css.peek()))}async function Se(){if(!P.peek()){P.value=!0,D.value="",B.value="Sending\u2026";try{const e=await w("/settings/test-email",{method:"POST",body:JSON.stringify({to:ae.peek().trim()||void 0})});if(!c())return;B.value=e?.message||"Test email sent.",D.value="is-ok"}catch(e){if(!c())return;B.value=e.message||"Could not send the test email.",D.value="is-bad"}finally{P.value=!1}}}async function j(){N.value=!0;try{const[e,t]=await Promise.all([w("/cache/status").catch(()=>null),w("/cache/keys")]);if(!c())return;v.value=e,F.value=Array.isArray(t?.entries)?t.entries:[],V.value=""}catch(e){if(!c())return;V.value=`Could not read the cache: ${e.message||e}`}finally{N.value=!1}}async function Ce(){if(await E.confirm("Clear the whole response cache? The next visitor to each page renders it afresh.")){try{await w("/cache/clear",{method:"POST"}),E.toast("Cache cleared.",{type:"success"})}catch(e){E.toast(e.message||"Could not clear the cache.",{type:"error"})}c()&&await j()}}async function Me(){const e=v.peek()?.enabled===!0;if(!(e&&!await E.confirm("Turn the response cache off? Every visit will render its page afresh until it is turned back on. The choice is saved (config/cache.json)."))){try{v.value=await w("/cache/toggle",{method:"POST",body:JSON.stringify({enabled:!e})}),E.toast(e?"Cache turned off.":"Cache turned on.",{type:"success"}),ve(ye)}catch(t){E.toast(t.message||"Could not change the cache.",{type:"error"})}c()&&await j()}}const ie=e=>F.peek().find(t=>t.key===e);function xe(e){const t=ie(e);if(!t)return[];const s=X(t.key);return[{header:s.path},{label:"Open the page",icon:"external-link",disabled:!s.page,action:()=>window.open(s.path,"_blank","noopener")},{label:"Copy key",icon:"copy",action:async()=>{try{await navigator.clipboard.writeText(t.key),E.toast("Key copied.",{type:"success"})}catch{E.toast("Could not copy - your browser blocked the clipboard.",{type:"warning"})}}},...s.page?[{label:"Copy address",icon:"link",action:async()=>{try{await navigator.clipboard.writeText(new URL(s.path,location.origin).href),E.toast("Address copied.",{type:"success"})}catch{E.toast("Could not copy - your browser blocked the clipboard.",{type:"warning"})}}}]:[]]}a.querySelector(".stx-problems").innerHTML=ze;const x=a.querySelector(".stx-list");x.innerHTML=Ne;const Te=E.tabs(a.querySelector(".stx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),Le=M.applyBindings(we,a,{methods:{save:W,discard:Ee,sendTest:Se,refreshCache:j,clearCache:Ce,toggleCache:Me,pickBrandImage:async e=>{const t=await qe({title:"Choose a branding image",current:n[e].peek()});t&&c()&&(n[e].value=t)},clearBrandImage:e=>{n[e].value=""},corner:e=>{n.bcPosition.value=e}}});r(()=>Le.dispose());for(const e of a.querySelectorAll("[data-brand-icon]")){const t=e.dataset.brandIcon,s=Ue("Choose an icon",n[t].peek());s.input.id=`stx-${t}`,s.input.classList.add("form-input");const o=()=>{n[t].value=s.input.value};s.input.addEventListener("input",o),r(()=>s.input.removeEventListener("input",o)),r(M.effect(()=>{s.input.value=n[t].value})),e.appendChild(s.el)}r($e(a,{position:"bottom"}));const Oe=e=>{const t=e.target.closest("[data-go]");if(!t)return;const s=Ve.indexOf(t.dataset.go);s>=0&&Te.activate(s)};a.querySelector(".stx-problems").addEventListener("click",Oe);const Y=a.querySelector("#stx-admin-home");We(Y,He(n.adminHome.peek())).then(()=>{c()&&(Y.value=n.adminHome.peek(),q.value=!0)}).catch(()=>{});const b=a.querySelector(".stx-css");b.value=String(n.css.peek());let u=null;if(!f)b.disabled=!0;else if(E.editor)try{u=E.editor(b,{mode:"code",language:"css",lineNumbers:!0,showToolbar:!1,minHeight:420,placeholder:"/* Add your custom CSS here */",characterCount:!0,onChange:e=>{n.css.value=e}})}catch{u=null}if(u)u._editorEl&&u._editorEl.addEventListener("keydown",e=>{if(!(e.ctrlKey||e.metaKey)||e.key!=="c"&&e.key!=="x")return;const t=u._editorEl;if(t.selectionStart!==t.selectionEnd||typeof t.value!="string")return;e.preventDefault();const s=t.value,o=t.selectionStart,h=s.lastIndexOf(`
11
+ </div>`,Ye=["general","branding","favicon","seo","footer","email","backToTop","cookies","breadcrumbs","css","cache"];async function Ge(l,p){const[r,L]=await Promise.all([H.get("/api/sidebar/menu").catch(()=>({items:[]})),H.get("/api/sidebar/registered-items").catch(()=>[])]),a=new Map([["/","Dashboard (built in)"]]),c=(C,m)=>{for(const i of C||[]){if(!i||i.hidden||i.restricted)continue;const f=typeof i.url=="string"?i.url:"";f.startsWith("#/")&&f.length>2&&!a.has(f.slice(1))&&a.set(f.slice(1),`${m?`${m} \u203A `:""}${i.text||f}`),c(i.items,i.url?m:i.text||m)}};c($e(r?.items||[],L||[]),""),a.has(p)||a.set(p,`${p} (not in the sidebar)`),l.replaceChildren();for(const[C,m]of a){const i=document.createElement("option");i.value=C,i.textContent=m,l.appendChild(i)}}let g=null,we=!1;function Je(){we||!window.R?.use||(we=!0,R.use((l,p,r)=>!(Z.test(p?.path||"")&&!Z.test(l?.path||""))||!g?.dirty()?r():E.confirm("You have unsaved settings. Leave anyway? They are kept as a draft for your next visit (not the SMTP password).").then(a=>{a?r():history.replaceState(null,"","#/settings")})),M.subscribe("router:afterChange",({to:l}={})=>{if(g&&!Z.test(l?.path||"")){const p=g;g=null,p.dispose()}}))}export const settingsView={templateUrl:"/admin/js/templates/settings.html",async onMount(l){if(Je(),g){const e=g;g=null,e.dispose()}const p=[],r=e=>(p.push(e),e);let L=!1;const a=l.get(0).querySelector(".stx")||l.get(0),c=()=>!L&&a.isConnected,C=E.loader(l.get(0),{type:"dots"});let m,i;try{[m,i]=await Promise.all([J.settings.get(),w("/settings/custom-css").then(e=>e?.css||"").catch(()=>null)])}catch(e){C.destroy(),E.toast(`Could not load the settings: ${e.message||e}`,{type:"error"});return}if(C.destroy(),!a.isConnected)return;const f=i!==null,ee=M.observable(m||{}),te=He(m||{},i||""),n={};for(const[e,t]of Object.entries(te))n[e]=M.observable(t);const K=()=>Object.fromEntries(Object.keys(n).map(e=>[e,n[e].value])),se=()=>Object.fromEntries(Object.keys(n).map(e=>[e,n[e].peek()])),y=M.observable(te),_=M.computed(()=>ye(y.value)),O=M.computed(()=>ye(K())!==_.value),$=M.observable(!1),A=M.observable(""),q=M.observable(!1),ne=M.computed(()=>{const e=new Set(Pe(K(),y.value));return Object.fromEntries(Object.keys(qe).map(t=>[t,e.has(t)]))}),U=M.observable([]);r(M.effect(()=>{U.value=_e(K()).map(e=>({...e,label:e.tone==="danger"?"Mail":"Check"}))}));const ae=M.observable(""),P=M.observable(!1),B=M.observable(""),D=M.observable(""),v=M.observable(null),F=M.observable([]),z=M.observable(""),N=M.observable(!0),V=M.observable(""),oe=M.observable([]);r(M.effect(()=>{const e=z.value.trim().toLowerCase(),t=Date.now();oe.value=[...F.value].reverse().filter(s=>!e||String(s.key).toLowerCase().includes(e)||(s.tags||[]).some(o=>String(o).toLowerCase().includes(e))).map(s=>{const o=X(s.key);return{id:s.key,path:o.path,meta:[o.page?`as ${o.role}`:"not a page",(s.tags||[]).join(", ")].filter(Boolean).join(" \xB7 "),expires:De(s.expiresAt,t)}})}));const re=Fe(n.favicon,n.brandLogo,c),Ee={...re.data,...n,saving:$,dirty:O,tabs:ne,problems:U,testTo:ae,testing:P,testResult:B,testTone:D,cacheQuery:z,cacheLoading:N,cacheError:V,cacheRows:oe,errorText:A,problemsText:M.computed(()=>U.value.length?"yes":""),siteUrlError:M.computed(()=>ge(n.siteUrl.value)),noSmtp:M.computed(()=>!String(n.smtpHost.value||"").trim()),dirtyEmail:M.computed(()=>ne.value.email),seoLength:M.computed(()=>{const e=String(n.seoDescription.value||"").trim().length;return e?`${e} characters${e>160?" - search engines usually cut it off after about 150":""}`:""}),cssSize:M.computed(()=>{if(!f)return"Could not load content/custom.css - reload to try again. It will not be saved until it loads.";const e=new Blob([String(n.css.value||"")]).size/1024;return`${e.toFixed(1)} KB of 100 KB${e>100?" - too big to save":""}`}),cacheOn:M.computed(()=>v.value?.enabled===!0),cacheState:M.computed(()=>{const e=v.value;return e?e.enabled?`On (${e.driver})`:"Off":"Status unknown"}),cacheSize:M.computed(()=>{const e=v.value;return e?.enabled&&e.size!==null&&e.size!==void 0?`${e.size}${e.maxItems?` of ${e.maxItems}`:""} entries`:""}),cacheHits:M.computed(()=>{const e=v.value?.hitRate;return e==null?"":`${Math.round(e*100)}% served from the cache`}),cacheToggleText:M.computed(()=>v.value?.enabled?"Turn off":"Turn on"),cacheEmpty:M.computed(()=>z.value.trim()?"Nothing matches.":v.value?.enabled?"The cache is empty - pages are cached as visitors open them.":"The cache is off.")};async function W(){if($.peek())return;const e=se(),t=y.peek(),s=String(e.css)!==String(t.css);if(s&&!f){E.toast("The custom CSS did not load, so it cannot be saved - reload the screen first.",{type:"error"});return}const o=ge(e.siteUrl);if(o){E.toast(`Site URL: ${o}`,{type:"error"});return}$.value=!0,A.value="";const h=String(e.title).trim()!==String(t.title).trim();try{await J.settings.save(Ke(e,ee.peek(),{adminHomeReady:q.peek()})),s&&await w("/settings/custom-css",{method:"PUT",body:JSON.stringify({css:String(e.css)})});const d=await J.settings.get().catch(()=>null);if(!c())return;d&&(ee.value=d);const G={...e,smtpPass:""};n.smtpPass.value="",y.value=G;try{S.remove?S.remove(T):S.set(T,null)}catch{}document.dispatchEvent(new CustomEvent("dm:admin-home-changed")),(h||["adminBrandTitle","adminBrandLogo","adminBrandIcon"].some(he=>e[he]!==t[he]))&&document.dispatchEvent(new CustomEvent("dm:sidebar-rebuild")),be(ke),E.toast("Settings saved.",{type:"success"})}catch(d){A.value=d.message||"Could not save the settings.",E.toast("Settings not saved - see the message at the top.",{type:"error"})}finally{$.value=!1}}async function Se(){await E.confirm("Throw away your unsaved settings?")&&(ie(y.peek()),A.value="")}function ie(e){for(const[t,s]of Object.entries(e))n[t]&&(n[t].value=s);u&&u.getValue()!==String(n.css.peek())?u.setValue(String(n.css.peek())):!u&&b&&(b.value=String(n.css.peek()))}async function Ce(){if(!P.peek()){P.value=!0,D.value="",B.value="Sending\u2026";try{const e=await w("/settings/test-email",{method:"POST",body:JSON.stringify({to:ae.peek().trim()||void 0})});if(!c())return;B.value=e?.message||"Test email sent.",D.value="is-ok"}catch(e){if(!c())return;B.value=e.message||"Could not send the test email.",D.value="is-bad"}finally{P.value=!1}}}async function j(){N.value=!0;try{const[e,t]=await Promise.all([w("/cache/status").catch(()=>null),w("/cache/keys")]);if(!c())return;v.value=e,F.value=Array.isArray(t?.entries)?t.entries:[],V.value=""}catch(e){if(!c())return;V.value=`Could not read the cache: ${e.message||e}`}finally{N.value=!1}}async function Me(){if(await E.confirm("Clear the whole response cache? The next visitor to each page renders it afresh.")){try{await w("/cache/clear",{method:"POST"}),E.toast("Cache cleared.",{type:"success"})}catch(e){E.toast(e.message||"Could not clear the cache.",{type:"error"})}c()&&await j()}}async function xe(){const e=v.peek()?.enabled===!0;if(!(e&&!await E.confirm("Turn the response cache off? Every visit will render its page afresh until it is turned back on. The choice is saved (config/cache.json)."))){try{v.value=await w("/cache/toggle",{method:"POST",body:JSON.stringify({enabled:!e})}),E.toast(e?"Cache turned off.":"Cache turned on.",{type:"success"}),be(ke)}catch(t){E.toast(t.message||"Could not change the cache.",{type:"error"})}c()&&await j()}}const ce=e=>F.peek().find(t=>t.key===e);function Te(e){const t=ce(e);if(!t)return[];const s=X(t.key);return[{header:s.path},{label:"Open the page",icon:"external-link",disabled:!s.page,action:()=>window.open(s.path,"_blank","noopener")},{label:"Copy key",icon:"copy",action:async()=>{try{await navigator.clipboard.writeText(t.key),E.toast("Key copied.",{type:"success"})}catch{E.toast("Could not copy - your browser blocked the clipboard.",{type:"warning"})}}},...s.page?[{label:"Copy address",icon:"link",action:async()=>{try{await navigator.clipboard.writeText(new URL(s.path,location.origin).href),E.toast("Address copied.",{type:"success"})}catch{E.toast("Could not copy - your browser blocked the clipboard.",{type:"warning"})}}}]:[]]}a.querySelector(".stx-problems").innerHTML=Ve;const x=a.querySelector(".stx-list");x.innerHTML=We;const Le=E.tabs(a.querySelector(".stx-tabs"),{tabSelector:".tab-item",panelSelector:".tab-panel"}),Oe=M.applyBindings(Ee,a,{methods:{...re.methods,save:W,discard:Se,sendTest:Ce,refreshCache:j,clearCache:Me,toggleCache:xe,pickBrandImage:async e=>{const t=await Ue({title:"Choose a branding image",current:n[e].peek()});t&&c()&&(n[e].value=t)},clearBrandImage:e=>{n[e].value=""},corner:e=>{n.bcPosition.value=e}}});r(()=>Oe.dispose());for(const e of a.querySelectorAll("[data-brand-icon]")){const t=e.dataset.brandIcon,s=ze("Choose an icon",n[t].peek());s.input.id=`stx-${t}`,s.input.classList.add("form-input");const o=()=>{n[t].value=s.input.value};s.input.addEventListener("input",o),r(()=>s.input.removeEventListener("input",o)),r(M.effect(()=>{s.input.value=n[t].value})),e.appendChild(s.el)}r(Ae(a,{position:"bottom"}));const Re=e=>{const t=e.target.closest("[data-go]");if(!t)return;const s=Ye.indexOf(t.dataset.go);s>=0&&Le.activate(s)};a.querySelector(".stx-problems").addEventListener("click",Re);const Y=a.querySelector("#stx-admin-home");Ge(Y,je(n.adminHome.peek())).then(()=>{c()&&(Y.value=n.adminHome.peek(),q.value=!0)}).catch(()=>{});const b=a.querySelector(".stx-css");b.value=String(n.css.peek());let u=null;if(!f)b.disabled=!0;else if(E.editor)try{u=E.editor(b,{mode:"code",language:"css",lineNumbers:!0,showToolbar:!1,minHeight:420,placeholder:"/* Add your custom CSS here */",characterCount:!0,onChange:e=>{n.css.value=e}})}catch{u=null}if(u)u._editorEl&&u._editorEl.addEventListener("keydown",e=>{if(!(e.ctrlKey||e.metaKey)||e.key!=="c"&&e.key!=="x")return;const t=u._editorEl;if(t.selectionStart!==t.selectionEnd||typeof t.value!="string")return;e.preventDefault();const s=t.value,o=t.selectionStart,h=s.lastIndexOf(`
12
12
  `,o-1)+1,d=s.indexOf(`
13
- `,o),J=d===-1?s.slice(h):s.slice(h,d+1);navigator.clipboard?.writeText(J).catch(()=>{}),e.key==="x"?(t.value=s.slice(0,h)+(d===-1?"":s.slice(d+1)),t.selectionStart=t.selectionEnd=h,t.dispatchEvent(new Event("input",{bubbles:!0})),E.toast("Line cut",{type:"info",duration:1500})):E.toast("Line copied",{type:"info",duration:1500})});else{const e=()=>{n.css.value=b.value};b.addEventListener("input",e),r(()=>b.removeEventListener("input",e))}r(()=>{try{u?.destroy?.()}catch{}});const Re=E.contextMenu(x,{cursor:"glyph",match:".stx-row",inherit:!1,density:"compact",items:e=>xe(e.dataset.id)});r(()=>Re?.destroy?.());const ce=e=>{const t=ie(e.dataset.id),s=t&&X(t.key);s?.page?window.open(s.path,"_blank","noopener"):Q(e)},le=e=>{const t=e.target.closest(".stx-row");if(!t)return;const s=e.target.closest(".stx-kebab");if(s){Q(s);return}ce(t)},ue=e=>{const t=e.target.closest(".stx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Q(t)):e.key==="Enter"&&e.target===t&&ce(t))};x.addEventListener("click",le),x.addEventListener("keydown",ue),r(()=>{x.removeEventListener("click",le),x.removeEventListener("keydown",ue)}),he(l,{id:"settings-view-site",icon:"external-link",title:"View site",onClick:()=>window.open("/","_blank","noopener")}),he(l,{id:"settings-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>W()});const de=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||(e.preventDefault(),e.stopImmediatePropagation(),W())};document.addEventListener("keydown",de,!0),r(()=>document.removeEventListener("keydown",de,!0));const pe=()=>{if(O.peek())try{S.set(T,{form:Pe(se()),base:_.peek(),at:new Date().toISOString()})}catch{}},me=e=>{O.peek()&&(pe(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",me),r(()=>window.removeEventListener("beforeunload",me)),g={dirty:()=>O.peek(),dispose(){L=!0,pe(),p.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(a),j();let k=null;try{k=S.get(T)}catch{}if(k?.form){try{S.remove?S.remove(T):S.set(T,null)}catch{}const e=k.base&&k.base!==_.peek(),t=k.at?` (${Fe(k.at)})`:"",s=e?`You left unsaved settings${t}, but the saved settings have changed since. Restore your version anyway?`:`You left unsaved settings${t}. Restore them?`;if(await E.confirm(s)&&c()){const o={...y.peek()};for(const[h,d]of Object.entries(k.form))h in o&&h!=="smtpPass"&&(o[h]=d);f||(o.css=y.peek().css),re(o),q.peek()&&(Y.value=n.adminHome.peek())}}}};
13
+ `,o),G=d===-1?s.slice(h):s.slice(h,d+1);navigator.clipboard?.writeText(G).catch(()=>{}),e.key==="x"?(t.value=s.slice(0,h)+(d===-1?"":s.slice(d+1)),t.selectionStart=t.selectionEnd=h,t.dispatchEvent(new Event("input",{bubbles:!0})),E.toast("Line cut",{type:"info",duration:1500})):E.toast("Line copied",{type:"info",duration:1500})});else{const e=()=>{n.css.value=b.value};b.addEventListener("input",e),r(()=>b.removeEventListener("input",e))}r(()=>{try{u?.destroy?.()}catch{}});const Ie=E.contextMenu(x,{cursor:"glyph",match:".stx-row",inherit:!1,density:"compact",items:e=>Te(e.dataset.id)});r(()=>Ie?.destroy?.());const le=e=>{const t=ce(e.dataset.id),s=t&&X(t.key);s?.page?window.open(s.path,"_blank","noopener"):Q(e)},ue=e=>{const t=e.target.closest(".stx-row");if(!t)return;const s=e.target.closest(".stx-kebab");if(s){Q(s);return}le(t)},de=e=>{const t=e.target.closest(".stx-row");t&&(e.key==="F10"&&e.shiftKey?(e.preventDefault(),Q(t)):e.key==="Enter"&&e.target===t&&le(t))};x.addEventListener("click",ue),x.addEventListener("keydown",de),r(()=>{x.removeEventListener("click",ue),x.removeEventListener("keydown",de)}),ve(l,{id:"settings-view-site",icon:"external-link",title:"View site",onClick:()=>window.open("/","_blank","noopener")}),ve(l,{id:"settings-save",icon:"save",title:"Save (Ctrl+S)",onClick:()=>W()});const pe=e=>{!(e.ctrlKey||e.metaKey)||e.key!=="s"||(e.preventDefault(),e.stopImmediatePropagation(),W())};document.addEventListener("keydown",pe,!0),r(()=>document.removeEventListener("keydown",pe,!0));const me=()=>{if(O.peek())try{S.set(T,{form:Be(se()),base:_.peek(),at:new Date().toISOString()})}catch{}},fe=e=>{O.peek()&&(me(),e.preventDefault(),e.returnValue="")};window.addEventListener("beforeunload",fe),r(()=>window.removeEventListener("beforeunload",fe)),g={dirty:()=>O.peek(),dispose(){L=!0,me(),p.splice(0).reverse().forEach(e=>{try{e()}catch{}})}},I.scan(a),j();let k=null;try{k=S.get(T)}catch{}if(k?.form){try{S.remove?S.remove(T):S.set(T,null)}catch{}const e=k.base&&k.base!==_.peek(),t=k.at?` (${Ne(k.at)})`:"",s=e?`You left unsaved settings${t}, but the saved settings have changed since. Restore your version anyway?`:`You left unsaved settings${t}. Restore them?`;if(await E.confirm(s)&&c()){const o={...y.peek()};for(const[h,d]of Object.entries(k.form))h in o&&h!=="smtpPass"&&(o[h]=d);f||(o.css=y.peek().css),ie(o),q.peek()&&(Y.value=n.adminHome.peek())}}}};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.6.31",
3
+ "version": "1.6.33",
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@8b9c2f1",
3
+ "source": "dcms-marketplace@91d03cf",
4
4
  "plugins": {
5
5
  "blog": {
6
6
  "version": "1.9.5",
@@ -175,32 +175,32 @@
175
175
  }
176
176
  },
177
177
  "shopping-cart": {
178
- "version": "1.1.1",
178
+ "version": "1.2.0",
179
179
  "files": {
180
- "CLAUDE.md": "70765cf061e97d0a57890d7731e0624b224e36267df42741e763e3efcd076d05",
181
- "admin/css/index.css": "a6bab1d0072e364ee1d94c2d7d7b99c3660ba91c5eba537e269a579f5b17f924",
182
- "admin/lib/kit.js": "c2111d1b10c6c8b18bc061f9f55c83ee8baecc2bcff66f74eb38298f39886d86",
180
+ "CLAUDE.md": "0a0d019e6eceb2260cf965d3eb589c9a53615c388f212af382a00a129cb2c0dc",
181
+ "admin/css/index.css": "2ea348d9f969193f198dac25b1962a400967566b835c7bf1402e17e33d81bfd2",
182
+ "admin/lib/kit.js": "ebb439a31123b9c4fbda8a2c299cc79d1ec50e66d019b5cc49dcd00f269124d5",
183
183
  "admin/templates/shop.html": "c5bb03f1f7cbabfbd8202f3533783eb1848103755b1ba25fdcf1fd8e9478cb8e",
184
- "admin/views/orders.js": "e69694e5fe0b56662a2df3f7c7b8a5cc5f9a602debda469c2da464cb78fdd458",
184
+ "admin/views/orders.js": "c10789a82cf3efb4419f223668a5fa71b021420a094cda291c8d492a644b25da",
185
185
  "admin/views/overview.js": "6abd9779cf5fc359c4708f96cf0e8ecad155fb52583b2ec854400b288fc9ba98",
186
- "admin/views/products.js": "4947de61aa57da59fd0e819ac0f5380bbdee6b73855bd91d3872445fdd6c3e2d",
186
+ "admin/views/products.js": "e109660355aaec8db07eb65fa1169e7d2ec20bc2ec45293cc20e34f7c1bd62bf",
187
187
  "admin/views/settings.js": "28cb0b5e8d45e3723ffe77e46299bfc13fbad02a2ae8103843c6894dc232387a",
188
- "admin/views/shop.js": "a53a1793251b962a87c5d01685f934b6476bcfcdd51e3dea3a319eb8c03a4137",
188
+ "admin/views/shop.js": "1b6ee66b4ef768983ed32752711ed4ca8438bd37eb33be9587f6679452557b2c",
189
189
  "collections/shop-orders.json": "0bca8eef2d5f9108651f73d273480265c167dc983aa115ce59613d13015ca255",
190
190
  "collections/shop-products.json": "4d4613ecec7b8a5a0744c4f0a57b17a4429b461e060206a4bbdea5ad7c8c75dc",
191
191
  "config.js": "dcb91db2839c1ab9e40bb1811397024fad9e37e3753d17e2baa2b9b31d4a7d7c",
192
- "docs/guide.md": "2aae4382723fb2c6a3b4a953547cda28c3d2f19024b736e10e0548af0b106360",
193
- "inject/body.html": "de20f92c83229545d20e5186fd37ef0046a8820881bff34e7df5391cd7a2c75d",
194
- "lib/extensions.js": "2cba90efef5b437ace557c7265fe7363ac7923d155681087a63e31e1168f6601",
192
+ "docs/guide.md": "4b4fbf3d61d73114f3b1b42ba48c72debb720e2c65bcd17df2feaa372463882f",
193
+ "inject/body.html": "9deda0324d60ef1e62f653513748faa4c6ac546493906c8bd195e445a55f7476",
194
+ "lib/extensions.js": "180820bcfb8ada9acfb260d0cc0a1c8cc4f37f9ab969400f4b6e8bf2d5babb3f",
195
195
  "lib/payments.js": "84ad14813688de1f38448b4935180a786cda8af2f7f4b8890a8f1d426982d1aa",
196
196
  "lib/pricing.js": "3ae965e042f5af0acdcecc66d27145ca5f608c656717288832b881a2a181dd2e",
197
- "lib/render.js": "310d528f69cd0160db3bd3d3c88f16221e43004c93856587175c0bb149fe3a3d",
197
+ "lib/render.js": "442cd305810e53edcfa184796db19371b304567100e2662c96b4b3bee2cf7231",
198
198
  "lib/samples.js": "912b3d5bd543dffa5f870caf3c2b873f642fd55e3ebf3f97e1b31eeaaac77d3b",
199
- "lib/shop.js": "4399931dd46ab4dc9bd28e8613c992420ab822bd694b1495ef2b4ad6483b5153",
199
+ "lib/shop.js": "6edd3618d53ecd614a03bf476abe5cb8615fd7d19ac765406a6ac550ebcf75e9",
200
200
  "lib/summary.js": "5697ffa45c9cc5338bbcb6eada08671c3e8376c49c629d2ae99b022397523f71",
201
- "plugin.js": "1f8477188b4054d172b37d953a439b4d5d4a532c6d26daaf9808346f332ddfcf",
202
- "plugin.json": "e379f3d5a713684d3f1d32dfa2ae1ec26f8a79a346e2591b1b2ea9b1263d6f54",
203
- "plugin.public.js": "98fa32b6c69db4a967577f3536fc521c926053319dd20d2bae8bc7e94830e3e8",
201
+ "plugin.js": "c72c526ccd71d8c055b82ef7e166afc542d4e8c36251e2f4ca1861505d5febb4",
202
+ "plugin.json": "bb7e9268ce075ec400f9df0932a74b26f61ca8930471ebbf41f3a8deef2e813a",
203
+ "plugin.public.js": "99ef559dc8c3bd369047091623bbad29a1f6067322f07ac0db547938d97fe014",
204
204
  "public/cart.js": "fcc706773a36e4bfd02ea235bcdad629137e996218e0a0e23892e33c689f2a6e",
205
205
  "public/lib/money.js": "d22b2b6f80dde6f7887b69e24188c05ea93c02217aba639b5331aa3bd616f4fc",
206
206
  "public/lib/orders.js": "09478e20d238adc8e387b3d9efcb13c441898fab18387ce6a1a8a92304ac4c5b",
@@ -217,10 +217,10 @@
217
217
  "public/samples/tee.svg": "287a82d7fc00acb1543b3209927ae20dcbe3d4b2a2b3de03574ab0c463385f3c",
218
218
  "public/samples/tote-2.svg": "9c7f41cc57866e71973e413a6c87f46be48f7488d3490ceea58e24559c334813",
219
219
  "public/samples/tote.svg": "01de817a32c8d3d8ea47ff491049f439ca3e4d4fce943dbdc62a4187139e016c",
220
- "public/shop.css": "277904382e491f0ecc76aad916126970b87e161ef06ac6deeff193358b4e4c31",
220
+ "public/shop.css": "c5adbc7b08cff3dc3b1c2b796ba693ad52c586ef35d236d0bba9a0cb7c3673c1",
221
221
  "templates/shop.html": "4649e57d23458833a157799dc80141824eb0f658d96734bd1859c68e0ba9a8b9",
222
- "tests/api.test.js": "04105c5d4f7bf4fd7b9e4dea5e8b8de4a4ebbba0a8795b47c166b0a6798fd089",
223
- "tests/pure.test.js": "f00f27ee8c941d6268eb780520007b7413b0872e2590487f1b460ce5ea09dbb1"
222
+ "tests/api.test.js": "8b71353b9649d6274c4e6d9358bac8fa6737d3effee00c35c62dab6f2a377968",
223
+ "tests/pure.test.js": "27a40f04315c17b92e4782d6f8621c965ae89bb73df377064775eec71335a5b7"
224
224
  }
225
225
  },
226
226
  "support": {
@@ -112,3 +112,28 @@ A cached page can show a stale stock flag until it is re-rendered; checkout re-p
112
112
  - The product form can never set or clear the two fields (POST strips them, PUT keeps the stored ones).
113
113
  - A copy is the shop's own.
114
114
  - `services.saveProduct` and `services.removeProduct` are how the extension manages them.
115
+
116
+ ## 1.2.0: extensions add to products, orders and the receipt
117
+
118
+ For Shopping Cart Pro's downloads. All optional: a Cart without extensions, or an extension that registers none of
119
+ these, behaves as 1.1.
120
+ - **`panels: [{key, label, icon, module, help, on}]`** (`on`: `product` | `order`) - a `<details>` section in the
121
+ product editor (collapsed) or an order (open), marked Pro. `admin/lib/kit.js` `mountPanels` imports the module
122
+ (`/plugins/...` only) and calls `mount(body, {product | order, variants(), changed(order), ctx})`, which may
123
+ return `{value(), dirty(), destroy()}`. A section that fails to load says so and sends nothing.
124
+ - **`product.clean(input, {had, copy, product})` -> `{data, errors}`** - the editor saves each panel's `value()` as
125
+ `ext[owner]`; `registry.cleanProductExt` hands each part to its own extension. Not sent, or the extension not
126
+ running (lapsed licence), or its cleaner throwing: the stored part stays. `data: null` removes it; errors come
127
+ back keyed `<owner>.<field>` and refuse the save. PATCH (quick edits) never touches `ext`. A copy starts empty
128
+ and each extension decides what carries over (`copy: true`). `publicProduct` never includes `ext`, but the admin
129
+ product list does - anything secret (licence keys) is visible to `shop:read`.
130
+ - **`receipt(order, {settings})` -> HTML** - `registry.receiptParts` collects them for the receipt page, above the
131
+ items, in `.shop-r-ext[data-ext=owner]`. The extension escapes its own HTML. Each has 3 s; one that throws or
132
+ dawdles is left out. The page is server-rendered and reloads once paid, but `on.paid` does not wait, so a
133
+ receipt handler should not assume `paid` has run.
134
+ - **`services.updateProductExt(id, owner, fn)`** - `fn(part) -> part` (null removes) on the product as it is now,
135
+ under the order lock, no form check: an extension's own bookkeeping (Pro taking licence keys). `saveProduct` runs
136
+ under the same lock, so a sale's stock, an edit and this never overwrite each other.
137
+ - A copy (`POST /products/:id/duplicate`) gives options new ids and passes `variantMap` (old -> new) to `clean`.
138
+ - **`services.moveOrder(id, to, {by, note})`** - moves an order as the admin's status route does (same rules,
139
+ 409 on a move the table refuses) and emits `status`.
@@ -151,6 +151,8 @@ a.shp-tile:hover { border-color: var(--dm-primary); }
151
151
  .shp-sect > summary { cursor: pointer; font-weight: 600; padding: .2rem 0; }
152
152
  .shp-sect[open] > summary { margin-bottom: .6rem; }
153
153
  .shp-sect > * + * { margin-top: .6rem; }
154
+ .shp-sect.is-ext > summary::after { content: 'Pro'; margin-left: .4rem; font-size: .62rem; font-weight: 700; letter-spacing: .04em; padding: .05rem .35rem;
155
+ border-radius: 999px; background: color-mix(in srgb, var(--dm-primary) 15%, transparent); color: var(--dm-primary); vertical-align: middle; }
154
156
 
155
157
  .shp-pics { display: flex; flex-wrap: wrap; gap: .5rem; }
156
158
  .shp-pic, .shp-pic-add { position: relative; width: 5.5rem; height: 5.5rem; border-radius: var(--dm-radius-md); overflow: hidden; }
@@ -156,6 +156,47 @@ export async function openPanel({title, size = 'md', html, model = {}, methods =
156
156
  return {so, panel, close: (force) => so.close(force)};
157
157
  }
158
158
 
159
+ /**
160
+ * Extensions' sections in the product editor or an order (`ctx.extensions.panels`,
161
+ * on 'product' | 'order'), each a <details> put before `anchor` - collapsed in
162
+ * the editor, open on an order; a module can open its own. The module's
163
+ * `mount(body, {...info, ctx})` may return `{value(), dirty(), destroy()}`:
164
+ * the editor saves `value()` as `ext[owner]`. One that fails to load says so
165
+ * and sends nothing, so what it stored is kept.
166
+ *
167
+ * @returns {Promise<{panels: Array<{owner: string, handle: object}>, destroy: () => void}>}
168
+ */
169
+ export async function mountPanels(anchor, {on, ctx, info = {}}) {
170
+ const {helpPopovers} = await loadKit();
171
+ const panels = [];
172
+ const undo = [];
173
+ for (const p of (ctx.extensions?.panels || []).filter(x => x.on === on)) {
174
+ const sect = fragment(`<details class="shp-sect is-ext" data-ext="${esc(p.owner)}"${on === 'order' ? ' open' : ''}>
175
+ <summary><span data-icon="${esc(p.icon)}" data-icon-size="14"></span> ${esc(p.label)}${p.help ? ` <span data-help="${esc(p.help)}"></span>` : ''}</summary>
176
+ <div class="shp-ext-body"></div>
177
+ </details>`);
178
+ anchor.before(sect);
179
+ const body = sect.querySelector('.shp-ext-body');
180
+ try {
181
+ const mod = await import(p.module);
182
+ const mount = mod.mount || mod.default?.mount;
183
+ const handle = (await mount(body, {...info, ctx})) || {};
184
+ panels.push({owner: p.owner, handle});
185
+ if (typeof handle.destroy === 'function') undo.push(() => handle.destroy());
186
+ } catch (err) {
187
+ body.textContent = '';
188
+ const e = document.createElement('p');
189
+ e.className = 'shp-error';
190
+ e.textContent = `This section could not load: ${err.message || err}`;
191
+ body.appendChild(e);
192
+ }
193
+ // After the mount, so the section's own help marks work too.
194
+ I.scan(sect);
195
+ undo.push(helpPopovers(sect));
196
+ }
197
+ return {panels, destroy: () => undo.forEach(fn => { try { fn(); } catch { /* gone */ } })};
198
+ }
199
+
159
200
  /**
160
201
  * Rows: click (or Enter) = the main action; right-click, ⋮ or Shift+F10 = the
161
202
  * menu. `scope` receives the teardown.
@@ -9,7 +9,7 @@
9
9
  * slip. A notification's link (#/plugins/shopping-cart/orders/<id>) opens one.
10
10
  */
11
11
 
12
- import {api, copy, entriesOf, esc, fmtDate, fmtDateTime, money, openPanel, refreshBadge, store, when, wireRows} from '../lib/kit.js';
12
+ import {api, copy, entriesOf, esc, fmtDate, fmtDateTime, money, mountPanels, openPanel, refreshBadge, store, when, wireRows} from '../lib/kit.js';
13
13
  import {NEXT, STATUS_LABEL, STATUS_TONE, addressLines, orderLabel} from '/plugins/shopping-cart/public/lib/orders.js';
14
14
 
15
15
  const FILTERS = [
@@ -269,6 +269,7 @@ ${(d.lines || []).map(l => `<tr><td class="q">${l.qty}</td><td>${esc(l.name)}${l
269
269
  const staffNote = M.observable(d.staffNote || '');
270
270
  const status = M.observable(d.status);
271
271
  let panel = null;
272
+ let extPanels = null;
272
273
  const ext = Object.entries(d.ext || {}).flatMap(([owner, facts]) => Object.entries(facts || {}).map(([k, v]) => ({owner, k, v})))
273
274
  .filter(x => ['string', 'number', 'boolean'].includes(typeof x.v));
274
275
  const addr = addressLines(d.address);
@@ -301,6 +302,7 @@ ${(d.lines || []).map(l => `<tr><td class="q">${l.qty}</td><td>${esc(l.name)}${l
301
302
  <textarea class="form-input" rows="2" data-model="staffNote.value" data-part="staff" placeholder="Wrapped as a gift, sent second class…"></textarea>
302
303
  ${d.paymentRef || d.providerRef ? `<p class="shp-muted shp-ref">Payment reference: <code>${esc(d.paymentRef || d.providerRef)}</code></p>` : ''}
303
304
  ${ext.length ? `<div class="shp-ext">${ext.map(x => `<span class="shp-chip is-muted">${esc(x.k)}: ${esc(String(x.v))}</span>`).join('')}</div>` : ''}
305
+ <div data-part="ext-panels" hidden></div>
304
306
  <h4>History</h4>
305
307
  <ol class="shp-history">${(d.history || []).slice().reverse().map(h => `<li><span class="shp-chip is-${STATUS_TONE[h.status] || 'muted'}">${esc(STATUS_LABEL[h.status] || h.status)}</span>
306
308
  <span>${esc(fmtDateTime(h.at))} · ${esc(h.by || '')}${h.note ? ` - ${esc(h.note)}` : ''}</span></li>`).join('')}</ol>
@@ -311,10 +313,17 @@ ${(d.lines || []).map(l => `<tr><td class="q">${l.qty}</td><td>${esc(l.name)}${l
311
313
  model: {staffNote, status},
312
314
  html,
313
315
  before: (el) => { const n = el.querySelector('.shp-callout p'); if (n) n.textContent = d.note; },
314
- cleanup: () => panels.delete(panel?.so)
316
+ cleanup: () => { panels.delete(panel?.so); extPanels?.destroy(); }
315
317
  });
316
318
  panels.add(panel.so);
317
319
  const el = panel.panel;
320
+ // Extensions' sections (Shopping Cart Pro's Downloads). `changed` takes the order an extension
321
+ // updated, so the list keeps up.
322
+ extPanels = await mountPanels(el.querySelector('[data-part="ext-panels"]'), {on: 'order', ctx, info: {
323
+ order: o,
324
+ changed: (next) => { if (next?.id === o.id) replace(next); }
325
+ }});
326
+ if (!el.isConnected) extPanels.destroy();
318
327
 
319
328
  // Main actions as buttons, the rest in ⋮.
320
329
  const renderActs = (cur) => {
@@ -10,7 +10,7 @@
10
10
  * adds one; #/plugins/shopping-cart/products/new opens a blank one.
11
11
  */
12
12
 
13
- import {api, entriesOf, esc, getCurrency, money, moneyIn, moneyOut, openPanel, store, wireRows} from '../lib/kit.js';
13
+ import {api, entriesOf, esc, getCurrency, money, moneyIn, moneyOut, mountPanels, openPanel, store, wireRows} from '../lib/kit.js';
14
14
  import {STATUS_LABEL, cleanProduct, priceRange, slugify, totalStock} from '/plugins/shopping-cart/public/lib/products.js';
15
15
 
16
16
  const FILTERS = [
@@ -205,7 +205,9 @@ export async function mount(root, ctx) {
205
205
 
206
206
  const snapshot = () => JSON.stringify([Object.fromEntries(Object.entries(f).map(([k, o]) => [k, o.peek()])), images.peek(), variants.peek()]);
207
207
  const opened = snapshot();
208
- const dirty = () => snapshot() !== opened;
208
+ // Extensions' sections (Shopping Cart Pro's Downloads), mounted once the panel is open.
209
+ let ext = {panels: [], destroy() {}};
210
+ const dirty = () => snapshot() !== opened || ext.panels.some(x => x.handle.dirty?.());
209
211
 
210
212
  const payload = () => ({
211
213
  ...Object.fromEntries(Object.entries(f).map(([k, o]) => [k, o.peek()])),
@@ -216,8 +218,15 @@ export async function mount(root, ctx) {
216
218
  weight: f.weight.peek() === '' ? null : Number(f.weight.peek()),
217
219
  images: images.peek(),
218
220
  variants: variants.peek().map(v => ({id: v.id, name: v.name, sku: v.sku,
219
- price: moneyOut(v.price), stock: v.stock === '' || v.stock === null ? null : Number(v.stock)}))
221
+ price: moneyOut(v.price), stock: v.stock === '' || v.stock === null ? null : Number(v.stock)})),
222
+ ...extPayload()
220
223
  });
224
+ // Only the sections that loaded send their part; the server keeps the rest as stored.
225
+ const extPayload = () => {
226
+ const out = {};
227
+ for (const {owner, handle} of ext.panels) if (typeof handle.value === 'function') out[owner] = handle.value();
228
+ return Object.keys(out).length ? {ext: out} : {};
229
+ };
221
230
  // Checked as typed, by the server's own rules.
222
231
  const check = M.computed(() => {
223
232
  f.name.value; f.price.value; f.compareAt.value; f.stock.value; f.weight.value; images.value; variants.value;
@@ -402,7 +411,7 @@ export async function mount(root, ctx) {
402
411
  renderPics(el);
403
412
  renderVariants(el);
404
413
  },
405
- cleanup: () => { panels.delete(panel?.so); stopSlug?.(); },
414
+ cleanup: () => { panels.delete(panel?.so); stopSlug?.(); ext.destroy(); },
406
415
  methods: {
407
416
  cancel: () => panel?.close(),
408
417
  editSlug: () => { panel.panel.querySelector('[data-part="slug"]').hidden = false; slugTouched.value = true; panel.panel.querySelector('#sp-slug').focus(); },
@@ -431,6 +440,12 @@ export async function mount(root, ctx) {
431
440
  });
432
441
  panels.add(panel.so);
433
442
  const el = panel.panel;
443
+ ext = await mountPanels(el.querySelector('.shp-actions'), {on: 'product', ctx, info: {
444
+ product: had ? {id: had.id, data: had.data} : null,
445
+ // Options as they are in the form; one added since the last save has no id yet.
446
+ variants: () => variants.peek().map(v => ({id: v.id || '', name: v.name}))
447
+ }});
448
+ if (!el.isConnected) ext.destroy();
434
449
 
435
450
  // Pictures: add from Media, remove, drag to reorder.
436
451
  const pics = el.querySelector('[data-part="pics"]');
@@ -15,7 +15,7 @@
15
15
  import {api, loadKit, setCurrency, store} from '../lib/kit.js';
16
16
 
17
17
  const ROUTE = /^#\/plugins\/shopping-cart(\/|$)/;
18
- const V = '1.0.0';
18
+ const V = '1.2.0';
19
19
  const TABS = [
20
20
  {key: 'overview', label: 'Overview', icon: 'bar-chart', module: `/plugins/shopping-cart/admin/views/overview.js?v=${V}`},
21
21
  {key: 'orders', label: 'Orders', icon: 'receipt', module: `/plugins/shopping-cart/admin/views/orders.js?v=${V}`},
@@ -91,6 +91,8 @@ The product form:
91
91
  - **Options & stock** - tick **Count stock** to track stock; it then sells out at 0. Add options with **Add an option**, each with a name, price (empty uses the product's price), SKU and stock. **Options are** names them (for example Size).
92
92
  - **Delivery & tax** - untick **Needs delivering** for downloads, services and tickets (no delivery charge, no address asked). **Weight (g)** and **Tax class** are used by Shopping Cart Pro.
93
93
 
94
+ Shopping Cart Pro adds sections of its own, marked **Pro**, to the product form and to an order.
95
+
94
96
  Ctrl+Enter saves the form.
95
97
 
96
98
  ## Settings
@@ -1,2 +1,2 @@
1
- <link rel="stylesheet" href="/plugins/shopping-cart/public/shop.css?v=1.0.0">
1
+ <link rel="stylesheet" href="/plugins/shopping-cart/public/shop.css?v=1.2.0">
2
2
  <script type="module" src="/plugins/shopping-cart/public/cart.js?v=1.0.0"></script>
@@ -14,6 +14,9 @@
14
14
  * stages: {discount, shipping, tax}, // see lib/pricing.js
15
15
  * on: {paid(order), status(order, from, to), checkout(order)},
16
16
  * tabs: [{key, label, icon, module, help}], // admin screens in the Shop
17
+ * panels: [{key, label, icon, module, help, on}], // a section in the product editor or an order (on: 'product' | 'order')
18
+ * product: {clean(input, {had, copy, product, variantMap})}, // checks what a product panel saves (product.ext[owner])
19
+ * receipt(order, {settings}), // HTML for the shopper's receipt page (escape it yourself)
17
20
  * checkout: {codes: true} // storefront features to show
18
21
  * });
19
22
  *
@@ -28,6 +31,10 @@ export const KEY = Symbol.for('domma.shopping-cart');
28
31
  export const VERSION = 1;
29
32
 
30
33
  const EVENTS = ['paid', 'status', 'checkout'];
34
+ const PANEL_ON = ['product', 'order'];
35
+ const RECEIPT_WAIT = 3000;
36
+
37
+ const panelOk = (t) => /^[a-z0-9-]{1,40}$/.test(t.key) && t.module.startsWith('/plugins/') && PANEL_ON.includes(t.on);
31
38
 
32
39
  /** The registry, made once per process. */
33
40
  export function cartRegistry() {
@@ -45,6 +52,9 @@ export function cartRegistry() {
45
52
  stages: ext.stages || {},
46
53
  on: ext.on || {},
47
54
  tabs: Array.isArray(ext.tabs) ? ext.tabs : [],
55
+ panels: Array.isArray(ext.panels) ? ext.panels : [],
56
+ product: ext.product || {},
57
+ receipt: typeof ext.receipt === 'function' ? ext.receipt : null,
48
58
  checkout: ext.checkout || {},
49
59
  order: Number(ext.order) || 0
50
60
  });
@@ -76,6 +86,67 @@ export function cartRegistry() {
76
86
  }))).filter(t => /^[a-z0-9-]{1,40}$/.test(t.key) && t.module.startsWith('/plugins/'));
77
87
  },
78
88
 
89
+ /** Sections an extension adds to the product editor and the order view. */
90
+ panels() {
91
+ return reg.list().flatMap(e => e.panels.map(t => ({
92
+ key: String(t.key), label: String(t.label || t.key), icon: String(t.icon || 'puzzle'),
93
+ module: String(t.module || ''), help: String(t.help || ''), on: String(t.on || 'product'), owner: e.owner
94
+ }))).filter(panelOk);
95
+ },
96
+
97
+ /**
98
+ * A product's `ext`, saved. What each extension keeps on a product is
99
+ * its own: `input` (from the product form) is checked by that
100
+ * extension's `product.clean`, and anything it does not send, or an
101
+ * extension that is not running (a lapsed licence) left there, is kept
102
+ * as it was. A copy starts with nothing and asks each extension what it
103
+ * carries over (`copy: true`). A cleaner that throws keeps what was
104
+ * stored: an extension cannot stop a product being saved.
105
+ *
106
+ * @returns {Promise<{ext: object, errors: object}>}
107
+ */
108
+ async cleanProductExt(input, had, {copy = false, product = null, variantMap = null} = {}) {
109
+ const ext = copy ? {} : {...(had && typeof had === 'object' ? had : {})};
110
+ const errors = {};
111
+ if (!input || typeof input !== 'object') return {ext, errors};
112
+ for (const e of reg.list()) {
113
+ const clean = e.product?.clean;
114
+ if (typeof clean !== 'function' || !Object.hasOwn(input, e.owner)) continue;
115
+ try {
116
+ const r = await clean(input[e.owner], {had: had?.[e.owner] ?? null, copy, product, variantMap});
117
+ for (const [k, v] of Object.entries(r?.errors || {})) errors[`${e.owner}.${k}`] = String(v);
118
+ if (r?.data === null || r?.data === undefined) delete ext[e.owner];
119
+ else ext[e.owner] = r.data;
120
+ } catch (err) {
121
+ console.warn(`[shopping-cart] ${e.owner} could not check a product: ${err?.message || err}`);
122
+ }
123
+ }
124
+ return {ext, errors};
125
+ },
126
+
127
+ /**
128
+ * What extensions add to a shopper's receipt: [{owner, html}]. Each has
129
+ * a few seconds; one that fails or dawdles is left out, never the page.
130
+ */
131
+ async receiptParts(order, ctx = {}) {
132
+ const parts = await Promise.all(reg.list().filter(e => e.receipt).map(async (e) => {
133
+ let timer;
134
+ try {
135
+ const html = await Promise.race([
136
+ Promise.resolve().then(() => e.receipt(order, ctx)),
137
+ new Promise((_, no) => { timer = setTimeout(() => no(new Error('took too long')), RECEIPT_WAIT); })
138
+ ]);
139
+ return typeof html === 'string' && html.trim() ? {owner: e.owner, html} : null;
140
+ } catch (err) {
141
+ console.warn(`[shopping-cart] ${e.owner} failed on the receipt: ${err?.message || err}`);
142
+ return null;
143
+ } finally {
144
+ clearTimeout(timer);
145
+ }
146
+ }));
147
+ return parts.filter(Boolean);
148
+ },
149
+
79
150
  /**
80
151
  * Tell every extension. Never throws and never waits: an email that
81
152
  * fails must not fail the payment that triggered it.
@@ -233,8 +233,11 @@ export function cartHtml({settings, base}) {
233
233
  </div>`;
234
234
  }
235
235
 
236
- /** A shopper's receipt. */
237
- export function receiptHtml({order, settings, base, problem = ''}) {
236
+ /**
237
+ * A shopper's receipt. `parts` are extensions' sections ([{owner, html}],
238
+ * their own HTML, escaped by them) - Shopping Cart Pro's downloads.
239
+ */
240
+ export function receiptHtml({order, settings, base, problem = '', parts = []}) {
238
241
  const cur = order.currency;
239
242
  const status = order.status;
240
243
  const waiting = status === 'pending';
@@ -271,6 +274,7 @@ export function receiptHtml({order, settings, base, problem = ''}) {
271
274
  ${status === 'awaiting' && settings.offlineInstructions ? `<div class="shop-r-note"><strong>${esc(settings.offlineLabel || 'How to pay')}</strong><p>${esc(settings.offlineInstructions)}</p><p>Please quote <strong>#${esc(order.number)}</strong> with your payment - ${esc(formatMoney(order.total, cur))}.</p></div>` : ''}
272
275
  ${closed ? '' : `<ol class="shop-r-steps">${steps.map(s => `<li class="${s.done ? 'is-done' : ''}"><span></span>${esc(s.label)}</li>`).join('')}</ol>`}
273
276
  ${t ? `<div class="shop-r-note">${ICON.truck} <strong>${esc(t.carrier || 'Tracking')}</strong> ${esc(t.number || '')} ${t.url ? `<a href="${esc(t.url)}" target="_blank" rel="noopener">Track it</a>` : ''}</div>` : ''}
277
+ ${parts.map(p => `<div class="shop-r-ext" data-ext="${esc(p.owner)}">${p.html}</div>`).join('')}
274
278
  <div class="shop-r-grid">
275
279
  <section><h2>Items</h2><ul class="shop-r-lines">${lines}</ul>${totals}</section>
276
280
  <section class="shop-r-side">