ghost 6.18.2 → 6.19.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 (191) hide show
  1. package/components/tryghost-i18n-6.19.0.tgz +0 -0
  2. package/components/tryghost-parse-email-address-6.19.0.tgz +0 -0
  3. package/core/built/admin/assets/{PolarAngleAxis-DALH8FDm.js → PolarAngleAxis-CGprvq8M.js} +1 -1
  4. package/core/built/admin/assets/{_baseAssignValue-D_UsvJRN.js → _baseAssignValue-Bwn07Ln5.js} +1 -1
  5. package/core/built/admin/assets/{a-large-small-DVyx4GMu.js → a-large-small-p58xibEK.js} +1 -1
  6. package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +1 -1
  7. package/core/built/admin/assets/admin-x-settings/{code-editor-view-DBrulgE8.mjs → code-editor-view-DCKvO1TY.mjs} +2 -2
  8. package/core/built/admin/assets/admin-x-settings/{index-DgGwb1L-.mjs → index-BxWVtVnD.mjs} +6 -6
  9. package/core/built/admin/assets/admin-x-settings/{index-Cypgljb3.mjs → index-DyZIONe1.mjs} +2 -2
  10. package/core/built/admin/assets/admin-x-settings/{index-CsGHbqSU.mjs → index-HfMJcj2U.mjs} +2 -2
  11. package/core/built/admin/assets/admin-x-settings/{modals-rdo7i0D3.mjs → modals-CIiqfKY0.mjs} +7593 -7509
  12. package/core/built/admin/assets/{at-sign-CGNkBrZS.js → at-sign-BZPuAx5m.js} +1 -1
  13. package/core/built/admin/assets/audience-Cy9gLIa9.js +1 -0
  14. package/core/built/admin/assets/{avatar-flipboard-jqr9Aapd.js → avatar-flipboard-CLmWb5Xn.js} +1 -1
  15. package/core/built/admin/assets/{bluesky-sharing-C2749SVt.js → bluesky-sharing-BHt0oiii.js} +1 -1
  16. package/core/built/admin/assets/{chart-BAQCVPCH.js → chart--RFLGIL4.js} +1 -1
  17. package/core/built/admin/assets/{chunk.524.428356d01feabbc7b932.js → chunk.524.5c5c3f3273a86d1d9913.js} +7 -7
  18. package/core/built/admin/assets/{chunk.582.b8b41ba720f49d724992.js → chunk.582.e6c787cdf7e6de7819e0.js} +9 -9
  19. package/core/built/admin/assets/{code-editor-view-YNhJ1w71.js → code-editor-view-BW9y0JXN.js} +1 -1
  20. package/core/built/admin/assets/comments-C_lilDBp.js +1 -0
  21. package/core/built/admin/assets/content-helpers-BOnQQADZ.js +1 -0
  22. package/core/built/admin/assets/{copy-CNsHZEFR.js → copy-Bi7VpgPR.js} +1 -1
  23. package/core/built/admin/assets/{data-list-Dn5MkmyD.js → data-list-Bu3jzs1s.js} +1 -1
  24. package/core/built/admin/assets/{deleted-feed-item-Dmm_clMl.js → deleted-feed-item-B-doIIKc.js} +1 -1
  25. package/core/built/admin/assets/{edit-profile-BLSdtROQ.js → edit-profile-ZBdjFfvL.js} +1 -1
  26. package/core/built/admin/assets/{empty-indicator-C2Z0dddU.js → empty-indicator-C6BBh99D.js} +1 -1
  27. package/core/built/admin/assets/{en-DuTkaMAI.js → en-C7eSFQRw.js} +1 -1
  28. package/core/built/admin/assets/{feed-B4Xp4C2J.js → feed-CAioauwD.js} +1 -1
  29. package/core/built/admin/assets/{filters-gnOx5Z45.js → filters-DEKF0tIB.js} +1 -1
  30. package/core/built/admin/assets/{gh-chart-B2aWYkGg.js → gh-chart-D9KI0_sO.js} +1 -1
  31. package/core/built/admin/assets/{ghost-29f8f3c80e41126ba5e3c52ad5727e8a.js → ghost-6bff5c535ef015b0ed9421ceb6c699a7.js} +220 -211
  32. package/core/built/admin/assets/{growth-BysawIWe.js → growth-C6XGchvT.js} +1 -1
  33. package/core/built/admin/assets/{hash-j3sh-UI3.js → hash-DFhnGHId.js} +1 -1
  34. package/core/built/admin/assets/{inbox-1cUdr8Mm.js → inbox-p-Y5uScT.js} +1 -1
  35. package/core/built/admin/assets/{index-BVoBTlp_.js → index--VlvGKWi.js} +1 -1
  36. package/core/built/admin/assets/{index-DGBK5k9H.js → index-3uU4zg_6.js} +1 -1
  37. package/core/built/admin/assets/index-4ZkTY34B.css +1 -0
  38. package/core/built/admin/assets/{index-BhY_SbGB.js → index-BWnrLoWI.js} +1 -1
  39. package/core/built/admin/assets/{index-I9n711UW.js → index-BzU2odQq.js} +1 -1
  40. package/core/built/admin/assets/{index-mhojl7aD.js → index-CIZ_upe2.js} +1 -1
  41. package/core/built/admin/assets/{index-WjY3mGep.js → index-CS4XqyC-.js} +2 -2
  42. package/core/built/admin/assets/{index-BgrwCpgw.js → index-D17PEsUB.js} +1 -1
  43. package/core/built/admin/assets/{index-B8G0f0hb.js → index-DCTdAEHx.js} +3 -3
  44. package/core/built/admin/assets/{index-DarAAzVH.js → index-FPf-AERu.js} +1 -1
  45. package/core/built/admin/assets/index-JmqyGBro.js +1 -0
  46. package/core/built/admin/assets/{index-DKAlEWM4.js → index-LyfDjTto.js} +1 -1
  47. package/core/built/admin/assets/index-S62-c8TV.js +13 -0
  48. package/core/built/admin/assets/{index-D8CXbJm4.js → index-Wx8_AGZX.js} +1 -1
  49. package/core/built/admin/assets/{index-CkAUXgAi.js → index-jAidr8jI.js} +1 -1
  50. package/core/built/admin/assets/{index-Dz8eTtOq.js → index-lPLmWtv0.js} +1 -1
  51. package/core/built/admin/assets/koenig-lexical/index.css +1 -1
  52. package/core/built/admin/assets/koenig-lexical/koenig-lexical.js +1853 -1863
  53. package/core/built/admin/assets/koenig-lexical/koenig-lexical.umd.js +94 -94
  54. package/core/built/admin/assets/{koenig-lexical-coiS9dIC.js → koenig-lexical-CWnkYKQt.js} +77 -77
  55. package/core/built/admin/assets/{kpi-card-BEFHo2gr.js → kpi-card-B5UaJgOT.js} +1 -1
  56. package/core/built/admin/assets/{kpis-D-gz-lUk.js → kpis-Cc2RxnvZ.js} +1 -1
  57. package/core/built/admin/assets/{label-BvlHzzcJ.js → label-CW7Kmp9w.js} +1 -1
  58. package/core/built/admin/assets/{links-tdJvBrsT.js → links-wWNc9Uat.js} +1 -1
  59. package/core/built/admin/assets/{list-filter-DPE4Xj2T.js → list-filter-3EF6cVg-.js} +1 -1
  60. package/core/built/admin/assets/{lucide-react-ClQ3Iy7l.js → lucide-react-DtMIZssr.js} +214 -214
  61. package/core/built/admin/assets/{main-layout-DY96wK-_.js → main-layout-BFYhCrLn.js} +1 -1
  62. package/core/built/admin/assets/{message-square-text-Dd1XS5-H.js → message-square-text-BDZtUW-2.js} +1 -1
  63. package/core/built/admin/assets/{minus-CyEc3BE5.js → minus-CSphShdJ.js} +1 -1
  64. package/core/built/admin/assets/modals-CDWP04Iy.js +77 -0
  65. package/core/built/admin/assets/{moderation-B0fdmlig.js → moderation-qcng2_lB.js} +1 -1
  66. package/core/built/admin/assets/{newsletter-Byg3ARa5.js → newsletter-R-IYcV4M.js} +1 -1
  67. package/core/built/admin/assets/{newsletters-DbH39vgA.js → newsletters-BB4e24i1.js} +1 -1
  68. package/core/built/admin/assets/{note-Cwy-rIAF.js → note-B_bpRX66.js} +1 -1
  69. package/core/built/admin/assets/overview-Nh-IoP-v.js +1 -0
  70. package/core/built/admin/assets/{pagemenu-CkzsZrHC.js → pagemenu-Cd45frnF.js} +1 -1
  71. package/core/built/admin/assets/{post-analytics-context-BLkup0Xh.js → post-analytics-context-BST4bzps.js} +1 -1
  72. package/core/built/admin/assets/post-analytics-header-DAZPp6M8.js +1 -0
  73. package/core/built/admin/assets/{post-analytics-aA-nG9Fq.js → post-analytics-jHZOhQ8L.js} +1 -1
  74. package/core/built/admin/assets/{post-share-modal-CkvJtrRw.js → post-share-modal-D1JQfuYx.js} +1 -1
  75. package/core/built/admin/assets/posts/{comments-Bn_vn_sb.mjs → comments-C0ntS6jH.mjs} +678 -692
  76. package/core/built/admin/assets/posts/{dialog-CKPcMrj7.mjs → dialog-CXEmd-rC.mjs} +3 -3
  77. package/core/built/admin/assets/posts/{empty-indicator-kkrhP6K_.mjs → empty-indicator-CY1NEQZt.mjs} +2 -2
  78. package/core/built/admin/assets/posts/{filters-DHsxxy0F.mjs → filters-CWWDQAf0.mjs} +6 -6
  79. package/core/built/admin/assets/posts/{growth-D7ACh-oR.mjs → growth-DUnL-W24.mjs} +20 -19
  80. package/core/built/admin/assets/posts/{heading-BrKVbOxA.mjs → heading-DZWin3nG.mjs} +2 -2
  81. package/core/built/admin/assets/posts/{hooks-BBEMuLiW.mjs → hooks-DQYjPsLg.mjs} +2 -2
  82. package/core/built/admin/assets/posts/{index-C0rRguZx.mjs → index-BiGQFQkQ.mjs} +9 -9
  83. package/core/built/admin/assets/posts/{kpis-1o524OIK.mjs → kpis-DNnkyd0U.mjs} +10 -10
  84. package/core/built/admin/assets/posts/{links-JpeATx1f.mjs → links-D3nSZFlJ.mjs} +4 -4
  85. package/core/built/admin/assets/posts/{loading-indicator-BHAmSf8j.mjs → loading-indicator-CZQmPRvv.mjs} +3 -3
  86. package/core/built/admin/assets/posts/{main-layout-BTItAOQE.mjs → main-layout-D5WnaGr4.mjs} +2 -2
  87. package/core/built/admin/assets/posts/{newsletter-CQdsCEHv.mjs → newsletter-C5eikVBq.mjs} +13 -13
  88. package/core/built/admin/assets/posts/{overview-sYh4JN1D.mjs → overview-C5mCMPJY.mjs} +113 -112
  89. package/core/built/admin/assets/posts/{post-analytics-DtLt2SWx.mjs → post-analytics-Cc2NCQis.mjs} +6 -6
  90. package/core/built/admin/assets/posts/{post-analytics-context-CB1yM9fk.mjs → post-analytics-context-qXWjDzSt.mjs} +44 -41
  91. package/core/built/admin/assets/posts/{post-analytics-header-BYaWuL9W.mjs → post-analytics-header-QGsYyvhA.mjs} +11 -11
  92. package/core/built/admin/assets/posts/{post-share-modal-V0HTOBaf.mjs → post-share-modal-B9Q7FJsH.mjs} +4 -4
  93. package/core/built/admin/assets/posts/posts-C2SKGLoU.mjs +17 -0
  94. package/core/built/admin/assets/posts/posts.js +1 -1
  95. package/core/built/admin/assets/posts/{search-CLjC37AT.mjs → search-C8k0TFkh.mjs} +2 -2
  96. package/core/built/admin/assets/posts/{separator-KNoTIaJx.mjs → separator-CJnnsW1a.mjs} +5 -5
  97. package/core/built/admin/assets/posts/{sheet-DBg_SCDt.mjs → sheet-HDzKgNHc.mjs} +3 -3
  98. package/core/built/admin/assets/posts/{skeleton-DvsoolWu.mjs → skeleton-BlZGKNzB.mjs} +3 -3
  99. package/core/built/admin/assets/posts/{source-icon-OmRaTU2G.mjs → source-icon-DjBIjtaY.mjs} +3 -3
  100. package/core/built/admin/assets/posts/{stats-BC2DzntY.mjs → stats-BRVHejqx.mjs} +4 -4
  101. package/core/built/admin/assets/posts/{table-CxX9OKAj.mjs → table-D7AEpPWL.mjs} +2 -2
  102. package/core/built/admin/assets/posts/{tabs-B1jw7cBi.mjs → tabs-RcpmzDgr.mjs} +10 -10
  103. package/core/built/admin/assets/posts/{tags-BitrLT_j.mjs → tags-8k1xw8R8.mjs} +2 -2
  104. package/core/built/admin/assets/posts/{tags-BBilTo1a.mjs → tags-DNY2rBlf.mjs} +11 -11
  105. package/core/built/admin/assets/posts/{use-infinite-virtual-scroll-DaijA1ao.mjs → use-infinite-virtual-scroll-CMiIpcDA.mjs} +3 -3
  106. package/core/built/admin/assets/posts/{web-CKmyC4Xj.mjs → web-BCl5J6bB.mjs} +15 -15
  107. package/core/built/admin/assets/{posts-DDkuYoN7.js → posts-DFiJPOft.js} +1 -1
  108. package/core/built/admin/assets/{repeat-B-SL0yPM.js → repeat-Smml9n0k.js} +1 -1
  109. package/core/built/admin/assets/{reply-BdCPoUQ9.js → reply-CooY4lmk.js} +1 -1
  110. package/core/built/admin/assets/{select-C7aNW8QS.js → select-WwMsbBbg.js} +1 -1
  111. package/core/built/admin/assets/{settings-D-dhma2e.js → settings-CQEhPDQU.js} +4 -4
  112. package/core/built/admin/assets/{settings-Bzy1GNfD.js → settings-CxWtvcUf.js} +1 -1
  113. package/core/built/admin/assets/{sort-button-Dv8vjh13.js → sort-button-DFr8vezl.js} +1 -1
  114. package/core/built/admin/assets/{source-icon-DTz4isLK.js → source-icon-VpYWpwYb.js} +1 -1
  115. package/core/built/admin/assets/{sprout-BwLQTzMf.js → sprout-DRqlFJSK.js} +1 -1
  116. package/core/built/admin/assets/{square-YF1YE9ex.js → square-CwHQ9sJ2.js} +1 -1
  117. package/core/built/admin/assets/stats/audience-Caf7BjaU.mjs +269 -0
  118. package/core/built/admin/assets/stats/{url-helpers-Drq3xg0l.mjs → content-helpers-MbtAvUJh.mjs} +83 -106
  119. package/core/built/admin/assets/stats/{index-CcCyLMxL.mjs → index-BGqKbF1D.mjs} +8 -8
  120. package/core/built/admin/assets/stats/{index-DXU2rE9t.mjs → index-BTjfXOi6.mjs} +30 -30
  121. package/core/built/admin/assets/stats/{index-K7ASx7EG.mjs → index-ChjIxhHy.mjs} +5 -5
  122. package/core/built/admin/assets/stats/{index-D5mlMG4l.mjs → index-DMkZbbZs.mjs} +14 -14
  123. package/core/built/admin/assets/stats/{index-CUuQaROI.mjs → index-wT0OIw_N.mjs} +355 -335
  124. package/core/built/admin/assets/stats/{sort-button-CELUx6Zp.mjs → sort-button-B8n8fYMi.mjs} +23 -23
  125. package/core/built/admin/assets/stats/{stats-d_u_in4l.mjs → stats-CGH-0JNs.mjs} +120 -119
  126. package/core/built/admin/assets/stats/stats.js +1 -1
  127. package/core/built/admin/assets/stats/{tabs-3wLZsy0v.mjs → tabs-EQCUj3Y9.mjs} +19 -19
  128. package/core/built/admin/assets/stats/{use-growth-stats-28Sr42va.mjs → use-growth-stats--CIfag21.mjs} +3 -3
  129. package/core/built/admin/assets/{stats-C5ad0fgQ.js → stats-lnFiOQZx.js} +1 -1
  130. package/core/built/admin/assets/{stats-view-BvkxPYNX.js → stats-view-BJVawuIv.js} +1 -1
  131. package/core/built/admin/assets/{step-1-D7_s-D99.js → step-1-Bx8MiQVF.js} +1 -1
  132. package/core/built/admin/assets/{step-2-B94Yf7FF.js → step-2-CLLXppER.js} +1 -1
  133. package/core/built/admin/assets/{step-3-DswXYYf4.js → step-3-C5WrQe8u.js} +2 -2
  134. package/core/built/admin/assets/{table-D30IXfUP.js → table-2waWpIF5.js} +1 -1
  135. package/core/built/admin/assets/{tabs-CjNdfW0y.js → tabs-CBD0hW8V.js} +1 -1
  136. package/core/built/admin/assets/{tags-B0ux9_dT.js → tags-Bgw5Hrb5.js} +1 -1
  137. package/core/built/admin/assets/{tags-PGeGAafJ.js → tags-CKrBptuC.js} +1 -1
  138. package/core/built/admin/assets/{tiers-BaXK0JoI.js → tiers-CO-pz1FX.js} +1 -1
  139. package/core/built/admin/assets/{toggle-group-DdY8HF3Y.js → toggle-group-BXAZzvJ9.js} +1 -1
  140. package/core/built/admin/assets/{topic-filter-Boh22uGD.js → topic-filter-U3qUH2P_.js} +1 -1
  141. package/core/built/admin/assets/{trash-D7ZWrnDq.js → trash-CJY1Wro_.js} +1 -1
  142. package/core/built/admin/assets/{use-growth-stats-Bg0nE0WG.js → use-growth-stats-BrLZI7da.js} +1 -1
  143. package/core/built/admin/assets/{use-infinite-virtual-scroll-DoeI5IY-.js → use-infinite-virtual-scroll-CiRn3kpz.js} +2 -2
  144. package/core/built/admin/assets/{use-simple-pagination-BJzBULR3.js → use-simple-pagination-CskJ0MDP.js} +1 -1
  145. package/core/built/admin/assets/{user-round-check-BLc3L-ei.js → user-round-check-zOuzV3OS.js} +1 -1
  146. package/core/built/admin/assets/{wallet-cards-xX4QZik7.js → wallet-cards-ddLuIN-b.js} +1 -1
  147. package/core/built/admin/assets/{web-DQ2qBymm.js → web-CGbkZWn3.js} +1 -1
  148. package/core/built/admin/index.html +6 -5
  149. package/core/frontend/services/theme-engine/i18n/i18n.js +9 -14
  150. package/core/server/api/endpoints/settings-public.js +3 -1
  151. package/core/server/api/endpoints/utils/serializers/input/settings.js +6 -1
  152. package/core/server/data/migrations/versions/6.19/2026-02-10-12-00-00-add-transistor-portal-settings.js +39 -0
  153. package/core/server/data/schema/default-settings/default-settings.json +28 -0
  154. package/core/server/data/schema/schema.js +2 -2
  155. package/core/server/data/seeders/importers/offers-importer.js +2 -2
  156. package/core/server/data/tinybird/endpoints/api_top_pages_router.pipe +28 -0
  157. package/core/server/data/tinybird/endpoints/api_top_pages_v3.pipe +8 -4
  158. package/core/server/data/tinybird/tests/api_top_pages_router.yaml +74 -0
  159. package/core/server/services/comments/comments-service-emails.js +1 -4
  160. package/core/server/services/email-service/email-templates/partials/styles.hbs +43 -0
  161. package/core/server/services/koenig/node-renderers/transistor-renderer.js +23 -17
  162. package/core/server/services/members/members-api/repositories/member-repository.js +30 -6
  163. package/core/server/services/members/members-api/services/next-payment-calculator.js +28 -20
  164. package/core/server/services/members/members-api/services/payments-service.js +1 -1
  165. package/core/server/services/members/members-api/utils/add-calendar-months.js +49 -0
  166. package/core/server/services/offers/application/offer-mapper.js +2 -2
  167. package/core/server/services/offers/domain/models/offer-amount.js +16 -0
  168. package/core/server/services/offers/domain/models/offer-duration.js +3 -4
  169. package/core/server/services/offers/domain/models/offer-type.js +5 -3
  170. package/core/server/services/offers/domain/models/offer.js +8 -1
  171. package/core/server/services/staff/staff-service-emails.js +2 -0
  172. package/core/server/services/stripe/stripe-api.js +19 -0
  173. package/core/shared/config/defaults.json +1 -1
  174. package/core/shared/labs.js +0 -1
  175. package/core/shared/settings-cache/cache-manager.js +8 -0
  176. package/core/shared/settings-cache/public.js +5 -0
  177. package/package.json +5 -5
  178. package/yarn.lock +4 -4
  179. package/components/tryghost-i18n-6.18.2.tgz +0 -0
  180. package/components/tryghost-parse-email-address-6.18.2.tgz +0 -0
  181. package/core/built/admin/assets/audience-CJHVR7kD.js +0 -1
  182. package/core/built/admin/assets/comments-CPd_iCc3.js +0 -1
  183. package/core/built/admin/assets/index-B2yksBz4.js +0 -13
  184. package/core/built/admin/assets/index-Cl_EPbQ2.js +0 -1
  185. package/core/built/admin/assets/index-D_sGUCda.css +0 -1
  186. package/core/built/admin/assets/modals-C06GcVIm.js +0 -77
  187. package/core/built/admin/assets/overview-CVMLGrVt.js +0 -1
  188. package/core/built/admin/assets/post-analytics-header-D9srAPT5.js +0 -1
  189. package/core/built/admin/assets/posts/posts-DeQT3knv.mjs +0 -21
  190. package/core/built/admin/assets/stats/audience-BWqU7WWT.mjs +0 -245
  191. package/core/built/admin/assets/url-helpers-mt6MBIi0.js +0 -1
@@ -3,7 +3,6 @@ const logging = require('@tryghost/logging');
3
3
  const fs = require('fs-extra');
4
4
  const path = require('path');
5
5
  const MessageFormat = require('intl-messageformat');
6
- const jp = require('jsonpath');
7
6
  const isString = require('lodash/isString');
8
7
  const isObject = require('lodash/isObject');
9
8
  const isEqual = require('lodash/isEqual');
@@ -128,29 +127,25 @@ class I18n {
128
127
  }
129
128
 
130
129
  /**
131
- * Do the lookup within the JSON file using jsonpath
130
+ * Do the lookup within the translation strings
132
131
  *
133
132
  * @param {String} msgPath
134
133
  */
135
134
  _getCandidateString(msgPath) {
136
- // Our default string mode is "dot" for dot-notation, e.g. $.something.like.this used in the backend
137
- // Both jsonpath's dot-notation and bracket-notation start with '$' E.g.: $.store.book.title or $['store']['book']['title']
138
- // While bracket-notation allows any Unicode characters in keys (i.e. for themes / fulltext mode) E.g. $['Read more']
139
- // dot-notation allows only word characters in keys for backend messages (that is \w or [A-Za-z0-9_] in RegExp)
140
- let jsonPath = `$.${msgPath}`;
141
135
  let fallback = null;
142
136
 
143
137
  if (this._stringMode === 'fulltext') {
144
- jsonPath = jp.stringify(['$', msgPath]);
145
- // In fulltext mode we can use the passed string as a fallback
146
138
  fallback = msgPath;
139
+ } else if (/[^\w.]/.test(msgPath)) {
140
+ // In dot mode, keys must only contain word characters and dots.
141
+ // Reject anything else to match previous behavior.
142
+ this._handleInvalidKeyError(msgPath, new errors.InternalServerError({message: 'Invalid dot-notation path'}));
147
143
  }
148
144
 
149
- try {
150
- return jp.value(this._strings, jsonPath) || fallback;
151
- } catch (err) {
152
- this._handleInvalidKeyError(msgPath, err);
153
- }
145
+ // Use array form [msgPath] for fulltext mode to prevent lodash splitting on dots.
146
+ // Use string form for dot mode so lodash splits 'a.b.c' into nested lookup.
147
+ const lookupPath = this._stringMode === 'fulltext' ? [msgPath] : msgPath;
148
+ return get(this._strings, lookupPath) || fallback;
154
149
  }
155
150
 
156
151
  /**
@@ -1,4 +1,5 @@
1
1
  const settingsCache = require('../../../shared/settings-cache');
2
+ const labs = require('../../../shared/labs');
2
3
  const urlUtils = require('../../../shared/url-utils');
3
4
  const ghostVersion = require('@tryghost/version');
4
5
 
@@ -17,7 +18,8 @@ const controller = {
17
18
  return Object.assign({},
18
19
  settingsCache.getPublic(), {
19
20
  url: urlUtils.urlFor('home', true),
20
- version: ghostVersion.safe
21
+ version: ghostVersion.safe,
22
+ labs: labs.getAll()
21
23
  }
22
24
  );
23
25
  }
@@ -80,7 +80,12 @@ const EDITABLE_SETTINGS = [
80
80
  'explore_ping',
81
81
  'explore_ping_growth',
82
82
  'indexnow_api_key',
83
- 'transistor'
83
+ 'transistor',
84
+ 'transistor_portal_enabled',
85
+ 'transistor_portal_heading',
86
+ 'transistor_portal_description',
87
+ 'transistor_portal_button_text',
88
+ 'transistor_portal_url_template'
84
89
  ];
85
90
 
86
91
  module.exports = {
@@ -0,0 +1,39 @@
1
+ const {combineTransactionalMigrations, addSetting} = require('../../utils');
2
+
3
+ module.exports = combineTransactionalMigrations(
4
+ addSetting({
5
+ key: 'transistor_portal_enabled',
6
+ value: 'true',
7
+ type: 'boolean',
8
+ group: 'transistor',
9
+ flags: 'PUBLIC'
10
+ }),
11
+ addSetting({
12
+ key: 'transistor_portal_heading',
13
+ value: 'Podcasts',
14
+ type: 'string',
15
+ group: 'transistor',
16
+ flags: 'PUBLIC'
17
+ }),
18
+ addSetting({
19
+ key: 'transistor_portal_description',
20
+ value: 'Access your private podcast feed',
21
+ type: 'string',
22
+ group: 'transistor',
23
+ flags: 'PUBLIC'
24
+ }),
25
+ addSetting({
26
+ key: 'transistor_portal_button_text',
27
+ value: 'View',
28
+ type: 'string',
29
+ group: 'transistor',
30
+ flags: 'PUBLIC'
31
+ }),
32
+ addSetting({
33
+ key: 'transistor_portal_url_template',
34
+ value: 'https://partner.transistor.fm/ghost/{memberUuid}',
35
+ type: 'string',
36
+ group: 'transistor',
37
+ flags: 'PUBLIC'
38
+ })
39
+ );
@@ -486,6 +486,34 @@
486
486
  "isIn": [["true", "false"]]
487
487
  },
488
488
  "type": "boolean"
489
+ },
490
+ "transistor_portal_enabled": {
491
+ "defaultValue": "true",
492
+ "validations": {
493
+ "isIn": [["true", "false"]]
494
+ },
495
+ "type": "boolean",
496
+ "flags": "PUBLIC"
497
+ },
498
+ "transistor_portal_heading": {
499
+ "defaultValue": "Podcasts",
500
+ "type": "string",
501
+ "flags": "PUBLIC"
502
+ },
503
+ "transistor_portal_description": {
504
+ "defaultValue": "Access your private podcast feed",
505
+ "type": "string",
506
+ "flags": "PUBLIC"
507
+ },
508
+ "transistor_portal_button_text": {
509
+ "defaultValue": "View",
510
+ "type": "string",
511
+ "flags": "PUBLIC"
512
+ },
513
+ "transistor_portal_url_template": {
514
+ "defaultValue": "https://partner.transistor.fm/ghost/{memberUuid}",
515
+ "type": "string",
516
+ "flags": "PUBLIC"
489
517
  }
490
518
  },
491
519
  "views": {
@@ -487,9 +487,9 @@ module.exports = {
487
487
  stripe_coupon_id: {type: 'string', maxlength: 255, nullable: true, unique: true},
488
488
  interval: {type: 'string', maxlength: 50, nullable: false, validations: {isIn: [['month', 'year']]}},
489
489
  currency: {type: 'string', maxlength: 50, nullable: true},
490
- discount_type: {type: 'string', maxlength: 50, nullable: false, validations: {isIn: [['percent', 'amount', 'trial']]}},
490
+ discount_type: {type: 'string', maxlength: 50, nullable: false, validations: {isIn: [['percent', 'amount', 'trial', 'free_months']]}},
491
491
  discount_amount: {type: 'integer', nullable: false},
492
- duration: {type: 'string', maxlength: 50, nullable: false, validations: {isIn: [['trial', 'once', 'repeating', 'forever']]}},
492
+ duration: {type: 'string', maxlength: 50, nullable: false, validations: {isIn: [['trial', 'free_months', 'once', 'repeating', 'forever']]}},
493
493
  duration_in_months: {type: 'integer', nullable: true},
494
494
  portal_title: {type: 'string', maxlength: 191, nullable: true},
495
495
  portal_description: {type: 'string', maxlength: 2000, nullable: true},
@@ -38,9 +38,9 @@ class OffersImporter extends TableImporter {
38
38
  // stripe_coupon_id: {type: 'string', maxlength: 255, nullable: true, unique: true},
39
39
  // interval: {type: 'string', maxlength: 50, nullable: false, validations: {isIn: [['month', 'year']]}},
40
40
  // currency: {type: 'string', maxlength: 50, nullable: true},
41
- // discount_type: {type: 'string', maxlength: 50, nullable: false, validations: {isIn: [['percent', 'amount', 'trial']]}},
41
+ // discount_type: {type: 'string', maxlength: 50, nullable: false, validations: {isIn: [['percent', 'amount', 'trial', 'free_months']]}},
42
42
  // discount_amount: {type: 'integer', nullable: false},
43
- // duration: {type: 'string', maxlength: 50, nullable: false, validations: {isIn: [['trial', 'once', 'repeating', 'forever']]}},
43
+ // duration: {type: 'string', maxlength: 50, nullable: false, validations: {isIn: [['trial', 'free_months', 'once', 'repeating', 'forever']]}},
44
44
  // duration_in_months: {type: 'integer', nullable: true},
45
45
  // portal_title: {type: 'string', maxlength: 191, nullable: true},
46
46
  // portal_description: {type: 'string', maxlength: 2000, nullable: true},
@@ -0,0 +1,28 @@
1
+ # api_top_pages_router - Smart router that delegates to optimal top pages implementation
2
+ #
3
+ # ROUTING LOGIC:
4
+ # - If session-level or location filters are present (device, location, source, utm_*),
5
+ # use api_top_pages which joins with filtered_sessions to support these filters
6
+ # - Otherwise, use api_top_pages_v3 which uses the daily materialized view
7
+ # for 3-4x faster queries
8
+ #
9
+ # Query parameters cascade automatically to the underlying pipe.
10
+
11
+ TOKEN "stats_page" READ
12
+ TOKEN "axis" READ
13
+
14
+ NODE router
15
+ DESCRIPTION >
16
+ Routes to the appropriate implementation based on filter parameters.
17
+ Session-level filters (device, location, source, utm_*) require the original pipe.
18
+ Simple queries use the faster v3 materialized view.
19
+
20
+ SQL >
21
+ %
22
+ {% if defined(device) or defined(location) or defined(source) or defined(utm_source) or defined(utm_medium) or defined(utm_campaign) or defined(utm_term) or defined(utm_content) %}
23
+ SELECT * FROM api_top_pages
24
+ {% else %}
25
+ SELECT * FROM api_top_pages_v3
26
+ {% end %}
27
+
28
+ TYPE ENDPOINT
@@ -34,8 +34,10 @@ SQL >
34
34
  FROM _mv_daily_pages
35
35
  WHERE
36
36
  site_uuid = {{ String(site_uuid, 'mock_site_uuid', description="Tenant ID", required=True) }}
37
- AND day >= toDate({{ Date(date_from, description="Start date for filtering", required=True) }}, {{ String(timezone, 'Etc/UTC', description="Site timezone", required=True) }})
38
- AND day <= toDate({{ Date(date_to, description="End date for filtering", required=True) }}, {{ String(timezone, 'Etc/UTC', description="Site timezone", required=True) }})
37
+ {% if defined(date_from) %}
38
+ AND day >= toDate({{ Date(date_from, description="Start date for filtering", required=False) }}, {{ String(timezone, 'Etc/UTC', description="Site timezone", required=True) }})
39
+ {% end %}
40
+ AND day <= toDate({% if defined(date_to) %}{{ Date(date_to, description="End date for filtering", required=False) }}{% else %}now(){% end %}, {{ String(timezone, 'Etc/UTC', description="Site timezone", required=True) }})
39
41
  AND day != toDate(now(), {{ String(timezone, 'Etc/UTC', description="Site timezone", required=True) }})
40
42
  {% if defined(member_status) %}
41
43
  AND member_status IN (
@@ -70,8 +72,10 @@ SQL >
70
72
  WHERE
71
73
  site_uuid = {{ String(site_uuid, 'mock_site_uuid', description="Tenant ID", required=True) }}
72
74
  AND toDate(timestamp, {{ String(timezone, 'Etc/UTC', description="Site timezone", required=True) }}) = toDate(now(), {{ String(timezone, 'Etc/UTC', description="Site timezone", required=True) }})
73
- AND timestamp >= toDateTime({{ Date(date_from, description="Start date for filtering", required=True) }}, {{ String(timezone, 'Etc/UTC', description="Site timezone", required=True) }})
74
- AND timestamp < toDateTime({{ Date(date_to, description="End date for filtering", required=True) }}, {{ String(timezone, 'Etc/UTC', description="Site timezone", required=True) }}) + interval 1 day
75
+ {% if defined(date_from) %}
76
+ AND timestamp >= toDateTime({{ Date(date_from, description="Start date for filtering", required=False) }}, {{ String(timezone, 'Etc/UTC', description="Site timezone", required=True) }})
77
+ {% end %}
78
+ AND timestamp < toDateTime({% if defined(date_to) %}{{ Date(date_to, description="End date for filtering", required=False) }}{% else %}now(){% end %}, {{ String(timezone, 'Etc/UTC', description="Site timezone", required=True) }}) + interval 1 day
75
79
  {% if defined(member_status) %}
76
80
  AND member_status IN (
77
81
  SELECT arrayJoin(
@@ -0,0 +1,74 @@
1
+
2
+ - name: no_session_level_filters
3
+ description: Query without session-level filters (should route to optimized pipe)
4
+ parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC
5
+ expected_result: |
6
+ {"post_uuid":"6b8635fb-292f-4422-9fe4-d76cfab2ba31","pathname":"\/blog\/hello-world\/","visits":9}
7
+ {"post_uuid":"06b1b0c9-fb53-4a15-a060-3db3fde7b1fc","pathname":"\/about\/","visits":8}
8
+ {"post_uuid":"","pathname":"\/","visits":7}
9
+ {"post_uuid":"06b1b0c9-fb53-4a15-a060-3db3fde7b1dd","pathname":"\/blog\/hello-world\/","visits":1}
10
+
11
+ - name: pathname_filter
12
+ description: Query with pathname filter (should route to optimized pipe)
13
+ parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&pathname=%2Fabout%2F
14
+ expected_result: |
15
+ {"post_uuid":"06b1b0c9-fb53-4a15-a060-3db3fde7b1fc","pathname":"\/about\/","visits":8}
16
+
17
+ - name: post_uuid_filter
18
+ description: Query with post_uuid filter (should route to optimized pipe)
19
+ parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&post_uuid=6b8635fb-292f-4422-9fe4-d76cfab2ba31
20
+ expected_result: |
21
+ {"post_uuid":"6b8635fb-292f-4422-9fe4-d76cfab2ba31","pathname":"\/blog\/hello-world\/","visits":9}
22
+
23
+ - name: device_filter
24
+ description: Query with device filter routes to api_top_pages
25
+ parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&device=desktop
26
+ expected_result: |
27
+ {"post_uuid":"06b1b0c9-fb53-4a15-a060-3db3fde7b1fc","pathname":"\/about\/","visits":8}
28
+ {"post_uuid":"6b8635fb-292f-4422-9fe4-d76cfab2ba31","pathname":"\/blog\/hello-world\/","visits":8}
29
+ {"post_uuid":"","pathname":"\/","visits":7}
30
+ {"post_uuid":"06b1b0c9-fb53-4a15-a060-3db3fde7b1dd","pathname":"\/blog\/hello-world\/","visits":1}
31
+
32
+ - name: location_filter
33
+ description: Query with location filter routes to api_top_pages
34
+ parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&location=GB
35
+ expected_result: |
36
+ {"post_uuid":"6b8635fb-292f-4422-9fe4-d76cfab2ba31","pathname":"\/blog\/hello-world\/","visits":6}
37
+ {"post_uuid":"06b1b0c9-fb53-4a15-a060-3db3fde7b1fc","pathname":"\/about\/","visits":4}
38
+ {"post_uuid":"","pathname":"\/","visits":4}
39
+
40
+ - name: source_filter
41
+ description: Query with source filter routes to api_top_pages
42
+ parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&source=bing.com
43
+ expected_result: |
44
+ {"post_uuid":"06b1b0c9-fb53-4a15-a060-3db3fde7b1fc","pathname":"\/about\/","visits":2}
45
+ {"post_uuid":"","pathname":"\/","visits":1}
46
+ {"post_uuid":"6b8635fb-292f-4422-9fe4-d76cfab2ba31","pathname":"\/blog\/hello-world\/","visits":1}
47
+
48
+ - name: utm_source_filter
49
+ description: Query with utm_source filter routes to api_top_pages
50
+ parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&utm_source=google
51
+ expected_result: |
52
+ {"post_uuid":"6b8635fb-292f-4422-9fe4-d76cfab2ba31","pathname":"\/blog\/hello-world\/","visits":2}
53
+ {"post_uuid":"06b1b0c9-fb53-4a15-a060-3db3fde7b1dd","pathname":"\/blog\/hello-world\/","visits":1}
54
+
55
+ - name: date_from_only
56
+ description: Query with only date_from (no date_to) routes to v3 without error (fixture data is far into the future)
57
+ parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&timezone=Etc/UTC
58
+ expected_result: ''
59
+
60
+ - name: date_to_only
61
+ description: Query with only date_to (no date_from) routes to v3 and returns all
62
+ data up to date_to
63
+ parameters: site_uuid=mock_site_uuid&date_to=2100-01-07&timezone=Etc/UTC
64
+ expected_result: |
65
+ {"post_uuid":"6b8635fb-292f-4422-9fe4-d76cfab2ba31","pathname":"\/blog\/hello-world\/","visits":9}
66
+ {"post_uuid":"06b1b0c9-fb53-4a15-a060-3db3fde7b1fc","pathname":"\/about\/","visits":8}
67
+ {"post_uuid":"","pathname":"\/","visits":7}
68
+ {"post_uuid":"06b1b0c9-fb53-4a15-a060-3db3fde7b1dd","pathname":"\/blog\/hello-world\/","visits":1}
69
+
70
+ - name: no_date_filters
71
+ description: Query with no date filters routes to v3 without error (date_to defaults
72
+ to now and fixture data is far into the future)
73
+ parameters: site_uuid=mock_site_uuid&timezone=Etc/UTC
74
+ expected_result: ''
@@ -26,10 +26,7 @@ class CommentsServiceEmails {
26
26
  */
27
27
  getPostUrl(postId, commentId) {
28
28
  const baseUrl = this.urlService.getUrlByResourceId(postId, {absolute: true});
29
- if (this.labs.isSet('commentPermalinks')) {
30
- return `${baseUrl}#ghost-comments-${commentId}`;
31
- }
32
- return `${baseUrl}#ghost-comments-root`;
29
+ return `${baseUrl}#ghost-comments-${commentId}`;
33
30
  }
34
31
 
35
32
  async notifyPostAuthors(comment) {
@@ -1788,6 +1788,49 @@ img.kg-cta-image {
1788
1788
  color: rgba(0, 0, 0, 0.6) !important;
1789
1789
  {{/if}}
1790
1790
  }
1791
+
1792
+ .kg-transistor-card {
1793
+ width: auto;
1794
+ width: 100%;
1795
+ margin: 0 auto 1.5em;
1796
+ border-radius: 3px;
1797
+ {{#if backgroundIsDark}}
1798
+ background-color: #15212A;
1799
+ background-color: rgba(0, 0, 0, 0.15);
1800
+ border: 1px solid #343434;
1801
+ border: 1px solid rgba(255, 255, 255, 0.25);
1802
+ {{else}}
1803
+ background-color: #ffffff;
1804
+ background-color: rgba(255, 255, 255, 0.25);
1805
+ border: 1px solid #e0e7eb;
1806
+ border: 1px solid rgba(0, 0, 0, 0.12);
1807
+ {{/if}}
1808
+ }
1809
+ .kg-transistor-title {
1810
+ display: block;
1811
+ padding-right: 20px;
1812
+ padding-bottom: 4px;
1813
+ padding-top: 4px;
1814
+ font-size: 16px;
1815
+ font-weight: 600;
1816
+ line-height: 18px;
1817
+ text-decoration: none !important;
1818
+ color: {{#if backgroundIsDark}}#ffffff{{else}}#15212A{{/if}} !important;
1819
+ }
1820
+ .kg-transistor-description {
1821
+ display: block;
1822
+ font-size: 13px;
1823
+ line-height: 1.35em;
1824
+ text-decoration: none !important;
1825
+ {{#if backgroundIsDark}}
1826
+ color: #ffffff !important;
1827
+ color: rgba(255, 255, 255, 0.6) !important;
1828
+ {{else}}
1829
+ color: #15212a !important;
1830
+ color: rgba(0, 0, 0, 0.6) !important;
1831
+ {{/if}}
1832
+ }
1833
+
1791
1834
  .kg-file-card {
1792
1835
  width: auto;
1793
1836
  width: 100%;
@@ -43,8 +43,8 @@ function frontendTemplate(node, document, options) {
43
43
  }
44
44
 
45
45
  function emailTemplate(node, document, options) {
46
- const accentColor = node.accentColor || '#15171A';
47
- const transistorLogo = 'data:image/svg+xml,' + encodeURIComponent(`<svg viewBox="5 0.5 144 144" xmlns="http://www.w3.org/2000/svg"><g fill="${accentColor}"><path d="M77 120.3c-2.6 0-4.8-2.1-4.8-4.8V29.4c0-2.6 2.1-4.8 4.8-4.8s4.8 2.1 4.8 4.8v86.2c0 2.6-2.2 4.7-4.8 4.7z"/><path d="M57 77.3H34c-2.6 0-4.8-2.1-4.8-4.8 0-2.6 2.1-4.8 4.8-4.8h23c2.6 0 4.8 2.1 4.8 4.8 0 2.6-2.1 4.8-4.8 4.8z"/><path d="M120.1 77.3h-23c-2.6 0-4.8-2.1-4.8-4.8 0-2.6 2.1-4.8 4.8-4.8h23c2.6 0 4.8 2.1 4.8 4.8 0 2.6-2.2 4.8-4.8 4.8z"/><path d="M77 144.5c-39.7 0-72-32.3-72-72s32.3-72 72-72 72 32.3 72 72-32.3 72-72 72zM77 10c-34.4 0-62.4 28-62.4 62.4 0 34.4 28 62.4 62.4 62.4 34.4 0 62.4-28 62.4-62.4C139.4 38 111.4 10 77 10z"/></g></svg>`);
46
+ // Use the site accent color from the newsletter/email design settings
47
+ const accentColor = options.design?.accentColor || '#15171A';
48
48
 
49
49
  // Use {uuid} replacement string - wrapReplacementStrings converts this to %%{uuid}%%
50
50
  // which gets replaced with actual member UUID when email is sent to each recipient
@@ -53,21 +53,27 @@ function emailTemplate(node, document, options) {
53
53
  const cardHtml = html`
54
54
  <table class="kg-card kg-transistor-card" cellspacing="0" cellpadding="0" border="0" width="100%">
55
55
  <tr>
56
- <td align="center" style="padding: 24px 0; text-align: center;">
57
- <a href="${transistorUrl}" style="text-decoration: none;">
58
- <img src="${transistorLogo}"
59
- width="56" height="56"
60
- alt="Transistor"
61
- style="border-radius: 8px; display: block; margin: 0 auto 12px auto;">
62
- </a>
63
- <a href="${transistorUrl}"
64
- style="font-weight: 600; text-decoration: none; color: ${accentColor}; font-size: 16px; display: block;">
65
- Listen on Transistor
66
- </a>
67
- <a href="${transistorUrl}"
68
- style="color: #738a94; font-size: 14px; text-decoration: none; display: block; margin-top: 4px;">
69
- Get your private podcast feed
70
- </a>
56
+ <td style="padding: 4px;">
57
+ <table cellspacing="0" cellpadding="0" border="0" width="100%">
58
+ <tr>
59
+ <td valign="middle" width="56" style="padding-right: 14px;">
60
+ <a href="${transistorUrl}" style="display: block; width: 52px; height: 52px; padding-top: 4px; padding-right: 4px; padding-bottom: 4px; padding-left: 4px; border-radius: 2px; background-color: ${accentColor}">
61
+ <img src="https://static.ghost.org/v6.0.0/images/transistor-logo-ondark.png"
62
+ width="36" height="36"
63
+ alt="Transistor"
64
+ style="width: 36px; height: 36px; padding: 8px;">
65
+ </a>
66
+ </td>
67
+ <td valign="middle" style="vertical-align: middle;">
68
+ <a href="${transistorUrl}" class="kg-transistor-title">
69
+ Listen to your podcasts
70
+ </a>
71
+ <a href="${transistorUrl}" class="kg-transistor-description">
72
+ Get your personal podcast feed to subscribe and listen in your favorite podcast app.
73
+ </a>
74
+ </td>
75
+ </tr>
76
+ </table>
71
77
  </td>
72
78
  </tr>
73
79
  </table>
@@ -8,6 +8,7 @@ const ObjectId = require('bson-objectid').default;
8
8
  const {NotFoundError} = require('@tryghost/errors');
9
9
  const validator = require('@tryghost/validator');
10
10
  const crypto = require('crypto');
11
+ const addCalendarMonths = require('../utils/add-calendar-months');
11
12
  const StartOutboxProcessingEvent = require('../../../outbox/events/start-outbox-processing-event');
12
13
  const {MEMBER_WELCOME_EMAIL_SLUGS} = require('../../../member-welcome-emails/constants');
13
14
  const messages = {
@@ -1800,23 +1801,46 @@ module.exports = class MemberRepository {
1800
1801
  });
1801
1802
  }
1802
1803
 
1803
- // Validate coupon was provided (getCouponForOffer returns null for trial offers or if offer has no stripe_coupon_id)
1804
+ const stripeSubscriptionId = subscriptionModel.get('subscription_id');
1805
+ const isFreeMonthsOffer = offer.type === 'free_months';
1806
+
1807
+ if (isFreeMonthsOffer) {
1808
+ const currentPeriodEnd = subscriptionModel.get('current_period_end');
1809
+ if (!currentPeriodEnd) {
1810
+ throw new errors.BadRequestError({
1811
+ message: tpl(messages.subscriptionNotActive)
1812
+ });
1813
+ }
1814
+
1815
+ const trialEndDate = addCalendarMonths(currentPeriodEnd, offer.amount);
1816
+ const trialEnd = Math.floor(trialEndDate.getTime() / 1000);
1817
+
1818
+ const updatedSubscription = await this._stripeAPIService.updateSubscriptionTrialEnd(
1819
+ stripeSubscriptionId,
1820
+ trialEnd,
1821
+ {prorationBehavior: 'none'}
1822
+ );
1823
+
1824
+ return this.linkSubscription({
1825
+ id: member.id,
1826
+ subscription: updatedSubscription,
1827
+ offerId: data.offerId
1828
+ }, options);
1829
+ }
1830
+
1831
+ // Besides free_months and trial offers, all other offers rely on a Stripe Coupon
1804
1832
  if (!data.couponId) {
1805
1833
  throw new errors.BadRequestError({
1806
1834
  message: tpl(messages.offerNoCoupon)
1807
1835
  });
1808
1836
  }
1809
1837
 
1810
- const stripeSubscriptionId = subscriptionModel.get('subscription_id');
1811
-
1812
- // Apply coupon to Stripe subscription
1813
1838
  const updatedSubscription = await this._stripeAPIService.addCouponToSubscription(
1814
1839
  stripeSubscriptionId,
1815
1840
  data.couponId
1816
1841
  );
1817
1842
 
1818
- // Sync local state with Stripe
1819
- await this.linkSubscription({
1843
+ return this.linkSubscription({
1820
1844
  id: member.id,
1821
1845
  subscription: updatedSubscription,
1822
1846
  offerId: data.offerId
@@ -7,8 +7,8 @@
7
7
  * @prop {string} offer_id
8
8
  * @prop {string} start
9
9
  * @prop {string|null} end
10
- * @prop {'once'|'repeating'|'forever'} duration
11
- * @prop {'percent'|'fixed'} type
10
+ * @prop {'once'|'repeating'|'forever'|'free_months'} duration
11
+ * @prop {'percent'|'fixed'|'free_months'} type
12
12
  * @prop {number} amount
13
13
  */
14
14
 
@@ -47,36 +47,29 @@ class NextPaymentCalculator {
47
47
  const interval = subscription.plan.interval;
48
48
  const currency = subscription.plan.currency;
49
49
  const offer = subscription.offer || null;
50
+ const defaultNextPayment = {
51
+ original_amount: originalAmount,
52
+ amount: originalAmount,
53
+ interval,
54
+ currency,
55
+ discount: null
56
+ };
50
57
 
51
58
  if (!offer || offer.type === 'trial') {
52
- return {
53
- original_amount: originalAmount,
54
- amount: originalAmount,
55
- interval,
56
- currency,
57
- discount: null
58
- };
59
+ return defaultNextPayment;
59
60
  }
60
61
 
61
62
  const activeDiscount = this._getActiveDiscount(subscription, offer);
62
63
 
63
64
  if (!activeDiscount) {
64
- return {
65
- original_amount: originalAmount,
66
- amount: originalAmount,
67
- interval,
68
- currency,
69
- discount: null
70
- };
65
+ return defaultNextPayment;
71
66
  }
72
67
 
73
68
  const discountedAmount = this._calculateDiscountedAmount(originalAmount, offer);
74
69
 
75
70
  return {
76
- original_amount: originalAmount,
71
+ ...defaultNextPayment,
77
72
  amount: discountedAmount,
78
- interval,
79
- currency,
80
73
  discount: {
81
74
  offer_id: offer.id,
82
75
  start: activeDiscount.start ? new Date(activeDiscount.start).toISOString() : null,
@@ -106,6 +99,21 @@ class NextPaymentCalculator {
106
99
  * @returns {ActiveDiscount|null}
107
100
  */
108
101
  _getActiveDiscount(subscription, offer) {
102
+ // Free months are based on trial periods in Stripe: they're active if the trial period is still ongoing
103
+ if (offer.type === 'free_months') {
104
+ const end = new Date(subscription.trial_end_at);
105
+
106
+ if (new Date() >= end) {
107
+ return null;
108
+ }
109
+
110
+ return {
111
+ start: subscription.trial_start_at,
112
+ end
113
+ };
114
+ }
115
+
116
+ // Other offers are based on a Stripe coupon, with a discount_start / discount_end
109
117
  if (subscription.discount_start) {
110
118
  return {
111
119
  start: subscription.discount_start,
@@ -113,7 +121,7 @@ class NextPaymentCalculator {
113
121
  };
114
122
  }
115
123
 
116
- // Backportability for signup offers without discount_start / discount_end
124
+ // Backportability for old signup offers without discount_start / discount_end
117
125
  if (offer.redemption_type !== 'signup') {
118
126
  return null;
119
127
  }
@@ -482,7 +482,7 @@ class PaymentsService {
482
482
  */
483
483
  async getCouponForOffer(offerId) {
484
484
  const row = await this.OfferModel.where({id: offerId}).query().select('stripe_coupon_id', 'discount_type').first();
485
- if (!row || row.discount_type === 'trial') {
485
+ if (!row || row.discount_type === 'trial' || row.discount_type === 'free_months') {
486
486
  return null;
487
487
  }
488
488
  if (!row.stripe_coupon_id) {
@@ -0,0 +1,49 @@
1
+ const errors = require('@tryghost/errors');
2
+
3
+ /**
4
+ * Add calendar months in UTC while preserving month-end behavior.
5
+ * Example: Jan 31 + 1 month => Feb 28/29 (not Mar 2/3).
6
+ *
7
+ * @param {Date|string|number} inputDate
8
+ * @param {number} months
9
+ * @returns {Date}
10
+ */
11
+ module.exports = function addCalendarMonths(inputDate, months) {
12
+ const sourceDate = new Date(inputDate);
13
+ const normalizedMonths = Number(months);
14
+
15
+ if (Number.isNaN(sourceDate.getTime())) {
16
+ throw new errors.BadRequestError({
17
+ message: 'inputDate must be a valid date'
18
+ });
19
+ }
20
+
21
+ if (!Number.isInteger(normalizedMonths) || normalizedMonths < 1) {
22
+ throw new errors.BadRequestError({
23
+ message: 'months must be a positive integer'
24
+ });
25
+ }
26
+
27
+ const target = new Date(Date.UTC(
28
+ sourceDate.getUTCFullYear(),
29
+ sourceDate.getUTCMonth(),
30
+ 1,
31
+ sourceDate.getUTCHours(),
32
+ sourceDate.getUTCMinutes(),
33
+ sourceDate.getUTCSeconds(),
34
+ sourceDate.getUTCMilliseconds()
35
+ ));
36
+
37
+ target.setUTCMonth(target.getUTCMonth() + normalizedMonths);
38
+
39
+ const originalDay = sourceDate.getUTCDate();
40
+ const daysInTargetMonth = new Date(Date.UTC(
41
+ target.getUTCFullYear(),
42
+ target.getUTCMonth() + 1,
43
+ 0
44
+ )).getUTCDate();
45
+
46
+ target.setUTCDate(Math.min(originalDay, daysInTargetMonth));
47
+
48
+ return target;
49
+ };