@cogenta/starters 0.3.0 → 0.4.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.
Files changed (371) hide show
  1. package/dist/blueprints/assets/photos/association/coat-bank.jpg +0 -0
  2. package/dist/blueprints/assets/photos/association/food-bank.jpg +0 -0
  3. package/dist/blueprints/assets/photos/association/garden-corner.jpg +0 -0
  4. package/dist/blueprints/assets/photos/association/garden-planting.jpg +0 -0
  5. package/dist/blueprints/assets/photos/association/hall.jpg +0 -0
  6. package/dist/blueprints/assets/photos/association/hero.jpg +0 -0
  7. package/dist/blueprints/assets/photos/association/homework-club.jpg +0 -0
  8. package/dist/blueprints/assets/photos/association/orientation.jpg +0 -0
  9. package/dist/blueprints/assets/photos/association/partner-fr-college.png +0 -0
  10. package/dist/blueprints/assets/photos/association/partner-fr-delcourt.png +0 -0
  11. package/dist/blueprints/assets/photos/association/partner-fr-fondation.png +0 -0
  12. package/dist/blueprints/assets/photos/association/partner-fr-habitat.png +0 -0
  13. package/dist/blueprints/assets/photos/association/partner-fr-mairie.png +0 -0
  14. package/dist/blueprints/assets/photos/association/partner-fr-pharmacie.png +0 -0
  15. package/dist/blueprints/assets/photos/association/supper.jpg +0 -0
  16. package/dist/blueprints/assets/photos/blog/desk-setup.jpg +0 -0
  17. package/dist/blueprints/assets/photos/blog/library.jpg +0 -0
  18. package/dist/blueprints/assets/photos/blog/notebook-and-coffee.jpg +0 -0
  19. package/dist/blueprints/assets/photos/blog/notebooks.jpg +0 -0
  20. package/dist/blueprints/assets/photos/blog/platform.jpg +0 -0
  21. package/dist/blueprints/assets/photos/blog/pour-over.jpg +0 -0
  22. package/dist/blueprints/assets/photos/blog/typing.jpg +0 -0
  23. package/dist/blueprints/assets/photos/documentation/delivery-flow-en.png +0 -0
  24. package/dist/blueprints/assets/photos/documentation/delivery-flow-fr.png +0 -0
  25. package/dist/blueprints/assets/photos/magazine/allotments.jpg +0 -0
  26. package/dist/blueprints/assets/photos/magazine/archive-stacks.jpg +0 -0
  27. package/dist/blueprints/assets/photos/magazine/baguettes.jpg +0 -0
  28. package/dist/blueprints/assets/photos/magazine/dining-room.jpg +0 -0
  29. package/dist/blueprints/assets/photos/magazine/empty-tram-stop.jpg +0 -0
  30. package/dist/blueprints/assets/photos/magazine/gallery.jpg +0 -0
  31. package/dist/blueprints/assets/photos/magazine/office-block-dusk.jpg +0 -0
  32. package/dist/blueprints/assets/photos/magazine/reading-room.jpg +0 -0
  33. package/dist/blueprints/assets/photos/magazine/reception-hall.jpg +0 -0
  34. package/dist/blueprints/assets/photos/magazine/theatre.jpg +0 -0
  35. package/dist/blueprints/assets/photos/magazine/town-hall.jpg +0 -0
  36. package/dist/blueprints/assets/photos/magazine/tram-stop.jpg +0 -0
  37. package/dist/blueprints/assets/photos/portfolio/coast-boats.jpg +0 -0
  38. package/dist/blueprints/assets/photos/portfolio/coast-cover.jpg +0 -0
  39. package/dist/blueprints/assets/photos/portfolio/coast-ridge.jpg +0 -0
  40. package/dist/blueprints/assets/photos/portfolio/coast-water.jpg +0 -0
  41. package/dist/blueprints/assets/photos/portfolio/forest-beech.jpg +0 -0
  42. package/dist/blueprints/assets/photos/portfolio/forest-birch.jpg +0 -0
  43. package/dist/blueprints/assets/photos/portfolio/forest-branches.jpg +0 -0
  44. package/dist/blueprints/assets/photos/portfolio/forest-cover.jpg +0 -0
  45. package/dist/blueprints/assets/photos/portfolio/glacier-cave.jpg +0 -0
  46. package/dist/blueprints/assets/photos/portfolio/glacier-cover.jpg +0 -0
  47. package/dist/blueprints/assets/photos/portfolio/glacier-melt.jpg +0 -0
  48. package/dist/blueprints/assets/photos/portfolio/glacier-tongue.jpg +0 -0
  49. package/dist/blueprints/assets/photos/portfolio/lagoon-cover.jpg +0 -0
  50. package/dist/blueprints/assets/photos/portfolio/lagoon-egret.jpg +0 -0
  51. package/dist/blueprints/assets/photos/portfolio/lagoon-shore.jpg +0 -0
  52. package/dist/blueprints/assets/photos/portfolio/lagoon-storm.jpg +0 -0
  53. package/dist/blueprints/assets/photos/portfolio/port-cover.jpg +0 -0
  54. package/dist/blueprints/assets/photos/portfolio/port-dusk.jpg +0 -0
  55. package/dist/blueprints/assets/photos/portfolio/port-marina.jpg +0 -0
  56. package/dist/blueprints/assets/photos/portfolio/port-quay.jpg +0 -0
  57. package/dist/blueprints/assets/photos/portfolio/towers-cover.jpg +0 -0
  58. package/dist/blueprints/assets/photos/portfolio/towers-green.jpg +0 -0
  59. package/dist/blueprints/assets/photos/portfolio/towers-single.jpg +0 -0
  60. package/dist/blueprints/assets/photos/portfolio/towers-valley.jpg +0 -0
  61. package/dist/blueprints/assets/photos/portfolio/vines-cover.jpg +0 -0
  62. package/dist/blueprints/assets/photos/portfolio/vines-grapes.jpg +0 -0
  63. package/dist/blueprints/assets/photos/portfolio/vines-shed.jpg +0 -0
  64. package/dist/blueprints/assets/photos/portfolio/vines-tree.jpg +0 -0
  65. package/dist/blueprints/assets/photos/restaurant/beetroot-carpaccio.jpg +0 -0
  66. package/dist/blueprints/assets/photos/restaurant/chocolate-tart.jpg +0 -0
  67. package/dist/blueprints/assets/photos/restaurant/cod-vin-jaune.jpg +0 -0
  68. package/dist/blueprints/assets/photos/restaurant/duck-confit.jpg +0 -0
  69. package/dist/blueprints/assets/photos/restaurant/grilled-octopus.jpg +0 -0
  70. package/dist/blueprints/assets/photos/restaurant/hero.jpg +0 -0
  71. package/dist/blueprints/assets/photos/restaurant/market-stall.jpg +0 -0
  72. package/dist/blueprints/assets/photos/restaurant/mushroom-risotto.jpg +0 -0
  73. package/dist/blueprints/assets/photos/restaurant/pike-quenelle.jpg +0 -0
  74. package/dist/blueprints/assets/photos/restaurant/poached-pear.jpg +0 -0
  75. package/dist/blueprints/assets/photos/restaurant/praline-cake.jpg +0 -0
  76. package/dist/blueprints/assets/photos/restaurant/pumpkin-soup.jpg +0 -0
  77. package/dist/blueprints/assets/photos/restaurant/street-burdeau.jpg +0 -0
  78. package/dist/blueprints/assets/photos/saas/api-webhooks-en.png +0 -0
  79. package/dist/blueprints/assets/photos/saas/api-webhooks-fr.png +0 -0
  80. package/dist/blueprints/assets/photos/saas/approvals-queue-en.png +0 -0
  81. package/dist/blueprints/assets/photos/saas/approvals-queue-fr.png +0 -0
  82. package/dist/blueprints/assets/photos/saas/audit-log-en.png +0 -0
  83. package/dist/blueprints/assets/photos/saas/audit-log-fr.png +0 -0
  84. package/dist/blueprints/assets/photos/saas/erp-sync-en.png +0 -0
  85. package/dist/blueprints/assets/photos/saas/erp-sync-fr.png +0 -0
  86. package/dist/blueprints/assets/photos/saas/logo-castellan-alimentaire.png +0 -0
  87. package/dist/blueprints/assets/photos/saas/logo-cliniques-du-vivier.png +0 -0
  88. package/dist/blueprints/assets/photos/saas/logo-marenne-logistique.png +0 -0
  89. package/dist/blueprints/assets/photos/saas/logo-nordgate-habitat.png +0 -0
  90. package/dist/blueprints/assets/photos/saas/logo-tessaly-energies.png +0 -0
  91. package/dist/blueprints/assets/photos/saas/office-london.jpg +0 -0
  92. package/dist/blueprints/assets/photos/saas/office-montreal.jpg +0 -0
  93. package/dist/blueprints/assets/photos/saas/office-paris.jpg +0 -0
  94. package/dist/blueprints/assets/photos/saas/office-toronto.jpg +0 -0
  95. package/dist/blueprints/assets/photos/saas/policy-editor-en.png +0 -0
  96. package/dist/blueprints/assets/photos/saas/policy-editor-fr.png +0 -0
  97. package/dist/blueprints/assets/photos/saas/security-racks.jpg +0 -0
  98. package/dist/blueprints/assets/photos/saas/spend-reporting-en.png +0 -0
  99. package/dist/blueprints/assets/photos/saas/spend-reporting-fr.png +0 -0
  100. package/dist/blueprints/assets/photos/saas/sso-scim-en.png +0 -0
  101. package/dist/blueprints/assets/photos/saas/sso-scim-fr.png +0 -0
  102. package/dist/blueprints/assets/photos/store/aleppo-soap.jpg +0 -0
  103. package/dist/blueprints/assets/photos/store/copper-pan.jpg +0 -0
  104. package/dist/blueprints/assets/photos/store/crochet-throw.jpg +0 -0
  105. package/dist/blueprints/assets/photos/store/enamel-mug.jpg +0 -0
  106. package/dist/blueprints/assets/photos/store/hero.jpg +0 -0
  107. package/dist/blueprints/assets/photos/store/knit-sweater.jpg +0 -0
  108. package/dist/blueprints/assets/photos/store/leather-boots.jpg +0 -0
  109. package/dist/blueprints/assets/photos/store/leather-duffel.jpg +0 -0
  110. package/dist/blueprints/assets/photos/store/leather-wallet.jpg +0 -0
  111. package/dist/blueprints/assets/photos/store/moka-pot.jpg +0 -0
  112. package/dist/blueprints/assets/photos/store/skillet.jpg +0 -0
  113. package/dist/blueprints/assets/photos/store/stoneware-bottles.jpg +0 -0
  114. package/dist/blueprints/assets/photos/store/story.jpg +0 -0
  115. package/dist/blueprints/assets/photos/store/turned-bowl.jpg +0 -0
  116. package/dist/blueprints/association-copy-en.d.ts +3 -0
  117. package/dist/blueprints/association-copy-en.d.ts.map +1 -0
  118. package/dist/blueprints/association-copy-en.js +834 -0
  119. package/dist/blueprints/association-copy-en.js.map +1 -0
  120. package/dist/blueprints/association-copy-fr.d.ts +3 -0
  121. package/dist/blueprints/association-copy-fr.d.ts.map +1 -0
  122. package/dist/blueprints/association-copy-fr.js +841 -0
  123. package/dist/blueprints/association-copy-fr.js.map +1 -0
  124. package/dist/blueprints/association-copy.d.ts +301 -0
  125. package/dist/blueprints/association-copy.d.ts.map +1 -0
  126. package/dist/blueprints/association-copy.js +17 -0
  127. package/dist/blueprints/association-copy.js.map +1 -0
  128. package/dist/blueprints/association-credits.d.ts +16 -0
  129. package/dist/blueprints/association-credits.d.ts.map +1 -0
  130. package/dist/blueprints/association-credits.js +80 -0
  131. package/dist/blueprints/association-credits.js.map +1 -0
  132. package/dist/blueprints/association-typography.d.ts +23 -0
  133. package/dist/blueprints/association-typography.d.ts.map +1 -0
  134. package/dist/blueprints/association-typography.js +55 -0
  135. package/dist/blueprints/association-typography.js.map +1 -0
  136. package/dist/blueprints/association.d.ts +86 -287
  137. package/dist/blueprints/association.d.ts.map +1 -1
  138. package/dist/blueprints/association.js +503 -1078
  139. package/dist/blueprints/association.js.map +1 -1
  140. package/dist/blueprints/blog-copy-en.d.ts +4 -0
  141. package/dist/blueprints/blog-copy-en.d.ts.map +1 -0
  142. package/dist/blueprints/blog-copy-en.js +701 -0
  143. package/dist/blueprints/blog-copy-en.js.map +1 -0
  144. package/dist/blueprints/blog-copy-fr.d.ts +12 -0
  145. package/dist/blueprints/blog-copy-fr.d.ts.map +1 -0
  146. package/dist/blueprints/blog-copy-fr.js +722 -0
  147. package/dist/blueprints/blog-copy-fr.js.map +1 -0
  148. package/dist/blueprints/blog-copy.d.ts +157 -0
  149. package/dist/blueprints/blog-copy.d.ts.map +1 -0
  150. package/dist/blueprints/blog-copy.js +2 -0
  151. package/dist/blueprints/blog-copy.js.map +1 -0
  152. package/dist/blueprints/blog-credits.d.ts +28 -0
  153. package/dist/blueprints/blog-credits.d.ts.map +1 -0
  154. package/dist/blueprints/blog-credits.js +73 -0
  155. package/dist/blueprints/blog-credits.js.map +1 -0
  156. package/dist/blueprints/blog-rich.d.ts +19 -0
  157. package/dist/blueprints/blog-rich.d.ts.map +1 -0
  158. package/dist/blueprints/blog-rich.js +51 -0
  159. package/dist/blueprints/blog-rich.js.map +1 -0
  160. package/dist/blueprints/blog.d.ts +51 -176
  161. package/dist/blueprints/blog.d.ts.map +1 -1
  162. package/dist/blueprints/blog.js +358 -947
  163. package/dist/blueprints/blog.js.map +1 -1
  164. package/dist/blueprints/content-pack.d.ts.map +1 -1
  165. package/dist/blueprints/content-pack.js +11 -16
  166. package/dist/blueprints/content-pack.js.map +1 -1
  167. package/dist/blueprints/content-packs.d.ts +2 -4
  168. package/dist/blueprints/content-packs.d.ts.map +1 -1
  169. package/dist/blueprints/content-packs.js +27 -15
  170. package/dist/blueprints/content-packs.js.map +1 -1
  171. package/dist/blueprints/documentation-copy-en.d.ts +3 -0
  172. package/dist/blueprints/documentation-copy-en.d.ts.map +1 -0
  173. package/dist/blueprints/documentation-copy-en.js +883 -0
  174. package/dist/blueprints/documentation-copy-en.js.map +1 -0
  175. package/dist/blueprints/documentation-copy-fr.d.ts +3 -0
  176. package/dist/blueprints/documentation-copy-fr.d.ts.map +1 -0
  177. package/dist/blueprints/documentation-copy-fr.js +939 -0
  178. package/dist/blueprints/documentation-copy-fr.js.map +1 -0
  179. package/dist/blueprints/documentation-copy.d.ts +52 -0
  180. package/dist/blueprints/documentation-copy.d.ts.map +1 -0
  181. package/dist/blueprints/documentation-copy.js +2 -0
  182. package/dist/blueprints/documentation-copy.js.map +1 -0
  183. package/dist/blueprints/documentation-writer.d.ts +67 -0
  184. package/dist/blueprints/documentation-writer.d.ts.map +1 -0
  185. package/dist/blueprints/documentation-writer.js +190 -0
  186. package/dist/blueprints/documentation-writer.js.map +1 -0
  187. package/dist/blueprints/documentation.d.ts +26 -29
  188. package/dist/blueprints/documentation.d.ts.map +1 -1
  189. package/dist/blueprints/documentation.js +106 -1064
  190. package/dist/blueprints/documentation.js.map +1 -1
  191. package/dist/blueprints/magazine-copy-en.d.ts +13 -0
  192. package/dist/blueprints/magazine-copy-en.d.ts.map +1 -0
  193. package/dist/blueprints/magazine-copy-en.js +553 -0
  194. package/dist/blueprints/magazine-copy-en.js.map +1 -0
  195. package/dist/blueprints/magazine-copy-fr.d.ts +14 -0
  196. package/dist/blueprints/magazine-copy-fr.d.ts.map +1 -0
  197. package/dist/blueprints/magazine-copy-fr.js +568 -0
  198. package/dist/blueprints/magazine-copy-fr.js.map +1 -0
  199. package/dist/blueprints/magazine-copy.d.ts +166 -0
  200. package/dist/blueprints/magazine-copy.d.ts.map +1 -0
  201. package/dist/blueprints/magazine-copy.js +58 -0
  202. package/dist/blueprints/magazine-copy.js.map +1 -0
  203. package/dist/blueprints/magazine-credits.d.ts +19 -0
  204. package/dist/blueprints/magazine-credits.d.ts.map +1 -0
  205. package/dist/blueprints/magazine-credits.js +109 -0
  206. package/dist/blueprints/magazine-credits.js.map +1 -0
  207. package/dist/blueprints/magazine.d.ts +30 -78
  208. package/dist/blueprints/magazine.d.ts.map +1 -1
  209. package/dist/blueprints/magazine.js +269 -690
  210. package/dist/blueprints/magazine.js.map +1 -1
  211. package/dist/blueprints/portfolio-copy-en.d.ts +4 -0
  212. package/dist/blueprints/portfolio-copy-en.d.ts.map +1 -0
  213. package/dist/blueprints/portfolio-copy-en.js +745 -0
  214. package/dist/blueprints/portfolio-copy-en.js.map +1 -0
  215. package/dist/blueprints/portfolio-copy-fr.d.ts +9 -0
  216. package/dist/blueprints/portfolio-copy-fr.d.ts.map +1 -0
  217. package/dist/blueprints/portfolio-copy-fr.js +737 -0
  218. package/dist/blueprints/portfolio-copy-fr.js.map +1 -0
  219. package/dist/blueprints/portfolio-copy.d.ts +204 -0
  220. package/dist/blueprints/portfolio-copy.d.ts.map +1 -0
  221. package/dist/blueprints/portfolio-copy.js +15 -0
  222. package/dist/blueprints/portfolio-copy.js.map +1 -0
  223. package/dist/blueprints/portfolio-credits.d.ts +18 -0
  224. package/dist/blueprints/portfolio-credits.d.ts.map +1 -0
  225. package/dist/blueprints/portfolio-credits.js +239 -0
  226. package/dist/blueprints/portfolio-credits.js.map +1 -0
  227. package/dist/blueprints/portfolio-typography.d.ts +19 -0
  228. package/dist/blueprints/portfolio-typography.d.ts.map +1 -0
  229. package/dist/blueprints/portfolio-typography.js +41 -0
  230. package/dist/blueprints/portfolio-typography.js.map +1 -0
  231. package/dist/blueprints/portfolio.d.ts +68 -157
  232. package/dist/blueprints/portfolio.d.ts.map +1 -1
  233. package/dist/blueprints/portfolio.js +475 -727
  234. package/dist/blueprints/portfolio.js.map +1 -1
  235. package/dist/blueprints/restaurant-copy-en.d.ts +3 -0
  236. package/dist/blueprints/restaurant-copy-en.d.ts.map +1 -0
  237. package/dist/blueprints/restaurant-copy-en.js +641 -0
  238. package/dist/blueprints/restaurant-copy-en.js.map +1 -0
  239. package/dist/blueprints/restaurant-copy-fr.d.ts +3 -0
  240. package/dist/blueprints/restaurant-copy-fr.d.ts.map +1 -0
  241. package/dist/blueprints/restaurant-copy-fr.js +665 -0
  242. package/dist/blueprints/restaurant-copy-fr.js.map +1 -0
  243. package/dist/blueprints/restaurant-copy.d.ts +234 -0
  244. package/dist/blueprints/restaurant-copy.d.ts.map +1 -0
  245. package/dist/blueprints/restaurant-copy.js +15 -0
  246. package/dist/blueprints/restaurant-copy.js.map +1 -0
  247. package/dist/blueprints/restaurant-credits.d.ts +18 -0
  248. package/dist/blueprints/restaurant-credits.d.ts.map +1 -0
  249. package/dist/blueprints/restaurant-credits.js +121 -0
  250. package/dist/blueprints/restaurant-credits.js.map +1 -0
  251. package/dist/blueprints/restaurant.d.ts +55 -128
  252. package/dist/blueprints/restaurant.d.ts.map +1 -1
  253. package/dist/blueprints/restaurant.js +425 -770
  254. package/dist/blueprints/restaurant.js.map +1 -1
  255. package/dist/blueprints/saas-copy-en.d.ts +3 -0
  256. package/dist/blueprints/saas-copy-en.d.ts.map +1 -0
  257. package/dist/blueprints/saas-copy-en.js +901 -0
  258. package/dist/blueprints/saas-copy-en.js.map +1 -0
  259. package/dist/blueprints/saas-copy-fr.d.ts +3 -0
  260. package/dist/blueprints/saas-copy-fr.d.ts.map +1 -0
  261. package/dist/blueprints/saas-copy-fr.js +912 -0
  262. package/dist/blueprints/saas-copy-fr.js.map +1 -0
  263. package/dist/blueprints/saas-copy.d.ts +266 -0
  264. package/dist/blueprints/saas-copy.d.ts.map +1 -0
  265. package/dist/blueprints/saas-copy.js +18 -0
  266. package/dist/blueprints/saas-copy.js.map +1 -0
  267. package/dist/blueprints/saas-credits.d.ts +22 -0
  268. package/dist/blueprints/saas-credits.d.ts.map +1 -0
  269. package/dist/blueprints/saas-credits.js +44 -0
  270. package/dist/blueprints/saas-credits.js.map +1 -0
  271. package/dist/blueprints/saas.d.ts +59 -203
  272. package/dist/blueprints/saas.d.ts.map +1 -1
  273. package/dist/blueprints/saas.js +505 -1108
  274. package/dist/blueprints/saas.js.map +1 -1
  275. package/dist/blueprints/store-copy-en.d.ts +4 -0
  276. package/dist/blueprints/store-copy-en.d.ts.map +1 -0
  277. package/dist/blueprints/store-copy-en.js +521 -0
  278. package/dist/blueprints/store-copy-en.js.map +1 -0
  279. package/dist/blueprints/store-copy-fr.d.ts +4 -0
  280. package/dist/blueprints/store-copy-fr.d.ts.map +1 -0
  281. package/dist/blueprints/store-copy-fr.js +521 -0
  282. package/dist/blueprints/store-copy-fr.js.map +1 -0
  283. package/dist/blueprints/store-copy.d.ts +219 -0
  284. package/dist/blueprints/store-copy.d.ts.map +1 -0
  285. package/dist/blueprints/store-copy.js +16 -0
  286. package/dist/blueprints/store-copy.js.map +1 -0
  287. package/dist/blueprints/store-credits.d.ts +3 -0
  288. package/dist/blueprints/store-credits.d.ts.map +1 -0
  289. package/dist/blueprints/store-credits.js +117 -0
  290. package/dist/blueprints/store-credits.js.map +1 -0
  291. package/dist/blueprints/store.d.ts +43 -231
  292. package/dist/blueprints/store.d.ts.map +1 -1
  293. package/dist/blueprints/store.js +466 -789
  294. package/dist/blueprints/store.js.map +1 -1
  295. package/dist/blueprints/vitrine-copy-en.d.ts.map +1 -1
  296. package/dist/blueprints/vitrine-copy-en.js +1 -0
  297. package/dist/blueprints/vitrine-copy-en.js.map +1 -1
  298. package/dist/blueprints/vitrine-copy-fr.d.ts.map +1 -1
  299. package/dist/blueprints/vitrine-copy-fr.js +1 -0
  300. package/dist/blueprints/vitrine-copy-fr.js.map +1 -1
  301. package/dist/blueprints/vitrine-copy.d.ts +1 -0
  302. package/dist/blueprints/vitrine-copy.d.ts.map +1 -1
  303. package/dist/blueprints/vitrine.d.ts +1 -0
  304. package/dist/blueprints/vitrine.d.ts.map +1 -1
  305. package/dist/blueprints/vitrine.js +24 -3
  306. package/dist/blueprints/vitrine.js.map +1 -1
  307. package/package.json +10 -10
  308. package/dist/blueprints/assets/photos/association/fundraising-dinner.jpg +0 -0
  309. package/dist/blueprints/assets/photos/association/volunteer-orientation.jpg +0 -0
  310. package/dist/blueprints/assets/photos/association/volunteer-portrait.jpg +0 -0
  311. package/dist/blueprints/assets/photos/association/winter-coat-collection.jpg +0 -0
  312. package/dist/blueprints/assets/photos/documentation/delivery-flow.png +0 -0
  313. package/dist/blueprints/assets/photos/magazine/archive-room.jpg +0 -0
  314. package/dist/blueprints/assets/photos/magazine/banquet-hall.jpg +0 -0
  315. package/dist/blueprints/assets/photos/magazine/city-hall.jpg +0 -0
  316. package/dist/blueprints/assets/photos/magazine/downtown-towers.jpg +0 -0
  317. package/dist/blueprints/assets/photos/magazine/gallery-opening.jpg +0 -0
  318. package/dist/blueprints/assets/photos/magazine/garden-volunteers.jpg +0 -0
  319. package/dist/blueprints/assets/photos/magazine/station-platform.jpg +0 -0
  320. package/dist/blueprints/assets/photos/magazine/theater-stage.jpg +0 -0
  321. package/dist/blueprints/assets/photos/portfolio/fenmore-branch.jpg +0 -0
  322. package/dist/blueprints/assets/photos/portfolio/fenmore-cover.jpg +0 -0
  323. package/dist/blueprints/assets/photos/portfolio/fenmore-stationery.jpg +0 -0
  324. package/dist/blueprints/assets/photos/portfolio/gannet-cover.jpg +0 -0
  325. package/dist/blueprints/assets/photos/portfolio/gannet-sardines.jpg +0 -0
  326. package/dist/blueprints/assets/photos/portfolio/gannet-shelf.jpg +0 -0
  327. package/dist/blueprints/assets/photos/portfolio/hollis-crane-cover.jpg +0 -0
  328. package/dist/blueprints/assets/photos/portfolio/hollis-crane-grid.jpg +0 -0
  329. package/dist/blueprints/assets/photos/portfolio/hollis-crane-salt-year.jpg +0 -0
  330. package/dist/blueprints/assets/photos/portfolio/hollis-crane-spines.jpg +0 -0
  331. package/dist/blueprints/assets/photos/portfolio/rookery-hall-brochure.jpg +0 -0
  332. package/dist/blueprints/assets/photos/portfolio/rookery-hall-cover.jpg +0 -0
  333. package/dist/blueprints/assets/photos/portfolio/rookery-hall-poster-carnival.jpg +0 -0
  334. package/dist/blueprints/assets/photos/portfolio/rookery-hall-poster-sibelius.jpg +0 -0
  335. package/dist/blueprints/assets/photos/portfolio/rookery-hall-series.jpg +0 -0
  336. package/dist/blueprints/assets/photos/portfolio/saltmarsh-quay-cover.jpg +0 -0
  337. package/dist/blueprints/assets/photos/portfolio/saltmarsh-quay-gate.jpg +0 -0
  338. package/dist/blueprints/assets/photos/portfolio/saltmarsh-quay-pictograms.jpg +0 -0
  339. package/dist/blueprints/assets/photos/portfolio/saltmarsh-quay-totem.jpg +0 -0
  340. package/dist/blueprints/assets/photos/portfolio/tidewater-cover.jpg +0 -0
  341. package/dist/blueprints/assets/photos/portfolio/tidewater-data.jpg +0 -0
  342. package/dist/blueprints/assets/photos/portfolio/tidewater-report-cover.jpg +0 -0
  343. package/dist/blueprints/assets/photos/portfolio/warp-and-weft-cover.jpg +0 -0
  344. package/dist/blueprints/assets/photos/portfolio/warp-and-weft-room.jpg +0 -0
  345. package/dist/blueprints/assets/photos/portfolio/westmoor-rail-components.jpg +0 -0
  346. package/dist/blueprints/assets/photos/portfolio/westmoor-rail-cover.jpg +0 -0
  347. package/dist/blueprints/assets/photos/portfolio/westmoor-rail-departures.jpg +0 -0
  348. package/dist/blueprints/assets/photos/restaurant/beetroot-goats-curd.jpg +0 -0
  349. package/dist/blueprints/assets/photos/restaurant/ceps-risotto.jpg +0 -0
  350. package/dist/blueprints/assets/photos/restaurant/duck-leg-red-cabbage.jpg +0 -0
  351. package/dist/blueprints/assets/photos/restaurant/hake-green-peppercorns.jpg +0 -0
  352. package/dist/blueprints/assets/photos/restaurant/vegetable-broth.jpg +0 -0
  353. package/dist/blueprints/assets/photos/saas/adrian-tan.jpg +0 -0
  354. package/dist/blueprints/assets/photos/saas/api-webhooks.png +0 -0
  355. package/dist/blueprints/assets/photos/saas/approvals-queue.png +0 -0
  356. package/dist/blueprints/assets/photos/saas/audit-log.png +0 -0
  357. package/dist/blueprints/assets/photos/saas/erp-sync.png +0 -0
  358. package/dist/blueprints/assets/photos/saas/policy-editor.png +0 -0
  359. package/dist/blueprints/assets/photos/saas/spend-reporting.png +0 -0
  360. package/dist/blueprints/assets/photos/saas/sso-scim.png +0 -0
  361. package/dist/blueprints/assets/photos/store/brushed-cotton-overshirt.jpg +0 -0
  362. package/dist/blueprints/assets/photos/store/cable-knit-beanie.jpg +0 -0
  363. package/dist/blueprints/assets/photos/store/canvas-shoulder-bag.jpg +0 -0
  364. package/dist/blueprints/assets/photos/store/canvas-tote.jpg +0 -0
  365. package/dist/blueprints/assets/photos/store/cast-iron-skillet.jpg +0 -0
  366. package/dist/blueprints/assets/photos/store/field-jacket.jpg +0 -0
  367. package/dist/blueprints/assets/photos/store/heavyweight-t-shirt.jpg +0 -0
  368. package/dist/blueprints/assets/photos/store/leather-card-holder.jpg +0 -0
  369. package/dist/blueprints/assets/photos/store/linen-table-runner.jpg +0 -0
  370. package/dist/blueprints/assets/photos/store/porcelain-pour-over-set.jpg +0 -0
  371. package/dist/blueprints/assets/photos/store/striped-wool-blanket.jpg +0 -0
@@ -2,36 +2,15 @@ import { CogentaError } from '@cogenta/core';
2
2
  import { createContentStore, createMenuStore, defineCollection, ensureMenuTables, f, validateCollectionSet, } from '@cogenta/schema';
3
3
  import { coverArt, logoArt } from '../demo-art/compositions.js';
4
4
  import { definePageCollection, richTextParagraph, SEO_FIELDS, toBlockZoneEntry, } from './content-pack.js';
5
+ import { SAAS_COPY_EN } from './saas-copy-en.js';
6
+ import { SAAS_COPY_FR } from './saas-copy-fr.js';
7
+ import { SAAS_PHOTO_CREDITS } from './saas-credits.js';
5
8
  import { STARTING_SKINS } from './starting-skins.js';
6
- /**
7
- * The `saas` blueprint: the marketing site of a B2B software product for
8
- * finance and operations teams (spend approvals, an audit log, ERP sync),
9
- * rewritten to studio level by L27.
10
- *
11
- * The product is named after the site (`SeedContext.siteName`), falling back
12
- * to "Ledgerline". Its customers, people and figures are invented and
13
- * consistent from page to page: the customer quoted on the home page is the
14
- * workspace shown in every screenshot.
15
- *
16
- * Content model:
17
- *
18
- * - `feature`: one page per capability, routed at `/features/:slug`, with a
19
- * short summary, an icon name (`@cogenta/theme-kit`'s `renderIcon`), a
20
- * screenshot of that part of the product as its cover, and a block zone for
21
- * the page itself.
22
- * - `changelog`: dated product updates at `/changelog/:slug`. `publishedAt`
23
- * is declared so a seeded update carries the day it shipped rather than the
24
- * instant of the scaffold.
25
- * - `page`: home, product, pricing, security, changelog, company, book a
26
- * demo, legal and privacy.
27
- *
28
- * Pricing tiers stay page-authored content (a `pricingTable` block): a plan
29
- * has no lifecycle of its own. There is no signup or billing system behind
30
- * this site, so no action pretends there is one: every "Start a trial" and
31
- * "Book a demo" leads to the `demo` page, which says how a trial is set up by
32
- * a person, and gives a real email address and telephone number.
33
- */
34
- export const DEFAULT_PRODUCT_NAME = 'Ledgerline';
9
+ export const DEFAULT_PRODUCT_NAME = SAAS_COPY_EN.defaultProductName;
10
+ /** The copy for a site's default locale: French for any `fr*` locale, English otherwise. */
11
+ export function saasCopyFor(locale) {
12
+ return locale.toLowerCase().startsWith('fr') ? SAAS_COPY_FR : SAAS_COPY_EN;
13
+ }
35
14
  function nameOf(siteName) {
36
15
  const trimmed = siteName?.trim();
37
16
  return trimmed === undefined || trimmed === '' ? DEFAULT_PRODUCT_NAME : trimmed;
@@ -46,8 +25,7 @@ export function saasEmail(siteName, mailbox = 'hello') {
46
25
  return `${mailbox}@${domain === '' ? 'example' : domain}.com`;
47
26
  }
48
27
  /** A London number in the range Ofcom reserves for drama: it rings nobody. */
49
- export const SAAS_PHONE = '+44 20 7946 0321';
50
- const SAAS_PHONE_HREF = 'tel:+442079460321';
28
+ export const SAAS_PHONE = SAAS_COPY_EN.phone.display;
51
29
  // ---------------------------------------------------------------------------
52
30
  // Collections
53
31
  // ---------------------------------------------------------------------------
@@ -56,62 +34,57 @@ const EDITORIAL_PERMISSIONS = {
56
34
  create: ['editor', 'admin'],
57
35
  update: ['editor', 'admin'],
58
36
  delete: ['admin'],
37
+ // An undeclared action grants nobody, admin included: without this line
38
+ // nothing on this site could ever leave draft.
39
+ publish: ['admin'],
59
40
  };
60
- export const feature = defineCollection({
61
- name: 'feature',
62
- labels: { singular: 'Feature', plural: 'Features' },
63
- routing: { pattern: '/features/:slug' },
64
- fields: {
65
- name: f.text({ required: true, max: 120 }),
66
- slug: f.slug({ from: 'name', unique: true }),
67
- description: f.text({
68
- max: 300,
69
- multiline: true,
70
- admin: {
71
- label: 'Summary',
72
- help: 'One or two sentences, shown under the name on the product pages and at the top of the feature page.',
73
- },
74
- }),
75
- icon: f.text({
76
- max: 64,
77
- admin: {
78
- label: 'Icon',
79
- help: 'One of the symbol names @cogenta/theme-kit recognises (for example "layers", "shield", "cloud", "lock", "chart", "code").',
80
- },
81
- }),
82
- coverImage: f.media({
83
- accept: ['image'],
84
- admin: {
85
- label: 'Screenshot',
86
- help: 'A screenshot of this part of the product, shown on the product tour and at the top of the feature page.',
87
- },
88
- }),
89
- blocks: f.blocks({ required: true }),
90
- ...SEO_FIELDS,
91
- },
92
- indexes: [['slug']],
93
- permissions: EDITORIAL_PERMISSIONS,
94
- });
95
- export const changelog = defineCollection({
96
- name: 'changelog',
97
- labels: { singular: 'Changelog entry', plural: 'Changelog' },
98
- routing: { pattern: '/changelog/:slug' },
99
- fields: {
100
- title: f.text({ required: true, max: 200 }),
101
- slug: f.slug({ from: 'title', unique: true }),
102
- summary: f.text({ max: 300, multiline: true }),
103
- // Declared so a seeded update can carry the day it shipped: without it
104
- // every entry's date would be the instant of the scaffold.
105
- publishedAt: f.datetime(),
106
- blocks: f.blocks({ required: true }),
107
- ...SEO_FIELDS,
108
- },
109
- indexes: [['publishedAt', 'desc'], ['slug']],
110
- permissions: EDITORIAL_PERMISSIONS,
111
- });
112
- export const page = definePageCollection('/:slug');
113
- export const SAAS_COLLECTIONS = [feature, changelog, page];
114
- validateCollectionSet(SAAS_COLLECTIONS);
41
+ /** The content model, labelled and routed in the copy's language. */
42
+ export function saasSchema(copy) {
43
+ const feature = defineCollection({
44
+ name: 'feature',
45
+ versioning: { drafts: true, history: true },
46
+ labels: copy.schema.labels.feature,
47
+ routing: { pattern: copy.schema.routes.feature },
48
+ fields: {
49
+ name: f.text({ required: true, max: 120 }),
50
+ slug: f.slug({ from: 'name', unique: true }),
51
+ description: f.text({ max: 300, multiline: true }),
52
+ icon: f.text({ max: 64 }),
53
+ coverImage: f.media({ accept: ['image'] }),
54
+ blocks: f.blocks({ required: true }),
55
+ ...SEO_FIELDS,
56
+ },
57
+ indexes: [['slug']],
58
+ permissions: EDITORIAL_PERMISSIONS,
59
+ });
60
+ const changelog = defineCollection({
61
+ name: 'changelog',
62
+ versioning: { drafts: true, history: true },
63
+ labels: copy.schema.labels.changelog,
64
+ routing: { pattern: copy.schema.routes.changelog },
65
+ fields: {
66
+ title: f.text({ required: true, max: 200 }),
67
+ slug: f.slug({ from: 'title', unique: true }),
68
+ summary: f.text({ max: 300, multiline: true }),
69
+ // Declared so a seeded update can carry the day it shipped: without it
70
+ // every entry's date would be the instant of the scaffold.
71
+ publishedAt: f.datetime(),
72
+ blocks: f.blocks({ required: true }),
73
+ ...SEO_FIELDS,
74
+ },
75
+ indexes: [['publishedAt', 'desc'], ['slug']],
76
+ permissions: EDITORIAL_PERMISSIONS,
77
+ });
78
+ const page = definePageCollection('/:slug');
79
+ const collections = [feature, changelog, page];
80
+ validateCollectionSet(collections);
81
+ return { feature, changelog, page, collections };
82
+ }
83
+ const ENGLISH_SCHEMA = saasSchema(SAAS_COPY_EN);
84
+ export const feature = ENGLISH_SCHEMA.feature;
85
+ export const changelog = ENGLISH_SCHEMA.changelog;
86
+ export const page = ENGLISH_SCHEMA.page;
87
+ export const SAAS_COLLECTIONS = ENGLISH_SCHEMA.collections;
115
88
  // ---------------------------------------------------------------------------
116
89
  // Rich text
117
90
  // ---------------------------------------------------------------------------
@@ -155,263 +128,48 @@ function qa(key, items) {
155
128
  answer: richTextParagraph(`${key}-${index}-a`, answer),
156
129
  }));
157
130
  }
131
+ function block(key, type, data) {
132
+ return { _key: key, _type: type, _version: BLOCK_VERSION, ...data };
133
+ }
158
134
  // ---------------------------------------------------------------------------
159
- // Media
135
+ // Addresses
136
+ // ---------------------------------------------------------------------------
137
+ /** `/features/:slug` with a slug in it. */
138
+ function routeTo(pattern, slug) {
139
+ return pattern.replace(':slug', slug);
140
+ }
141
+ /** The path of a page of the site, in the copy's language. */
142
+ function pagePath(copy, key) {
143
+ return `/${copy.slugs[key]}`;
144
+ }
145
+ function linkTarget(copy, link) {
146
+ return 'page' in link
147
+ ? pagePath(copy, link.page)
148
+ : routeTo(copy.schema.routes.feature, link.feature);
149
+ }
150
+ // ---------------------------------------------------------------------------
151
+ // Features and changelog
160
152
  // ---------------------------------------------------------------------------
161
153
  /** The customers whose wordmarks appear on the site, in the order of the strip. */
162
- export const SAAS_CUSTOMERS = [
163
- { key: 'logo-halvorsen-freight', name: 'Halvorsen Freight' },
164
- { key: 'logo-brightwell-clinics', name: 'Brightwell Clinics' },
165
- { key: 'logo-castlemere-foods', name: 'Castlemere Foods' },
166
- { key: 'logo-tessaly-energy', name: 'Tessaly Energy' },
167
- { key: 'logo-orrin-and-vale', name: 'Orrin & Vale' },
168
- { key: 'logo-northgate-homes', name: 'Northgate Homes' },
169
- ];
170
- export const SAAS_DEMO_FEATURES = [
171
- {
172
- name: 'Approval routing',
173
- slug: 'approval-routing',
174
- short: 'Rules on amount, cost centre and vendor send each request to the right approvers.',
175
- description: 'Write your approval matrix once, as rules on amount, cost centre, vendor and category, and every request finds its approvers without anyone forwarding it.',
176
- icon: 'layers',
177
- screenshot: 'policy-editor',
178
- body: (name) => [
179
- { h2: 'Your approval matrix, as rules' },
180
- {
181
- p: `Most finance teams already have an approval matrix: a spreadsheet that says who signs off on what, above which amount, for which cost centre. In ${name} that spreadsheet becomes a policy. Each row is a step with its conditions, its approvers and a deadline, and a request is matched against the policy the moment it is submitted.`,
182
- },
183
- {
184
- p: 'Approvers can be named people, roles such as the budget owner of a cost centre, or groups synced from your identity provider. Steps run in sequence by default. A step can also run in parallel with another, so procurement can review a new vendor while the budget owner reviews the spend.',
185
- },
186
- { h2: 'Change it safely' },
187
- {
188
- p: 'Policies are versioned. An edit is saved as a draft, and before you publish it you can run it against the requests of the last 90 days to see which of them would have been routed differently. Every published version stays in the audit log, with the name of the person who published it.',
189
- },
190
- {
191
- ul: [
192
- 'Conditions on amount, currency, cost centre, vendor, category and any custom field',
193
- 'Deadlines in business days, with reminders the day before',
194
- 'Delegates for approvers who are away, and a fallback to their manager',
195
- 'Separate policies for purchase requests, vendor invoices and contract changes',
196
- ],
197
- },
198
- ],
199
- details: [
200
- [
201
- 'What happens when nobody matches a request?',
202
- 'It goes to the default step you choose for the policy, usually the finance controller, and the request is flagged so the gap in the matrix can be fixed.',
203
- ],
204
- [
205
- 'Can one person approve two steps?',
206
- 'Only if you allow it. By default a person who approved one step is skipped on the next, and the step moves to the next eligible approver.',
207
- ],
208
- [
209
- 'How many policies can we have?',
210
- 'Ten on the Team plan and as many as you need on Business and Enterprise.',
211
- ],
212
- ],
213
- },
214
- {
215
- name: 'Audit log',
216
- slug: 'audit-log',
217
- short: 'Every edit and decision is written once and never rewritten. Auditors read it directly.',
218
- description: 'Every request, edit, comment and decision is written to a log that cannot be rewritten, and your auditors can filter and export it themselves.',
219
- icon: 'shield',
220
- screenshot: 'audit-log',
221
- body: (name) => [
222
- { h2: 'One record for every decision' },
223
- {
224
- p: `When a request is created, changed, commented on or decided, ${name} writes an event: who did it, what changed, when, and from where. Events are appended and never edited. Each one carries a hash of the event before it, so a gap or a change anywhere in the chain is detected the next time the log is verified.`,
225
- },
226
- {
227
- p: 'The log records what people do in the browser and what integrations do on their behalf. A bill posted to NetSuite, a budget line read from your ERP and a decision made from a Slack message all appear in the same list, in order.',
228
- },
229
- { h2: 'Evidence your auditors can pull themselves' },
230
- {
231
- p: 'Give an external auditor a read-only role limited to a date range. They can filter by request, person, event or amount and export exactly the sample they need, without a screenshot from your team and without seeing anything else in the workspace.',
232
- },
233
- {
234
- ul: [
235
- 'Exports as CSV and PDF, signed so a file can be checked against the log',
236
- 'Retention of one year on Team, seven years on Business and ten years on Enterprise',
237
- 'Chain verification on demand and every night',
238
- ],
239
- },
240
- ],
241
- details: [
242
- [
243
- 'Can an administrator delete an event?',
244
- 'No. Administrators can hide personal data from an event after a valid erasure request, and the hiding is itself recorded as an event.',
245
- ],
246
- [
247
- 'Which time zone does the log use?',
248
- 'Events are stored in UTC and shown in the time zone of the person reading them. Exports use UTC.',
249
- ],
250
- ],
251
- },
252
- {
253
- name: 'ERP sync',
254
- slug: 'erp-sync',
255
- short: 'Approved bills and orders post to NetSuite, Xero, QuickBooks or Business Central.',
256
- description: 'Approved vendor bills and purchase orders post to your ERP within a minute, and budget lines come back every fifteen minutes so approvers see what is left.',
257
- icon: 'cloud',
258
- screenshot: 'erp-sync',
259
- body: (name) => [
260
- { h2: 'Approvals that finish in the ledger' },
261
- {
262
- p: `An approval is only finished when the ERP knows about it. ${name} connects to NetSuite, Xero, QuickBooks Online and Microsoft Dynamics 365 Business Central. When the last step approves, the vendor bill or purchase order is created or updated in the ERP with the approvers, the dates and a link back to the request.`,
263
- },
264
- {
265
- p: 'The connection works in both directions. Vendors, cost centres, subsidiaries and budget lines are read from the ERP, so a request is coded the same way your books are, and an approver sees the remaining budget before deciding.',
266
- },
267
- { h2: 'Failures you can see' },
268
- {
269
- p: 'Every sync is listed with its result. When the ERP rejects a record or limits the rate of calls, the sync is retried with a delay and the finance team is told if it still fails after an hour. Nothing is dropped quietly.',
270
- },
271
- {
272
- ul: [
273
- 'Mapping for subsidiaries, currencies and tax codes',
274
- 'Posting as draft or approved, per record type',
275
- 'A nightly CSV import for ERPs without an API',
276
- ],
277
- },
278
- ],
279
- details: [
280
- [
281
- 'How long does a connection take to set up?',
282
- 'Usually an hour with your ERP administrator. We send the list of permissions the integration user needs beforehand.',
283
- ],
284
- [
285
- 'Does the sync write anything we did not approve?',
286
- 'No. It writes approved records and the approval details, and reads the reference data listed above.',
287
- ],
288
- ],
289
- },
290
- {
291
- name: 'SSO and SCIM',
292
- slug: 'sso-and-scim',
293
- short: 'Sign in with Okta, Entra ID or Google. Access follows your directory.',
294
- description: 'Members sign in through your identity provider, and SCIM keeps people, groups and roles in step with your directory.',
295
- icon: 'lock',
296
- screenshot: 'sso-scim',
297
- body: () => [
298
- { h2: 'Sign-in through your identity provider' },
299
- {
300
- p: 'SAML single sign-on works with Okta, Microsoft Entra ID, Google Workspace and any provider that follows the standard. Once it is on, you can turn password sign-in off for the whole workspace, and the session length follows the policy you set.',
301
- },
302
- { h2: 'Provisioning from your directory' },
303
- {
304
- p: 'With SCIM, a person added to a group in your directory appears in the workspace with the right role, and a person removed from the directory loses access at once. Groups map to roles, so the finance controllers group in Okta becomes the finance approvers in the product without anyone updating a list by hand.',
305
- },
306
- { h2: 'Access that ends on time' },
307
- {
308
- p: 'Auditors and contractors can be given an end date when they are invited. On that date their access ends, any step still waiting on them moves to the person who invited them, and the change is written to the audit log.',
309
- },
310
- {
311
- ul: [
312
- 'Roles for requesters, approvers, accounts payable, administrators and auditors',
313
- 'Time-limited auditor access, ended automatically on the date you set',
314
- 'Sign-in events recorded in the audit log',
315
- ],
316
- },
317
- ],
318
- details: [
319
- [
320
- 'Which plans include SSO?',
321
- 'Google sign-in is included on every plan. SAML and SCIM are included on Business and Enterprise.',
322
- ],
323
- [
324
- 'What happens to open approvals when someone leaves?',
325
- 'Their pending steps move to their delegate, or to their manager if they had none, and the reassignment is recorded.',
326
- ],
327
- ],
328
- },
329
- {
330
- name: 'Spend reporting',
331
- slug: 'spend-reporting',
332
- short: 'See where requests wait, who is slow and which budgets are running over.',
333
- description: 'Reports on time to approve, spend against budget and requests waiting too long, built from the same records as the audit log.',
334
- icon: 'chart',
335
- screenshot: 'spend-reporting',
336
- body: () => [
337
- { h2: 'Where approvals wait' },
338
- {
339
- p: 'The time-to-approve report shows the median time from request to final decision, by week, month or quarter, and breaks it down by step and by approver. When a step is slow, you see which one, and whether it is slow for every amount or only for large ones.',
340
- },
341
- { h2: 'Spend against budget' },
342
- {
343
- p: 'Because budget lines are read from the ERP, every approved request is counted against its cost centre as soon as it is approved, weeks before the invoice arrives. Budget owners see committed spend, and finance sees which cost centres will run over before the month closes.',
344
- },
345
- { h2: 'Figures you can trace' },
346
- {
347
- p: 'Every figure in a report opens the requests it was computed from, each with its approval chain and its history in the audit log. A median quoted in a board pack can be traced back to the individual requests in two clicks.',
348
- },
349
- {
350
- ul: [
351
- 'Saved views that can be shared with a link',
352
- 'A weekly email to budget owners with their committed spend',
353
- 'CSV export of every report, and a read-only API for your data warehouse',
354
- ],
355
- },
356
- ],
357
- details: [
358
- [
359
- 'Do reports include requests that were rejected?',
360
- 'Yes, as a separate series, so the time spent on rejected requests is visible too.',
361
- ],
362
- ],
363
- },
364
- {
365
- name: 'API and webhooks',
366
- slug: 'api-and-webhooks',
367
- short: 'A versioned REST API and signed webhooks, with the permissions of the app.',
368
- description: 'Everything the product does in the browser can be done through a versioned REST API, and signed webhooks tell your systems when a decision is made.',
369
- icon: 'code',
370
- screenshot: 'api-webhooks',
371
- body: () => [
372
- { h2: 'The same product, through an API' },
373
- {
374
- p: 'The REST API is versioned by date and covers requests, approvals, policies, vendors, the audit log and reports. API keys are scoped to read or write and to the parts of the workspace they need, and every call they make is written to the audit log like any other action.',
375
- },
376
- { h2: 'Webhooks for every decision' },
377
- {
378
- p: 'Webhooks fire when a request is created, approved, rejected or changed. Each delivery is signed with HMAC-SHA256 and retried for up to 24 hours when your endpoint does not answer, and the last deliveries of every endpoint are listed with their response codes.',
379
- },
380
- { h2: 'Changes announced in advance' },
381
- {
382
- p: 'A new API version is published with a date, and the previous version keeps working for at least 12 months after it. Every change is listed in the changelog, and the owners of the keys that still call a retiring version are told by email three months before it ends.',
383
- },
384
- {
385
- ul: [
386
- 'Idempotency keys on every write',
387
- 'A sandbox workspace with test data for every customer',
388
- 'Rate limits of 600 requests a minute per key',
389
- ],
390
- },
391
- ],
392
- details: [
393
- [
394
- 'Is there a client library?',
395
- 'We publish clients for TypeScript and Python. Any language that can make an HTTPS request can use the API directly.',
396
- ],
397
- ],
398
- },
399
- ];
400
- function featureBlocks(demo, name) {
154
+ export const SAAS_CUSTOMERS = SAAS_COPY_EN.customers;
155
+ export const SAAS_DEMO_FEATURES = SAAS_COPY_EN.features;
156
+ /** Oldest first: seeded in this order, listed newest first by id. */
157
+ export const SAAS_DEMO_UPDATES = SAAS_COPY_EN.updates;
158
+ function featureBlocks(copy, demo, name) {
401
159
  return [
402
160
  prose(`feature-${demo.slug}-body`, demo.body(name)),
403
161
  {
404
162
  _key: `feature-${demo.slug}-details`,
405
163
  _type: 'accordion',
406
164
  _version: BLOCK_VERSION,
407
- title: 'Details',
165
+ title: copy.detailsTitle,
408
166
  items: qa(`feature-${demo.slug}-details`, demo.details),
409
167
  },
410
168
  {
411
169
  _key: `feature-${demo.slug}-others`,
412
170
  _type: 'collectionList',
413
171
  _version: BLOCK_VERSION,
414
- title: 'Other features',
172
+ title: copy.otherFeaturesTitle,
415
173
  collection: 'feature',
416
174
  sort: { field: 'id', direction: 'asc' },
417
175
  limit: 6,
@@ -420,123 +178,10 @@ function featureBlocks(demo, name) {
420
178
  ];
421
179
  }
422
180
  /** The block zone of a feature page. Exported for the blueprint test. */
423
- export function saasFeatureBlocks(demo, siteName) {
424
- return featureBlocks(demo, nameOf(siteName));
181
+ export function saasFeatureBlocks(demo, siteName, copy = SAAS_COPY_EN) {
182
+ return featureBlocks(copy, demo, nameOf(siteName));
425
183
  }
426
- /** Oldest first: seeded in this order, listed newest first by id. */
427
- export const SAAS_DEMO_UPDATES = [
428
- {
429
- title: 'SCIM group mapping',
430
- slug: 'scim-group-mapping',
431
- summary: 'Groups in your identity provider now map to roles, so directory changes set permissions too.',
432
- publishedAt: '2026-05-14T09:00:00.000Z',
433
- screenshot: 'sso-scim',
434
- caption: 'Sign-in and provisioning settings, with five Okta groups mapped to roles.',
435
- body: () => [
436
- {
437
- p: 'Until now SCIM created and removed members, and roles were set by hand. From today you can map a group in Okta, Microsoft Entra ID or Google Workspace to a role, and membership of that group sets the role.',
438
- },
439
- {
440
- ul: [
441
- 'A group can map to requester, approver, accounts payable, administrator or auditor',
442
- 'An approver role can be limited to the cost centres the group owns',
443
- 'Changes are applied within a minute of the directory sending them',
444
- ],
445
- },
446
- {
447
- p: 'Existing roles are left as they are until you save a mapping. Settings, then Sign-in and provisioning, lists every group your directory has sent.',
448
- },
449
- ],
450
- },
451
- {
452
- title: 'Deadlines and reminders on every step',
453
- slug: 'deadlines-and-reminders',
454
- summary: 'Each step of a policy can now carry a deadline in business days, with a reminder the day before.',
455
- publishedAt: '2026-06-04T09:00:00.000Z',
456
- body: () => [
457
- {
458
- p: 'A request that waits on one person holds up everyone after them. Steps now carry a deadline, counted in business days from the moment the step opens and following the public holidays of the approver’s country.',
459
- },
460
- {
461
- p: 'The approver gets a reminder the working day before the deadline, by email and in Slack or Microsoft Teams if connected. When a deadline passes, the request is marked overdue in the queue and the step can move to a delegate automatically.',
462
- },
463
- ],
464
- },
465
- {
466
- title: 'Delegation while you are away',
467
- slug: 'delegation-while-away',
468
- summary: 'Approvers can name a delegate for a date range, and their steps move to that person while they are out.',
469
- publishedAt: '2026-06-25T09:00:00.000Z',
470
- body: () => [
471
- {
472
- p: 'Set a date range and a delegate from your profile, or let your calendar do it: when Google Calendar or Outlook shows you out of office, the delegate you chose receives your steps for those days.',
473
- },
474
- {
475
- ul: [
476
- 'A delegate cannot approve a request they submitted themselves',
477
- 'Delegated decisions show both names in the request and in the audit log',
478
- 'Administrators can set a delegate for someone on unplanned leave',
479
- ],
480
- },
481
- ],
482
- },
483
- {
484
- title: 'Microsoft Dynamics 365 Business Central',
485
- slug: 'business-central',
486
- summary: 'Business Central joins NetSuite, Xero and QuickBooks Online as a two-way ERP connection.',
487
- publishedAt: '2026-07-16T09:00:00.000Z',
488
- screenshot: 'erp-sync',
489
- caption: 'The recent activity of an ERP connection, with one sync retried after a rate limit.',
490
- body: () => [
491
- {
492
- p: 'The Business Central connection reads vendors, dimensions and budget entries, and writes approved purchase invoices and purchase orders. Dimensions map to cost centres, so an approval policy written for a department works on the dimension your books already use.',
493
- },
494
- {
495
- p: 'Connections are available on Business and Enterprise. Setting one up takes a Business Central user with the permissions listed in the setup screen, and about an hour.',
496
- },
497
- ],
498
- },
499
- {
500
- title: 'Signed audit exports',
501
- slug: 'signed-audit-exports',
502
- summary: 'CSV and PDF exports of the audit log are now signed, so a file can be checked against the log.',
503
- publishedAt: '2026-08-06T09:00:00.000Z',
504
- screenshot: 'audit-log',
505
- caption: 'The audit log filtered to one purchase request, ready to export.',
506
- body: () => [
507
- {
508
- p: 'Each export of the audit log now comes with a signature over its contents and the hash of the last event it includes. Anyone with the file can check it against the workspace’s public key, and an auditor can confirm that no row was added, removed or edited after the export.',
509
- },
510
- {
511
- p: 'Signing is on for every export from Business and Enterprise workspaces, with nothing to set up. The verification steps are described under Settings, then Audit log.',
512
- },
513
- ],
514
- },
515
- {
516
- title: 'Parallel approval steps',
517
- slug: 'parallel-approval-steps',
518
- summary: 'Two steps can now run at the same time, so procurement and a budget owner review a request together.',
519
- publishedAt: '2026-08-27T09:00:00.000Z',
520
- screenshot: 'policy-editor',
521
- caption: 'A purchase request policy with procurement reviewing new vendors in parallel with step 1.',
522
- body: () => [
523
- {
524
- p: 'Steps used to run strictly one after the other. A policy can now open two steps at once: procurement reviews a new vendor while the budget owner reviews the spend, and the request moves on when both have approved.',
525
- },
526
- {
527
- ul: [
528
- 'Mark a step as parallel with the step before it in the policy editor',
529
- 'A rejection on either step ends the request, with the reason shown to the requester',
530
- 'Reports count the time of a parallel step from when it opened',
531
- ],
532
- },
533
- {
534
- p: 'Across the workspaces that tried it in August, requests from new vendors reached a decision a day and a half sooner.',
535
- },
536
- ],
537
- },
538
- ];
539
- function updateBlocks(update, name, media) {
184
+ function updateBlocks(copy, update, name, media) {
540
185
  const screenshot = update.screenshot === undefined ? undefined : media[update.screenshot];
541
186
  return [
542
187
  prose(`update-${update.slug}-body`, update.body(name)),
@@ -557,7 +202,7 @@ function updateBlocks(update, name, media) {
557
202
  _key: `update-${update.slug}-more`,
558
203
  _type: 'collectionList',
559
204
  _version: BLOCK_VERSION,
560
- title: 'More updates',
205
+ title: copy.moreUpdatesTitle,
561
206
  collection: 'changelog',
562
207
  sort: { field: 'id', direction: 'desc' },
563
208
  limit: 4,
@@ -567,140 +212,116 @@ function updateBlocks(update, name, media) {
567
212
  }
568
213
  /** The block zone of a changelog entry. Exported for the blueprint test. */
569
214
  export function saasUpdateBlocks(update, options = {}) {
570
- return updateBlocks(update, nameOf(options.siteName), options.media ?? {});
215
+ return updateBlocks(options.copy ?? SAAS_COPY_EN, update, nameOf(options.siteName), options.media ?? {});
571
216
  }
572
217
  // ---------------------------------------------------------------------------
573
- // Pricing and questions
218
+ // Pricing
574
219
  // ---------------------------------------------------------------------------
575
- function pricingTiers() {
576
- return [
577
- {
578
- _key: 'tier-team',
579
- name: 'Team',
580
- price: '$12',
581
- interval: 'per approver, per month, billed yearly',
582
- features: [
583
- 'Approvers: Up to 25',
584
- 'Requesters: Unlimited, free',
585
- 'Approval policies: 10',
586
- 'ERP connections: 1',
587
- 'Audit log history: 1 year',
588
- 'Single sign-on: Google',
589
- 'Support: Email, next business day',
590
- ],
591
- action: { label: 'Start a trial', target: { href: '/demo' } },
592
- },
593
- {
594
- _key: 'tier-business',
595
- name: 'Business',
596
- price: '$24',
597
- interval: 'per approver, per month, billed yearly',
598
- features: [
599
- 'Approvers: Up to 250',
600
- 'Requesters: Unlimited, free',
601
- 'Approval policies: Unlimited',
602
- 'ERP connections: 3',
603
- 'Audit log history: 7 years',
604
- 'Single sign-on: SAML and SCIM',
605
- 'Support: Email and chat, same business day',
606
- 'Parallel approval steps',
607
- 'Signed audit exports',
608
- ],
609
- action: { label: 'Start a trial', target: { href: '/demo' }, emphasis: 'primary' },
610
- highlighted: true,
611
- },
612
- {
613
- _key: 'tier-enterprise',
614
- name: 'Enterprise',
615
- price: 'Custom',
616
- interval: 'annual contract, from 100 approvers',
617
- features: [
618
- 'Approvers: Unlimited',
619
- 'Requesters: Unlimited, free',
620
- 'Approval policies: Unlimited',
621
- 'ERP connections: Unlimited',
622
- 'Audit log history: 10 years',
623
- 'Single sign-on: SAML and SCIM',
624
- 'Support: Named engineer, 1-hour response',
625
- 'Parallel approval steps',
626
- 'Signed audit exports',
627
- 'Data residency in the EU or the US',
628
- 'Invoicing on 30-day terms',
629
- ],
630
- action: { label: 'Talk to sales', target: { href: '/demo' } },
220
+ function pricingTiers(copy) {
221
+ return copy.tiers.map((tier) => ({
222
+ _key: tier.key,
223
+ name: tier.name,
224
+ price: tier.price,
225
+ interval: tier.interval,
226
+ features: tier.features,
227
+ action: {
228
+ label: tier.actionLabel,
229
+ target: { href: pagePath(copy, 'demo') },
230
+ ...(tier.primary === true ? { emphasis: 'primary' } : {}),
631
231
  },
632
- ];
232
+ ...(tier.highlighted === true ? { highlighted: true } : {}),
233
+ }));
633
234
  }
634
- function homeQuestions(name) {
635
- return [
636
- [
637
- 'How long does it take to set up?',
638
- `Most teams approve their first real request within two weeks. A solutions engineer turns your current approval matrix into a policy with you, and connecting the ERP takes about an hour with your administrator.`,
639
- ],
640
- [
641
- 'Do requesters need a paid seat?',
642
- `No. Anyone in the company can submit a request or upload an invoice to ${name} at no cost. You pay for approvers, and only for the months in which they approve something.`,
643
- ],
644
- [
645
- 'Which ERPs do you connect to?',
646
- 'NetSuite, Xero, QuickBooks Online and Microsoft Dynamics 365 Business Central, in both directions. Any other system can use the API, or a nightly CSV import.',
647
- ],
648
- [
649
- 'Can our auditors get access?',
650
- 'Yes. The auditor role is read-only and limited to the date range you set. Auditors filter and export the log themselves, and their access ends on its own.',
651
- ],
652
- [
653
- 'Where is our data stored?',
654
- 'In Frankfurt or in northern Virginia, chosen when the workspace is created. Backups stay in the same region, and Enterprise customers can keep them in a second region of their choice.',
655
- ],
656
- [
657
- 'What happens to our data if we leave?',
658
- 'You can export every request, decision and attachment at any time. We delete the workspace 30 days after the end of the contract and send a certificate of deletion.',
659
- ],
235
+ // ---------------------------------------------------------------------------
236
+ // Pages
237
+ // ---------------------------------------------------------------------------
238
+ /**
239
+ * One bullet of the credits list: title, author, licence (linked when it has a
240
+ * page) and a link to the source, the way an attribution licence asks for it.
241
+ */
242
+ function creditNode(key, credit, sourceWord) {
243
+ const links = [];
244
+ const span = (index, text, href) => {
245
+ if (href === undefined)
246
+ return { _key: `${key}-s${index}`, _type: 'span', text, marks: [] };
247
+ const markKey = `${key}-l${index}`;
248
+ links.push({ _key: markKey, _type: 'link', href });
249
+ return { _key: `${key}-s${index}`, _type: 'span', text, marks: [markKey] };
250
+ };
251
+ const children = [
252
+ span(0, `${credit.title} · ${credit.author} · `),
253
+ span(1, credit.licence, credit.licenceUrl),
254
+ span(2, ' · '),
255
+ span(3, sourceWord, credit.source),
660
256
  ];
661
- }
662
- function block(key, type, data) {
663
- return { _key: key, _type: type, _version: BLOCK_VERSION, ...data };
664
- }
665
- function demoAction(label = 'Book a demo') {
666
- return { label, target: { href: '/demo' }, emphasis: 'primary' };
257
+ return {
258
+ _key: key,
259
+ _type: 'block',
260
+ style: 'normal',
261
+ listItem: 'bullet',
262
+ level: 1,
263
+ children,
264
+ markDefs: links,
265
+ };
667
266
  }
668
267
  /**
669
268
  * Every page of the site. A function of `media` (the ids `seedDemoMedia`
670
269
  * assigned), of the features' real ids (the home page grid links to each
671
- * feature page) and of the site's name.
270
+ * feature page), of the site's name and of the copy's language.
672
271
  *
673
272
  * A block that needs a picture which was not seeded is left out rather than
674
273
  * emitted invalid, so the pages still validate with no media at all.
675
274
  */
676
- export function buildSaasDemoPages(media, featureIdBySlug, siteName) {
275
+ export function buildSaasDemoPages(media, featureIdBySlug, siteName, copy = SAAS_COPY_EN) {
677
276
  const name = nameOf(siteName);
277
+ const mail = (mailbox) => saasEmail(name, mailbox);
278
+ const words = copy.pages(name, mail);
279
+ const demoHref = pagePath(copy, 'demo');
280
+ const demoAction = (label = words.demoAction) => ({
281
+ label,
282
+ target: { href: demoHref },
283
+ emphasis: 'primary',
284
+ });
678
285
  const featureLink = (slug) => {
679
286
  const id = featureIdBySlug.get(slug);
680
287
  return id === undefined ? {} : { link: { collection: 'feature', id } };
681
288
  };
682
- const logos = SAAS_CUSTOMERS.flatMap((customer) => media[customer.key] === undefined
289
+ const logos = copy.customers.flatMap((customer) => media[customer.key] === undefined
683
290
  ? []
684
291
  : [{ key: customer.key, name: customer.name, media: media[customer.key] }]);
685
- const home = [
292
+ const figure = (key, mediaName, caption, align = 'start') => media[mediaName] === undefined
293
+ ? []
294
+ : [
295
+ block(key, 'mediaFigure', {
296
+ media: media[mediaName],
297
+ caption,
298
+ ratio: 'original',
299
+ align,
300
+ }),
301
+ ];
302
+ const home = words.home;
303
+ const homeBlocks = [
686
304
  block('home-hero', 'hero', {
687
- eyebrow: 'For finance and operations teams',
688
- title: 'Spend approvals with the audit trail built in',
689
- subtitle: `${name} routes purchase requests, vendor invoices and contract changes to the right approvers by amount, cost centre and vendor, then writes every decision to a log your auditors can read for themselves.`,
305
+ eyebrow: home.heroEyebrow,
306
+ title: home.heroTitle,
307
+ subtitle: home.heroSubtitle,
690
308
  ...(media.app === undefined ? {} : { media: media.app }),
691
- actions: [demoAction(), { label: 'See pricing', target: { href: '/pricing' } }],
309
+ actions: [
310
+ demoAction(),
311
+ { label: home.heroSecondary, target: { href: pagePath(copy, 'pricing') } },
312
+ ],
692
313
  }),
693
314
  ...(logos.length === 0
694
315
  ? []
695
316
  : [
696
317
  block('home-customers', 'logoStrip', {
697
- caption: `Finance teams at 1,400 companies approve spend in ${name}`,
318
+ caption: home.customersCaption,
698
319
  logos: logos.map((logo) => ({ _key: `home-customers-${logo.key}`, media: logo.media })),
699
320
  }),
700
321
  ]),
701
322
  block('home-features', 'featureGrid', {
702
- title: 'One system for every spend decision',
703
- items: SAAS_DEMO_FEATURES.map((demo) => ({
323
+ title: home.featuresTitle,
324
+ items: copy.features.map((demo) => ({
704
325
  _key: `home-feature-${demo.slug}`,
705
326
  icon: demo.icon,
706
327
  title: demo.name,
@@ -709,94 +330,61 @@ export function buildSaasDemoPages(media, featureIdBySlug, siteName) {
709
330
  })),
710
331
  }),
711
332
  block('home-steps', 'featureGrid', {
712
- title: `How a request moves through ${name}`,
713
- items: [
714
- {
715
- _key: 'home-step-request',
716
- title: 'A request comes in',
717
- text: 'From a form, an email to your approvals address or a draft bill synced from the ERP, with the quote attached.',
718
- },
719
- {
720
- _key: 'home-step-route',
721
- title: 'Your policy routes it',
722
- text: 'Amount, cost centre, vendor and category decide who approves, in what order and by when.',
723
- },
724
- {
725
- _key: 'home-step-record',
726
- title: 'The decision is recorded',
727
- text: 'Approvers decide from the web, Slack or email. The ERP is updated and the audit log keeps who, what and when.',
728
- },
729
- ],
333
+ title: home.stepsTitle,
334
+ items: home.steps.map((step, index) => ({
335
+ _key: `home-step-${index + 1}`,
336
+ title: step.title,
337
+ text: step.text,
338
+ })),
730
339
  }),
731
340
  block('home-tour', 'collectionList', {
732
- title: 'A closer look at the product',
341
+ title: home.tourTitle,
733
342
  collection: 'feature',
734
343
  sort: { field: 'id', direction: 'asc' },
735
344
  limit: 3,
736
345
  layout: 'list',
737
346
  }),
738
347
  block('home-figures', 'stats', {
739
- title: 'Across every workspace, last quarter',
740
- items: [
741
- {
742
- _key: 'home-figure-time',
743
- value: '3.6',
744
- unit: 'h',
745
- label: 'median time from request to final approval',
746
- },
747
- {
748
- _key: 'home-figure-volume',
749
- value: '2.1',
750
- unit: 'M',
751
- label: 'requests and invoices approved',
752
- },
753
- {
754
- _key: 'home-figure-uptime',
755
- value: '99.99',
756
- unit: '%',
757
- label: 'uptime over the last twelve months',
758
- },
759
- {
760
- _key: 'home-figure-history',
761
- value: '7',
762
- unit: 'years',
763
- label: 'of audit history kept on Business',
764
- },
765
- ],
348
+ title: home.figuresTitle,
349
+ items: home.figures.map((figureItem, index) => ({
350
+ _key: `home-figure-${index + 1}`,
351
+ value: figureItem.value,
352
+ unit: figureItem.unit,
353
+ label: figureItem.label,
354
+ })),
766
355
  }),
767
356
  block('home-customer', 'testimonial', {
768
357
  quote: [
769
- ...richTextParagraph('home-customer-1', 'We used to close the month with a folder of forwarded emails as our evidence. Now the auditors filter the log themselves and stop asking us for screenshots.'),
770
- ...richTextParagraph('home-customer-2', 'Invoice approvals went from nine days to under two in the first quarter, and nobody on my team has chased a signature since March.'),
358
+ ...richTextParagraph('home-customer-1', home.quote[0]),
359
+ ...richTextParagraph('home-customer-2', home.quote[1]),
771
360
  ],
772
- attribution: {
773
- name: 'Adrian Tan',
774
- role: 'Financial Controller, Halvorsen Freight',
775
- ...(media.portrait === undefined ? {} : { avatar: media.portrait }),
776
- },
361
+ // No portrait: the quoted customer is fictional, and a photograph of a
362
+ // real person would present them as someone they are not.
363
+ attribution: { name: home.quoteName, role: home.quoteRole },
777
364
  }),
778
365
  block('home-pricing', 'pricingTable', {
779
- title: 'Priced per approver',
780
- tiers: pricingTiers(),
366
+ title: home.pricingTitle,
367
+ tiers: pricingTiers(copy),
781
368
  }),
782
369
  block('home-questions', 'faq', {
783
- title: 'Questions finance teams ask us',
784
- items: qa('home-questions', homeQuestions(name)),
370
+ title: home.questionsTitle,
371
+ items: qa('home-questions', copy.homeQuestions(name)),
785
372
  }),
786
373
  block('home-close', 'cta', {
787
- title: 'See it with your own approval policy',
788
- text: 'Send us your approval matrix. A solutions engineer builds it in a trial workspace before a 30-minute call.',
374
+ title: home.ctaTitle,
375
+ text: home.ctaText,
789
376
  actions: [
790
377
  demoAction(),
791
- { label: 'Read the security overview', target: { href: '/security' } },
378
+ { label: home.ctaSecurity, target: { href: pagePath(copy, 'security') } },
792
379
  ],
793
380
  }),
794
381
  ];
795
- const product = [
382
+ const p = words.product;
383
+ const productBlocks = [
796
384
  block('product-hero', 'hero', {
797
- eyebrow: 'Product',
798
- title: 'From the request to the ledger in one system',
799
- subtitle: `${name} replaces the approval email chain, the matrix spreadsheet and the folder of evidence with one record per request, from the moment it is submitted to the moment it is posted.`,
385
+ eyebrow: p.heroEyebrow,
386
+ title: p.heroTitle,
387
+ subtitle: p.heroSubtitle,
800
388
  actions: [demoAction()],
801
389
  }),
802
390
  block('product-tour', 'collectionList', {
@@ -806,200 +394,96 @@ export function buildSaasDemoPages(media, featureIdBySlug, siteName) {
806
394
  layout: 'list',
807
395
  }),
808
396
  block('product-steps', 'featureGrid', {
809
- title: 'What changes in the first month',
810
- items: [
811
- {
812
- _key: 'product-step-1',
813
- title: 'Week one',
814
- text: 'Your approval matrix becomes a policy, tested against last quarter’s requests before anyone uses it.',
815
- },
816
- {
817
- _key: 'product-step-2',
818
- title: 'Week two',
819
- text: 'The ERP is connected, and requesters start submitting through a form instead of an email.',
820
- },
821
- {
822
- _key: 'product-step-3',
823
- title: 'Week four',
824
- text: 'The first month closes with every approval in the log and the evidence exported in one file.',
825
- },
826
- ],
397
+ title: p.stepsTitle,
398
+ items: p.steps.map((step, index) => ({
399
+ _key: `product-step-${index + 1}`,
400
+ title: step.title,
401
+ text: step.text,
402
+ })),
827
403
  }),
828
404
  block('product-close', 'cta', {
829
- title: 'See your own policy running',
830
- text: 'We build it in a trial workspace before the call, so the demo uses your cost centres and your amounts.',
831
- actions: [demoAction(), { label: 'Compare plans', target: { href: '/pricing' } }],
405
+ title: p.ctaTitle,
406
+ text: p.ctaText,
407
+ actions: [demoAction(), { label: p.ctaCompare, target: { href: pagePath(copy, 'pricing') } }],
832
408
  }),
833
409
  ];
834
- const pricing = [
410
+ const pr = words.pricing;
411
+ const pricingBlocks = [
835
412
  block('pricing-hero', 'hero', {
836
- eyebrow: 'Pricing',
837
- title: 'Pay for the people who approve',
838
- subtitle: `Anyone in the company can submit requests and invoices to ${name} at no cost. Plans are billed yearly per approver, and an approver who decides nothing in a month is not counted that month.`,
839
- actions: [demoAction('Start a trial')],
413
+ eyebrow: pr.heroEyebrow,
414
+ title: pr.heroTitle,
415
+ subtitle: pr.heroSubtitle,
416
+ actions: [demoAction(pr.trialAction)],
417
+ }),
418
+ block('pricing-plans', 'pricingTable', {
419
+ title: pr.plansTitle,
420
+ tiers: pricingTiers(copy),
840
421
  }),
841
- block('pricing-plans', 'pricingTable', { title: 'Compare plans', tiers: pricingTiers() }),
842
422
  block('pricing-questions', 'faq', {
843
- title: 'Billing',
844
- items: qa('pricing-questions', [
845
- [
846
- 'How does the trial work?',
847
- 'A solutions engineer sets up a workspace with your approval policy and a copy of last month’s requests. You use it for 14 days. It does not turn into a subscription on its own, and no card is asked for.',
848
- ],
849
- [
850
- 'Who counts as an approver?',
851
- 'Anyone who approved or rejected at least one step in the month. People who only submit requests, upload invoices or read reports are free.',
852
- ],
853
- [
854
- 'Can we pay by invoice?',
855
- 'Yes. Business and Enterprise are invoiced yearly on 30-day terms, with your purchase order number on the invoice. Team is paid by card.',
856
- ],
857
- [
858
- 'What if we grow during the year?',
859
- 'Extra approvers are billed at the end of each quarter for the months they were active, at the rate of your plan. Nothing is charged in advance.',
860
- ],
861
- [
862
- 'Do you offer discounts?',
863
- 'Registered charities and schools get 40 percent off Team and Business. Write to us from your organisation’s address.',
864
- ],
865
- [
866
- 'Can we change plans later?',
867
- 'Yes. An upgrade applies at once and is prorated to the day. A downgrade applies at the next renewal.',
868
- ],
869
- ]),
423
+ title: pr.billingTitle,
424
+ items: qa('pricing-questions', pr.billing),
870
425
  }),
871
426
  block('pricing-close', 'cta', {
872
- title: 'Help choosing a plan',
873
- text: 'Tell us how many people approve spend and which ERP you use, and we will recommend a plan in one email.',
427
+ title: pr.ctaTitle,
428
+ text: pr.ctaText,
874
429
  actions: [
875
430
  {
876
- label: 'Email sales',
877
- target: { href: `mailto:${saasEmail(name, 'sales')}` },
431
+ label: pr.ctaEmail,
432
+ target: { href: `mailto:${mail(copy.locale === 'fr' ? 'ventes' : 'sales')}` },
878
433
  emphasis: 'primary',
879
434
  },
880
435
  ],
881
436
  }),
882
437
  ];
883
- const security = [
438
+ const s = words.security;
439
+ const securityMailbox = copy.locale === 'fr' ? 'securite' : 'security';
440
+ const securityBlocks = [
884
441
  block('security-hero', 'hero', {
885
- eyebrow: 'Security',
886
- title: 'How we protect your financial data',
887
- subtitle: `${name} holds your vendors, your budgets and the record of who approved what. Here is how that data is hosted, encrypted, accessed and kept.`,
442
+ eyebrow: s.heroEyebrow,
443
+ title: s.heroTitle,
444
+ subtitle: s.heroSubtitle,
888
445
  actions: [
889
446
  {
890
- label: 'Request the security pack',
891
- target: { href: `mailto:${saasEmail(name, 'security')}` },
447
+ label: s.packAction,
448
+ target: { href: `mailto:${mail(securityMailbox)}` },
892
449
  emphasis: 'primary',
893
450
  },
894
451
  ],
895
452
  }),
896
453
  block('security-controls', 'featureGrid', {
897
- title: 'Controls in place',
898
- items: [
899
- {
900
- _key: 'security-control-encryption',
901
- icon: 'lock',
902
- title: 'Encryption',
903
- text: 'TLS 1.3 in transit and AES-256 at rest, with keys rotated every 90 days.',
904
- },
905
- {
906
- _key: 'security-control-residency',
907
- icon: 'globe',
908
- title: 'Data residency',
909
- text: 'Workspaces are hosted in Frankfurt or northern Virginia, and data stays in the region.',
910
- },
911
- {
912
- _key: 'security-control-access',
913
- icon: 'users',
914
- title: 'Access',
915
- text: 'Single sign-on, SCIM and role-based permissions, with every sign-in recorded.',
916
- },
917
- {
918
- _key: 'security-control-log',
919
- icon: 'shield',
920
- title: 'Tamper-evident log',
921
- text: 'Every event carries the hash of the one before it, and the chain is verified nightly.',
922
- },
923
- {
924
- _key: 'security-control-backups',
925
- icon: 'refresh',
926
- title: 'Backups',
927
- text: 'Encrypted backups every hour, kept for 35 days and restored in a test every month.',
928
- },
929
- {
930
- _key: 'security-control-audits',
931
- icon: 'check',
932
- title: 'Independent audits',
933
- text: 'A SOC 2 Type II report each year and an external penetration test every six months.',
934
- },
935
- ],
454
+ title: s.controlsTitle,
455
+ items: s.controls.map((control, index) => ({
456
+ _key: `security-control-${index + 1}`,
457
+ icon: control.icon,
458
+ title: control.title,
459
+ text: control.text,
460
+ })),
936
461
  }),
937
- prose('security-detail', [
938
- { h2: 'Hosting' },
939
- {
940
- p: 'Workspaces run on Amazon Web Services, in eu-central-1 (Frankfurt) or us-east-1 (northern Virginia). The region is chosen when the workspace is created and cannot be changed by our staff. Production runs across three availability zones, and the database is replicated between them.',
941
- },
942
- { h2: 'Access by our staff' },
943
- {
944
- p: 'Nobody at the company can read your workspace by default. A support engineer can be granted access to one workspace for 24 hours, only after an administrator of that workspace approves it, and every action taken during that time is written to your audit log.',
945
- },
946
- { h2: 'Keeping the record' },
947
- {
948
- p: 'The audit log is append-only. Events are hashed in a chain, the chain is verified every night and on demand, and exports are signed so a file can be checked against the workspace. Deleted requests are kept in the log as deletions.',
949
- },
950
- { h2: 'Reporting a vulnerability' },
951
- {
952
- p: `Write to ${saasEmail(name, 'security')}. We acknowledge reports within one business day, keep you informed while we fix the issue, and credit you when it is resolved if you wish.`,
953
- },
954
- ]),
955
- ...(media['audit-log'] === undefined
956
- ? []
957
- : [
958
- block('security-log', 'mediaFigure', {
959
- media: media['audit-log'],
960
- caption: 'The audit log of one purchase request, with the chain verified.',
961
- ratio: 'original',
962
- align: 'start',
963
- }),
964
- ]),
462
+ prose('security-detail', s.detail),
463
+ ...figure('security-racks', 'security-racks', s.racksCaption),
464
+ ...figure('security-log', 'audit-log', s.logCaption),
965
465
  block('security-subprocessors', 'accordion', {
966
- title: 'Subprocessors',
967
- items: qa('security-subprocessors', [
968
- [
969
- 'Amazon Web Services',
970
- 'Hosting, storage and backups, in the region of the workspace. Data processing agreement in place.',
971
- ],
972
- [
973
- 'Cloudflare',
974
- 'Network protection and delivery of the web application. Request metadata only, never the contents of a workspace.',
975
- ],
976
- [
977
- 'Postmark',
978
- 'Transactional email: approval requests, reminders and receipts. The subject and summary of a request, never its attachments.',
979
- ],
980
- [
981
- 'Slack and Microsoft Teams',
982
- 'Only when a customer connects them. Approval messages sent to the channels the customer chooses.',
983
- ],
984
- ]),
466
+ title: s.subprocessorsTitle,
467
+ items: qa('security-subprocessors', s.subprocessors),
985
468
  }),
986
469
  block('security-close', 'cta', {
987
- title: 'Security questionnaires',
988
- text: 'Our security pack includes the SOC 2 report, the latest penetration test summary and answers to the usual questionnaires.',
470
+ title: s.ctaTitle,
471
+ text: s.ctaText,
989
472
  actions: [
990
473
  {
991
- label: 'Request the security pack',
992
- target: { href: `mailto:${saasEmail(name, 'security')}` },
474
+ label: s.packAction,
475
+ target: { href: `mailto:${mail(securityMailbox)}` },
993
476
  emphasis: 'primary',
994
477
  },
995
478
  ],
996
479
  }),
997
480
  ];
998
- const changelogPage = [
481
+ const c = words.changelog;
482
+ const changelogBlocks = [
999
483
  block('changelog-hero', 'hero', {
1000
- eyebrow: 'Changelog',
1001
- title: `What is new in ${name}`,
1002
- subtitle: 'We ship every week. These are the changes that alter how you work, newest first.',
484
+ eyebrow: c.heroEyebrow,
485
+ title: c.heroTitle,
486
+ subtitle: c.heroSubtitle,
1003
487
  }),
1004
488
  block('changelog-list', 'collectionList', {
1005
489
  collection: 'changelog',
@@ -1008,47 +492,39 @@ export function buildSaasDemoPages(media, featureIdBySlug, siteName) {
1008
492
  layout: 'list',
1009
493
  }),
1010
494
  ];
1011
- const about = [
495
+ const a = words.about;
496
+ const aboutBlocks = [
1012
497
  block('about-hero', 'hero', {
1013
- eyebrow: 'Company',
1014
- title: 'Built by people who have closed the books',
1015
- subtitle: `${name} was started in London in 2019 by two former financial controllers who had spent too many month-ends chasing approvals by email.`,
498
+ eyebrow: a.heroEyebrow,
499
+ title: a.heroTitle,
500
+ subtitle: a.heroSubtitle,
1016
501
  }),
1017
- prose('about-story', [
1018
- {
1019
- p: `Ruth Okonjo and Tomasz Wierzbicki met at a logistics company where approving a purchase meant an email, a spreadsheet and a signature on a printed form. Each month-end, the finance team spent two days rebuilding who had approved what. They wrote the first version of ${name} for that team, and it ran there for a year before anyone else used it.`,
1020
- },
1021
- {
1022
- p: 'Today 58 people work on the product, in London and Toronto. Half of them write software. Most of the rest have worked in finance, procurement or audit, and every solutions engineer has run a month-end close.',
1023
- },
1024
- { h2: 'How we work' },
1025
- {
1026
- ul: [
1027
- 'One product, sold to finance and operations teams, and nothing else',
1028
- 'Pricing on the website, the same for every customer of the same size',
1029
- 'Support answered by the people who build the product',
1030
- 'A changelog that lists every change that alters how you work',
1031
- ],
1032
- },
1033
- { h2: 'Funding' },
1034
- {
1035
- p: 'We raised a seed round in 2020 and a Series A in 2023, and have been profitable since the last quarter of 2025.',
1036
- },
1037
- ]),
502
+ prose('about-story', a.story),
503
+ prose('about-places', a.places),
504
+ ...(media['office-1'] === undefined || media['office-2'] === undefined
505
+ ? []
506
+ : [
507
+ block('about-offices', 'gallery', {
508
+ layout: 'grid',
509
+ items: ['office-1', 'office-2'].map((key) => ({
510
+ _key: `about-office-${key}`,
511
+ media: media[key],
512
+ })),
513
+ }),
514
+ ]),
1038
515
  block('about-numbers', 'statCounter', {
1039
- title: 'The company today',
1040
- stats: [
1041
- { _key: 'about-number-customers', value: '1,400', label: 'customers' },
1042
- { _key: 'about-number-countries', value: '31', label: 'countries' },
1043
- { _key: 'about-number-people', value: '58', label: 'people' },
1044
- { _key: 'about-number-offices', value: '2', label: 'offices' },
1045
- ],
516
+ title: a.numbersTitle,
517
+ stats: a.numbers.map((number, index) => ({
518
+ _key: `about-number-${index + 1}`,
519
+ value: number.value,
520
+ label: number.label,
521
+ })),
1046
522
  }),
1047
523
  ...(logos.length === 0
1048
524
  ? []
1049
525
  : [
1050
526
  block('about-customers', 'logos', {
1051
- title: 'Some of our customers',
527
+ title: a.customersTitle,
1052
528
  items: logos.map((logo) => ({
1053
529
  _key: `about-customer-${logo.key}`,
1054
530
  media: logo.media,
@@ -1057,125 +533,89 @@ export function buildSaasDemoPages(media, featureIdBySlug, siteName) {
1057
533
  }),
1058
534
  ]),
1059
535
  block('about-close', 'cta', {
1060
- title: 'Work with us',
1061
- text: 'We hire engineers, solutions engineers and support specialists in London and Toronto.',
536
+ title: a.ctaTitle,
537
+ text: a.ctaText,
1062
538
  actions: [
1063
539
  {
1064
- label: 'Write to us',
1065
- target: { href: `mailto:${saasEmail(name, 'jobs')}` },
540
+ label: a.ctaAction,
541
+ target: { href: `mailto:${mail(copy.locale === 'fr' ? 'recrutement' : 'jobs')}` },
1066
542
  emphasis: 'primary',
1067
543
  },
1068
544
  ],
1069
545
  }),
1070
546
  ];
1071
- const demo = [
547
+ const d = words.demo;
548
+ const demoBlocks = [
1072
549
  block('demo-hero', 'hero', {
1073
- eyebrow: 'Book a demo',
1074
- title: 'Talk to someone who has run a month-end close',
1075
- subtitle: 'A 30-minute call with a solutions engineer, then a trial workspace set up with your own approval policy.',
550
+ eyebrow: d.heroEyebrow,
551
+ title: d.heroTitle,
552
+ subtitle: d.heroSubtitle,
1076
553
  actions: [
1077
554
  {
1078
- label: `Email ${saasEmail(name, 'demo')}`,
555
+ label: d.emailAction,
1079
556
  target: {
1080
- href: `mailto:${saasEmail(name, 'demo')}?subject=${encodeURIComponent('Demo request')}`,
557
+ href: `mailto:${mail('demo')}?subject=${encodeURIComponent(d.emailSubject)}`,
1081
558
  },
1082
559
  emphasis: 'primary',
1083
560
  },
1084
561
  ],
1085
562
  }),
1086
563
  block('demo-contact', 'featureGrid', {
1087
- title: 'Ways to reach us',
564
+ title: d.contactTitle,
1088
565
  items: [
1089
566
  {
1090
567
  _key: 'demo-contact-email',
1091
568
  icon: 'mail',
1092
- title: 'Email',
1093
- text: `${saasEmail(name, 'demo')}, answered within one business day.`,
1094
- link: { href: `mailto:${saasEmail(name, 'demo')}` },
569
+ title: copy.locale === 'fr' ? 'E-mail' : 'Email',
570
+ text: d.emailText,
571
+ link: { href: `mailto:${mail('demo')}` },
1095
572
  },
1096
573
  {
1097
574
  _key: 'demo-contact-phone',
1098
575
  icon: 'phone',
1099
- title: SAAS_PHONE,
1100
- text: 'Monday to Friday, 08:00 to 18:00 UK time.',
1101
- link: { href: SAAS_PHONE_HREF },
576
+ title: copy.phone.display,
577
+ text: d.phoneText,
578
+ link: { href: copy.phone.href },
1102
579
  },
1103
580
  {
1104
581
  _key: 'demo-contact-security',
1105
582
  icon: 'shield',
1106
- title: 'Security review',
1107
- text: 'Start your security review before the call.',
1108
- link: { href: '/security' },
583
+ title: d.securityTitle,
584
+ text: d.securityText,
585
+ link: { href: pagePath(copy, 'security') },
1109
586
  },
1110
587
  ],
1111
588
  }),
1112
- prose('demo-how', [
1113
- { h2: 'Before the call' },
1114
- {
1115
- p: 'Write to us with the number of people who approve spend, the ERP you use, and your current approval matrix if you can share it. A spreadsheet is fine. We reply within one business day to agree a time.',
1116
- },
1117
- { h2: 'On the call' },
1118
- {
1119
- p: 'A solutions engineer shows the product with your own policy already built, answers questions from finance, procurement and IT, and goes through what a rollout would take for your team.',
1120
- },
1121
- { h2: 'The trial' },
1122
- {
1123
- p: 'After the call, you get a workspace for 14 days with your policy and a copy of last month’s requests from a CSV export. Your team can submit and approve requests in it. The trial does not turn into a subscription on its own, and we do not ask for a card.',
1124
- },
1125
- ]),
589
+ prose('demo-how', d.how),
1126
590
  ];
1127
- const legal = [
1128
- prose('legal-body', [
1129
- { h2: 'Company details' },
1130
- {
1131
- p: `${name} is operated by ${name} Ltd, a company registered in England and Wales, whose registered office is at 2nd floor, 41 Tabernacle Street, London EC2A 4AA, United Kingdom.`,
1132
- },
1133
- { h2: 'Terms of service' },
1134
- {
1135
- p: 'Use of the product is governed by the subscription agreement signed with each customer, or for Team plans by the terms accepted when the workspace is created. Where the two differ, the signed agreement applies.',
1136
- },
1137
- { h2: 'Data processing' },
1138
- {
1139
- p: 'We process personal data in customer workspaces as a processor, under a data processing agreement that includes the standard contractual clauses. The list of subprocessors is on the security page, and customers are told 30 days before a new one is added.',
1140
- },
1141
- { h2: 'Contact' },
1142
- { p: `For legal questions, write to ${saasEmail(name, 'legal')}.` },
1143
- ]),
1144
- ];
1145
- const privacy = [
1146
- prose('privacy-body', [
1147
- {
1148
- p: 'This notice covers the personal data we collect through this website and when you contact us. Data inside a customer workspace is covered by that customer’s agreement with us.',
1149
- },
1150
- { h2: 'What we collect' },
1151
- {
1152
- ul: [
1153
- 'The name, work email and company you give us when you ask for a demo or write to us',
1154
- 'Records of our emails and calls with you',
1155
- 'Anonymous page views, counted without cookies and without tracking you across sites',
1156
- ],
1157
- },
1158
- { h2: 'How long we keep it' },
1159
- {
1160
- p: 'Enquiries that do not lead to a contract are deleted after 24 months. Customer contacts are kept for the length of the contract and six years after it, as UK law requires for business records.',
1161
- },
1162
- { h2: 'Your rights' },
1163
- {
1164
- p: `You can ask to see, correct or delete the data we hold about you by writing to ${saasEmail(name, 'privacy')}. You can also complain to the Information Commissioner’s Office.`,
1165
- },
1166
- ]),
591
+ const creditsBlocks = [
592
+ {
593
+ _key: 'credits-body',
594
+ _type: 'prose',
595
+ _version: BLOCK_VERSION,
596
+ body: [
597
+ ...richText('credits-intro', words.credits.intro),
598
+ ...SAAS_PHOTO_CREDITS.filter((credit) => credit.locales.includes(copy.locale)).map((credit, index) => creditNode(`credits-${index}`, credit, words.credits.sourceWord)),
599
+ ],
600
+ },
1167
601
  ];
1168
- return [
1169
- { title: 'Home', slug: 'home', blocks: home },
1170
- { title: 'Product', slug: 'product', blocks: product },
1171
- { title: 'Pricing', slug: 'pricing', blocks: pricing },
1172
- { title: 'Security', slug: 'security', blocks: security },
1173
- { title: 'Changelog', slug: 'changelog', blocks: changelogPage },
1174
- { title: 'Company', slug: 'about', blocks: about },
1175
- { title: 'Book a demo', slug: 'demo', blocks: demo },
1176
- { title: 'Legal', slug: 'legal', blocks: legal },
1177
- { title: 'Privacy', slug: 'privacy', blocks: privacy },
602
+ const entries = [
603
+ ['home', homeBlocks],
604
+ ['product', productBlocks],
605
+ ['pricing', pricingBlocks],
606
+ ['security', securityBlocks],
607
+ ['changelog', changelogBlocks],
608
+ ['about', aboutBlocks],
609
+ ['demo', demoBlocks],
610
+ ['legal', [prose('legal-body', words.legal)]],
611
+ ['privacy', [prose('privacy-body', words.privacy)]],
612
+ ['credits', creditsBlocks],
1178
613
  ];
614
+ return entries.map(([key, blocks]) => ({
615
+ title: words.titles[key],
616
+ slug: copy.slugs[key],
617
+ blocks,
618
+ }));
1179
619
  }
1180
620
  const CHANGELOG_ENTRY = { kind: 'collection', collection: 'changelog' };
1181
621
  const CHANGELOG_MONTH = { kind: 'dateArchive' };
@@ -1184,54 +624,55 @@ const SEARCH_RESULTS = { kind: 'search' };
1184
624
  function onlyOn(...targets) {
1185
625
  return { pages: { mode: 'only', targets } };
1186
626
  }
1187
- export const SAAS_WIDGETS = [
1188
- {
1189
- area: 'sidebar',
1190
- type: 'search',
1191
- settings: { placeholder: 'Features, releases' },
1192
- visibility: onlyOn(CHANGELOG_ENTRY, CHANGELOG_MONTH, FEATURE_PAGE),
1193
- },
1194
- {
1195
- area: 'sidebar',
1196
- type: 'archives',
1197
- title: 'Changelog by month',
1198
- settings: { collection: 'changelog', granularity: 'month', showCounts: true, limit: 12 },
1199
- visibility: onlyOn(CHANGELOG_ENTRY, CHANGELOG_MONTH, SEARCH_RESULTS),
1200
- },
1201
- {
1202
- area: 'sidebar',
1203
- type: 'recentEntries',
1204
- title: 'Recently shipped',
1205
- settings: { collection: 'changelog', count: 3, showDate: true },
1206
- visibility: onlyOn(FEATURE_PAGE, SEARCH_RESULTS),
1207
- },
1208
- {
1209
- area: 'sidebar',
1210
- type: 'links',
1211
- title: 'Resources',
1212
- settings: {
1213
- items: [
1214
- { label: 'Product overview', href: '/product' },
1215
- { label: 'API and webhooks', href: '/features/api-and-webhooks' },
1216
- { label: 'ERP sync', href: '/features/erp-sync' },
1217
- { label: 'Security and compliance', href: '/security' },
1218
- { label: 'Pricing', href: '/pricing' },
1219
- ],
627
+ export function saasWidgets(copy) {
628
+ const w = copy.widgets;
629
+ return [
630
+ {
631
+ area: 'sidebar',
632
+ type: 'search',
633
+ settings: { placeholder: w.searchPlaceholder },
634
+ visibility: onlyOn(CHANGELOG_ENTRY, CHANGELOG_MONTH, FEATURE_PAGE),
1220
635
  },
1221
- visibility: onlyOn(CHANGELOG_ENTRY, CHANGELOG_MONTH, FEATURE_PAGE, SEARCH_RESULTS),
1222
- },
1223
- {
1224
- area: 'sidebar',
1225
- type: 'cta',
1226
- settings: {
1227
- heading: 'See it on your own approval policy',
1228
- body: 'A 30-minute call with a solutions engineer, then a trial workspace set up with your policy.',
1229
- label: 'Book a demo',
1230
- href: '/demo',
636
+ {
637
+ area: 'sidebar',
638
+ type: 'archives',
639
+ title: w.archivesTitle,
640
+ settings: { collection: 'changelog', granularity: 'month', showCounts: true, limit: 12 },
641
+ visibility: onlyOn(CHANGELOG_ENTRY, CHANGELOG_MONTH, SEARCH_RESULTS),
1231
642
  },
1232
- visibility: onlyOn(CHANGELOG_ENTRY, CHANGELOG_MONTH, FEATURE_PAGE, SEARCH_RESULTS),
1233
- },
1234
- ];
643
+ {
644
+ area: 'sidebar',
645
+ type: 'recentEntries',
646
+ title: w.recentTitle,
647
+ settings: { collection: 'changelog', count: 3, showDate: true },
648
+ visibility: onlyOn(FEATURE_PAGE, SEARCH_RESULTS),
649
+ },
650
+ {
651
+ area: 'sidebar',
652
+ type: 'links',
653
+ title: w.resourcesTitle,
654
+ settings: {
655
+ items: w.resources.map((item) => ({
656
+ label: item.label,
657
+ href: linkTarget(copy, item),
658
+ })),
659
+ },
660
+ visibility: onlyOn(CHANGELOG_ENTRY, CHANGELOG_MONTH, FEATURE_PAGE, SEARCH_RESULTS),
661
+ },
662
+ {
663
+ area: 'sidebar',
664
+ type: 'cta',
665
+ settings: {
666
+ heading: w.ctaHeading,
667
+ body: w.ctaBody,
668
+ label: w.ctaLabel,
669
+ href: pagePath(copy, 'demo'),
670
+ },
671
+ visibility: onlyOn(CHANGELOG_ENTRY, CHANGELOG_MONTH, FEATURE_PAGE, SEARCH_RESULTS),
672
+ },
673
+ ];
674
+ }
675
+ export const SAAS_WIDGETS = saasWidgets(SAAS_COPY_EN);
1235
676
  export const SAAS_RECOMMENDED_AGENTS = [
1236
677
  {
1237
678
  name: 'performanceAgent',
@@ -1244,6 +685,9 @@ export const SAAS_RECOMMENDED_AGENTS = [
1244
685
  reason: 'Tracks dependency vulnerabilities, the first thing a buyer’s security review asks about.',
1245
686
  },
1246
687
  ];
688
+ // ---------------------------------------------------------------------------
689
+ // Media
690
+ // ---------------------------------------------------------------------------
1247
691
  /**
1248
692
  * `saas`'s own starting skin, asserted present with a real check rather than
1249
693
  * a `!`: `STARTING_SKINS` is keyed by blueprint id and TypeScript cannot see
@@ -1262,136 +706,80 @@ function saasPalette() {
1262
706
  }
1263
707
  /**
1264
708
  * Every picture this blueprint seeds, all bundled files (`assets/photos/saas/`):
1265
- * interface screenshots rendered once from the product's own UI language with
1266
- * Geist and Geist Mono (OFL), six customer wordmarks rendered once from OFL
1267
- * typefaces, and one customer portrait. The procedural `spec` is only the
1268
- * fallback `DemoMediaSpec` requires when a file is missing.
709
+ * interface screenshots drawn once from the product's own UI language with
710
+ * Geist and Geist Mono (OFL) in each language, customer wordmarks drawn once
711
+ * from OFL typefaces, and three real photographs. The procedural `spec` is only
712
+ * the fallback `DemoMediaSpec` requires when a file is missing.
1269
713
  */
1270
- export const SAAS_MEDIA_SPECS = [
1271
- {
1272
- name: 'app',
1273
- spec: coverArt(saasPalette(), 21),
1274
- alt: 'The approvals queue of a freight company, with a purchase request for brake servicing open beside the list and its approval chain',
1275
- photo: 'saas/approvals-queue.png',
1276
- },
1277
- {
1278
- name: 'policy-editor',
1279
- spec: coverArt(saasPalette(), 22),
1280
- alt: 'The policy editor for purchase requests, with four approval steps and their conditions on amount, vendor and category',
1281
- photo: 'saas/policy-editor.png',
1282
- },
1283
- {
1284
- name: 'audit-log',
1285
- spec: coverArt(saasPalette(), 23),
1286
- alt: 'The audit log filtered to one purchase request, listing six events with their time, actor and chain hash',
1287
- photo: 'saas/audit-log.png',
1288
- },
1289
- {
1290
- name: 'erp-sync',
1291
- spec: coverArt(saasPalette(), 24),
1292
- alt: 'The NetSuite connection settings and its recent activity, with four synced records',
1293
- photo: 'saas/erp-sync.png',
1294
- },
1295
- {
1296
- name: 'sso-scim',
1297
- spec: coverArt(saasPalette(), 25),
1298
- alt: 'Sign-in and provisioning settings, with SAML single sign-on through Okta and five groups mapped to roles',
1299
- photo: 'saas/sso-scim.png',
1300
- },
1301
- {
1302
- name: 'spend-reporting',
1303
- spec: coverArt(saasPalette(), 26),
1304
- alt: 'The time-to-approve report, a bar chart of the median hours to approval falling from 6.8 to 3.6 over thirteen weeks',
1305
- photo: 'saas/spend-reporting.png',
1306
- },
1307
- {
1308
- name: 'api-webhooks',
1309
- spec: coverArt(saasPalette(), 27),
1310
- alt: 'The API and webhooks settings, with an HTTP request creating a purchase request and two webhook endpoints',
1311
- photo: 'saas/api-webhooks.png',
1312
- },
1313
- {
1314
- name: 'portrait',
1315
- spec: coverArt(saasPalette(), 28),
1316
- alt: 'Portrait of Adrian Tan, financial controller at Halvorsen Freight',
1317
- photo: 'saas/adrian-tan.jpg',
1318
- },
1319
- ...SAAS_CUSTOMERS.map((customer, index) => ({
1320
- name: customer.key,
1321
- spec: logoArt(30 + index),
1322
- alt: `${customer.name} logo`,
1323
- photo: `saas/${customer.key}.png`,
1324
- })),
1325
- ];
1326
- /** Header navigation and the header action. The footer is grouped in columns: see `SAAS_FOOTER`. */
1327
- export const SAAS_MENUS = {
1328
- header: [
1329
- { label: 'Product', url: '/product' },
1330
- { label: 'Security', url: '/security' },
1331
- { label: 'Pricing', url: '/pricing' },
1332
- { label: 'Changelog', url: '/changelog' },
1333
- { label: 'Company', url: '/about' },
1334
- ],
1335
- // Seeded with the demo content instead (`seedFooterMenu`): a footer in
1336
- // columns needs an unlinked heading per column, which `BlueprintMenus`
1337
- // cannot express, and its links lead to pages the demo content creates.
1338
- footer: [],
1339
- headerAction: { label: 'Book a demo', url: '/demo' },
1340
- };
714
+ export function saasMediaSpecs(copy) {
715
+ const palette = saasPalette();
716
+ return [
717
+ ...copy.media.map((item, index) => ({
718
+ name: item.name,
719
+ spec: coverArt(palette, 21 + index),
720
+ alt: item.alt,
721
+ photo: `saas/${item.file}`,
722
+ })),
723
+ ...copy.customers.map((customer, index) => ({
724
+ name: customer.key,
725
+ spec: logoArt(30 + index),
726
+ alt: customer.name,
727
+ photo: `saas/${customer.key}.png`,
728
+ })),
729
+ ];
730
+ }
731
+ export const SAAS_MEDIA_SPECS = saasMediaSpecs(SAAS_COPY_EN);
732
+ // ---------------------------------------------------------------------------
733
+ // Menus and settings
734
+ // ---------------------------------------------------------------------------
735
+ /** Header navigation and the header action. The footer is grouped in columns: see `saasFooter`. */
736
+ export function saasMenus(copy) {
737
+ return {
738
+ header: copy.menus.header.map((item) => ({
739
+ label: item.label,
740
+ url: pagePath(copy, item.page),
741
+ })),
742
+ // Seeded with the demo content instead (`seedFooterMenu`): a footer in
743
+ // columns needs an unlinked heading per column, which `BlueprintMenus`
744
+ // cannot express, and its links lead to pages the demo content creates.
745
+ footer: [],
746
+ headerAction: { label: copy.menus.action, url: pagePath(copy, 'demo') },
747
+ };
748
+ }
1341
749
  /** The footer menu: one unlinked heading per column, its links under it. */
1342
- export const SAAS_FOOTER = [
1343
- {
1344
- heading: 'Product',
1345
- links: [
1346
- { label: 'Overview', url: '/product' },
1347
- { label: 'Approval routing', url: '/features/approval-routing' },
1348
- { label: 'Audit log', url: '/features/audit-log' },
1349
- { label: 'Pricing', url: '/pricing' },
1350
- ],
1351
- },
1352
- {
1353
- heading: 'Company',
1354
- links: [
1355
- { label: 'About', url: '/about' },
1356
- { label: 'Security', url: '/security' },
1357
- { label: 'Book a demo', url: '/demo' },
1358
- ],
1359
- },
1360
- {
1361
- heading: 'Resources',
1362
- links: [
1363
- { label: 'Changelog', url: '/changelog' },
1364
- { label: 'API and webhooks', url: '/features/api-and-webhooks' },
1365
- { label: 'ERP sync', url: '/features/erp-sync' },
1366
- ],
1367
- },
1368
- {
1369
- heading: 'Legal',
1370
- links: [
1371
- { label: 'Terms and company', url: '/legal' },
1372
- { label: 'Privacy', url: '/privacy' },
750
+ export function saasFooter(copy) {
751
+ return copy.footer.map((column) => ({
752
+ heading: column.heading,
753
+ links: column.links.map((link) => ({ label: link.label, url: linkTarget(copy, link) })),
754
+ }));
755
+ }
756
+ export const SAAS_MENUS = saasMenus(SAAS_COPY_EN);
757
+ export const SAAS_FOOTER = saasFooter(SAAS_COPY_EN);
758
+ export function saasSiteSettings(copy) {
759
+ return {
760
+ 'general.tagline': copy.settings.tagline,
761
+ 'general.socialLinks': [
762
+ { label: 'LinkedIn', url: 'https://linkedin.com/company/example' },
763
+ { label: 'GitHub', url: 'https://github.com/example' },
764
+ { label: 'X', url: 'https://x.com/example' },
1373
765
  ],
1374
- },
1375
- ];
1376
- export const SAAS_SITE_SETTINGS = {
1377
- 'general.tagline': 'Spend approvals and audit trail for finance and operations teams.',
1378
- 'general.socialLinks': [
1379
- { label: 'LinkedIn', url: 'https://linkedin.com/company/example' },
1380
- { label: 'GitHub', url: 'https://github.com/example' },
1381
- { label: 'X', url: 'https://x.com/example' },
1382
- ],
1383
- 'general.footerNote': '2nd floor, 41 Tabernacle Street\nLondon EC2A 4AA\nUnited Kingdom\n\nSupport on business days, 08:00 to 18:00 UK time.',
1384
- // A changelog entry and a feature page are not discussions.
1385
- 'discussion.enabled': false,
1386
- };
766
+ 'general.footerNote': copy.settings.footerNote,
767
+ // A changelog entry and a feature page are not discussions.
768
+ 'discussion.enabled': false,
769
+ };
770
+ }
771
+ export const SAAS_SITE_SETTINGS = saasSiteSettings(SAAS_COPY_EN);
772
+ // ---------------------------------------------------------------------------
773
+ // Seeding
774
+ // ---------------------------------------------------------------------------
1387
775
  /** Seeds the footer menu at the `footer` location, one placeholder per column with its links as children. */
1388
- async function seedFooterMenu(db, locale) {
776
+ async function seedFooterMenu(db, locale, columns) {
1389
777
  await ensureMenuTables(db);
1390
778
  const store = createMenuStore({ db });
1391
779
  if ((await store.byLocation('footer', locale)) !== null)
1392
780
  return;
1393
781
  const menu = await store.create({ name: 'footer', locale, label: 'footer', location: 'footer' });
1394
- for (const column of SAAS_FOOTER) {
782
+ for (const column of columns) {
1395
783
  const heading = await store.createItem(menu.id, {
1396
784
  label: column.heading,
1397
785
  kind: 'submenu-placeholder',
@@ -1413,60 +801,69 @@ async function seedFooterMenu(db, locale) {
1413
801
  * descending shows the newest first. Everything is published: a theme lists
1414
802
  * only published entries.
1415
803
  */
1416
- async function seedSaasDemoContent(ctx) {
1417
- const { db, defaultLocale, adminId, media } = ctx;
1418
- const name = nameOf(ctx.siteName);
1419
- const featureStore = createContentStore({ db, collection: feature, defaultLocale });
1420
- const changelogStore = createContentStore({ db, collection: changelog, defaultLocale });
1421
- const pageStore = createContentStore({ db, collection: page, defaultLocale });
1422
- const featureIdBySlug = new Map();
1423
- for (const demo of SAAS_DEMO_FEATURES) {
1424
- const screenshot = media[demo.screenshot];
1425
- const created = await featureStore.create({
1426
- status: 'published',
1427
- createdBy: adminId,
1428
- values: {
1429
- name: demo.name,
1430
- slug: demo.slug,
1431
- description: demo.description,
1432
- icon: demo.icon,
1433
- ...(screenshot === undefined ? {} : { coverImage: screenshot }),
1434
- },
1435
- blocks: { blocks: featureBlocks(demo, name).map(toBlockZoneEntry) },
1436
- });
1437
- featureIdBySlug.set(demo.slug, created.id);
1438
- }
1439
- for (const update of SAAS_DEMO_UPDATES) {
1440
- await changelogStore.create({
1441
- status: 'published',
1442
- createdBy: adminId,
1443
- values: {
1444
- title: update.title,
1445
- slug: update.slug,
1446
- summary: update.summary,
1447
- publishedAt: update.publishedAt,
1448
- },
1449
- blocks: { blocks: updateBlocks(update, name, media).map(toBlockZoneEntry) },
1450
- });
1451
- }
1452
- for (const demo of buildSaasDemoPages(media, featureIdBySlug, name)) {
1453
- await pageStore.create({
1454
- status: 'published',
1455
- createdBy: adminId,
1456
- values: { title: demo.title, slug: demo.slug },
1457
- blocks: { blocks: demo.blocks.map(toBlockZoneEntry) },
1458
- });
1459
- }
1460
- await seedFooterMenu(db, defaultLocale);
804
+ function seedSaasDemoContent(copy, model) {
805
+ return async (ctx) => {
806
+ const { db, defaultLocale, adminId, media } = ctx;
807
+ const name = nameOf(ctx.siteName);
808
+ const featureStore = createContentStore({ db, collection: model.feature, defaultLocale });
809
+ const changelogStore = createContentStore({ db, collection: model.changelog, defaultLocale });
810
+ const pageStore = createContentStore({ db, collection: model.page, defaultLocale });
811
+ const featureIdBySlug = new Map();
812
+ for (const demo of copy.features) {
813
+ const screenshot = media[demo.screenshot];
814
+ const created = await featureStore.create({
815
+ status: 'published',
816
+ createdBy: adminId,
817
+ values: {
818
+ name: demo.name,
819
+ slug: demo.slug,
820
+ description: demo.description,
821
+ icon: demo.icon,
822
+ ...(screenshot === undefined ? {} : { coverImage: screenshot }),
823
+ },
824
+ blocks: { blocks: featureBlocks(copy, demo, name).map(toBlockZoneEntry) },
825
+ });
826
+ featureIdBySlug.set(demo.slug, created.id);
827
+ }
828
+ for (const update of copy.updates) {
829
+ await changelogStore.create({
830
+ status: 'published',
831
+ createdBy: adminId,
832
+ values: {
833
+ title: update.title,
834
+ slug: update.slug,
835
+ summary: update.summary,
836
+ publishedAt: update.publishedAt,
837
+ },
838
+ blocks: { blocks: updateBlocks(copy, update, name, media).map(toBlockZoneEntry) },
839
+ });
840
+ }
841
+ for (const demo of buildSaasDemoPages(media, featureIdBySlug, name, copy)) {
842
+ await pageStore.create({
843
+ status: 'published',
844
+ createdBy: adminId,
845
+ values: { title: demo.title, slug: demo.slug },
846
+ blocks: { blocks: demo.blocks.map(toBlockZoneEntry) },
847
+ });
848
+ }
849
+ await seedFooterMenu(db, defaultLocale, saasFooter(copy));
850
+ };
1461
851
  }
1462
- export const saasContentPack = {
1463
- collections: SAAS_COLLECTIONS,
1464
- recommendedAgents: SAAS_RECOMMENDED_AGENTS,
1465
- seedDemoContent: seedSaasDemoContent,
1466
- defaultTheme: '@cogenta/theme-saas',
1467
- menus: SAAS_MENUS,
1468
- widgets: SAAS_WIDGETS,
1469
- siteSettings: SAAS_SITE_SETTINGS,
1470
- mediaSpecs: SAAS_MEDIA_SPECS,
1471
- };
852
+ /** The whole blueprint, in one language. */
853
+ export function createSaasContentPack(locale) {
854
+ const copy = saasCopyFor(locale);
855
+ const model = saasSchema(copy);
856
+ return {
857
+ collections: model.collections,
858
+ recommendedAgents: SAAS_RECOMMENDED_AGENTS,
859
+ seedDemoContent: seedSaasDemoContent(copy, model),
860
+ defaultTheme: '@cogenta/theme-saas',
861
+ menus: saasMenus(copy),
862
+ widgets: saasWidgets(copy),
863
+ siteSettings: saasSiteSettings(copy),
864
+ mediaSpecs: saasMediaSpecs(copy),
865
+ };
866
+ }
867
+ /** The English pack, which `BLUEPRINT_CONTENT_PACKS` lists; `contentPackFor` picks by locale. */
868
+ export const saasContentPack = createSaasContentPack('en');
1472
869
  //# sourceMappingURL=saas.js.map