From e7144db5c344f4d8a6d68340e5caaa9ccdcac438 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 30 Dec 2024 10:41:04 +0000 Subject: [PATCH 01/90] Update dependency mailgun.js to v10.4.0 (#21957) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | Age | Adoption | Passing | Confidence | |---|---|---|---|---|---| | [mailgun.js](https://redirect.github.com/mailgun/mailgun.js) | [`10.3.0` -> `10.4.0`](https://renovatebot.com/diffs/npm/mailgun.js/10.3.0/10.4.0) | [![age](https://developer.mend.io/api/mc/badges/age/npm/mailgun.js/10.4.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/mailgun.js/10.4.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/mailgun.js/10.3.0/10.4.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/mailgun.js/10.3.0/10.4.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes
mailgun/mailgun.js (mailgun.js) ### [`v10.4.0`](https://redirect.github.com/mailgun/mailgun.js/blob/HEAD/CHANGELOG.md#1040-2024-12-30) [Compare Source](https://redirect.github.com/mailgun/mailgun.js/compare/v10.3.0...ffb37a53371756c36abdfdadac27309051f3cebf) ##### Features - Add support for metrics ([de16ccd](https://redirect.github.com/mailgun/mailgun.js/commits/de16ccd9b8dfdab5ab6e87fb8eba44cef80dd1a5)) ##### Other changes - Add tests ([78f9990](https://redirect.github.com/mailgun/mailgun.js/commits/78f9990fe6e6972f52df1af8ad1e720110890aa7)) - Update readme ([a724689](https://redirect.github.com/mailgun/mailgun.js/commits/a7246892a24f98393453c6a0e53e8bdf4ec64fd8))
--- ### Configuration 📅 **Schedule**: Branch creation - "* * * * 1-5" (UTC), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Never, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/TryGhost/Ghost). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- ghost/mailgun-client/package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ghost/mailgun-client/package.json b/ghost/mailgun-client/package.json index bcb8b6e84b5..51008400827 100644 --- a/ghost/mailgun-client/package.json +++ b/ghost/mailgun-client/package.json @@ -29,6 +29,6 @@ "@tryghost/metrics": "1.0.34", "form-data": "4.0.0", "lodash": "4.17.21", - "mailgun.js": "10.3.0" + "mailgun.js": "10.4.0" } } diff --git a/yarn.lock b/yarn.lock index 124f5e4a8e2..f541ab81052 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22812,10 +22812,10 @@ magic-string@^0.30.0, magic-string@^0.30.1: dependencies: "@jridgewell/sourcemap-codec" "^1.4.15" -mailgun.js@10.3.0: - version "10.3.0" - resolved "https://registry.yarnpkg.com/mailgun.js/-/mailgun.js-10.3.0.tgz#7599600a4c144e4df63675ede48de8fe9b38dbfc" - integrity sha512-HJPmninRDGlzs8izNyfM/hbvefz6ol1gqeZ+doiumEHli7kGCrLlK6hURsq6oLjDoTNwgS37CUDhBPQy7x5PeQ== +mailgun.js@10.4.0: + version "10.4.0" + resolved "https://registry.yarnpkg.com/mailgun.js/-/mailgun.js-10.4.0.tgz#8b6221e0f1172248a487e7bfa156471058efe4dd" + integrity sha512-YrdaZEAJwwjXGBTfZTNQ1LM7tmkdUaz2NpZEu7+zULcG4Wrlhd7cWSNZW0bxT3bP48k5N0mZWz8C2f9gc2+Geg== dependencies: axios "^1.7.4" base-64 "^1.0.0" From 7ac2f5e8303b12f726ed5f3014f6bd7e6b08fe1c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 31 Dec 2024 16:55:23 +0000 Subject: [PATCH 02/90] Update dependency react-hot-toast to v2.5.1 (#21960) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | Age | Adoption | Passing | Confidence | |---|---|---|---|---|---| | [react-hot-toast](https://redirect.github.com/timolins/react-hot-toast) | [`2.4.1` -> `2.5.1`](https://renovatebot.com/diffs/npm/react-hot-toast/2.4.1/2.5.1) | [![age](https://developer.mend.io/api/mc/badges/age/npm/react-hot-toast/2.5.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/react-hot-toast/2.5.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/react-hot-toast/2.4.1/2.5.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/react-hot-toast/2.4.1/2.5.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes
timolins/react-hot-toast (react-hot-toast) ### [`v2.5.1`](https://redirect.github.com/timolins/react-hot-toast/compare/v2.5.0...v2.5.1) [Compare Source](https://redirect.github.com/timolins/react-hot-toast/compare/v2.5.0...v2.5.1) ### [`v2.5.0`](https://redirect.github.com/timolins/react-hot-toast/releases/tag/v2.5.0): 2.5.0 [Compare Source](https://redirect.github.com/timolins/react-hot-toast/compare/v2.4.1...v2.5.0) #### What's new ##### `toast.promise()` improvements - Make messages optional - [#​179](https://redirect.github.com/timolins/react-hot-toast/issues/179) from n33pm/feat/promiseOptionalMsg) [`25bd873`](https://redirect.github.com/timolins/react-hot-toast/commit/25bd873) - Allow to pass async function - [#​301](https://redirect.github.com/timolins/react-hot-toast/issues/301) from kagurazaka-0/feat-toast-promise-async-func [`c80d57f`](https://redirect.github.com/timolins/react-hot-toast/commit/c80d57f) ##### Other - Add customizable `removeDelay` - [#​89](https://redirect.github.com/timolins/react-hot-toast/issues/89) from heyfuaad/main [`c3d6739`](https://redirect.github.com/timolins/react-hot-toast/commit/c3d6739) - Refactor removal of dismissed toasts - Make `csstype` a dependency to avoid warning. [`d695daf`](https://redirect.github.com/timolins/react-hot-toast/commit/d695daf) ***
--- ### Configuration 📅 **Schedule**: Branch creation - "* * * * 1-5" (UTC), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Never, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/TryGhost/Ghost). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- apps/admin-x-design-system/package.json | 2 +- apps/shade/package.json | 2 +- yarn.lock | 27 +++++++++++++------------ 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/apps/admin-x-design-system/package.json b/apps/admin-x-design-system/package.json index 343b3602de2..fbb2d1c851a 100644 --- a/apps/admin-x-design-system/package.json +++ b/apps/admin-x-design-system/package.json @@ -82,7 +82,7 @@ "postcss": "8.4.39", "postcss-import": "16.1.0", "react-colorful": "5.6.1", - "react-hot-toast": "2.4.1", + "react-hot-toast": "2.5.1", "react-select": "5.8.2", "tailwindcss": "3.4.14" }, diff --git a/apps/shade/package.json b/apps/shade/package.json index 7ab8096865c..6d4970f753b 100644 --- a/apps/shade/package.json +++ b/apps/shade/package.json @@ -87,7 +87,7 @@ "postcss": "8.4.39", "postcss-import": "16.1.0", "react-colorful": "5.6.1", - "react-hot-toast": "2.4.1", + "react-hot-toast": "2.5.1", "react-select": "5.8.2", "tailwind-merge": "^2.6.0", "tailwindcss": "3.4.14", diff --git a/yarn.lock b/yarn.lock index f541ab81052..380fcb3d1c0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14015,10 +14015,10 @@ cssstyle@^4.0.1: dependencies: rrweb-cssom "^0.6.0" -csstype@^3.0.2: - version "3.1.1" - resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.1.tgz#841b532c45c758ee546a11d5bd7b7b473c8c30b9" - integrity sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw== +csstype@^3.0.2, csstype@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" + integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== csv-writer@1.6.0: version "1.6.0" @@ -18922,10 +18922,10 @@ globrex@^0.1.2: resolved "https://registry.yarnpkg.com/globrex/-/globrex-0.1.2.tgz#dd5d9ec826232730cd6793a5e33a9302985e6098" integrity sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg== -goober@^2.1.10: - version "2.1.13" - resolved "https://registry.yarnpkg.com/goober/-/goober-2.1.13.tgz#e3c06d5578486212a76c9eba860cbc3232ff6d7c" - integrity sha512-jFj3BQeleOoy7t93E9rZ2de+ScC4lQICLwiAQmKMg9F6roKGaLSHoCDYKkWlSafg138jejvq/mTdvmnwDQgqoQ== +goober@^2.1.16: + version "2.1.16" + resolved "https://registry.yarnpkg.com/goober/-/goober-2.1.16.tgz#7d548eb9b83ff0988d102be71f271ca8f9c82a95" + integrity sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g== "google-caja-bower@https://github.com/acburdine/google-caja-bower#ghost": version "6011.0.0" @@ -27255,12 +27255,13 @@ react-error-boundary@^3.1.0: dependencies: "@babel/runtime" "^7.12.5" -react-hot-toast@2.4.1: - version "2.4.1" - resolved "https://registry.yarnpkg.com/react-hot-toast/-/react-hot-toast-2.4.1.tgz#df04295eda8a7b12c4f968e54a61c8d36f4c0994" - integrity sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ== +react-hot-toast@2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/react-hot-toast/-/react-hot-toast-2.5.1.tgz#fcb182d96353c803ee5af82e96c806d5eaa4dcfa" + integrity sha512-54Gq1ZD1JbmAb4psp9bvFHjS7lje+8ubboUmvKZkCsQBLH6AOpZ9JemfRvIdHcfb9AZXRaFLrb3qUobGYDJhFQ== dependencies: - goober "^2.1.10" + csstype "^3.1.3" + goober "^2.1.16" react-is@18.1.0: version "18.1.0" From 943c393542b9952bb623459aa312489af537729d Mon Sep 17 00:00:00 2001 From: John O'Nolan Date: Mon, 6 Jan 2025 11:40:59 +0000 Subject: [PATCH 03/90] 2025 Co-authored-by: Hannah Wolfe github.erisds@gmail.com --- LICENSE | 2 +- README.md | 2 +- apps/announcement-bar/LICENSE | 2 +- apps/announcement-bar/README.md | 2 +- apps/comments-ui/LICENSE | 2 +- apps/comments-ui/README.md | 2 +- apps/portal/README.md | 2 +- apps/sodo-search/LICENSE | 2 +- apps/sodo-search/README.md | 2 +- ghost/admin/README.md | 2 +- ghost/core/test/utils/fixtures/themes/casper/LICENSE | 2 +- ghost/core/test/utils/fixtures/themes/source/LICENSE | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) diff --git a/LICENSE b/LICENSE index ce0968e726b..d060f0e682c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2013-2024 Ghost Foundation +Copyright (c) 2013-2025 Ghost Foundation Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation diff --git a/README.md b/README.md index 7fa794ca2ce..8aa47ee5f71 100644 --- a/README.md +++ b/README.md @@ -98,5 +98,5 @@ To stay up to date with all the latest news and product updates, make sure you [ # Copyright & license -Copyright (c) 2013-2024 Ghost Foundation - Released under the [MIT license](LICENSE). +Copyright (c) 2013-2025 Ghost Foundation - Released under the [MIT license](LICENSE). Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage. diff --git a/apps/announcement-bar/LICENSE b/apps/announcement-bar/LICENSE index fc33a5ecee8..37c0d47d6f9 100644 --- a/apps/announcement-bar/LICENSE +++ b/apps/announcement-bar/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2013-2023 Ghost Foundation +Copyright (c) 2013-2025 Ghost Foundation Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/apps/announcement-bar/README.md b/apps/announcement-bar/README.md index 831b99de486..9dc5523fd87 100644 --- a/apps/announcement-bar/README.md +++ b/apps/announcement-bar/README.md @@ -13,4 +13,4 @@ You can automatically start the announcement-bar dev server when developing Ghos # Copyright & License -Copyright (c) 2013-2023 Ghost Foundation - Released under the [MIT license](LICENSE). +Copyright (c) 2013-2025 Ghost Foundation - Released under the [MIT license](LICENSE). diff --git a/apps/comments-ui/LICENSE b/apps/comments-ui/LICENSE index fc33a5ecee8..37c0d47d6f9 100644 --- a/apps/comments-ui/LICENSE +++ b/apps/comments-ui/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2013-2023 Ghost Foundation +Copyright (c) 2013-2025 Ghost Foundation Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/apps/comments-ui/README.md b/apps/comments-ui/README.md index 68280e3a99b..a1a4d158050 100644 --- a/apps/comments-ui/README.md +++ b/apps/comments-ui/README.md @@ -30,4 +30,4 @@ A patch release can be rolled out instantly in production, whereas a minor/major # Copyright & License -Copyright (c) 2013-2024 Ghost Foundation - Released under the [MIT license](LICENSE). +Copyright (c) 2013-2025 Ghost Foundation - Released under the [MIT license](LICENSE). diff --git a/apps/portal/README.md b/apps/portal/README.md index 1c9119506ea..e467b064003 100644 --- a/apps/portal/README.md +++ b/apps/portal/README.md @@ -85,4 +85,4 @@ In order to have Ghost's e2e tests run against the new code on CI or to test the # Copyright & License -Copyright (c) 2013-2024 Ghost Foundation - Released under the [MIT license](LICENSE). +Copyright (c) 2013-2025 Ghost Foundation - Released under the [MIT license](LICENSE). diff --git a/apps/sodo-search/LICENSE b/apps/sodo-search/LICENSE index fc33a5ecee8..37c0d47d6f9 100644 --- a/apps/sodo-search/LICENSE +++ b/apps/sodo-search/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2013-2023 Ghost Foundation +Copyright (c) 2013-2025 Ghost Foundation Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/apps/sodo-search/README.md b/apps/sodo-search/README.md index e87963cae46..27140eff292 100644 --- a/apps/sodo-search/README.md +++ b/apps/sodo-search/README.md @@ -13,4 +13,4 @@ You can automatically start the Sodo-Search dev server when developing Ghost by # Copyright & License -Copyright (c) 2013-2023 Ghost Foundation - Released under the [MIT license](LICENSE). +Copyright (c) 2013-2025 Ghost Foundation - Released under the [MIT license](LICENSE). diff --git a/ghost/admin/README.md b/ghost/admin/README.md index 04fa03e8edb..f48cbb89f4f 100644 --- a/ghost/admin/README.md +++ b/ghost/admin/README.md @@ -56,4 +56,4 @@ ember exam --help # Copyright & License -Copyright (c) 2013-2024 Ghost Foundation - Released under the [MIT license](LICENSE). Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage. +Copyright (c) 2013-2025 Ghost Foundation - Released under the [MIT license](LICENSE). Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage. diff --git a/ghost/core/test/utils/fixtures/themes/casper/LICENSE b/ghost/core/test/utils/fixtures/themes/casper/LICENSE index b52cfae1945..d060f0e682c 100644 --- a/ghost/core/test/utils/fixtures/themes/casper/LICENSE +++ b/ghost/core/test/utils/fixtures/themes/casper/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2013-2023 Ghost Foundation +Copyright (c) 2013-2025 Ghost Foundation Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation diff --git a/ghost/core/test/utils/fixtures/themes/source/LICENSE b/ghost/core/test/utils/fixtures/themes/source/LICENSE index b52cfae1945..d060f0e682c 100644 --- a/ghost/core/test/utils/fixtures/themes/source/LICENSE +++ b/ghost/core/test/utils/fixtures/themes/source/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2013-2023 Ghost Foundation +Copyright (c) 2013-2025 Ghost Foundation Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation From 98306d2337d39729a0539f2f0aebe06209fc9263 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20van=20der=20Winden?= Date: Mon, 6 Jan 2025 14:48:41 +0100 Subject: [PATCH 04/90] Fixed member filter dropdown breaking for long post titles (#21962) Previously, if you'd filter members by which page or post they signed up on, if you had very long titles for your posts they wouldn't display properly. This change removes the fixed height set for items within this dropdown, which fixes that. Fixes https://linear.app/ghost/issue/DES-1057/long-post-titles-get-squashedcut-off-in-members-filter-dropdown --- ghost/admin/app/styles/components/power-select.css | 1 - 1 file changed, 1 deletion(-) diff --git a/ghost/admin/app/styles/components/power-select.css b/ghost/admin/app/styles/components/power-select.css index 4479ca6ed22..d793a31800b 100644 --- a/ghost/admin/app/styles/components/power-select.css +++ b/ghost/admin/app/styles/components/power-select.css @@ -82,7 +82,6 @@ margin: 0; padding: 6px 14px; color: var(--darkgrey); - height: 32px; } .ember-power-select-option[aria-current="true"] { From b6fe724b577e84f7dd174646d0323dabdcdf576e Mon Sep 17 00:00:00 2001 From: Hannah Wolfe Date: Mon, 6 Jan 2025 16:19:59 +0000 Subject: [PATCH 05/90] Updated test snapshots for 2025 (#21963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref https://github.com/TryGhost/Ghost/commit/a86bf463472ea15b55b167e8b98626c2fd3f26bd#diff-310689502a3d29cb4c582e61ebb4ac0c79c0a102d1096bd622ee5a7439642021L455 ref https://github.com/TryGhost/Ghost/commit/f568b35f267ea46398cce01fbd1cca67d9cf7688 - We shouldn't really hardcode these dates, but fixing it once a year is quicker than figuring out what to do instead 😅 --- .../__snapshots__/email-previews.test.js.snap | 20 +++--- .../__snapshots__/batch-sending.test.js.snap | 64 +++++++++---------- .../__snapshots__/cards.test.js.snap | 16 ++--- 3 files changed, 50 insertions(+), 50 deletions(-) diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/email-previews.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/email-previews.test.js.snap index 1894f17c700..36a59a4d12a 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/email-previews.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/email-previews.test.js.snap @@ -606,7 +606,7 @@ table.body h2 span { - + @@ -750,7 +750,7 @@ Another email card with a similar replacement, Jamie -Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&key=803e513e2d8b88e759d8f433779659af335d7308b4cbac809600d563f6b49a76&newsletter=requested-newsletter-uuid] +Ghost © 2025 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&key=803e513e2d8b88e759d8f433779659af335d7308b4cbac809600d563f6b49a76&newsletter=requested-newsletter-uuid] @@ -1304,7 +1304,7 @@ table.body h2 span {
Ghost © 2024 – UnsubscribeGhost © 2025 – Unsubscribe
- + @@ -1465,7 +1465,7 @@ Header Level 3 -Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&key=803e513e2d8b88e759d8f433779659af335d7308b4cbac809600d563f6b49a76&newsletter=requested-newsletter-uuid] +Ghost © 2025 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&key=803e513e2d8b88e759d8f433779659af335d7308b4cbac809600d563f6b49a76&newsletter=requested-newsletter-uuid] @@ -2045,7 +2045,7 @@ table.body h2 span {
Ghost © 2024 – UnsubscribeGhost © 2025 – Unsubscribe
- + @@ -2183,7 +2183,7 @@ Testing links [https://ghost.org/] in email excerpt and apostrophes ' -Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&key=803e513e2d8b88e759d8f433779659af335d7308b4cbac809600d563f6b49a76&newsletter=requested-newsletter-uuid] +Ghost © 2025 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&key=803e513e2d8b88e759d8f433779659af335d7308b4cbac809600d563f6b49a76&newsletter=requested-newsletter-uuid] @@ -3112,7 +3112,7 @@ table.body h2 span {
Ghost © 2024 – UnsubscribeGhost © 2025 – Unsubscribe
- + @@ -3257,7 +3257,7 @@ Testing links [https://ghost.org/] in email excerpt and apostrophes ' -Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&key=803e513e2d8b88e759d8f433779659af335d7308b4cbac809600d563f6b49a76&newsletter=requested-newsletter-uuid] +Ghost © 2025 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&key=803e513e2d8b88e759d8f433779659af335d7308b4cbac809600d563f6b49a76&newsletter=requested-newsletter-uuid] @@ -4212,7 +4212,7 @@ table.body h2 span {
Ghost © 2024 – UnsubscribeGhost © 2025 – Unsubscribe
- + @@ -4357,7 +4357,7 @@ Testing links [https://ghost.org/] in email excerpt and apostrophes ' -Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&key=803e513e2d8b88e759d8f433779659af335d7308b4cbac809600d563f6b49a76&newsletter=requested-newsletter-uuid] +Ghost © 2025 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&key=803e513e2d8b88e759d8f433779659af335d7308b4cbac809600d563f6b49a76&newsletter=requested-newsletter-uuid] diff --git a/ghost/core/test/integration/services/email-service/__snapshots__/batch-sending.test.js.snap b/ghost/core/test/integration/services/email-service/__snapshots__/batch-sending.test.js.snap index c38ba19b572..495c875e40c 100644 --- a/ghost/core/test/integration/services/email-service/__snapshots__/batch-sending.test.js.snap +++ b/ghost/core/test/integration/services/email-service/__snapshots__/batch-sending.test.js.snap @@ -519,7 +519,7 @@ table.body h2 span {
Ghost © 2024 – UnsubscribeGhost © 2025 – Unsubscribe
- + @@ -657,7 +657,7 @@ Testing feature image caption -Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=member-uuid&key=xxxxxx&newsletter=requested-newsletter-uuid] +Ghost © 2025 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=member-uuid&key=xxxxxx&newsletter=requested-newsletter-uuid] @@ -1188,7 +1188,7 @@ table.body h2 span {
Ghost © 2024 – UnsubscribeGhost © 2025 – Unsubscribe
- + @@ -1315,7 +1315,7 @@ Hello world -Ghost © 2024 – Unsubscribe [unsubscribe_url] +Ghost © 2025 – Unsubscribe [unsubscribe_url] @@ -1846,7 +1846,7 @@ table.body h2 span {
Ghost © 2024 – UnsubscribeGhost © 2025 – Unsubscribe
- + @@ -1973,7 +1973,7 @@ Hello world -Ghost © 2024 – Unsubscribe [unsubscribe_url] +Ghost © 2025 – Unsubscribe [unsubscribe_url] @@ -2504,7 +2504,7 @@ table.body h2 span {
Ghost © 2024 – UnsubscribeGhost © 2025 – Unsubscribe
- + @@ -2631,7 +2631,7 @@ Hello world -Ghost © 2024 – Unsubscribe [unsubscribe_url] +Ghost © 2025 – Unsubscribe [unsubscribe_url] @@ -3138,7 +3138,7 @@ table.body h2 span {
Ghost © 2024 – UnsubscribeGhost © 2025 – Unsubscribe
- + @@ -3237,7 +3237,7 @@ Hello world -Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=member-uuid&key=xxxxxx&newsletter=requested-newsletter-uuid] +Ghost © 2025 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=member-uuid&key=xxxxxx&newsletter=requested-newsletter-uuid] @@ -3768,7 +3768,7 @@ table.body h2 span {
Ghost © 2024 – UnsubscribeGhost © 2025 – Unsubscribe
- + @@ -3895,7 +3895,7 @@ Hello world -Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=member-uuid&key=xxxxxx&newsletter=requested-newsletter-uuid] +Ghost © 2025 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=member-uuid&key=xxxxxx&newsletter=requested-newsletter-uuid] @@ -4449,7 +4449,7 @@ table.body h2 span {
Ghost © 2024 – UnsubscribeGhost © 2025 – Unsubscribe
- + @@ -4613,7 +4613,7 @@ Comment -Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=member-uuid&key=xxxxxx&newsletter=requested-newsletter-uuid] +Ghost © 2025 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=member-uuid&key=xxxxxx&newsletter=requested-newsletter-uuid] @@ -5157,7 +5157,7 @@ table.body h2 span {
Ghost © 2024 – UnsubscribeGhost © 2025 – Unsubscribe
- + @@ -5303,7 +5303,7 @@ Comment -Ghost © 2024 – Unsubscribe [unsubscribe_url] +Ghost © 2025 – Unsubscribe [unsubscribe_url] @@ -7257,7 +7257,7 @@ table.body h2 span {
Ghost © 2024 – UnsubscribeGhost © 2025 – Unsubscribe
- + @@ -7470,7 +7470,7 @@ Hello world [http://127.0.0.1:2369/r/xxxxxx?m=member-uuid] -Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=member-uuid&key=xxxxxx&newsletter=requested-newsletter-uuid] +Ghost © 2025 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=member-uuid&key=xxxxxx&newsletter=requested-newsletter-uuid] @@ -8021,7 +8021,7 @@ table.body h2 span {
Ghost © 2024 – UnsubscribeGhost © 2025 – Unsubscribe
- + @@ -8183,7 +8183,7 @@ Manage subscription → [http://127.0.0.1:2369/#/portal/account] -Ghost © 2024 – Unsubscribe [unsubscribe_url] +Ghost © 2025 – Unsubscribe [unsubscribe_url] @@ -8734,7 +8734,7 @@ table.body h2 span {
Ghost © 2024 – UnsubscribeGhost © 2025 – Unsubscribe
- + @@ -8896,7 +8896,7 @@ Manage subscription → [http://127.0.0.1:2369/#/portal/account] -Ghost © 2024 – Unsubscribe [unsubscribe_url] +Ghost © 2025 – Unsubscribe [unsubscribe_url] @@ -9447,7 +9447,7 @@ table.body h2 span {
Ghost © 2024 – UnsubscribeGhost © 2025 – Unsubscribe
- + @@ -9609,7 +9609,7 @@ Manage subscription → [http://127.0.0.1:2369/#/portal/account] -Ghost © 2024 – Unsubscribe [unsubscribe_url] +Ghost © 2025 – Unsubscribe [unsubscribe_url] @@ -10160,7 +10160,7 @@ table.body h2 span {
Ghost © 2024 – UnsubscribeGhost © 2025 – Unsubscribe
- + @@ -10322,7 +10322,7 @@ Manage subscription → [http://127.0.0.1:2369/#/portal/account] -Ghost © 2024 – Unsubscribe [unsubscribe_url] +Ghost © 2025 – Unsubscribe [unsubscribe_url] @@ -10873,7 +10873,7 @@ table.body h2 span {
Ghost © 2024 – UnsubscribeGhost © 2025 – Unsubscribe
- + @@ -11035,7 +11035,7 @@ Manage subscription → [http://127.0.0.1:2369/#/portal/account] -Ghost © 2024 – Unsubscribe [unsubscribe_url] +Ghost © 2025 – Unsubscribe [unsubscribe_url] @@ -11566,7 +11566,7 @@ table.body h2 span {
Ghost © 2024 – UnsubscribeGhost © 2025 – Unsubscribe
- + @@ -11695,7 +11695,7 @@ Hey Simon, Hey Simon, -Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=member-uuid&key=xxxxxx&newsletter=requested-newsletter-uuid] +Ghost © 2025 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=member-uuid&key=xxxxxx&newsletter=requested-newsletter-uuid] @@ -12226,7 +12226,7 @@ table.body h2 span {
Ghost © 2024 – UnsubscribeGhost © 2025 – Unsubscribe
- + @@ -12355,7 +12355,7 @@ Hey there, Hey , -Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=member-uuid&key=xxxxxx&newsletter=requested-newsletter-uuid] +Ghost © 2025 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=member-uuid&key=xxxxxx&newsletter=requested-newsletter-uuid] diff --git a/ghost/core/test/integration/services/email-service/__snapshots__/cards.test.js.snap b/ghost/core/test/integration/services/email-service/__snapshots__/cards.test.js.snap index fecf8d6afa8..9b55b64890e 100644 --- a/ghost/core/test/integration/services/email-service/__snapshots__/cards.test.js.snap +++ b/ghost/core/test/integration/services/email-service/__snapshots__/cards.test.js.snap @@ -506,7 +506,7 @@ table.body h2 span {
Ghost © 2024 – UnsubscribeGhost © 2025 – Unsubscribe
- + @@ -633,7 +633,7 @@ This is a paragraph test. -Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=member-uuid&key=xxxxxx&newsletter=requested-newsletter-uuid] +Ghost © 2025 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=member-uuid&key=xxxxxx&newsletter=requested-newsletter-uuid] @@ -1164,7 +1164,7 @@ table.body h2 span {
Ghost © 2024 – UnsubscribeGhost © 2025 – Unsubscribe
- + @@ -1291,7 +1291,7 @@ This is a paragraph -Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=member-uuid&key=xxxxxx&newsletter=requested-newsletter-uuid] +Ghost © 2025 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=member-uuid&key=xxxxxx&newsletter=requested-newsletter-uuid] @@ -2086,7 +2086,7 @@ Ghost: Independent technology for modern publishingBeautiful, modern publishing
Ghost © 2024 – UnsubscribeGhost © 2025 – Unsubscribe
- + @@ -2484,7 +2484,7 @@ http://127.0.0.1:2369/r/xxxxxx?m=member-uuid -Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=member-uuid&key=xxxxxx&newsletter=requested-newsletter-uuid] +Ghost © 2025 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=member-uuid&key=xxxxxx&newsletter=requested-newsletter-uuid] @@ -3279,7 +3279,7 @@ Ghost: Independent technology for modern publishingBeautiful, modern publishing
Ghost © 2024 – UnsubscribeGhost © 2025 – Unsubscribe
- + @@ -3677,7 +3677,7 @@ http://127.0.0.1:2369/r/xxxxxx?m=member-uuid -Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=member-uuid&key=xxxxxx&newsletter=requested-newsletter-uuid] +Ghost © 2025 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=member-uuid&key=xxxxxx&newsletter=requested-newsletter-uuid] From 1fd2175a44cd5edea6d4dc0dc90b733308c93d30 Mon Sep 17 00:00:00 2001 From: Sag Date: Tue, 7 Jan 2025 15:32:32 +0700 Subject: [PATCH 06/90] Fixed copy in Portal when signup is not available (#21965) ref https://linear.app/ghost/issue/ENG-1235 - we currently have three different messages when signup is not available (this site is invite-only, this site only accepts paid memebers, membership unavailable); the first two offer a link to sign in, whereas the third one does not as all membership features are disabled - this PR fixes the logic to render the correct message, given the reason why signup is not available - also removes the usage of `allowSelfSignup` in Portal, as 1) the naming is poor and 2) `allowSelfSignup` is computed based on the existing `membersSignupAccess` and is therefore redundant --- apps/portal/src/App.js | 2 - apps/portal/src/components/Frame.styles.js | 2 +- .../portal/src/components/pages/SigninPage.js | 5 +- .../portal/src/components/pages/SignupPage.js | 36 +++---- .../src/components/pages/SignupPage.test.js | 99 ++++++++++++++++++- .../src/components/pages/SupportSuccess.js | 3 +- apps/portal/src/tests/SignupFlow.test.js | 4 +- apps/portal/src/tests/portal-links.test.js | 12 +-- apps/portal/src/utils/fixtures-generator.js | 2 - apps/portal/src/utils/helpers.js | 20 +--- 10 files changed, 135 insertions(+), 50 deletions(-) diff --git a/apps/portal/src/App.js b/apps/portal/src/App.js index b9bf0ba13b2..fddfcb48e61 100644 --- a/apps/portal/src/App.js +++ b/apps/portal/src/App.js @@ -397,8 +397,6 @@ export default class App extends React.Component { currency = currencyValue; } else if (key === 'disableBackground') { data.site.disableBackground = JSON.parse(value); - } else if (key === 'allowSelfSignup') { - data.site.allow_self_signup = JSON.parse(value); } else if (key === 'membersSignupAccess' && value) { data.site.members_signup_access = value; } else if (key === 'portalDefaultPlan' && value) { diff --git a/apps/portal/src/components/Frame.styles.js b/apps/portal/src/components/Frame.styles.js index 9c8cf9d8208..af9689b4383 100644 --- a/apps/portal/src/components/Frame.styles.js +++ b/apps/portal/src/components/Frame.styles.js @@ -999,7 +999,7 @@ const MobileStyles = ` margin-bottom: 0; } - .preview .gh-portal-invite-only-notification + .gh-portal-signup-message { + .preview .gh-portal-invite-only-notification + .gh-portal-signup-message, .preview .gh-portal-paid-members-only-notification + .gh-portal-signup-message { margin-bottom: 16px; } diff --git a/apps/portal/src/components/pages/SigninPage.js b/apps/portal/src/components/pages/SigninPage.js index 10621f80047..9adb3a08ffa 100644 --- a/apps/portal/src/components/pages/SigninPage.js +++ b/apps/portal/src/components/pages/SigninPage.js @@ -5,7 +5,7 @@ import CloseButton from '../common/CloseButton'; import AppContext from '../../AppContext'; import InputForm from '../common/InputForm'; import {ValidateInputForm} from '../../utils/form'; -import {isSigninAllowed} from '../../utils/helpers'; +import {hasAvailablePrices, isSigninAllowed, isSignupAllowed} from '../../utils/helpers'; import {ReactComponent as InvitationIcon} from '../../images/icons/invitation.svg'; export default class SigninPage extends React.Component { @@ -131,6 +131,7 @@ export default class SigninPage extends React.Component { renderForm() { const {site, t} = this.context; + const isSignupAvailable = isSignupAllowed({site}) && hasAvailablePrices({site}); if (!isSigninAllowed({site})) { return ( @@ -158,7 +159,7 @@ export default class SigninPage extends React.Component {
{this.renderSubmitButton()} - {this.renderSignupMessage()} + {isSignupAvailable && this.renderSignupMessage()}
); diff --git a/apps/portal/src/components/pages/SignupPage.js b/apps/portal/src/components/pages/SignupPage.js index 394eb6e0165..f46e944b1ac 100644 --- a/apps/portal/src/components/pages/SignupPage.js +++ b/apps/portal/src/components/pages/SignupPage.js @@ -7,7 +7,7 @@ import NewsletterSelectionPage from './NewsletterSelectionPage'; import ProductsSection from '../common/ProductsSection'; import InputForm from '../common/InputForm'; import {ValidateInputForm} from '../../utils/form'; -import {getSiteProducts, getSitePrices, hasAvailablePrices, hasOnlyFreePlan, isInviteOnly, isFreeSignupAllowed, isPaidMembersOnly, freeHasBenefitsOrDescription, hasMultipleNewsletters, hasFreeTrialTier, isSignupAllowed} from '../../utils/helpers'; +import {getSiteProducts, getSitePrices, hasAvailablePrices, hasOnlyFreePlan, isInviteOnly, isFreeSignupAllowed, isPaidMembersOnly, freeHasBenefitsOrDescription, hasMultipleNewsletters, hasFreeTrialTier, isSignupAllowed, isSigninAllowed} from '../../utils/helpers'; import {ReactComponent as InvitationIcon} from '../../images/icons/invitation.svg'; import {interceptAnchorClicks} from '../../utils/links'; @@ -177,7 +177,7 @@ footer.gh-portal-signup-footer.invite-only .gh-portal-signup-message { margin-top: 0; } -.gh-portal-invite-only-notification, .gh-portal-members-disabled-notification { +.gh-portal-invite-only-notification, .gh-portal-members-disabled-notification, .gh-portal-paid-members-only-notification { margin: 8px 32px 24px; padding: 0; text-align: center; @@ -194,7 +194,7 @@ footer.gh-portal-signup-footer.invite-only .gh-portal-signup-message { padding-bottom: 32px; } -.gh-portal-invite-only-notification + .gh-portal-signup-message { +.gh-portal-invite-only-notification + .gh-portal-signup-message, .gh-portal-paid-members-only-notification + .gh-portal-signup-message { margin-bottom: 12px; } @@ -670,6 +670,7 @@ class SignupPage extends React.Component {
{t('Already a member?')}
- - - - {{else}} - - - - {{svg-jar "dotdotdot"}} - - - - -
  • - - View Stripe customer - -
  • -
  • -
  • - - View Stripe subscription - -
  • -
  • - {{#if (not-eq sub.status "canceled")}} - {{#if sub.cancel_at_period_end}} - - {{else}} - +
  • +
    +
    + {{else}} + + + + {{svg-jar "dotdotdot"}} + + + + +
  • + + View Stripe customer + +
  • +
  • +
  • + + View Stripe subscription + +
  • +
  • + {{#if (not-eq sub.status "canceled")}} + {{#if sub.cancel_at_period_end}} + + {{else}} + + {{/if}} {{/if}} - {{/if}} -
  • -
    -
    - {{/if}} - - {{/each}} + + + + {{/if}} + + {{/each}} - {{#if (eq tier.subscriptions.length 0)}} -
    -
    + {{#if (eq tier.subscriptions.length 0)}} +
    - Complimentary - Active +
    + Complimentary + Active +
    +
    Created on
    -
    Created on
    -
    -
    -
    -
    - $ - 0 +
    +
    +
    + $ + 0 +
    +
    yearly
    -
    yearly
    + + + + {{svg-jar "dotdotdot"}} + + + + +
  • + +
  • +
    +
    - - - - {{svg-jar "dotdotdot"}} - - - - -
  • - -
  • -
    -
    -
    - {{/if}} + {{/if}} + {{/each}}
    - {{/each}} + {{/if}} {{#if (and this.tiers this.isAddComplimentaryAllowed)}}
    Ghost © 2024 – UnsubscribeGhost © 2025 – Unsubscribe
    1234
    abcd
    efgh
    ijkl
    Definition list
    Consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
    Lorem ipsum dolor sit amet
    Consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
    • Morbi in sem quis dui placerat ornare. Pellentesque odio nisi, euismod in, pharetra a, ultricies in, diam. Sed arcu. Cras consequat.
    • Praesent dapibus, neque id cursus faucibus, tortor neque egestas augue, eu vulputate magna eros eu erat. Aliquam erat volutpat. Nam dui mi, tincidunt quis, accumsan porttitor, facilisis luctus, metus.
    • Phasellus ultrices nulla quis nibh. Quisque a lectus. Donec consectetuer ligula vulputate sem tristique cursus. Nam nulla quam, gravida non, commodo a, sodales sit amet, nisi.
    • Pellentesque fermentum dolor. Aliquam quam lectus, facilisis auctor, ultrices ut, elementum vulputate, nunc.

    \\"}]],\\"sections\\":[[10,0]]}", - "newsletter": null, - "og_description": null, - "og_image": null, - "og_title": null, - "primary_author": Any, - "primary_tag": Any, - "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "reading_time": 1, - "slug": "not-so-short-bit-complex", - "status": "published", - "tags": Any, - "tiers": Any, - "title": "Not so short, bit complex", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": Any, - "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "visibility": "public", - }, - Object { - "authors": Any, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "comment_id": Any, - "count": Object { - "clicks": 0, - "negative_feedback": 0, - "positive_feedback": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "custom_excerpt": null, - "custom_template": null, - "email": null, - "email_only": false, - "email_segment": "all", - "email_subject": null, - "excerpt": "testing - - -mctesters - - - * test - * line - * items -", - "feature_image": "http://placekitten.com/500/200", - "feature_image_alt": null, - "feature_image_caption": null, - "featured": true, - "frontmatter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "lexical": null, - "meta_description": "meta description for short and sweet", - "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[[\\"markdown\\",{\\"markdown\\":\\"## testing\\\\n\\\\nmctesters\\\\n\\\\n- test\\\\n- line\\\\n- items\\"}]],\\"sections\\":[[10,0]]}", - "newsletter": null, - "og_description": null, - "og_image": null, - "og_title": null, - "primary_author": Any, - "primary_tag": Any, - "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "reading_time": 0, - "slug": "short-and-sweet", - "status": "published", - "tags": Any, - "tiers": Any, - "title": "Short and Sweet", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": Any, - "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "visibility": "public", - }, - ], -} -`; - -exports[`Collections API Automatic Collection Filtering Creates an automatic Collection with a featured filter 4: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "12649", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Automatic Collection Filtering Creates an automatic Collection with a published_at filter 1: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "Test Collection Description with published_at filter", - "feature_image": null, - "filter": "published_at:>=2022-05-25", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "published-at-filter", - "title": "Test Collection with published_at filter", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Automatic Collection Filtering Creates an automatic Collection with a published_at filter 2: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "357", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/collections\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-cache-invalidate": "/*", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Automatic Collection Filtering Creates an automatic Collection with a published_at filter 3: [body] 1`] = ` -Object { - "meta": Object { - "pagination": Object { - "limit": 15, - "next": null, - "page": 1, - "pages": 1, - "prev": null, - "total": 9, - }, - }, - "posts": Array [ - Object { - "authors": Any, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "comment_id": Any, - "count": Object { - "clicks": 0, - "negative_feedback": 0, - "positive_feedback": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "custom_excerpt": null, - "custom_template": null, - "email": null, - "email_only": false, - "email_segment": "all", - "email_subject": null, - "excerpt": "Welcome to my invisible post!", - "feature_image": null, - "feature_image_alt": null, - "feature_image_caption": null, - "featured": false, - "frontmatter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "lexical": null, - "meta_description": null, - "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[[\\"markdown\\",{\\"markdown\\":\\"

    Welcome to my invisible post!

    \\"}]],\\"sections\\":[[10,0]]}", - "newsletter": null, - "og_description": null, - "og_image": null, - "og_title": null, - "primary_author": Any, - "primary_tag": Any, - "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "reading_time": 0, - "slug": "scheduled-post", - "status": "scheduled", - "tags": Any, - "tiers": Any, - "title": "This is a scheduled post!!", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": Any, - "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "visibility": "public", - }, - Object { - "authors": Any, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "comment_id": Any, - "count": Object { - "clicks": 0, - "negative_feedback": 0, - "positive_feedback": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "custom_excerpt": null, - "custom_template": null, - "email": null, - "email_only": false, - "email_segment": "all", - "email_subject": null, - "excerpt": "HTML Ipsum Presents - -Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum o", - "feature_image": null, - "feature_image_alt": null, - "feature_image_caption": null, - "featured": false, - "frontmatter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "lexical": null, - "meta_description": "meta description for draft post", - "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[[\\"markdown\\",{\\"markdown\\":\\"

    HTML Ipsum Presents

    Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

    Header Level 2

    1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
    2. Aliquam tincidunt mauris eu risus.

    Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

    Header Level 3

    • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
    • Aliquam tincidunt mauris eu risus.
    #header h1 a{display: block;width: 300px;height: 80px;}
    \\"}]],\\"sections\\":[[10,0]]}", - "newsletter": null, - "og_description": null, - "og_image": null, - "og_title": null, - "primary_author": Any, - "primary_tag": Any, - "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "reading_time": 1, - "slug": "unfinished", - "status": "draft", - "tags": Any, - "tiers": Any, - "title": "Not finished yet", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": Any, - "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "visibility": "public", - }, - Object { - "authors": Any, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "comment_id": Any, - "count": Object { - "clicks": 0, - "negative_feedback": 0, - "positive_feedback": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "custom_excerpt": "We've crammed the most important information to help you get started with Ghost into this one post. It's your cheat-sheet to get started, and your shortcut to advanced features.", - "custom_template": null, - "email": null, - "email_only": false, - "email_segment": "all", - "email_subject": null, - "excerpt": "We've crammed the most important information to help you get started with Ghost into this one post. It's your cheat-sheet to get started, and your shortcut to advanced features.", - "feature_image": "https://static.ghost.org/v4.0.0/images/welcome-to-ghost.png", - "feature_image_alt": null, - "feature_image_caption": null, - "featured": false, - "frontmatter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "lexical": null, - "meta_description": null, - "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"atoms\\":[],\\"cards\\":[[\\"hr\\",{}],[\\"hr\\",{}]],\\"markups\\":[[\\"strong\\"],[\\"a\\",[\\"href\\",\\"http://127.0.0.1:2369/design/\\"]],[\\"a\\",[\\"href\\",\\"http://127.0.0.1:2369/write/\\"]],[\\"a\\",[\\"href\\",\\"http://127.0.0.1:2369/portal/\\"]],[\\"a\\",[\\"href\\",\\"http://127.0.0.1:2369/sell/\\"]],[\\"a\\",[\\"href\\",\\"http://127.0.0.1:2369/grow/\\"]],[\\"a\\",[\\"href\\",\\"http://127.0.0.1:2369/integrations/\\"]],[\\"a\\",[\\"href\\",\\"https://ghost.org/blog/\\"]],[\\"a\\",[\\"href\\",\\"https://ghost.org/pricing/\\"]],[\\"em\\"],[\\"a\\",[\\"href\\",\\"https://forum.ghost.org\\"]]],\\"sections\\":[[1,\\"p\\",[[0,[0],1,\\"Hey there\\"],[0,[],0,\\", welcome to your new home on the web! \\"]]],[1,\\"p\\",[[0,[],0,\\"Unlike social networks, this one is all yours. Publish your work on a custom domain, invite your audience to subscribe, send them new content by email newsletter, and offer premium subscriptions to generate sustainable recurring revenue to fund your work. \\"]]],[1,\\"p\\",[[0,[],0,\\"Ghost is an independent, open source app, which means you can customize absolutely everything. Inside the admin area, you'll find straightforward controls for changing themes, colors, navigation, logos and settings — so you can set your site up just how you like it. No technical knowledge required.\\"]]],[1,\\"p\\",[[0,[],0,\\"If you're feeling a little more adventurous, there's really no limit to what's possible. With just a little bit of HTML and CSS you can modify or build your very own theme from scratch, or connect to Zapier to explore advanced integrations. Advanced developers can go even further and build entirely custom workflows using the Ghost API.\\"]]],[1,\\"p\\",[[0,[],0,\\"This level of customization means that Ghost grows with you. It's easy to get started, but there's always another level of what's possible. So, you won't find yourself outgrowing the app in a few months time and wishing you'd chosen something more powerful!\\"]]],[10,0],[1,\\"p\\",[[0,[],0,\\"For now, you're probably just wondering what to do first. To help get you going as quickly as possible, we've populated your site with starter content (like this post!) covering all the key concepts and features of the product.\\"]]],[1,\\"p\\",[[0,[],0,\\"You'll find an outline of all the different topics below, with links to each section so you can explore the parts that interest you most.\\"]]],[1,\\"p\\",[[0,[],0,\\"Once you're ready to begin publishing and want to clear out these starter posts, you can delete the \\\\\\"Ghost\\\\\\" staff user. Deleting an author will automatically remove all of their posts, leaving you with a clean blank canvas.\\"]]],[1,\\"h2\\",[[0,[],0,\\"Your guide to Ghost\\"]]],[3,\\"ul\\",[[[0,[1],1,\\"Customizing your brand and site settings\\"]],[[0,[2],1,\\"Writing & managing content, an advanced guide for creators\\"]],[[0,[3],1,\\"Building your audience with subscriber signups\\"]],[[0,[4],1,\\"Selling premium memberships with recurring revenue\\"]],[[0,[5],1,\\"How to grow your business around an audience\\"]],[[0,[6],1,\\"Setting up custom integrations and apps\\"]]]],[1,\\"p\\",[[0,[],0,\\"If you get through all those and you're hungry for more, you can find an extensive library of content for creators over on \\"],[0,[7],1,\\"the Ghost blog\\"],[0,[],0,\\".\\"]]],[10,1],[1,\\"h2\\",[[0,[],0,\\"Getting help\\"]]],[1,\\"p\\",[[0,[],0,\\"If you need help, \\"],[0,[8],1,\\"Ghost(Pro)\\"],[0,[],0,\\" customers can always reach our full-time support team by clicking on the \\"],[0,[9],1,\\"Ghost(Pro)\\"],[0,[],0,\\" link inside their admin panel.\\"]]],[1,\\"p\\",[[0,[],0,\\"If you're a developer working with the codebase in a self-managed install, check out our \\"],[0,[10],1,\\"developer community forum\\"],[0,[],0,\\" to chat with other users.\\"]]],[1,\\"p\\",[[0,[],0,\\"Have fun!\\"]]]],\\"ghostVersion\\":\\"4.0\\"}", - "newsletter": null, - "og_description": null, - "og_image": null, - "og_title": null, - "primary_author": Any, - "primary_tag": Any, - "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "reading_time": 2, - "slug": "welcome", - "status": "published", - "tags": Any, - "tiers": Any, - "title": "Start here for a quick overview of everything you need to know", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": Any, - "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "visibility": "public", - }, - Object { - "authors": Any, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "comment_id": Any, - "count": Object { - "clicks": 0, - "negative_feedback": 0, - "positive_feedback": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "custom_excerpt": "How to tweak a few settings in Ghost to transform your site from a generic template to a custom brand with style and personality.", - "custom_template": null, - "email": null, - "email_only": false, - "email_segment": "all", - "email_subject": null, - "excerpt": "How to tweak a few settings in Ghost to transform your site from a generic template to a custom brand with style and personality.", - "feature_image": "https://static.ghost.org/v4.0.0/images/publishing-options.png", - "feature_image_alt": null, - "feature_image_caption": null, - "featured": false, - "frontmatter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "lexical": null, - "meta_description": null, - "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"atoms\\":[],\\"cards\\":[[\\"image\\",{\\"src\\":\\"https://static.ghost.org/v4.0.0/images/brandsettings.png\\",\\"width\\":3456,\\"height\\":2338,\\"cardWidth\\":\\"wide\\",\\"caption\\":\\"Ghost Admin → Settings → Branding\\"}],[\\"image\\",{\\"src\\":\\"https://static.ghost.org/v4.0.0/images/themesettings.png\\",\\"width\\":3208,\\"height\\":1618,\\"cardWidth\\":\\"wide\\",\\"caption\\":\\"Ghost Admin → Settings → Theme\\"}],[\\"code\\",{\\"code\\":\\"{{#post}}\\\\n
    \\\\n\\\\n

    {{title}}

    \\\\n \\\\n {{#if feature_image}}\\\\n \\\\t\\\\\\"Feature\\\\n {{/if}}\\\\n \\\\n {{content}}\\\\n\\\\n
    \\\\n{{/post}}\\",\\"language\\":\\"handlebars\\",\\"caption\\":\\"A snippet from a post template\\"}]],\\"markups\\":[[\\"a\\",[\\"href\\",\\"http://127.0.0.1:2369/welcome/\\"]],[\\"strong\\"],[\\"em\\"],[\\"a\\",[\\"href\\",\\"https://ghost.org/themes/\\"]],[\\"a\\",[\\"href\\",\\"https://github.com/tryghost/casper/\\"]],[\\"a\\",[\\"href\\",\\"https://ghost.org/docs/themes/\\"]]],\\"sections\\":[[1,\\"p\\",[[0,[],0,\\"As discussed in the \\"],[0,[0],1,\\"introduction\\"],[0,[],0,\\" post, one of the best things about Ghost is just how much you can customize to turn your site into something unique. Everything about your layout and design can be changed, so you're not stuck with yet another clone of a social network profile.\\"]]],[1,\\"p\\",[[0,[],0,\\"How far you want to go with customization is completely up to you, there's no right or wrong approach! The majority of people use one of Ghost's built-in themes to get started, and then progress to something more bespoke later on as their site grows. \\"]]],[1,\\"p\\",[[0,[],0,\\"The best way to get started is with Ghost's branding settings, where you can set up colors, images and logos to fit with your brand.\\"]]],[10,0],[1,\\"p\\",[[0,[],0,\\"Any Ghost theme that's up to date and compatible with Ghost 4.0 and higher will reflect your branding settings in the preview window, so you can see what your site will look like as you experiment with different options.\\"]]],[1,\\"p\\",[[0,[],0,\\"When selecting an accent color, try to choose something which will contrast well with white text. Many themes will use your accent color as the background for buttons, headers and navigational elements. Vibrant colors with a darker hue tend to work best, as a general rule.\\"]]],[1,\\"h2\\",[[0,[],0,\\"Installing Ghost themes\\"]]],[1,\\"p\\",[[0,[],0,\\"By default, new sites are created with Ghost's friendly publication theme, called Casper. Everything in Casper is optimized to work for the most common types of blog, newsletter and publication that people create with Ghost — so it's a perfect place to start.\\"]]],[1,\\"p\\",[[0,[],0,\\"However, there are hundreds of different themes available to install, so you can pick out a look and feel that suits you best.\\"]]],[10,1],[1,\\"p\\",[[0,[],0,\\"Inside Ghost's theme settings you'll find 4 more official themes that can be directly installed and activated. Each theme is suited to slightly different use-cases.\\"]]],[3,\\"ul\\",[[[0,[1],1,\\"Casper\\"],[0,[],0,\\" \\"],[0,[2],1,\\"(default)\\"],[0,[],0,\\" — Made for all sorts of blogs and newsletters\\"]],[[0,[1],1,\\"Edition\\"],[0,[],0,\\" — A beautiful minimal template for newsletter authors\\"]],[[0,[1],1,\\"Alto\\"],[0,[],0,\\" — A slick news/magazine style design for creators\\"]],[[0,[1],1,\\"London\\"],[0,[],0,\\" — A light photography theme with a bold grid\\"]],[[0,[1],1,\\"Ease\\"],[0,[],0,\\" — A library theme for organizing large content archives\\"]]]],[1,\\"p\\",[[0,[],0,\\"And if none of those feel quite right, head on over to the \\"],[0,[3],1,\\"Ghost Marketplace\\"],[0,[],0,\\", where you'll find a huge variety of both free and premium themes.\\"]]],[1,\\"h2\\",[[0,[],0,\\"Building something custom\\"]]],[1,\\"p\\",[[0,[],0,\\"Finally, if you want something completely bespoke for your site, you can always build a custom theme from scratch and upload it to your site.\\"]]],[1,\\"p\\",[[0,[],0,\\"Ghost's theming template files are very easy to work with, and can be picked up in the space of a few hours by anyone who has just a little bit of knowledge of HTML and CSS. Templates from other platforms can also be ported to Ghost with relatively little effort.\\"]]],[1,\\"p\\",[[0,[],0,\\"If you want to take a quick look at the theme syntax to see what it's like, you can \\"],[0,[4],1,\\"browse through the files of the default Casper theme\\"],[0,[],0,\\". We've added tons of inline code comments to make it easy to learn, and the structure is very readable.\\"]]],[10,2],[1,\\"p\\",[[0,[],0,\\"See? Not that scary! But still completely optional. \\"]]],[1,\\"p\\",[[0,[],0,\\"If you're interested in creating your own Ghost theme, check out our extensive \\"],[0,[5],1,\\"theme documentation\\"],[0,[],0,\\" for a full guide to all the different template variables and helpers which are available.\\"]]]],\\"ghostVersion\\":\\"4.0\\"}", - "newsletter": null, - "og_description": null, - "og_image": null, - "og_title": null, - "primary_author": Any, - "primary_tag": Any, - "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "reading_time": 3, - "slug": "design", - "status": "published", - "tags": Any, - "tiers": Any, - "title": "Customizing your brand and design settings", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": Any, - "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "visibility": "public", - }, - Object { - "authors": Any, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "comment_id": Any, - "count": Object { - "clicks": 0, - "negative_feedback": 0, - "positive_feedback": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "custom_excerpt": "A full overview of all the features built into the Ghost editor, including powerful workflow automations to speed up your creative process.", - "custom_template": null, - "email": null, - "email_only": false, - "email_segment": "all", - "email_subject": null, - "excerpt": "A full overview of all the features built into the Ghost editor, including powerful workflow automations to speed up your creative process.", - "feature_image": "https://static.ghost.org/v4.0.0/images/writing-posts-with-ghost.png", - "feature_image_alt": null, - "feature_image_caption": null, - "featured": false, - "frontmatter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "lexical": null, - "meta_description": null, - "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"atoms\\":[],\\"cards\\":[[\\"image\\",{\\"src\\":\\"https://static.ghost.org/v4.0.0/images/editor.png\\",\\"width\\":3182,\\"height\\":1500,\\"cardWidth\\":\\"wide\\",\\"caption\\":\\"The Ghost editor. Also available in dark-mode, for late night writing sessions.\\"}],[\\"bookmark\\",{\\"type\\":\\"bookmark\\",\\"url\\":\\"https://opensubscriptionplatforms.com/\\",\\"metadata\\":{\\"url\\":\\"https://opensubscriptionplatforms.com\\",\\"title\\":\\"Open Subscription Platforms\\",\\"description\\":\\"A shared movement for independent subscription data.\\",\\"author\\":null,\\"publisher\\":\\"Open Subscription Platforms\\",\\"thumbnail\\":\\"https://opensubscriptionplatforms.com/images/osp-card.png\\",\\"icon\\":\\"https://opensubscriptionplatforms.com/images/favicon.png\\"}}],[\\"embed\\",{\\"url\\":\\"https://www.youtube.com/watch?v=hmH3XMlms8E\\",\\"html\\":\\"\\",\\"type\\":\\"video\\",\\"metadata\\":{\\"title\\":\\"\\\\\\"VELA\\\\\\" Episode 1 of 4 | John John Florence\\",\\"author_name\\":\\"John John Florence\\",\\"author_url\\":\\"https://www.youtube.com/c/JJF\\",\\"height\\":113,\\"width\\":200,\\"version\\":\\"1.0\\",\\"provider_name\\":\\"YouTube\\",\\"provider_url\\":\\"https://www.youtube.com/\\",\\"thumbnail_height\\":360,\\"thumbnail_width\\":480,\\"thumbnail_url\\":\\"https://i.ytimg.com/vi/hmH3XMlms8E/hqdefault.jpg\\"}}],[\\"image\\",{\\"src\\":\\"https://static.ghost.org/v4.0.0/images/andreas-selter-xSMqGH7gi6o-unsplash.jpg\\",\\"width\\":6000,\\"height\\":4000,\\"cardWidth\\":\\"full\\",\\"caption\\":\\"\\"}],[\\"gallery\\",{\\"images\\":[{\\"fileName\\":\\"andreas-selter-e4yK8QQlZa0-unsplash.jpg\\",\\"row\\":0,\\"width\\":4572,\\"height\\":3048,\\"src\\":\\"https://static.ghost.org/v4.0.0/images/andreas-selter-e4yK8QQlZa0-unsplash.jpg\\"},{\\"fileName\\":\\"steve-carter-Ixp4YhCKZkI-unsplash.jpg\\",\\"row\\":0,\\"width\\":4032,\\"height\\":2268,\\"src\\":\\"https://static.ghost.org/v4.0.0/images/steve-carter-Ixp4YhCKZkI-unsplash.jpg\\"}],\\"caption\\":\\"\\"}],[\\"image\\",{\\"src\\":\\"https://static.ghost.org/v4.0.0/images/lukasz-szmigiel-jFCViYFYcus-unsplash.jpg\\",\\"width\\":2560,\\"height\\":1705,\\"cardWidth\\":\\"wide\\"}],[\\"gallery\\",{\\"images\\":[{\\"fileName\\":\\"jd-mason-hPiEFq6-Eto-unsplash.jpg\\",\\"row\\":0,\\"width\\":5184,\\"height\\":3888,\\"src\\":\\"https://static.ghost.org/v4.0.0/images/jd-mason-hPiEFq6-Eto-unsplash.jpg\\"},{\\"fileName\\":\\"jp-valery-OBpOP9GVH9U-unsplash.jpg\\",\\"row\\":0,\\"width\\":5472,\\"height\\":3648,\\"src\\":\\"https://static.ghost.org/v4.0.0/images/jp-valery-OBpOP9GVH9U-unsplash.jpg\\"}],\\"caption\\":\\"Peaceful places\\"}],[\\"image\\",{\\"src\\":\\"https://static.ghost.org/v4.0.0/images/createsnippet.png\\",\\"width\\":2282,\\"height\\":1272,\\"cardWidth\\":\\"wide\\"}],[\\"hr\\",{}],[\\"image\\",{\\"src\\":\\"https://static.ghost.org/v4.0.0/images/preview.png\\",\\"width\\":3166,\\"height\\":2224,\\"cardWidth\\":\\"wide\\"}],[\\"hr\\",{}]],\\"markups\\":[[\\"em\\"],[\\"code\\"]],\\"sections\\":[[1,\\"p\\",[[0,[],0,\\"Ghost comes with a best-in-class editor which does its very best to get out of the way, and let you focus on your content. Don't let its minimal looks fool you, though, beneath the surface lies a powerful editing toolset designed to accommodate the extensive needs of modern creators.\\"]]],[1,\\"p\\",[[0,[],0,\\"For many, the base canvas of the Ghost editor will feel familiar. You can start writing as you would expect, highlight content to access the toolbar you would expect, and generally use all of the keyboard shortcuts you would expect.\\"]]],[1,\\"p\\",[[0,[],0,\\"Our main focus in building the Ghost editor is to try and make as many things that you hope/expect might work: actually work. \\"]]],[3,\\"ul\\",[[[0,[],0,\\"You can copy and paste raw content from web pages, and Ghost will do its best to correctly preserve the formatting. \\"]],[[0,[],0,\\"Pasting an image from your clipboard will upload inline.\\"]],[[0,[],0,\\"Pasting a social media URL will automatically create an embed.\\"]],[[0,[],0,\\"Highlight a word in the editor and paste a URL from your clipboard on top: Ghost will turn it into a link.\\"]],[[0,[],0,\\"You can also paste (or write!) Markdown and Ghost will usually be able to auto-convert it into fully editable, formatted content.\\"]]]],[10,0],[1,\\"p\\",[[0,[],0,\\"The goal, as much as possible, is for things to work so that you don't have to \\"],[0,[0],1,\\"think\\"],[0,[],0,\\" so much about the editor. You won't find any disastrous \\\\\\"block builders\\\\\\" here, where you have to open 6 submenus and choose from 18 different but identical alignment options. That's not what Ghost is about.\\"]]],[1,\\"p\\",[[0,[],0,\\"What you will find though, is dynamic cards which allow you to embed rich media into your posts and create beautifully laid out stories.\\"]]],[1,\\"h2\\",[[0,[],0,\\"Using cards\\"]]],[1,\\"p\\",[[0,[],0,\\"You can insert dynamic cards inside post content using the \\"],[0,[1],1,\\"+\\"],[0,[],0,\\" button, which appears on new lines, or by typing \\"],[0,[1],1,\\"/\\"],[0,[],0,\\" on a new line to trigger the card menu. Many of the choices are simple and intuitive, like bookmark cards, which allow you to create rich links with embedded structured data:\\"]]],[10,1],[1,\\"p\\",[[0,[],0,\\"or embed cards which make it easy to insert content you want to share with your audience, from external services:\\"]]],[10,2],[1,\\"p\\",[[0,[],0,\\"But, dig a little deeper, and you'll also find more advanced cards, like one that only shows up in email newsletters (great for personalized introductions) and a comprehensive set of specialized cards for different types of images and galleries.\\"]]],[1,\\"blockquote\\",[[0,[],0,\\"Once you start mixing text and image cards creatively, the whole narrative of the story changes. Suddenly, you're working in a new format.\\"]]],[10,3],[1,\\"p\\",[[0,[],0,\\"As it turns out, sometimes pictures and a thousand words go together really well. Telling people a great story often has much more impact if they can feel, even for a moment, as though they were right there with you.\\"]]],[10,4],[10,5],[10,6],[1,\\"p\\",[[0,[],0,\\"Galleries and image cards can be combined in so many different ways — the only limit is your imagination.\\"]]],[1,\\"h2\\",[[0,[],0,\\"Build workflows with snippets\\"]]],[1,\\"p\\",[[0,[],0,\\"One of the most powerful features of the Ghost editor is the ability to create and re-use content snippets. If you've ever used an email client with a concept of \\"],[0,[0],1,\\"saved replies\\"],[0,[],0,\\" then this will be immediately intuitive.\\"]]],[1,\\"p\\",[[0,[],0,\\"To create a snippet, select a piece of content in the editor that you'd like to re-use in future, then click on the snippet icon in the toolbar. Give your snippet a name, and you're all done. Now your snippet will be available from within the card menu, or you can search for it directly using the \\"],[0,[1],1,\\"/\\"],[0,[],0,\\" command.\\"]]],[1,\\"p\\",[[0,[],0,\\"This works really well for saving images you might want to use often, like a company logo or team photo, links to resources you find yourself often linking to, or introductions and passages that you want to remember.\\"]]],[10,7],[1,\\"p\\",[[0,[],0,\\"You can even build entire post templates or outlines to create a quick, re-usable workflow for publishing over time. Or build custom design elements for your post with an HTML card, and use a snippet to insert it.\\"]]],[1,\\"p\\",[[0,[],0,\\"Once you get a few useful snippets set up, it's difficult to go back to the old way of diving through media libraries and trawling for that one thing you know you used somewhere that one time.\\"]]],[10,8],[1,\\"h2\\",[[0,[],0,\\"Publishing and newsletters the easy way\\"]]],[1,\\"p\\",[[0,[],0,\\"When you're ready to publish, Ghost makes it as simple as possible to deliver your new post to all your existing members. Just hit the \\"],[0,[0],1,\\"Preview\\"],[0,[],0,\\" link and you'll get a chance to see what your content looks like on Web, Mobile, Email and Social.\\"]]],[10,9],[1,\\"p\\",[[0,[],0,\\"You can send yourself a test newsletter to make sure everything looks good in your email client, and then hit the \\"],[0,[0],1,\\"Publish\\"],[0,[],0,\\" button to decide who to deliver it to.\\"]]],[1,\\"p\\",[[0,[],0,\\"Ghost comes with a streamlined, optimized email newsletter template that has settings built-in for you to customize the colors and typography. We've spent countless hours refining the template to make sure it works great across all email clients, and performs well for email deliverability.\\"]]],[1,\\"p\\",[[0,[],0,\\"So, you don't need to fight the awful process of building a custom email template from scratch. It's all done already!\\"]]],[10,10],[1,\\"p\\",[[0,[],0,\\"The Ghost editor is powerful enough to do whatever you want it to do. With a little exploration, you'll be up and running in no time.\\"]]]],\\"ghostVersion\\":\\"4.0\\"}", - "newsletter": null, - "og_description": null, - "og_image": null, - "og_title": null, - "primary_author": Any, - "primary_tag": Any, - "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "reading_time": 5, - "slug": "write", - "status": "published", - "tags": Any, - "tiers": Any, - "title": "Writing and managing content in Ghost, an advanced guide", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": Any, - "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "visibility": "public", - }, - Object { - "authors": Any, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "comment_id": Any, - "count": Object { - "clicks": 0, - "negative_feedback": 0, - "positive_feedback": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "custom_excerpt": "How Ghost allows you to turn anonymous readers into an audience of active subscribers, so you know what's working and what isn't.", - "custom_template": null, - "email": null, - "email_only": false, - "email_segment": "all", - "email_subject": null, - "excerpt": "How Ghost allows you to turn anonymous readers into an audience of active subscribers, so you know what's working and what isn't.", - "feature_image": "https://static.ghost.org/v4.0.0/images/creating-a-custom-theme.png", - "feature_image_alt": null, - "feature_image_caption": null, - "featured": false, - "frontmatter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "lexical": null, - "meta_description": null, - "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"atoms\\":[],\\"cards\\":[[\\"image\\",{\\"src\\":\\"https://static.ghost.org/v4.0.0/images/portalsettings.png\\",\\"width\\":2924,\\"height\\":1810,\\"cardWidth\\":\\"wide\\"}],[\\"hr\\",{}]],\\"markups\\":[[\\"em\\"],[\\"a\\",[\\"href\\",\\"#/portal\\"]],[\\"a\\",[\\"href\\",\\"http://127.0.0.1:2369/sell/\\"]]],\\"sections\\":[[1,\\"p\\",[[0,[],0,\\"What sets Ghost apart from other products is that you can publish content and grow your audience using the same platform. Rather than just endlessly posting and hoping someone is listening, you can track real signups against your work and have them subscribe to be notified of future posts. The feature that makes all this possible is called \\"],[0,[0],1,\\"Portal\\"],[0,[],0,\\".\\"]]],[1,\\"p\\",[[0,[],0,\\"Portal is an embedded interface for your audience to sign up to your site. It works on every Ghost site, with every theme, and for any type of publisher. \\"]]],[1,\\"p\\",[[0,[],0,\\"You can customize the design, content and settings of Portal to suit your site, whether you just want people to sign up to your newsletter — or you're running a full premium publication with user sign-ins and private content.\\"]]],[10,0],[1,\\"p\\",[[0,[],0,\\"Once people sign up to your site, they'll receive an email confirmation with a link to click. The link acts as an automatic sign-in, so subscribers will be automatically signed-in to your site when they click on it. There are a couple of interesting angles to this:\\"]]],[1,\\"p\\",[[0,[],0,\\"Because subscribers are automatically able to sign in and out of your site as registered members: You can (optionally) restrict access to posts and pages depending on whether people are signed-in or not. So if you want to publish some posts for free, but keep some really great stuff for members-only, this can be a great draw to encourage people to sign up!\\"]]],[1,\\"p\\",[[0,[],0,\\"Ghost members sign in using email authentication links, so there are no passwords for people to set or forget. You can turn any list of email subscribers into a database of registered members who can sign in to your site. Like magic.\\"]]],[1,\\"p\\",[[0,[],0,\\"Portal makes all of this possible, and it appears by default as a floating button in the bottom-right corner of your site. When people are logged out, clicking it will open a sign-up/sign-in window. When members are logged in, clicking the Portal button will open the account menu where they can edit their name, email, and subscription settings.\\"]]],[1,\\"p\\",[[0,[],0,\\"The floating Portal button is completely optional. If you prefer, you can add manual links to your content, navigation, or theme to trigger it instead.\\"]]],[1,\\"p\\",[[0,[],0,\\"Like this! \\"],[0,[1],1,\\"Sign up here\\"]]],[10,1],[1,\\"p\\",[[0,[],0,\\"As you start to grow your registered audience, you'll be able to get a sense of who you're publishing \\"],[0,[0],1,\\"for\\"],[0,[],0,\\" and where those people are coming \\"],[0,[0],1,\\"from\\"],[0,[],0,\\". Best of all: You'll have a straightforward, reliable way to connect with people who enjoy your work.\\"]]],[1,\\"p\\",[[0,[],0,\\"Social networks go in and out of fashion all the time. Email addresses are timeless.\\"]]],[1,\\"p\\",[[0,[],0,\\"Growing your audience is valuable no matter what type of site you run, but if your content \\"],[0,[0],1,\\"is\\"],[0,[],0,\\" your business, then you might also be interested in \\"],[0,[2],1,\\"setting up premium subscriptions\\"],[0,[],0,\\".\\"]]]],\\"ghostVersion\\":\\"4.0\\"}", - "newsletter": null, - "og_description": null, - "og_image": null, - "og_title": null, - "primary_author": Any, - "primary_tag": Any, - "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "reading_time": 2, - "slug": "portal", - "status": "published", - "tags": Any, - "tiers": Any, - "title": "Building your audience with subscriber signups", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": Any, - "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "visibility": "public", - }, - Object { - "authors": Any, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "comment_id": Any, - "count": Object { - "clicks": 0, - "negative_feedback": 0, - "positive_feedback": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "custom_excerpt": null, - "custom_template": null, - "email": null, - "email_only": false, - "email_segment": "all", - "email_subject": null, - "excerpt": "For creators and aspiring entrepreneurs looking to generate a sustainable recurring revenue stream from their creative work, Ghost has built-in payments allowing you to create a subscription commerce business. - -Connect your Stripe account to Ghost, and you'll be able to quickly and easily create monthly and yearly premium plans for members to subscribe to, as well as complimentary plans for friends and family. - -Ghost takes 0% payment fees, so everything you make is yours to keep! - -Using subscrip", - "feature_image": "https://static.ghost.org/v4.0.0/images/organizing-your-content.png", - "feature_image_alt": null, - "feature_image_caption": null, - "featured": false, - "frontmatter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "lexical": null, - "meta_description": null, - "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"atoms\\":[],\\"cards\\":[[\\"image\\",{\\"src\\":\\"https://static.ghost.org/v4.0.0/images/thebrowser.jpg\\",\\"width\\":1600,\\"height\\":2000,\\"href\\":\\"https://thebrowser.com\\",\\"caption\\":\\"The Browser has over 10,000 paying subscribers\\"}],[\\"paywall\\",{}]],\\"markups\\":[[\\"a\\",[\\"href\\",\\"https://stripe.com\\"]],[\\"strong\\"],[\\"a\\",[\\"href\\",\\"https://stratechery.com\\"]],[\\"a\\",[\\"href\\",\\"https://www.theinformation.com\\"]],[\\"a\\",[\\"href\\",\\"https://thebrowser.com\\"]]],\\"sections\\":[[1,\\"p\\",[[0,[],0,\\"For creators and aspiring entrepreneurs looking to generate a sustainable recurring revenue stream from their creative work, Ghost has built-in payments allowing you to create a subscription commerce business.\\"]]],[1,\\"p\\",[[0,[],0,\\"Connect your \\"],[0,[0],1,\\"Stripe\\"],[0,[],0,\\" account to Ghost, and you'll be able to quickly and easily create monthly and yearly premium plans for members to subscribe to, as well as complimentary plans for friends and family.\\"]]],[1,\\"p\\",[[0,[],0,\\"Ghost takes \\"],[0,[1],1,\\"0% payment fees\\"],[0,[],0,\\", so everything you make is yours to keep!\\"]]],[1,\\"p\\",[[0,[],0,\\"Using subscriptions, you can build an independent media business like \\"],[0,[2],1,\\"Stratechery\\"],[0,[],0,\\", \\"],[0,[3],1,\\"The Information\\"],[0,[],0,\\", or \\"],[0,[4],1,\\"The Browser\\"],[0,[],0,\\".\\"]]],[1,\\"p\\",[[0,[],0,\\"The creator economy is just getting started, and Ghost allows you to build something based on technology that you own and control.\\"]]],[10,0],[1,\\"p\\",[[0,[],0,\\"Most successful subscription businesses publish a mix of free and paid posts to attract a new audience, and upsell the most loyal members to a premium offering. You can also mix different access levels within the same post, showing a free preview to logged out members and then, right when you're ready for a cliffhanger, that's a good time to...\\"]]],[10,1],[1,\\"p\\",[[0,[],0,\\"Hold back some of the best bits for paying members only! \\"]]],[1,\\"p\\",[[0,[],0,\\"The \\"],[0,[1],1,\\"Public preview\\"],[0,[],0,\\" card allows to create a divide between how much of your post should be available as a public free-preview, and how much should be restricted based on the post access level.\\"]]],[1,\\"p\\",[[0,[],0,\\"These last paragraphs are only visible on the site if you're logged in as a paying member. To test this out, you can connect a Stripe account, create a member account for yourself, and give yourself a complimentary premium plan.\\"]]]],\\"ghostVersion\\":\\"4.0\\"}", - "newsletter": null, - "og_description": null, - "og_image": null, - "og_title": null, - "primary_author": Any, - "primary_tag": Any, - "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "reading_time": 1, - "slug": "sell", - "status": "published", - "tags": Any, - "tiers": Any, - "title": "Selling premium memberships with recurring revenue", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": Any, - "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "visibility": "paid", - }, - Object { - "authors": Any, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "comment_id": Any, - "count": Object { - "clicks": 0, - "negative_feedback": 0, - "positive_feedback": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "custom_excerpt": "A guide to collaborating with other staff users to publish, and some resources to help you with the next steps of growing your business", - "custom_template": null, - "email": null, - "email_only": false, - "email_segment": "all", - "email_subject": null, - "excerpt": "A guide to collaborating with other staff users to publish, and some resources to help you with the next steps of growing your business", - "feature_image": "https://static.ghost.org/v4.0.0/images/admin-settings.png", - "feature_image_alt": null, - "feature_image_caption": null, - "featured": false, - "frontmatter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "lexical": null, - "meta_description": null, - "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"atoms\\":[[\\"soft-return\\",\\"\\",{}],[\\"soft-return\\",\\"\\",{}],[\\"soft-return\\",\\"\\",{}],[\\"soft-return\\",\\"\\",{}],[\\"soft-return\\",\\"\\",{}],[\\"soft-return\\",\\"\\",{}],[\\"soft-return\\",\\"\\",{}],[\\"soft-return\\",\\"\\",{}],[\\"soft-return\\",\\"\\",{}],[\\"soft-return\\",\\"\\",{}],[\\"soft-return\\",\\"\\",{}],[\\"soft-return\\",\\"\\",{}]],\\"cards\\":[[\\"hr\\",{}]],\\"markups\\":[[\\"strong\\"],[\\"a\\",[\\"href\\",\\"https://ghost.org/pricing/\\"]],[\\"em\\"],[\\"a\\",[\\"href\\",\\"https://ghost.org/blog/how-to-create-a-newsletter/\\"]],[\\"a\\",[\\"href\\",\\"https://ghost.org/blog/membership-sites/\\"]],[\\"a\\",[\\"href\\",\\"https://newsletterguide.org/\\"]],[\\"a\\",[\\"href\\",\\"https://ghost.org/blog/find-your-niche-creator-economy/\\"]],[\\"a\\",[\\"href\\",\\"https://ghost.org/blog/newsletter-referral-programs/\\"]]],\\"sections\\":[[1,\\"p\\",[[0,[],0,\\"As you grow, you'll probably want to start inviting team members and collaborators to your site. Ghost has a number of different user roles for your team:\\"]]],[1,\\"p\\",[[0,[0],1,\\"Contributors\\"],[1,[],0,0],[0,[],0,\\"This is the base user level in Ghost. Contributors can create and edit their own draft posts, but they are unable to edit drafts of others or publish posts. Contributors are \\"],[0,[0],1,\\"untrusted\\"],[0,[],0,\\" users with the most basic access to your publication.\\"]]],[1,\\"p\\",[[0,[0],1,\\"Authors\\"],[1,[],0,1],[0,[],0,\\"Authors are the 2nd user level in Ghost. Authors can write, edit and publish their own posts. Authors are \\"],[0,[0],1,\\"trusted\\"],[0,[],0,\\" users. If you don't trust users to be allowed to publish their own posts, they should be set as Contributors.\\"]]],[1,\\"p\\",[[0,[0],1,\\"Editors\\"],[1,[],0,2],[0,[],0,\\"Editors are the 3rd user level in Ghost. Editors can do everything that an Author can do, but they can also edit and publish the posts of others - as well as their own. Editors can also invite new Contributors & Authors to the site.\\"]]],[1,\\"p\\",[[0,[0],1,\\"Administrators\\"],[1,[],0,3],[0,[],0,\\"The top user level in Ghost is Administrator. Again, administrators can do everything that Authors and Editors can do, but they can also edit all site settings and data, not just content. Additionally, administrators have full access to invite, manage or remove any other user of the site.\\"],[1,[],0,4],[1,[],0,5],[0,[0],1,\\"The Owner\\"],[1,[],0,6],[0,[],0,\\"There is only ever one owner of a Ghost site. The owner is a special user which has all the same permissions as an Administrator, but with two exceptions: The Owner can never be deleted. And in some circumstances the owner will have access to additional special settings if applicable. For example: billing details, if using \\"],[0,[1,0],2,\\"Ghost(Pro)\\"],[0,[],0,\\".\\"]]],[1,\\"blockquote\\",[[0,[2],1,\\"Ask all of your users to fill out their user profiles, including bio and social links. These will populate rich structured data for posts and generally create more opportunities for themes to fully populate their design.\\"]]],[10,0],[1,\\"p\\",[[0,[],0,\\"If you're looking for insights, tips and reference materials to expand your content business, here's 5 top resources to get you started:\\"]]],[3,\\"ul\\",[[[0,[3,0],2,\\"How to create a premium newsletter (+ some case studies)\\"],[0,[0],1,\\" \\"],[0,[],0,\\" \\"],[1,[],0,7],[0,[],0,\\"Learn how others run successful paid email newsletter products\\"]],[[0,[0,4],2,\\"The ultimate guide to membership websites for creators\\"],[1,[],0,8],[0,[],0,\\"Tips to help you build, launch and grow your new membership business\\"]],[[0,[0,5],2,\\"The Newsletter Guide\\"],[1,[],0,9],[0,[],0,\\"A 201 guide for taking your newsletters to the next level\\"]],[[0,[6,0],2,\\"The proven way to find your niche, explained\\"],[1,[],0,10],[0,[],0,\\"Find the overlap and find a monetizable niche that gets noticed\\"]],[[0,[0,7],2,\\"Should you launch a referral program? \\"],[1,[],0,11],[0,[],0,\\"Strategies for building a sustainable referral growth machine\\"]]]]],\\"ghostVersion\\":\\"4.0\\"}", - "newsletter": null, - "og_description": null, - "og_image": null, - "og_title": null, - "primary_author": Any, - "primary_tag": Any, - "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "reading_time": 2, - "slug": "grow", - "status": "published", - "tags": Any, - "tiers": Any, - "title": "How to grow your business around an audience", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": Any, - "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "visibility": "public", - }, - Object { - "authors": Any, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "comment_id": Any, - "count": Object { - "clicks": 0, - "negative_feedback": 0, - "positive_feedback": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "custom_excerpt": "Work with all your favorite apps and tools or create your own custom integrations using the Ghost API.", - "custom_template": null, - "email": null, - "email_only": false, - "email_segment": "all", - "email_subject": null, - "excerpt": "Work with all your favorite apps and tools or create your own custom integrations using the Ghost API.", - "feature_image": "https://static.ghost.org/v4.0.0/images/app-integrations.png", - "feature_image_alt": null, - "feature_image_caption": null, - "featured": false, - "frontmatter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "lexical": null, - "meta_description": null, - "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"atoms\\":[],\\"cards\\":[[\\"image\\",{\\"src\\":\\"https://static.ghost.org/v4.0.0/images/integrations-icons.png\\",\\"cardWidth\\":\\"full\\"}],[\\"markdown\\",{\\"markdown\\":\\"\\\\n\\"}],[\\"image\\",{\\"src\\":\\"https://static.ghost.org/v4.0.0/images/iawriter-integration.png\\",\\"width\\":2244,\\"height\\":936}]],\\"markups\\":[[\\"a\\",[\\"href\\",\\"https://ghost.org/integrations/\\"]],[\\"strong\\"]],\\"sections\\":[[1,\\"p\\",[[0,[],0,\\"It's possible to extend your Ghost site and connect it with hundreds of the most popular apps and tools using integrations. \\"]]],[1,\\"p\\",[[0,[],0,\\"Whether you need to automatically publish new posts on social media, connect your favorite analytics tool, sync your community or embed forms into your content — our \\"],[0,[0],1,\\"integrations library\\"],[0,[],0,\\" has got it all covered with hundreds of integration tutorials.\\"]]],[1,\\"p\\",[[0,[],0,\\"Many integrations are as simple as inserting an embed by pasting a link, or copying a snippet of code directly from an app and pasting it into Ghost. Our integration tutorials are used by creators of all kinds to get apps and integrations up and running in no time — no technical knowledge required.\\"]]],[10,0],[1,\\"h2\\",[[0,[],0,\\"Zapier\\"]]],[1,\\"p\\",[[0,[],0,\\"Zapier is a no-code tool that allows you to build powerful automations, and our official integration allows you to connect your Ghost site to more than 1,000 external services.\\"]]],[1,\\"blockquote\\",[[0,[1],1,\\"Example\\"],[0,[],0,\\": When someone new subscribes to a newsletter on a Ghost site (Trigger) then the contact information is automatically pushed into MailChimp (Action).\\"]]],[1,\\"p\\",[[0,[1],1,\\"Here's a few of the most popular automation templates:\\"],[0,[],0,\\" \\"]]],[10,1],[1,\\"h2\\",[[0,[],0,\\"Custom integrations\\"]]],[1,\\"p\\",[[0,[],0,\\"For more advanced automation, it's possible to create custom Ghost integrations with dedicated API keys from the Integrations page within Ghost Admin. \\"]]],[10,2],[1,\\"p\\",[[0,[],0,\\"These custom integrations allow you to use the Ghost API without needing to write code, and create powerful workflows such as sending content from your favorite desktop editor into Ghost as a new draft.\\"]]]],\\"ghostVersion\\":\\"4.0\\"}", - "newsletter": null, - "og_description": null, - "og_image": null, - "og_title": null, - "primary_author": Any, - "primary_tag": Any, - "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "reading_time": 1, - "slug": "integrations", - "status": "published", - "tags": Any, - "tiers": Any, - "title": "Setting up apps and custom integrations", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": Any, - "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "visibility": "public", - }, - ], -} -`; - -exports[`Collections API Automatic Collection Filtering Creates an automatic Collection with a published_at filter 4: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "78560", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Automatic Collection Filtering Creates an automatic Collection with a tag filter, checking filter aliases 1: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "BACON!", - "feature_image": null, - "filter": "tag:['bacon']", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "bacon-tag-expansion", - "title": "Test Collection with tag filter alias", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Automatic Collection Filtering Creates an automatic Collection with a tag filter, checking filter aliases 2: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "296", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/collections\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-cache-invalidate": "/*", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Automatic Collection Filtering Creates an automatic Collection with a tag filter, checking filter aliases 3: [body] 1`] = ` -Object { - "meta": Object { - "pagination": Object { - "limit": 15, - "next": null, - "page": 1, - "pages": 1, - "prev": null, - "total": 2, - }, - }, - "posts": Array [ - Object { - "authors": Any, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "comment_id": Any, - "count": Object { - "clicks": 0, - "negative_feedback": 0, - "positive_feedback": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "custom_excerpt": null, - "custom_template": null, - "email": null, - "email_only": false, - "email_segment": "all", - "email_subject": null, - "excerpt": "HTML Ipsum Presents - -Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum o", - "feature_image": "http://127.0.0.1:2369/content/images/2018/hey.jpg", - "feature_image_alt": null, - "feature_image_caption": null, - "featured": false, - "frontmatter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "lexical": null, - "meta_description": null, - "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[[\\"markdown\\",{\\"markdown\\":\\"

    HTML Ipsum Presents

    Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

    Header Level 2

    1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
    2. Aliquam tincidunt mauris eu risus.

    Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

    Header Level 3

    • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
    • Aliquam tincidunt mauris eu risus.
    #header h1 a{display: block;width: 300px;height: 80px;}
    \\"}]],\\"sections\\":[[10,0]]}", - "newsletter": null, - "og_description": null, - "og_image": null, - "og_title": null, - "primary_author": Any, - "primary_tag": Any, - "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "reading_time": 1, - "slug": "ghostly-kitchen-sink", - "status": "published", - "tags": Array [ - Object { - "accent_color": null, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "description", - "feature_image": "https://example.com/super_photo.jpg", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "meta_description": null, - "meta_title": null, - "name": "kitchen sink", - "og_description": null, - "og_image": null, - "og_title": null, - "slug": "kitchen-sink", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": "http://127.0.0.1:2369/tag/kitchen-sink/", - "visibility": "public", - }, - Object { - "accent_color": null, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "description", - "feature_image": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "meta_description": null, - "meta_title": null, - "name": "bacon", - "og_description": null, - "og_image": null, - "og_title": null, - "slug": "bacon", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": "http://127.0.0.1:2369/tag/bacon/", - "visibility": "public", - }, - ], - "tiers": Any, - "title": "Ghostly Kitchen Sink", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": Any, - "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "visibility": "public", - }, - Object { - "authors": Any, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "comment_id": Any, - "count": Object { - "clicks": 0, - "negative_feedback": 0, - "positive_feedback": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "custom_excerpt": "This is my custom excerpt!", - "custom_template": null, - "email": null, - "email_only": false, - "email_segment": "all", - "email_subject": null, - "excerpt": "This is my custom excerpt!", - "feature_image": "https://example.com/super_photo.jpg", - "feature_image_alt": null, - "feature_image_caption": null, - "featured": false, - "frontmatter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "lexical": null, - "meta_description": null, - "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[[\\"markdown\\",{\\"markdown\\":\\"

    HTML Ipsum Presents

    Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

    Header Level 2

    1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
    2. Aliquam tincidunt mauris eu risus.

    Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

    Header Level 3

    • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
    • Aliquam tincidunt mauris eu risus.
    #header h1 a{display: block;width: 300px;height: 80px;}
    \\"}]],\\"sections\\":[[10,0]]}", - "newsletter": null, - "og_description": null, - "og_image": null, - "og_title": null, - "primary_author": Any, - "primary_tag": Any, - "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "reading_time": 1, - "slug": "html-ipsum", - "status": "published", - "tags": Array [ - Object { - "accent_color": null, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "description", - "feature_image": "https://example.com/super_photo.jpg", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "meta_description": null, - "meta_title": null, - "name": "kitchen sink", - "og_description": null, - "og_image": null, - "og_title": null, - "slug": "kitchen-sink", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": "http://127.0.0.1:2369/tag/kitchen-sink/", - "visibility": "public", - }, - Object { - "accent_color": null, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "description", - "feature_image": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "meta_description": null, - "meta_title": null, - "name": "bacon", - "og_description": null, - "og_image": null, - "og_title": null, - "slug": "bacon", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": "http://127.0.0.1:2369/tag/bacon/", - "visibility": "public", - }, - ], - "tiers": Any, - "title": "HTML Ipsum", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": Any, - "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "visibility": "public", - }, - ], -} -`; - -exports[`Collections API Automatic Collection Filtering Creates an automatic Collection with a tag filter, checking filter aliases 4: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "14423", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Automatic Collection Filtering Creates an automatic Collection with a tags filter 1: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "BACON!", - "feature_image": null, - "filter": "tags:['bacon']", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "tag-filter", - "title": "Test Collection with tag filter", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Automatic Collection Filtering Creates an automatic Collection with a tags filter 2: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "282", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/collections\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-cache-invalidate": "/*", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Automatic Collection Filtering Creates an automatic Collection with a tags filter 3: [body] 1`] = ` -Object { - "meta": Object { - "pagination": Object { - "limit": 15, - "next": null, - "page": 1, - "pages": 1, - "prev": null, - "total": 2, - }, - }, - "posts": Array [ - Object { - "authors": Any, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "comment_id": Any, - "count": Object { - "clicks": 0, - "negative_feedback": 0, - "positive_feedback": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "custom_excerpt": null, - "custom_template": null, - "email": null, - "email_only": false, - "email_segment": "all", - "email_subject": null, - "excerpt": "HTML Ipsum Presents - -Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum o", - "feature_image": "http://127.0.0.1:2369/content/images/2018/hey.jpg", - "feature_image_alt": null, - "feature_image_caption": null, - "featured": false, - "frontmatter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "lexical": null, - "meta_description": null, - "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[[\\"markdown\\",{\\"markdown\\":\\"

    HTML Ipsum Presents

    Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

    Header Level 2

    1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
    2. Aliquam tincidunt mauris eu risus.

    Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

    Header Level 3

    • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
    • Aliquam tincidunt mauris eu risus.
    #header h1 a{display: block;width: 300px;height: 80px;}
    \\"}]],\\"sections\\":[[10,0]]}", - "newsletter": null, - "og_description": null, - "og_image": null, - "og_title": null, - "primary_author": Any, - "primary_tag": Any, - "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "reading_time": 1, - "slug": "ghostly-kitchen-sink", - "status": "published", - "tags": Array [ - Object { - "accent_color": null, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "description", - "feature_image": "https://example.com/super_photo.jpg", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "meta_description": null, - "meta_title": null, - "name": "kitchen sink", - "og_description": null, - "og_image": null, - "og_title": null, - "slug": "kitchen-sink", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": "http://127.0.0.1:2369/tag/kitchen-sink/", - "visibility": "public", - }, - Object { - "accent_color": null, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "description", - "feature_image": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "meta_description": null, - "meta_title": null, - "name": "bacon", - "og_description": null, - "og_image": null, - "og_title": null, - "slug": "bacon", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": "http://127.0.0.1:2369/tag/bacon/", - "visibility": "public", - }, - ], - "tiers": Any, - "title": "Ghostly Kitchen Sink", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": Any, - "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "visibility": "public", - }, - Object { - "authors": Any, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "comment_id": Any, - "count": Object { - "clicks": 0, - "negative_feedback": 0, - "positive_feedback": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "custom_excerpt": "This is my custom excerpt!", - "custom_template": null, - "email": null, - "email_only": false, - "email_segment": "all", - "email_subject": null, - "excerpt": "This is my custom excerpt!", - "feature_image": "https://example.com/super_photo.jpg", - "feature_image_alt": null, - "feature_image_caption": null, - "featured": false, - "frontmatter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "lexical": null, - "meta_description": null, - "meta_title": null, - "mobiledoc": "{\\"version\\":\\"0.3.1\\",\\"markups\\":[],\\"atoms\\":[],\\"cards\\":[[\\"markdown\\",{\\"markdown\\":\\"

    HTML Ipsum Presents

    Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

    Header Level 2

    1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
    2. Aliquam tincidunt mauris eu risus.

    Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

    Header Level 3

    • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
    • Aliquam tincidunt mauris eu risus.
    #header h1 a{display: block;width: 300px;height: 80px;}
    \\"}]],\\"sections\\":[[10,0]]}", - "newsletter": null, - "og_description": null, - "og_image": null, - "og_title": null, - "primary_author": Any, - "primary_tag": Any, - "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "reading_time": 1, - "slug": "html-ipsum", - "status": "published", - "tags": Array [ - Object { - "accent_color": null, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "description", - "feature_image": "https://example.com/super_photo.jpg", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "meta_description": null, - "meta_title": null, - "name": "kitchen sink", - "og_description": null, - "og_image": null, - "og_title": null, - "slug": "kitchen-sink", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": "http://127.0.0.1:2369/tag/kitchen-sink/", - "visibility": "public", - }, - Object { - "accent_color": null, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "description", - "feature_image": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "meta_description": null, - "meta_title": null, - "name": "bacon", - "og_description": null, - "og_image": null, - "og_title": null, - "slug": "bacon", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": "http://127.0.0.1:2369/tag/bacon/", - "visibility": "public", - }, - ], - "tiers": Any, - "title": "HTML Ipsum", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": Any, - "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "visibility": "public", - }, - ], -} -`; - -exports[`Collections API Automatic Collection Filtering Creates an automatic Collection with a tags filter 4: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "14423", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Browse Can browse Collections 1: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "All posts", - "feature_image": null, - "filter": "", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "latest", - "title": "Latest", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "Featured posts", - "feature_image": null, - "filter": "featured:true", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "featured", - "title": "Featured", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], - "meta": Object { - "pagination": Object { - "limit": 2, - "next": null, - "page": 1, - "pages": 1, - "prev": null, - "total": 2, - }, - }, -} -`; - -exports[`Collections API Browse Can browse Collections 2: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "576", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Browse Can browse Collections and include the posts count 1: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "count": Object { - "posts": 13, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "All posts", - "feature_image": null, - "filter": "", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "latest", - "title": "Latest", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - Object { - "count": Object { - "posts": 2, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "Featured posts", - "feature_image": null, - "filter": "featured:true", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "featured", - "title": "Featured", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], - "meta": Object { - "pagination": Object { - "limit": 2, - "next": null, - "page": 1, - "pages": 1, - "prev": null, - "total": 2, - }, - }, -} -`; - -exports[`Collections API Browse Can browse Collections and include the posts count 2: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "617", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Browse Makes limited DB queries when browsing 1: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "All posts", - "feature_image": null, - "filter": "", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "latest", - "title": "Latest", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "Featured posts", - "feature_image": null, - "filter": "featured:true", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "featured", - "title": "Featured", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], - "meta": Object { - "pagination": Object { - "limit": 2, - "next": null, - "page": 1, - "pages": 1, - "prev": null, - "total": 2, - }, - }, -} -`; - -exports[`Collections API Browse Makes limited DB queries when browsing 2: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "576", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Collection Posts updates automatically Makes limited DB queries when updating due to post changes 1: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "count": Object { - "posts": 2, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "Featured posts", - "feature_image": null, - "filter": "featured:true", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "featured", - "title": "Featured", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Collection Posts updates automatically Makes limited DB queries when updating due to post changes 2: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "284", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Collection Posts updates automatically Makes limited DB queries when updating due to post changes 3: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "count": Object { - "posts": 2, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "Featured posts", - "feature_image": null, - "filter": "featured:true", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "featured", - "title": "Featured", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Collection Posts updates automatically Makes limited DB queries when updating due to post changes 4: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "284", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Collection Posts updates automatically Makes limited DB queries when updating due to post changes 5: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "count": Object { - "posts": 3, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "Featured posts", - "feature_image": null, - "filter": "featured:true", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "featured", - "title": "Featured", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Collection Posts updates automatically Makes limited DB queries when updating due to post changes 6: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "284", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Collection Posts updates automatically Makes limited DB queries when updating due to post changes 7: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "count": Object { - "posts": 2, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "Featured posts", - "feature_image": null, - "filter": "featured:true", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "featured", - "title": "Featured", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Collection Posts updates automatically Makes limited DB queries when updating due to post changes 8: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "284", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Collection Posts updates automatically Updates a collection with tag filter when tag is added to posts in bulk and when tag is removed 1: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": null, - "feature_image": null, - "filter": "tags:['papaya']", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "papaya-madness", - "title": "Papaya madness", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Collection Posts updates automatically Updates a collection with tag filter when tag is added to posts in bulk and when tag is removed 2: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "266", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/collections\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-cache-invalidate": "/*", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Collection Posts updates automatically Updates a collection with tag filter when tag is added to posts in bulk and when tag is removed 3: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "count": Object { - "posts": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": null, - "feature_image": null, - "filter": "tags:['papaya']", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "papaya-madness", - "title": "Papaya madness", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Collection Posts updates automatically Updates a collection with tag filter when tag is added to posts in bulk and when tag is removed 4: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "286", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Collection Posts updates automatically Updates a collection with tag filter when tag is added to posts in bulk and when tag is removed 5: [body] 1`] = ` -Object { - "bulk": Object { - "meta": Object { - "stats": Object { - "successful": 11, - "unsuccessful": 0, - }, - }, - }, -} -`; - -exports[`Collections API Collection Posts updates automatically Updates a collection with tag filter when tag is added to posts in bulk and when tag is removed 6: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "count": Object { - "posts": 11, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": null, - "feature_image": null, - "filter": "tags:['papaya']", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "papaya-madness", - "title": "Papaya madness", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Collection Posts updates automatically Updates a collection with tag filter when tag is added to posts in bulk and when tag is removed 7: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "287", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Collection Posts updates automatically Updates a collection with tag filter when tag is added to posts in bulk and when tag is removed 8: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "count": Object { - "posts": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": null, - "feature_image": null, - "filter": "tags:['papaya']", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "papaya-madness", - "title": "Papaya madness", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Collection Posts updates automatically Updates a collection with tag filter when tag is added to posts in bulk and when tag is removed 9: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "286", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Collection Posts updates automatically Updates collections when a Post is added/edited/deleted 1: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "count": Object { - "posts": 2, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "Featured posts", - "feature_image": null, - "filter": "featured:true", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "featured", - "title": "Featured", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Collection Posts updates automatically Updates collections when a Post is added/edited/deleted 2: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "284", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Collection Posts updates automatically Updates collections when a Post is added/edited/deleted 3: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "count": Object { - "posts": 2, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "Featured posts", - "feature_image": null, - "filter": "featured:true", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "featured", - "title": "Featured", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Collection Posts updates automatically Updates collections when a Post is added/edited/deleted 4: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "284", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Collection Posts updates automatically Updates collections when a Post is added/edited/deleted 5: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "count": Object { - "posts": 3, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "Featured posts", - "feature_image": null, - "filter": "featured:true", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "featured", - "title": "Featured", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Collection Posts updates automatically Updates collections when a Post is added/edited/deleted 6: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "284", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Collection Posts updates automatically Updates collections when a Post is added/edited/deleted 7: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "count": Object { - "posts": 2, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "Featured posts", - "feature_image": null, - "filter": "featured:true", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "featured", - "title": "Featured", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Collection Posts updates automatically Updates collections when a Post is added/edited/deleted 8: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "284", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Delete Can delete a Collection 1: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": null, - "feature_image": null, - "filter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "test-collection-to-delete", - "title": "Test Collection to Delete", - "type": "manual", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Delete Can delete a Collection 2: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "272", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/collections\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-cache-invalidate": "/*", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Delete Can delete a Collection 3: [body] 1`] = `Object {}`; - -exports[`Collections API Delete Can delete a Collection 4: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin", - "x-cache-invalidate": "/*", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Delete Can delete a Collection 5: [body] 1`] = ` -Object { - "errors": Array [ - Object { - "code": null, - "context": "Collection not found.", - "details": null, - "ghostErrorCode": null, - "help": null, - "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "message": "Resource not found error, cannot read collection.", - "property": null, - "type": "NotFoundError", - }, - ], -} -`; - -exports[`Collections API Delete Can delete a Collection 6: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "254", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Delete Cannot delete a built in collection 1: [body] 1`] = ` -Object { - "errors": Array [ - Object { - "code": null, - "context": Any, - "details": null, - "ghostErrorCode": null, - "help": null, - "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "message": "Method not allowed, cannot delete collection.", - "property": null, - "type": "MethodNotAllowedError", - }, - ], -} -`; - -exports[`Collections API Delete Cannot delete a built in collection 2: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "355", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Edit Can edit a Collection 1: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": null, - "feature_image": null, - "filter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "test-collection-to-edit", - "title": "Test Collection Edited", - "type": "manual", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Edit Can edit a Collection 2: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "267", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-cache-invalidate": "/*", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Edit Fails to edit unexistent Collection 1: [body] 1`] = ` -Object { - "errors": Array [ - Object { - "code": null, - "context": "Collection not found.", - "details": null, - "ghostErrorCode": null, - "help": null, - "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "message": "Resource not found error, cannot edit collection.", - "property": null, - "type": "NotFoundError", - }, - ], -} -`; - -exports[`Collections API Edit Fails to edit unexistent Collection 2: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "254", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Read Can read a Collection by id and slug 1: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": null, - "feature_image": null, - "filter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "test-collection-to-read", - "title": "Test Collection to Read", - "type": "manual", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Read Can read a Collection by id and slug 2: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "268", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/collections\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-cache-invalidate": "/*", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Read Can read a Collection by id and slug 3: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": null, - "feature_image": null, - "filter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "test-collection-to-read", - "title": "Test Collection to Read", - "type": "manual", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Read Can read a Collection by id and slug 4: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "268", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Read Can read a Collection by id and slug 5: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": null, - "feature_image": null, - "filter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "test-collection-to-read", - "title": "Test Collection to Read", - "type": "manual", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Read Can read a Collection by id and slug 6: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "268", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Read Can read a Collection by id and slug and include the post counts 1: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "count": Object { - "posts": 2, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "Featured posts", - "feature_image": null, - "filter": "featured:true", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "featured", - "title": "Featured", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Read Can read a Collection by id and slug and include the post counts 2: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "284", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Collections API Read Can read a Collection by id and slug and include the post counts 3: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "count": Object { - "posts": 2, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "Featured posts", - "feature_image": null, - "filter": "featured:true", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "featured", - "title": "Featured", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections API Read Can read a Collection by id and slug and include the post counts 4: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "284", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap index e1deccb0121..ef614cda16d 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap @@ -16,7 +16,6 @@ Object { "announcementBar": true, "audienceFeedback": true, "captcha": true, - "collections": true, "collectionsCard": true, "contentVisibility": true, "customFonts": true, diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/posts.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/posts.test.js.snap index e666a2cdb23..517a2ffb9cf 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/posts.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/posts.test.js.snap @@ -2082,559 +2082,6 @@ Object { } `; -exports[`Posts API Update Can add and remove collections 1: [body] 1`] = ` -Object { - "posts": Array [ - Object { - "authors": Any, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "comment_id": Any, - "count": Object { - "clicks": 0, - "negative_feedback": 0, - "positive_feedback": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "custom_excerpt": null, - "custom_template": null, - "email": null, - "email_only": false, - "email_segment": "all", - "email_subject": null, - "excerpt": null, - "feature_image": null, - "feature_image_alt": null, - "feature_image_caption": null, - "featured": false, - "frontmatter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "lexical": "{\\"root\\":{\\"children\\":[{\\"children\\":[],\\"direction\\":null,\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"paragraph\\",\\"version\\":1}],\\"direction\\":null,\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"root\\",\\"version\\":1}}", - "meta_description": null, - "meta_title": null, - "mobiledoc": null, - "newsletter": null, - "og_description": null, - "og_image": null, - "og_title": null, - "primary_author": Any, - "primary_tag": Any, - "published_at": null, - "slug": "collection-update-test", - "status": "draft", - "tags": Any, - "tiers": Array [ - Object { - "active": true, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "currency": null, - "description": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "monthly_price": null, - "monthly_price_id": null, - "name": "Free", - "slug": "free", - "trial_days": 0, - "type": "free", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "visibility": "public", - "welcome_page_url": null, - "yearly_price": null, - "yearly_price_id": null, - }, - Object { - "active": true, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "currency": "usd", - "description": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "monthly_price": 500, - "monthly_price_id": null, - "name": "Default Product", - "slug": "default-product", - "trial_days": 0, - "type": "paid", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "visibility": "public", - "welcome_page_url": null, - "yearly_price": 5000, - "yearly_price_id": null, - }, - ], - "title": "Collection update test", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": Any, - "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "visibility": "public", - }, - ], -} -`; - -exports[`Posts API Update Can add and remove collections 2: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "3906", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/posts\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-powered-by": "Express", -} -`; - -exports[`Posts API Update Can add and remove collections 3: [body] 1`] = ` -Object { - "posts": Array [ - Object { - "authors": Any, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "collections": Array [ - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z/, - "description": null, - "feature_image": null, - "filter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "posts": Array [ - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 0, - }, - ], - "slug": "collection-to-remove", - "title": "Collection to remove.", - "type": "manual", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z/, - }, - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z/, - "description": "All posts", - "feature_image": null, - "filter": "", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "posts": Array [ - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 0, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 1, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 2, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 3, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 4, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 5, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 6, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 7, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 8, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 9, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 10, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 11, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 12, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 13, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 14, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 15, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 16, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 17, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 18, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 19, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 20, - }, - ], - "slug": "latest", - "title": "Latest", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z/, - }, - ], - "comment_id": Any, - "count": Object { - "clicks": 0, - "negative_feedback": 0, - "positive_feedback": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "custom_excerpt": null, - "custom_template": null, - "email": null, - "email_only": false, - "email_segment": "all", - "email_subject": null, - "excerpt": null, - "feature_image": null, - "feature_image_alt": null, - "feature_image_caption": null, - "featured": false, - "frontmatter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "lexical": "{\\"root\\":{\\"children\\":[{\\"children\\":[],\\"direction\\":null,\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"paragraph\\",\\"version\\":1}],\\"direction\\":null,\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"root\\",\\"version\\":1}}", - "meta_description": null, - "meta_title": null, - "mobiledoc": null, - "newsletter": null, - "og_description": null, - "og_image": null, - "og_title": null, - "primary_author": Any, - "primary_tag": Any, - "published_at": null, - "slug": "collection-update-test", - "status": "draft", - "tags": Any, - "tiers": Array [ - Object { - "active": true, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "currency": null, - "description": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "monthly_price": null, - "monthly_price_id": null, - "name": "Free", - "slug": "free", - "trial_days": 0, - "type": "free", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "visibility": "public", - "welcome_page_url": null, - "yearly_price": null, - "yearly_price_id": null, - }, - Object { - "active": true, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "currency": "usd", - "description": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "monthly_price": 500, - "monthly_price_id": null, - "name": "Default Product", - "slug": "default-product", - "trial_days": 0, - "type": "paid", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "visibility": "public", - "welcome_page_url": null, - "yearly_price": 5000, - "yearly_price_id": null, - }, - ], - "title": "Collection update test", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": Any, - "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "visibility": "public", - }, - ], -} -`; - -exports[`Posts API Update Can add and remove collections 4: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "5502", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-cache-invalidate": StringMatching /\\\\/p\\\\/\\[0-9a-f\\]\\{8\\}-\\[0-9a-f\\]\\{4\\}-\\[0-9a-f\\]\\{4\\}-\\[0-9a-f\\]\\{4\\}-\\[0-9a-f\\]\\{12\\}/, - "x-powered-by": "Express", -} -`; - -exports[`Posts API Update Can add and remove collections 5: [body] 1`] = ` -Object { - "posts": Array [ - Object { - "authors": Any, - "canonical_url": null, - "codeinjection_foot": null, - "codeinjection_head": null, - "collections": Array [ - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z/, - "description": null, - "feature_image": null, - "filter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "posts": Array [ - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 0, - }, - ], - "slug": "collection-to-add", - "title": "Collection to add.", - "type": "manual", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z/, - }, - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z/, - "description": "All posts", - "feature_image": null, - "filter": "", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "posts": Array [ - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 0, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 1, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 2, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 3, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 4, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 5, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 6, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 7, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 8, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 9, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 10, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 11, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 12, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 13, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 14, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 15, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 16, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 17, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 18, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 19, - }, - Object { - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "sort_order": 20, - }, - ], - "slug": "latest", - "title": "Latest", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z/, - }, - ], - "comment_id": Any, - "count": Object { - "clicks": 0, - "negative_feedback": 0, - "positive_feedback": 0, - }, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "custom_excerpt": null, - "custom_template": null, - "email": null, - "email_only": false, - "email_segment": "all", - "email_subject": null, - "excerpt": null, - "feature_image": null, - "feature_image_alt": null, - "feature_image_caption": null, - "featured": false, - "frontmatter": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "lexical": "{\\"root\\":{\\"children\\":[{\\"children\\":[],\\"direction\\":null,\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"paragraph\\",\\"version\\":1}],\\"direction\\":null,\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"root\\",\\"version\\":1}}", - "meta_description": null, - "meta_title": null, - "mobiledoc": null, - "newsletter": null, - "og_description": null, - "og_image": null, - "og_title": null, - "primary_author": Any, - "primary_tag": Any, - "published_at": null, - "slug": "collection-update-test", - "status": "draft", - "tags": Any, - "tiers": Array [ - Object { - "active": true, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "currency": null, - "description": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "monthly_price": null, - "monthly_price_id": null, - "name": "Free", - "slug": "free", - "trial_days": 0, - "type": "free", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "visibility": "public", - "welcome_page_url": null, - "yearly_price": null, - "yearly_price_id": null, - }, - Object { - "active": true, - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "currency": "usd", - "description": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "monthly_price": 500, - "monthly_price_id": null, - "name": "Default Product", - "slug": "default-product", - "trial_days": 0, - "type": "paid", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "visibility": "public", - "welcome_page_url": null, - "yearly_price": 5000, - "yearly_price_id": null, - }, - ], - "title": "Collection update test", - "twitter_description": null, - "twitter_image": null, - "twitter_title": null, - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": Any, - "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "visibility": "public", - }, - ], -} -`; - -exports[`Posts API Update Can add and remove collections 6: [headers] 1`] = ` -Object { - "access-control-allow-origin": "http://127.0.0.1:2369", - "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "5496", - "content-type": "application/json; charset=utf-8", - "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, - "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, - "vary": "Accept-Version, Origin, Accept-Encoding", - "x-cache-invalidate": StringMatching /\\\\/p\\\\/\\[0-9a-f\\]\\{8\\}-\\[0-9a-f\\]\\{4\\}-\\[0-9a-f\\]\\{4\\}-\\[0-9a-f\\]\\{4\\}-\\[0-9a-f\\]\\{12\\}/, - "x-powered-by": "Express", -} -`; - exports[`Posts API Update Can update a post with lexical 1: [body] 1`] = ` Object { "posts": Array [ diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap index 992836b6ca0..2e8ac892610 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap @@ -1155,7 +1155,7 @@ exports[`Settings API Edit Can edit a setting 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "4439", + "content-length": "4418", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, diff --git a/ghost/core/test/e2e-api/admin/collections.test.js b/ghost/core/test/e2e-api/admin/collections.test.js deleted file mode 100644 index 14f3791adb0..00000000000 --- a/ghost/core/test/e2e-api/admin/collections.test.js +++ /dev/null @@ -1,861 +0,0 @@ -const assert = require('assert/strict'); -const DomainEvents = require('@tryghost/domain-events'); -const { - agentProvider, - fixtureManager, - mockManager, - matchers -} = require('../../utils/e2e-framework'); -const { - anyContentVersion, - anyEtag, - anyErrorId, - anyLocationFor, - anyObjectId, - anyISODateTime, - anyString, - anyUuid, - anyArray, - anyObject -} = matchers; - -const matchCollection = { - id: anyObjectId, - created_at: anyISODateTime, - updated_at: anyISODateTime -}; - -const tagSnapshotMatcher = { - id: anyObjectId, - created_at: anyISODateTime, - updated_at: anyISODateTime -}; - -const matchPostShallowIncludes = { - id: anyObjectId, - uuid: anyUuid, - comment_id: anyString, - url: anyString, - authors: anyArray, - primary_author: anyObject, - tags: anyArray, - primary_tag: anyObject, - tiers: anyArray, - created_at: anyISODateTime, - updated_at: anyISODateTime, - published_at: anyISODateTime -}; - -async function trackDb(fn, skip) { - const db = require('../../../core/server/data/db'); - if (db?.knex?.client?.config?.client !== 'sqlite3') { - return skip(); - } - /** @type {import('sqlite3').Database} */ - const database = db.knex.client; - - const queries = []; - function handler(/** @type {{sql: string}} */ query) { - queries.push(query); - } - - database.on('query', handler); - - await fn(); - - database.off('query', handler); - - return queries; -} - -describe('Collections API', function () { - let agent; - - before(async function () { - mockManager.mockLabsEnabled('collections'); - agent = await agentProvider.getAdminAPIAgent(); - await fixtureManager.init('users', 'posts'); - await agent.loginAsOwner(); - }); - - afterEach(function () { - mockManager.restore(); - }); - - describe('Browse', function () { - it('Can browse Collections', async function () { - await agent - .get('/collections/') - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - collections: [ - matchCollection, - matchCollection - ] - }); - }); - - it('Makes limited DB queries when browsing', async function () { - const queries = await trackDb(async () => { - await agent - .get('/collections/') - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - collections: [ - matchCollection, - matchCollection - ] - }); - }, this.skip.bind(this)); - const collectionRelatedQueries = queries.filter(query => query.sql.includes('collection')); - assert(collectionRelatedQueries.length === 3); - }); - - it('Can browse Collections and include the posts count', async function () { - await agent - .get('/collections/?include=count.posts') - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - collections: [ - {...matchCollection, count: {posts: 13}}, - {...matchCollection, count: {posts: 2}} - ] - }); - }); - }); - - describe('Read', function () { - it('Can read a Collection by id and slug', async function () { - const collection = { - title: 'Test Collection to Read' - }; - - const addResponse = await agent - .post('/collections/') - .body({ - collections: [collection] - }) - .expectStatus(201) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag, - location: anyLocationFor('collections') - }) - .matchBodySnapshot({ - collections: [matchCollection] - }); - - const collectionId = addResponse.body.collections[0].id; - - const readResponse = await agent - .get(`/collections/${collectionId}/`) - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - collections: [matchCollection] - }); - - assert.equal(readResponse.body.collections[0].title, 'Test Collection to Read'); - - const collectionSlug = addResponse.body.collections[0].slug; - const readBySlugResponse = await agent - .get(`/collections/slug/${collectionSlug}/`) - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - collections: [matchCollection] - }); - - assert.equal(readBySlugResponse.body.collections[0].title, 'Test Collection to Read'); - - await agent - .delete(`/collections/${collectionId}/`) - .expectStatus(204); - }); - - it('Can read a Collection by id and slug and include the post counts', async function () { - const {body: {collections: [collection]}} = await agent.get(`/collections/slug/featured/?include=count.posts`) - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - collections: [{ - ...matchCollection, - count: { - posts: 2 - } - }] - }); - - await agent.get(`/collections/${collection.id}/?include=count.posts`) - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - collections: [{ - ...matchCollection, - count: { - posts: 2 - } - }] - }); - }); - }); - - describe('Edit', function () { - let collectionToEdit; - - before(async function () { - const collection = { - title: 'Test Collection to Edit' - }; - - const addResponse = await agent - .post('/collections/') - .body({ - collections: [collection] - }) - .expectStatus(201); - - collectionToEdit = addResponse.body.collections[0]; - }); - - it('Can edit a Collection', async function () { - const editResponse = await agent - .put(`/collections/${collectionToEdit.id}/`) - .body({ - collections: [{ - title: 'Test Collection Edited' - }] - }) - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - collections: [matchCollection] - }); - - assert.equal(editResponse.body.collections[0].title, 'Test Collection Edited'); - }); - - it('Fails to edit unexistent Collection', async function () { - const unexistentID = '5951f5fca366002ebd5dbef7'; - await agent - .put(`/collections/${unexistentID}/`) - .body({ - collections: [{ - id: unexistentID, - title: 'Editing unexistent Collection' - }] - }) - .expectStatus(404) - .matchBodySnapshot({ - errors: [{ - id: anyErrorId - }] - }) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }); - }); - }); - - describe('Add', function () { - it('Can add a Collection', async function () { - const collection = { - title: 'Test Collection', - description: 'Test Collection Description' - }; - - const {body: {collections: [{id: collectionId}]}} = await agent - .post('/collections/') - .body({ - collections: [collection] - }) - .expectStatus(201) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag, - location: anyLocationFor('collections') - }) - .matchBodySnapshot({ - collections: [matchCollection] - }); - - await agent - .delete(`/collections/${collectionId}/`) - .expectStatus(204); - }); - }); - - describe('Delete', function () { - it('Can delete a Collection', async function () { - const collection = { - title: 'Test Collection to Delete' - }; - - const addResponse = await agent - .post('/collections/') - .body({ - collections: [collection] - }) - .expectStatus(201) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag, - location: anyLocationFor('collections') - }) - .matchBodySnapshot({ - collections: [matchCollection] - }); - - const collectionId = addResponse.body.collections[0].id; - - await agent - .delete(`/collections/${collectionId}/`) - .expectStatus(204) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot(); - - await agent - .get(`/collections/${collectionId}/`) - .expectStatus(404) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - errors: [{ - id: anyErrorId - }] - }); - }); - - it('Cannot delete a built in collection', async function () { - const builtInCollection = await agent - .get('/collections/?filter=slug:featured') - .expectStatus(200); - - assert.ok(builtInCollection.body.collections); - assert.equal(builtInCollection.body.collections.length, 1); - - await agent - .delete(`/collections/${builtInCollection.body.collections[0].id}/`) - .expectStatus(405) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - errors: [{ - id: anyErrorId, - context: anyString - }] - }); - }); - }); - - describe('Automatic Collection Filtering', function () { - it('Creates an automatic Collection with a featured filter', async function () { - const collection = { - title: 'Test Featured Collection', - slug: 'featured-filter', - description: 'Test Collection Description', - type: 'automatic', - filter: 'featured:true' - }; - - await agent - .post('/collections/') - .body({ - collections: [collection] - }) - .expectStatus(201) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag, - location: anyLocationFor('collections') - }) - .matchBodySnapshot({ - collections: [matchCollection] - }); - - await agent.get(`posts/?collection=${collection.slug}`) - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - posts: new Array(2).fill(matchPostShallowIncludes) - }); - }); - - it('Creates an automatic Collection with a published_at filter', async function () { - const collection = { - title: 'Test Collection with published_at filter', - slug: 'published-at-filter', - description: 'Test Collection Description with published_at filter', - type: 'automatic', - filter: 'published_at:>=2022-05-25' - }; - - await agent - .post('/collections/') - .body({ - collections: [collection] - }) - .expectStatus(201) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag, - location: anyLocationFor('collections') - }) - .matchBodySnapshot({ - collections: [matchCollection] - }); - - await agent.get(`posts/?collection=${collection.slug}`) - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - posts: new Array(9).fill(matchPostShallowIncludes) - }); - }); - - it('Creates an automatic Collection with a tags filter', async function () { - const collection = { - title: 'Test Collection with tag filter', - slug: 'tag-filter', - description: 'BACON!', - type: 'automatic', - filter: 'tags:[\'bacon\']' - }; - - await agent - .post('/collections/') - .body({ - collections: [collection] - }) - .expectStatus(201) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag, - location: anyLocationFor('collections') - }) - .matchBodySnapshot({ - collections: [matchCollection] - }); - - await agent.get(`posts/?collection=${collection.slug}`) - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - posts: new Array(2).fill({ - ...matchPostShallowIncludes, - tags: new Array(2).fill(tagSnapshotMatcher) - }) - }); - }); - - it('Creates an automatic Collection with a tag filter, checking filter aliases', async function () { - const collection = { - title: 'Test Collection with tag filter alias', - slug: 'bacon-tag-expansion', - description: 'BACON!', - type: 'automatic', - filter: 'tag:[\'bacon\']' - }; - - await agent - .post('/collections/') - .body({ - collections: [collection] - }) - .expectStatus(201) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag, - location: anyLocationFor('collections') - }) - .matchBodySnapshot({ - collections: [matchCollection] - }); - - await agent.get(`posts/?collection=${collection.slug}`) - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - posts: new Array(2).fill({ - ...matchPostShallowIncludes, - tags: new Array(2).fill(tagSnapshotMatcher) - }) - }); - }); - }); - - describe('Collection Posts updates automatically', function () { - it('Makes limited DB queries when updating due to post changes', async function () { - await agent - .get(`/collections/slug/featured/?include=count.posts`) - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - collections: [{ - ...matchCollection, - count: { - posts: 2 - } - }] - }); - - const postToAdd = { - title: 'Collection update test', - featured: false - }; - - let post; - - { - const queries = await trackDb(async () => { - const {body: {posts: [createdPost]}} = await agent - .post('/posts/') - .body({ - posts: [postToAdd] - }) - .expectStatus(201); - - await DomainEvents.allSettled(); - - post = createdPost; - }, this.skip.bind(this)); - - const collectionRelatedQueries = queries.filter(query => query.sql.includes('collection')); - assert.equal(collectionRelatedQueries.length, 7); - } - - await agent - .get(`/collections/slug/featured/?include=count.posts`) - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - collections: [{ - ...matchCollection, - count: { - posts: 2 - } - }] - }); - - { - const queries = await trackDb(async () => { - await agent - .put(`/posts/${post.id}/`) - .body({ - posts: [Object.assign({}, post, {featured: true})] - }) - .expectStatus(200); - - await DomainEvents.allSettled(); - }, this.skip.bind(this)); - - const collectionRelatedQueries = queries.filter(query => query.sql.includes('collection')); - assert.equal(collectionRelatedQueries.length, 16); - } - - await agent - .get(`/collections/slug/featured/?include=count.posts`) - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - collections: [{ - ...matchCollection, - count: { - posts: 3 - } - }] - }); - - { - const queries = await trackDb(async () => { - await agent - .delete(`/posts/${post.id}/`) - .expectStatus(204); - - await DomainEvents.allSettled(); - }, this.skip.bind(this)); - const collectionRelatedQueries = queries.filter(query => query.sql.includes('collection')); - - // deletion is handled on the DB layer through Cascade Delete, - // so collections should not execute any additional queries - assert.equal(collectionRelatedQueries.length, 0); - } - - await agent - .get(`/collections/slug/featured/?include=count.posts`) - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - collections: [{ - ...matchCollection, - count: { - posts: 2 - } - }] - }); - }); - it('Updates collections when a Post is added/edited/deleted', async function () { - await agent - .get(`/collections/slug/featured/?include=count.posts`) - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - collections: [{ - ...matchCollection, - count: { - posts: 2 - } - }] - }); - - const postToAdd = { - title: 'Collection update test', - featured: false - }; - - const {body: {posts: [post]}} = await agent - .post('/posts/') - .body({ - posts: [postToAdd] - }) - .expectStatus(201); - - await agent - .get(`/collections/slug/featured/?include=count.posts`) - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - collections: [{ - ...matchCollection, - count: { - posts: 2 - } - }] - }); - - await agent - .put(`/posts/${post.id}/`) - .body({ - posts: [Object.assign({}, post, {featured: true})] - }) - .expectStatus(200); - - await DomainEvents.allSettled(); - - await agent - .get(`/collections/slug/featured/?include=count.posts`) - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - collections: [{ - ...matchCollection, - count: { - posts: 3 - } - }] - }); - - await agent - .delete(`/posts/${post.id}/`) - .expectStatus(204); - - await DomainEvents.allSettled(); - - await agent - .get(`/collections/slug/featured/?include=count.posts`) - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - collections: [{ - ...matchCollection, - count: { - posts: 2 - } - }] - }); - }); - - it('Updates a collection with tag filter when tag is added to posts in bulk and when tag is removed', async function (){ - const collection = { - title: 'Papaya madness', - type: 'automatic', - filter: 'tags:[\'papaya\']' - }; - - const {body: {collections: [{id: collectionId}]}} = await agent - .post('/collections/') - .body({ - collections: [collection] - }) - .expectStatus(201) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag, - location: anyLocationFor('collections') - }) - .matchBodySnapshot({ - collections: [matchCollection] - }); - - // should contain no posts - await agent - .get(`/collections/${collectionId}/?include=count.posts`) - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - collections: [{ - ...matchCollection, - count: { - posts: 0 - } - }] - }); - - const tag = { - name: 'Papaya', - slug: 'papaya' - }; - - const {body: {tags: [{id: tagId}]}} = await agent - .post('/tags/') - .body({ - tags: [tag] - }) - .expectStatus(201); - - // add papaya tag to all posts - await agent - .put('/posts/bulk/?filter=' + encodeURIComponent('status:[published]')) - .body({ - bulk: { - action: 'addTag', - meta: { - tags: [ - { - id: tagId - } - ] - } - } - }) - .expectStatus(200) - .matchBodySnapshot(); - - await DomainEvents.allSettled(); - - // should contain posts with papaya tags - await agent - .get(`/collections/${collectionId}/?include=count.posts`) - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - collections: [{ - ...matchCollection, - count: { - posts: 11 - } - }] - }); - - await agent - .delete(`/tags/${tagId}/`) - .expectStatus(204); - - await DomainEvents.allSettled(); - - // should contain ZERO posts with papaya tags - await agent - .get(`/collections/${collectionId}/?include=count.posts`) - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - collections: [{ - ...matchCollection, - count: { - posts: 0 - } - }] - }); - }); - }); -}); diff --git a/ghost/core/test/e2e-api/admin/pages.test.js b/ghost/core/test/e2e-api/admin/pages.test.js index 88011bc1654..d690b01455c 100644 --- a/ghost/core/test/e2e-api/admin/pages.test.js +++ b/ghost/core/test/e2e-api/admin/pages.test.js @@ -1,4 +1,3 @@ -const _ = require('lodash'); const {mobiledocToLexical} = require('@tryghost/kg-converters'); const models = require('../../../core/server/models'); const {agentProvider, fixtureManager, mockManager, matchers} = require('../../utils/e2e-framework'); @@ -141,151 +140,6 @@ describe('Pages API', function () { }); }); - it('Works with latest collection card', async function () { - const initialLexical = { - root: { - children: [ - { - type: 'collection', - version: 1, - collection: 'latest', - postCount: 3, - layout: 'grid', - columns: 3, - header: 'Latest' - } - ], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - }; - - const updatedLexical = _.cloneDeep(initialLexical); - updatedLexical.root.children.push({ - children: [ - { - detail: 0, - format: 0, - mode: 'normal', - style: '', - text: 'Testing', - type: 'text', - version: 1 - } - ], - direction: 'ltr', - format: '', - indent: 0, - type: 'paragraph', - version: 1 - }); - - const page = { - title: 'Latest Collection Card Test', - status: 'draft', - lexical: JSON.stringify(initialLexical) - }; - - const {body: createBody} = await agent - .post('/pages/?formats=mobiledoc,lexical,html', { - headers: { - 'content-type': 'application/json' - } - }) - .body({pages: [page]}) - .expectStatus(201); - - const [createResponse] = createBody.pages; - - // does not match body snapshot as we mostly only care about the request succeeding. - // matching body snapshots is tricky because collection cards have dynamic content, - // most notably the post dates which are always changing. - await agent - .put(`/pages/${createResponse.id}/?formats=mobiledoc,lexical,html`) - .body({ - pages: [{ - id: createResponse.id, - lexical: JSON.stringify(updatedLexical), - updated_at: createResponse.updated_at // satisfy collision detection - }] - }) - .expectStatus(200); - }); - - it('Works with featured collection card', async function () { - const initialLexical = { - root: { - children: [ - { - type: 'collection', - version: 1, - collection: 'featured', - postCount: 3, - layout: 'grid', - columns: 3, - header: 'Featured' - } - ], - direction: null, - format: '', - indent: 0, - type: 'root', - version: 1 - } - }; - - const updatedLexical = _.cloneDeep(initialLexical); - updatedLexical.root.children.push({ - children: [ - { - detail: 0, - format: 0, - mode: 'normal', - style: '', - text: 'Testing', - type: 'text', - version: 1 - } - ], - direction: 'ltr', - format: '', - indent: 0, - type: 'paragraph', - version: 1 - }); - - const page = { - title: 'Latest Collection Card Test', - status: 'draft', - lexical: JSON.stringify(initialLexical) - }; - - const {body: createBody} = await agent - .post('/pages/?formats=mobiledoc,lexical,html', { - headers: { - 'content-type': 'application/json' - } - }) - .body({pages: [page]}) - .expectStatus(201); - - const [createResponse] = createBody.pages; - - await agent - .put(`/pages/${createResponse.id}/?formats=mobiledoc,lexical,html`) - .body({ - pages: [{ - id: createResponse.id, - lexical: JSON.stringify(updatedLexical), - updated_at: createResponse.updated_at // satisfy collision detection - }] - }) - .expectStatus(200); - }); - describe('Access', function () { describe('Visibility is set to tiers', function () { it('Saves only paid tiers', async function () { diff --git a/ghost/core/test/e2e-api/admin/posts-bulk.test.js b/ghost/core/test/e2e-api/admin/posts-bulk.test.js index ff23a720562..a1f526e4e44 100644 --- a/ghost/core/test/e2e-api/admin/posts-bulk.test.js +++ b/ghost/core/test/e2e-api/admin/posts-bulk.test.js @@ -9,7 +9,6 @@ describe('Posts Bulk API', function () { let agent; before(async function () { - mockManager.mockLabsEnabled('collections'); mockManager.mockLabsEnabled('collectionsCard'); agent = await agentProvider.getAdminAPIAgent(); @@ -43,10 +42,6 @@ describe('Posts Bulk API', function () { assert(amount > 0, 'Expect at least one post to be affected for this test to work'); - let featuredCollection = await models.Collection.findPage({filter: 'slug:featured', limit: 1, withRelated: ['collectionPosts']}); - let featuredCollectionPostsAmount = featuredCollection.data[0].toJSON().collectionPosts.length; - assert(featuredCollectionPostsAmount > 0, 'Expect to have multiple featured collection posts'); - const response = await agent .put('/posts/bulk/?filter=' + encodeURIComponent(filter)) .body({ @@ -72,10 +67,6 @@ describe('Posts Bulk API', function () { const posts = await models.Post.findAll({filter, status: 'all'}); assert.equal(posts.length, amount, `Expect all matching posts (${amount}) to be changed`); - featuredCollection = await models.Collection.findPage({filter: 'slug:featured', limit: 1, withRelated: ['collectionPosts']}); - featuredCollectionPostsAmount = featuredCollection.data[0].toJSON().collectionPosts.length; - assert.equal(featuredCollectionPostsAmount, amount, 'Expect to have same amount featured collection posts as changed'); - for (const post of posts) { assert(post.get('featured') === true, `Expect post ${post.id} to be featured`); } @@ -90,10 +81,6 @@ describe('Posts Bulk API', function () { assert(amount > 0, 'Expect at least one post to be affected for this test to work'); - let featuredCollection = await models.Collection.findPage({filter: 'slug:featured', limit: 1, withRelated: ['collectionPosts']}); - let featuredCollectionPostsAmount = featuredCollection.data[0].toJSON().collectionPosts.length; - assert(featuredCollectionPostsAmount > 0, 'Expect to have multiple featured collection posts'); - const response = await agent .put('/posts/bulk/?filter=' + encodeURIComponent(filter)) .body({ @@ -112,10 +99,6 @@ describe('Posts Bulk API', function () { const posts = await models.Post.findAll({filter, status: 'all'}); assert.equal(posts.length, amount, `Expect all matching posts (${amount}) to be changed`); - featuredCollection = await models.Collection.findPage({filter: 'slug:featured', limit: 1, withRelated: ['collectionPosts']}); - featuredCollectionPostsAmount = featuredCollection.data[0].toJSON().collectionPosts.length; - assert.equal(featuredCollectionPostsAmount, 0, 'Expect to have no featured collection posts'); - for (const post of posts) { assert(post.get('featured') === false, `Expect post ${post.id} to be unfeatured`); } @@ -339,13 +322,6 @@ describe('Posts Bulk API', function () { assert(amount > 0, 'Expect at least one post to be affected for this test to work'); - await agent - .get('posts/?collection=latest') - .expectStatus(200) - .expect((res) => { - assert(res.body.posts.length > 0, 'Expect latest collection to have some posts'); - }); - const response = await agent .delete('/posts/?filter=' + encodeURIComponent(filter)) .expectStatus(200) @@ -383,10 +359,6 @@ describe('Posts Bulk API', function () { // Check if all posts were deleted const posts = await models.Post.findPage({filter, status: 'all'}); assert.equal(posts.meta.pagination.total, 0, `Expect all matching posts (${amount}) to be deleted`); - - let latestCollection = await models.Collection.findPage({filter: 'slug:latest', limit: 1, withRelated: ['collectionPosts']}); - latestCollection = latestCollection.data[0].toJSON().collectionPosts.length; - assert.equal(latestCollection, 0, 'Expect to have no collection posts'); }); }); }); diff --git a/ghost/core/test/e2e-api/admin/posts.test.js b/ghost/core/test/e2e-api/admin/posts.test.js index 04c64403e1d..e31e2b49295 100644 --- a/ghost/core/test/e2e-api/admin/posts.test.js +++ b/ghost/core/test/e2e-api/admin/posts.test.js @@ -1,6 +1,4 @@ const should = require('should'); -const assert = require('assert/strict'); -const DomainEvents = require('@tryghost/domain-events'); const {agentProvider, fixtureManager, mockManager, matchers} = require('../../utils/e2e-framework'); const {anyArray, anyContentVersion, anyEtag, anyErrorId, anyLocationFor, anyObject, anyObjectId, anyISODateTime, anyString, anyStringNumber, anyUuid, stringMatching} = matchers; const models = require('../../../core/server/models'); @@ -28,23 +26,6 @@ const matchPostShallowIncludes = { published_at: anyISODateTime }; -const buildMatchPostShallowIncludes = (tiersCount = 2) => { - return { - id: anyObjectId, - uuid: anyUuid, - comment_id: anyString, - url: anyString, - authors: anyArray, - primary_author: anyObject, - tags: anyArray, - primary_tag: anyObject, - tiers: Array(tiersCount).fill(tierSnapshot), - created_at: anyISODateTime, - updated_at: anyISODateTime, - published_at: anyISODateTime - }; -}; - function testCleanedSnapshot(text, ignoreReplacements) { for (const {match, replacement} of ignoreReplacements) { if (match instanceof RegExp) { @@ -107,7 +88,6 @@ describe('Posts API', function () { let agent; before(async function () { - mockManager.mockLabsEnabled('collections', true); mockManager.mockLabsEnabled('collectionsCard', true); agent = await agentProvider.getAdminAPIAgent(); await fixtureManager.init('posts'); @@ -152,35 +132,6 @@ describe('Posts API', function () { }); }); - it('Can browse filtering by a collection', async function () { - await agent.get('posts/?collection=featured') - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - posts: new Array(2).fill(matchPostShallowIncludes) - }); - }); - - it('Can browse filtering by collection using paging parameters', async function () { - await agent - .get(`posts/?collection=latest&limit=1&page=6`) - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - posts: Array(1).fill(buildMatchPostShallowIncludes(2)) - }) - .expect((res) => { - // the total of posts with any status is 13 - assert.equal(res.body.meta.pagination.total, 13); - }); - }); - describe('Export', function () { it('Can export', async function () { const {text} = await agent.get('posts/export') @@ -563,103 +514,6 @@ describe('Posts API', function () { mobiledocRevisions.length.should.equal(0); }); - it('Can add and remove collections', async function () { - const {body: postBody} = await agent - .post('/posts/') - .body({ - posts: [{ - title: 'Collection update test' - }] - }) - .expectStatus(201) - .matchBodySnapshot({ - posts: [Object.assign({}, matchPostShallowIncludes, {published_at: null})] - }) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag, - location: anyLocationFor('posts') - }); - - const [postResponse] = postBody.posts; - - const {body: { - collections: [collectionToAdd] - }} = await agent - .post('/collections/') - .body({ - collections: [{ - title: 'Collection to add.' - }] - }); - - const {body: { - collections: [collectionToRemove] - }} = await agent - .post('/collections/') - .body({ - collections: [{ - title: 'Collection to remove.' - }] - }); - - const collectionPostMatcher = { - id: anyObjectId - }; - const collectionMatcher = { - id: anyObjectId, - created_at: stringMatching(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/), - updated_at: stringMatching(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/), - posts: [{ - id: anyObjectId - }] - }; - const buildCollectionMatcher = (postsCount) => { - return { - id: anyObjectId, - created_at: stringMatching(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/), - updated_at: stringMatching(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/), - posts: Array(postsCount).fill(collectionPostMatcher) - }; - }; - - await agent.put(`/posts/${postResponse.id}/`) - .body({posts: [Object.assign({}, postResponse, {collections: [collectionToRemove.id]})]}) - .expectStatus(200) - .matchBodySnapshot({ - posts: [ - Object.assign({}, matchPostShallowIncludes, {published_at: null}, {collections: [ - // collectionToRemove - collectionMatcher, - // automatic "latest" collection which cannot be removed - buildCollectionMatcher(21) - ]})] - }) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag, - 'x-cache-invalidate': stringMatching(/\/p\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/) - }); - - await agent.put(`/posts/${postResponse.id}/`) - .body({posts: [Object.assign({}, postResponse, {collections: [collectionToAdd.id]})]}) - .expectStatus(200) - .matchBodySnapshot({ - posts: [ - Object.assign({}, matchPostShallowIncludes, {published_at: null}, {collections: [ - // collectionToAdd - collectionMatcher, - // automatic "latest" collection which cannot be removed - buildCollectionMatcher(21) - ]})] - }) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag, - 'x-cache-invalidate': stringMatching(/\/p\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/) - }); - }); - it('Clears all page html fields when publishing a post', async function () { const totalPageCount = await models.Post.where({type: 'page'}).count(); should.exist(totalPageCount, 'total page count'); @@ -777,34 +631,6 @@ describe('Posts API', function () { }); }); - it('Can delete posts belonging to a collection and returns empty response when filtering by that collection', async function () { - const res = await agent.get('posts/?collection=featured') - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - posts: new Array(2).fill(matchPostShallowIncludes) - }); - - const posts = res.body.posts; - - await agent.delete(`posts/${posts[0].id}/`).expectStatus(204); - await agent.delete(`posts/${posts[1].id}/`).expectStatus(204); - - await DomainEvents.allSettled(); - - await agent - .get(`posts/?collection=featured`) - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot(); - }); - it('Clears all page html fields when deleting a published post', async function () { const totalPageCount = await models.Post.where({type: 'page'}).count(); should.exist(totalPageCount, 'total page count'); diff --git a/ghost/core/test/e2e-api/content/__snapshots__/collections.test.js.snap b/ghost/core/test/e2e-api/content/__snapshots__/collections.test.js.snap deleted file mode 100644 index 58709651a12..00000000000 --- a/ghost/core/test/e2e-api/content/__snapshots__/collections.test.js.snap +++ /dev/null @@ -1,37 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Collections Content API Can request a collection by slug and id 1: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "Featured posts", - "feature_image": null, - "filter": "featured:true", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "featured", - "title": "Featured", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; - -exports[`Collections Content API Can request a collection by slug and id 2: [body] 1`] = ` -Object { - "collections": Array [ - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "description": "Featured posts", - "feature_image": null, - "filter": "featured:true", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "slug": "featured", - "title": "Featured", - "type": "automatic", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - }, - ], -} -`; diff --git a/ghost/core/test/e2e-api/content/collections.test.js b/ghost/core/test/e2e-api/content/collections.test.js deleted file mode 100644 index 972eb3744d4..00000000000 --- a/ghost/core/test/e2e-api/content/collections.test.js +++ /dev/null @@ -1,34 +0,0 @@ -const {agentProvider, fixtureManager, matchers} = require('../../utils/e2e-framework'); -const {anyISODateTime, anyObjectId} = matchers; - -const collectionMatcher = { - id: anyObjectId, - created_at: anyISODateTime, - updated_at: anyISODateTime -}; - -describe('Collections Content API', function () { - let agent; - - before(async function () { - agent = await agentProvider.getContentAPIAgent(); - await fixtureManager.init('users', 'api_keys'); - await agent.authenticate(); - }); - - it('Can request a collection by slug and id', async function () { - const {body: {collections: [collection]}} = await agent - .get(`collections/slug/featured`) - .expectStatus(200) - .matchBodySnapshot({ - collections: [collectionMatcher] - }); - - await agent - .get(`collections/${collection.id}`) - .expectStatus(200) - .matchBodySnapshot({ - collections: [collectionMatcher] - }); - }); -}); diff --git a/ghost/core/test/e2e-api/content/posts.test.js b/ghost/core/test/e2e-api/content/posts.test.js index 7eeba91d843..51647c17b1a 100644 --- a/ghost/core/test/e2e-api/content/posts.test.js +++ b/ghost/core/test/e2e-api/content/posts.test.js @@ -48,8 +48,6 @@ describe('Posts Content API', function () { let agent; before(async function () { - // NOTE: can be removed after collections -> GA - mockManager.mockLabsEnabled('collections'); agent = await agentProvider.getContentAPIAgent(); await fixtureManager.init('owner:post', 'users', 'user:inactive', 'posts', 'tags:extra', 'api_keys', 'newsletters', 'members:newsletters'); await agent.authenticate(); @@ -197,36 +195,6 @@ describe('Posts Content API', function () { assert.equal(joePrimaryAuthors.length, 4, `Each post must either have the author 'joe-bloggs' or 'ghost', 'pat' is non existing author`); }); - it('Can browse filtering by collection', async function () { - await agent - .get(`posts/?collection=latest`) - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - posts: Array(11).fill(postMatcher) - }); - }); - - it('Can browse filtering by collection and using paging parameters', async function () { - await agent - .get(`posts/?collection=latest&limit=1&page=2`) - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - posts: Array(1).fill(postMatcher) - }) - .expect((res) => { - // there are total of 11 published posts - assert.equal(res.body.meta.pagination.total, 11); - }); - }); - it('Can request fields of posts', async function () { await agent .get('posts/?&fields=url') diff --git a/ghost/core/test/unit/server/services/collections/CollectionsServiceWrapper.test.js b/ghost/core/test/unit/server/services/collections/CollectionsServiceWrapper.test.js deleted file mode 100644 index a130eb56671..00000000000 --- a/ghost/core/test/unit/server/services/collections/CollectionsServiceWrapper.test.js +++ /dev/null @@ -1,11 +0,0 @@ -const assert = require('assert/strict'); -const collectionsServiceWrapper = require('../../../../../core/server/services/collections'); -const {CollectionsService} = require('@tryghost/collections'); - -describe('CollectionsServiceWrapper', function () { - it('Exposes a valid instance of CollectionsServiceWrapper', async function () { - assert.ok(collectionsServiceWrapper); - assert.ok(collectionsServiceWrapper.api); - assert.ok(collectionsServiceWrapper.api instanceof CollectionsService); - }); -}); diff --git a/ghost/model-to-domain-event-interceptor/.eslintrc.js b/ghost/model-to-domain-event-interceptor/.eslintrc.js deleted file mode 100644 index cb690be63fa..00000000000 --- a/ghost/model-to-domain-event-interceptor/.eslintrc.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: ['ghost'], - extends: [ - 'plugin:ghost/ts' - ] -}; diff --git a/ghost/model-to-domain-event-interceptor/README.md b/ghost/model-to-domain-event-interceptor/README.md deleted file mode 100644 index 0c47b244ad3..00000000000 --- a/ghost/model-to-domain-event-interceptor/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# Model To Domain Event Interceptor - -Model event interceptor that maps legacy model events to Domain event - - -## Usage - - -## Develop - -This is a monorepo package. - -Follow the instructions for the top-level repo. -1. `git clone` this repo & `cd` into it as usual -2. Run `yarn` to install top-level dependencies. - - - -## Test - -- `yarn lint` run just eslint -- `yarn test` run lint and tests - diff --git a/ghost/model-to-domain-event-interceptor/package.json b/ghost/model-to-domain-event-interceptor/package.json deleted file mode 100644 index 70383339d97..00000000000 --- a/ghost/model-to-domain-event-interceptor/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@tryghost/model-to-domain-event-interceptor", - "version": "0.0.0", - "repository": "https://github.com/TryGhost/Ghost/tree/main/packages/model-to-domain-event-interceptor", - "author": "Ghost Foundation", - "private": true, - "main": "build/index.js", - "types": "build/index.d.ts", - "scripts": { - "build": "yarn build:ts", - "build:ts": "tsc", - "test:unit": "NODE_ENV=testing c8 --src src --all --check-coverage --100 --reporter text --reporter cobertura -- mocha --reporter dot -r ts-node/register './test/**/*.test.ts'", - "test": "yarn test:types && yarn test:unit", - "test:types": "tsc --noEmit", - "lint:code": "eslint src/ --ext .ts --cache", - "lint": "yarn lint:code && yarn lint:test", - "lint:test": "eslint -c test/.eslintrc.js test/ --ext .ts --cache" - }, - "files": [ - "build" - ], - "devDependencies": { - "@tryghost/domain-events": "0.0.0", - "c8": "8.0.1", - "mocha": "10.2.0", - "sinon": "15.2.0" - }, - "dependencies": { - "@tryghost/collections": "0.0.0", - "@tryghost/post-events": "0.0.0" - }, - "c8": { - "exclude": [ - "src/**/*.d.ts" - ] - } -} diff --git a/ghost/model-to-domain-event-interceptor/src/ModelToDomainEventInterceptor.ts b/ghost/model-to-domain-event-interceptor/src/ModelToDomainEventInterceptor.ts deleted file mode 100644 index 46b6518b35e..00000000000 --- a/ghost/model-to-domain-event-interceptor/src/ModelToDomainEventInterceptor.ts +++ /dev/null @@ -1,110 +0,0 @@ -import {PostDeletedEvent} from '@tryghost/post-events'; -import {PostAddedEvent, PostEditedEvent, TagDeletedEvent} from '@tryghost/collections'; - -type ModelToDomainEventInterceptorDeps = { - ModelEvents: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - hasRegisteredListener: (event: any, listenerName: string) => boolean; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - on: (eventName: string, callback: (data: any) => void) => void; - }, - DomainEvents: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - dispatch: (event: any) => void; - } -} - -export class ModelToDomainEventInterceptor { - ModelEvents; - DomainEvents; - - constructor(deps: ModelToDomainEventInterceptorDeps) { - this.ModelEvents = deps.ModelEvents; - this.DomainEvents = deps.DomainEvents; - } - - init() { - const ghostModelUpdateEvents = [ - 'post.added', - 'post.deleted', - 'post.edited', - // NOTE: currently unmapped and unused event - 'tag.added', - 'tag.deleted' - ]; - - for (const modelEventName of ghostModelUpdateEvents) { - if (!this.ModelEvents.hasRegisteredListener(modelEventName, 'collectionListener')) { - const dispatcher = this.domainEventDispatcher.bind(this); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const listener = function (data: any) { - dispatcher(modelEventName, data); - }; - Object.defineProperty(listener, 'name', {value: `${modelEventName}.domainEventInterceptorListener`, writable: false}); - - this.ModelEvents.on(modelEventName, listener); - } - } - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - domainEventDispatcher(modelEventName: string, data: any) { - let event; - - switch (modelEventName) { - case 'post.deleted': - event = PostDeletedEvent.create({ - id: data.id || data._previousAttributes?.id - }); - break; - case 'post.added': - event = PostAddedEvent.create({ - id: data.id, - featured: data.attributes.featured, - status: data.attributes.status, - published_at: data.attributes.published_at - }); - break; - case 'post.edited': - event = PostEditedEvent.create({ - id: data.id, - current: { - id: data.id, - title: data.attributes.title, - status: data.attributes.status, - featured: data.attributes.featured, - published_at: data.attributes.published_at, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - tags: data.relations?.tags?.models.map((tag: any) => ({ - slug: tag.get('slug') - })) - }, - // @NOTE: this will need to represent the previous state of the post - // will be needed to optimize the query for the collection - previous: { - id: data.id, - title: data._previousAttributes?.title, - status: data._previousAttributes?.status, - featured: data._previousAttributes?.featured, - published_at: data._previousAttributes?.published_at, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - tags: data._previousRelations?.tags?.models.map((tag: any) => ({ - slug: tag.get('slug') - })) - } - }); - break; - case 'tag.deleted': - event = TagDeletedEvent.create({ - id: data.id || data._previousAttributes?.id, - slug: data.attributes?.slug || data._previousAttributes?.slug - }); - break; - default: - } - - if (event) { - this.DomainEvents.dispatch(event); - } - } -} diff --git a/ghost/model-to-domain-event-interceptor/src/index.ts b/ghost/model-to-domain-event-interceptor/src/index.ts deleted file mode 100644 index 21da506fde7..00000000000 --- a/ghost/model-to-domain-event-interceptor/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './ModelToDomainEventInterceptor'; diff --git a/ghost/model-to-domain-event-interceptor/src/libraries.d.ts b/ghost/model-to-domain-event-interceptor/src/libraries.d.ts deleted file mode 100644 index 8e8a65f91a8..00000000000 --- a/ghost/model-to-domain-event-interceptor/src/libraries.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module '@tryghost/domain-events' diff --git a/ghost/model-to-domain-event-interceptor/test/.eslintrc.js b/ghost/model-to-domain-event-interceptor/test/.eslintrc.js deleted file mode 100644 index 6fe6dc1504a..00000000000 --- a/ghost/model-to-domain-event-interceptor/test/.eslintrc.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = { - parser: '@typescript-eslint/parser', - plugins: ['ghost'], - extends: [ - 'plugin:ghost/test' - ] -}; diff --git a/ghost/model-to-domain-event-interceptor/test/model-to-domain-event-interceptor.test.ts b/ghost/model-to-domain-event-interceptor/test/model-to-domain-event-interceptor.test.ts deleted file mode 100644 index 8f98808d004..00000000000 --- a/ghost/model-to-domain-event-interceptor/test/model-to-domain-event-interceptor.test.ts +++ /dev/null @@ -1,256 +0,0 @@ -import assert from 'assert/strict'; -import events from 'events'; -import sinon from 'sinon'; -import DomainEvents from '@tryghost/domain-events'; -import { - PostDeletedEvent -} from '@tryghost/post-events'; -import { - PostEditedEvent, - PostAddedEvent, - TagDeletedEvent -} from '@tryghost/collections'; - -import {ModelToDomainEventInterceptor} from '../src'; - -class EventRegistry extends events.EventEmitter { - hasRegisteredListener(eventName: string, listenerName: string) { - return !!(this.listeners(eventName).find(listener => (listener.name === listenerName))); - } -} - -describe('ModelToDomainEventInterceptor', function () { - it('Can instantiate a ModelToDomainEventInterceptor', function () { - const modelToDomainEventInterceptor = new ModelToDomainEventInterceptor({ - ModelEvents: new EventRegistry(), - DomainEvents: DomainEvents - }); - - assert.ok(modelToDomainEventInterceptor); - }); - - it('Starts event listeners after initialization', function () { - let eventRegistry = new EventRegistry(); - const modelToDomainEventInterceptor = new ModelToDomainEventInterceptor({ - ModelEvents: eventRegistry, - DomainEvents: DomainEvents - }); - - modelToDomainEventInterceptor.init(); - - assert.ok(eventRegistry.hasRegisteredListener('post.added', 'post.added.domainEventInterceptorListener'), 'post.added listener is registered'); - }); - - it('Intercepts post.added Model event and dispatches PostAddedEvent Domain event', async function () { - let eventRegistry = new EventRegistry(); - const modelToDomainEventInterceptor = new ModelToDomainEventInterceptor({ - ModelEvents: eventRegistry, - DomainEvents: DomainEvents - }); - - modelToDomainEventInterceptor.init(); - - let interceptedEvent; - DomainEvents.subscribe(PostAddedEvent, (event: any) => { - assert.equal(event.id, '1234-added'); - interceptedEvent = event; - }); - - eventRegistry.emit('post.added', { - id: '1234-added', - attributes: { - status: 'draft', - featured: false, - published_at: new Date() - } - }); - - await DomainEvents.allSettled(); - - assert.ok(interceptedEvent); - }); - - it('Intercepts post.edited Model event and dispatches PostEditedEvent Domain event', async function () { - let eventRegistry = new EventRegistry(); - const modelToDomainEventInterceptor = new ModelToDomainEventInterceptor({ - ModelEvents: eventRegistry, - DomainEvents: DomainEvents - }); - - modelToDomainEventInterceptor.init(); - - let interceptedEvent; - DomainEvents.subscribe(PostEditedEvent, async (event: any) => { - assert.equal(event.id, '1234-edited'); - assert.ok(event.data); - assert.ok(event.data.current); - assert.equal(event.data.current.status, 'draft'); - assert.equal(event.data.previous.status, 'published'); - - assert.deepEqual(event.data.current.tags[0], {slug: 'tag-current-slug'}); - assert.deepEqual(event.data.previous.tags[0], {slug: 'tag-previous-slug'}); - interceptedEvent = event; - }); - - eventRegistry.emit('post.edited', { - id: '1234-edited', - attributes: { - status: 'draft', - featured: false, - published_at: new Date() - }, - _previousAttributes: { - status: 'published', - featured: true - }, - relations: { - tags: { - models: [{ - get: function (key: string) { - return `tag-current-${key}`; - } - }] - } - }, - _previousRelations: { - tags: { - models: [{ - get: function (key: string) { - return `tag-previous-${key}`; - } - }] - } - } - }); - - await DomainEvents.allSettled(); - - assert.ok(interceptedEvent); - }); - - it('Intercepts post.deleted Model event and dispatches PostAddedEvent Domain event', async function () { - let eventRegistry = new EventRegistry(); - const modelToDomainEventInterceptor = new ModelToDomainEventInterceptor({ - ModelEvents: eventRegistry, - DomainEvents: DomainEvents - }); - - modelToDomainEventInterceptor.init(); - - let interceptedEvent; - DomainEvents.subscribe(PostDeletedEvent, (event: any) => { - assert.equal(event.id, '1234-deleted'); - interceptedEvent = event; - }); - - eventRegistry.emit('post.deleted', { - id: '1234-deleted' - }); - - await DomainEvents.allSettled(); - - assert.ok(interceptedEvent); - }); - - it('Intercepts post.deleted Model event without an id property and dispatches PostAddedEvent Domain event', async function () { - let eventRegistry = new EventRegistry(); - const modelToDomainEventInterceptor = new ModelToDomainEventInterceptor({ - ModelEvents: eventRegistry, - DomainEvents: DomainEvents - }); - - modelToDomainEventInterceptor.init(); - - let interceptedEvent; - DomainEvents.subscribe(PostDeletedEvent, (event: any) => { - assert.equal(event.id, '1234-deleted'); - interceptedEvent = event; - }); - - eventRegistry.emit('post.deleted', { - _previousAttributes: { - id: '1234-deleted' - } - }); - - await DomainEvents.allSettled(); - - assert.ok(interceptedEvent); - }); - - it('Intercepts tag.deleted Model event and dispatches TagDeletedEvent Domain event', async function () { - let eventRegistry = new EventRegistry(); - const modelToDomainEventInterceptor = new ModelToDomainEventInterceptor({ - ModelEvents: eventRegistry, - DomainEvents: DomainEvents - }); - - modelToDomainEventInterceptor.init(); - - let interceptedEvent; - DomainEvents.subscribe(TagDeletedEvent, (event: TagDeletedEvent) => { - assert.equal(event.id, '1234-deleted'); - assert.equal(event.data.slug, 'tag-slug'); - interceptedEvent = event; - }); - - eventRegistry.emit('tag.deleted', { - _previousAttributes: { - id: '1234-deleted', - slug: 'tag-slug' - } - }); - - await DomainEvents.allSettled(); - - assert.ok(interceptedEvent); - }); - - it('Intercepts tag.deleted Model event without an id property and dispatches TagDeletedEvent Domain event', async function () { - let eventRegistry = new EventRegistry(); - const modelToDomainEventInterceptor = new ModelToDomainEventInterceptor({ - ModelEvents: eventRegistry, - DomainEvents: DomainEvents - }); - - modelToDomainEventInterceptor.init(); - - let interceptedEvent; - DomainEvents.subscribe(TagDeletedEvent, (event: TagDeletedEvent) => { - assert.equal(event.id, '1234-deleted'); - assert.equal(event.data.slug, 'tag-slug'); - interceptedEvent = event; - }); - - eventRegistry.emit('tag.deleted', { - id: '1234-deleted', - attributes: { - slug: 'tag-slug' - } - }); - - await DomainEvents.allSettled(); - - assert.ok(interceptedEvent); - }); - - it('Intercepts unmapped Model event and dispatches nothing', async function () { - let eventRegistry = new EventRegistry(); - const modelToDomainEventInterceptor = new ModelToDomainEventInterceptor({ - ModelEvents: eventRegistry, - DomainEvents: DomainEvents - }); - - const domainEventsSpy = sinon.spy(DomainEvents, 'dispatch'); - - modelToDomainEventInterceptor.init(); - - eventRegistry.emit('tag.added', { - id: '1234-tag' - }); - - await DomainEvents.allSettled(); - - assert.equal(domainEventsSpy.called, false); - }); -}); diff --git a/ghost/model-to-domain-event-interceptor/tsconfig.json b/ghost/model-to-domain-event-interceptor/tsconfig.json deleted file mode 100644 index 7f7ed386648..00000000000 --- a/ghost/model-to-domain-event-interceptor/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../tsconfig.json", - "include": [ - "src/**/*" - ], - "compilerOptions": { - "outDir": "build" - } -} diff --git a/ghost/nql-filter-expansions/.eslintrc.js b/ghost/nql-filter-expansions/.eslintrc.js deleted file mode 100644 index cb690be63fa..00000000000 --- a/ghost/nql-filter-expansions/.eslintrc.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: ['ghost'], - extends: [ - 'plugin:ghost/ts' - ] -}; diff --git a/ghost/nql-filter-expansions/README.md b/ghost/nql-filter-expansions/README.md deleted file mode 100644 index 9d4b19a78be..00000000000 --- a/ghost/nql-filter-expansions/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# Nql Filter Expansions - -NQL Filter Expansions for entities/models/resources - - -## Usage - - -## Develop - -This is a monorepo package. - -Follow the instructions for the top-level repo. -1. `git clone` this repo & `cd` into it as usual -2. Run `yarn` to install top-level dependencies. - - - -## Test - -- `yarn lint` run just eslint -- `yarn test` run lint and tests - diff --git a/ghost/nql-filter-expansions/package.json b/ghost/nql-filter-expansions/package.json deleted file mode 100644 index f835e9f73f8..00000000000 --- a/ghost/nql-filter-expansions/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "@tryghost/nql-filter-expansions", - "version": "0.0.0", - "repository": "https://github.com/TryGhost/Ghost/tree/main/packages/nql-filter-expansions", - "author": "Ghost Foundation", - "private": true, - "main": "build/index.js", - "types": "build/index.d.ts", - "scripts": { - "build": "yarn build:ts", - "build:ts": "tsc", - "test:unit": "NODE_ENV=testing c8 --src src --all --check-coverage --100 --reporter text --reporter cobertura -- mocha --reporter dot -r ts-node/register './test/**/*.test.ts'", - "test": "yarn test:types && yarn test:unit", - "test:types": "tsc --noEmit", - "lint:code": "eslint src/ --ext .ts --cache", - "lint": "yarn lint:code && yarn lint:test", - "lint:test": "eslint -c test/.eslintrc.js test/ --ext .ts --cache" - }, - "files": [ - "build" - ], - "devDependencies": { - "c8": "8.0.1", - "mocha": "10.2.0", - "sinon": "15.2.0" - }, - "dependencies": {} -} diff --git a/ghost/nql-filter-expansions/src/index.ts b/ghost/nql-filter-expansions/src/index.ts deleted file mode 100644 index 17292679bd7..00000000000 --- a/ghost/nql-filter-expansions/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './nql-filter-expansions'; diff --git a/ghost/nql-filter-expansions/src/nql-filter-expansions.ts b/ghost/nql-filter-expansions/src/nql-filter-expansions.ts deleted file mode 100644 index b45bb237b22..00000000000 --- a/ghost/nql-filter-expansions/src/nql-filter-expansions.ts +++ /dev/null @@ -1,21 +0,0 @@ -export const posts = [{ - key: 'primary_tag', - replacement: 'tags.slug', - expansion: 'posts_tags.sort_order:0+tags.visibility:public' -}, { - key: 'primary_author', - replacement: 'authors.slug', - expansion: 'posts_authors.sort_order:0+authors.visibility:public' -}, { - key: 'authors', - replacement: 'authors.slug' -}, { - key: 'author', - replacement: 'authors.slug' -}, { - key: 'tag', - replacement: 'tags.slug' -}, { - key: 'tags', - replacement: 'tags.slug' -}]; diff --git a/ghost/nql-filter-expansions/test/.eslintrc.js b/ghost/nql-filter-expansions/test/.eslintrc.js deleted file mode 100644 index 6fe6dc1504a..00000000000 --- a/ghost/nql-filter-expansions/test/.eslintrc.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = { - parser: '@typescript-eslint/parser', - plugins: ['ghost'], - extends: [ - 'plugin:ghost/test' - ] -}; diff --git a/ghost/nql-filter-expansions/test/nql-filter-expansions.test.ts b/ghost/nql-filter-expansions/test/nql-filter-expansions.test.ts deleted file mode 100644 index dddbd595659..00000000000 --- a/ghost/nql-filter-expansions/test/nql-filter-expansions.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import assert from 'assert/strict'; -import {posts} from '../src/index'; - -describe('Expansions', function () { - it('Exposes correct expansions', function () { - assert.ok(posts); - assert.equal(posts[0].key, 'primary_tag'); - }); -}); diff --git a/ghost/nql-filter-expansions/tsconfig.json b/ghost/nql-filter-expansions/tsconfig.json deleted file mode 100644 index 9d02e69445a..00000000000 --- a/ghost/nql-filter-expansions/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../tsconfig.json", - "include": [ - "src/**/*" - ], - "compilerOptions": { - "outDir": "build" - } - } diff --git a/ghost/posts-service/lib/PostsService.js b/ghost/posts-service/lib/PostsService.js index 98cf806dd14..0dc5eaa57c9 100644 --- a/ghost/posts-service/lib/PostsService.js +++ b/ghost/posts-service/lib/PostsService.js @@ -21,20 +21,17 @@ const messages = { invalidTags: 'Invalid tags value.', invalidEmailSegment: 'The email segment parameter doesn\'t contain a valid filter', unsupportedBulkAction: 'Unsupported bulk action', - postNotFound: 'Post not found.', - collectionNotFound: 'Collection not found.' + postNotFound: 'Post not found.' }; class PostsService { - constructor({urlUtils, models, isSet, stats, emailService, postsExporter, collectionsService}) { + constructor({urlUtils, models, isSet, stats, emailService, postsExporter}) { this.urlUtils = urlUtils; this.models = models; this.isSet = isSet; this.stats = stats; this.emailService = emailService; this.postsExporter = postsExporter; - /** @type {import('@tryghost/collections').CollectionsService} */ - this.collectionsService = collectionsService; } /** @@ -43,45 +40,7 @@ class PostsService { * @returns {Promise} */ async browsePosts(options) { - let posts; - if (options.collection) { - let collection = await this.collectionsService.getById(options.collection, {transaction: options.transacting}); - - if (!collection) { - collection = await this.collectionsService.getBySlug(options.collection, {transaction: options.transacting}); - } - - if (!collection) { - throw new errors.NotFoundError({ - message: tpl(messages.collectionNotFound) - }); - } - - const postIds = collection.posts.map(post => post.id); - - if (postIds.length !== 0) { - options.filter = `id:[${postIds.join(',')}]+type:post`; - options.status = 'all'; - posts = await this.models.Post.findPage(options); - } else { - posts = { - data: [], - meta: { - pagination: { - page: 1, - pages: 1, - total: 0, - limit: options.limit || 15, - next: null, - prev: null - } - } - }; - } - } else { - posts = await this.models.Post.findPage(options); - } - + const posts = await this.models.Post.findPage(options); return posts; } @@ -94,13 +53,7 @@ class PostsService { }); } - const dto = model.toJSON(frame.options); - - if (this.isSet('collections') && frame?.original?.query?.include?.includes('collections')) { - dto.collections = await this.collectionsService.getCollectionsForPost(model.id); - } - - return dto; + return model.toJSON(frame.options); } /** @@ -131,54 +84,6 @@ class PostsService { } } - if (this.isSet('collections') && frame.data.posts[0].collections) { - const existingCollections = await this.collectionsService.getCollectionsForPost(frame.options.id); - for (const collection of frame.data.posts[0].collections) { - let collectionId = null; - if (typeof collection === 'string') { - collectionId = collection; - } - if (typeof collection?.id === 'string') { - collectionId = collection.id; - } - if (!collectionId) { - continue; - } - const existingCollection = existingCollections.find(c => c.id === collectionId); - if (existingCollection) { - continue; - } - const found = await this.collectionsService.getById(collectionId); - if (!found) { - continue; - } - if (found.type !== 'manual') { - continue; - } - await this.collectionsService.addPostToCollection(collectionId, { - id: frame.options.id, - featured: frame.data.posts[0].featured, - published_at: frame.data.posts[0].published_at - }); - } - for (const existingCollection of existingCollections) { - // we only remove posts from manual collections - if (existingCollection.type !== 'manual') { - continue; - } - - if (frame.data.posts[0].collections.find((item) => { - if (typeof item === 'string') { - return item === existingCollection.id; - } - return item.id === existingCollection.id; - })) { - continue; - } - await this.collectionsService.removePostFromCollection(existingCollection.id, frame.options.id); - } - } - const model = await this.models.Post.edit(frame.data.posts[0], frame.options); /**Handle newsletter email */ @@ -202,12 +107,6 @@ class PostsService { const dto = model.toJSON(frame.options); - if (this.isSet('collections')) { - if (frame?.original?.query?.include?.includes('collections') || frame.data.posts[0].collections) { - dto.collections = await this.collectionsService.getCollectionsForPost(model.id); - } - } - if (typeof options?.eventHandler === 'function') { await options.eventHandler(this.getChanges(model), dto); } From 73f8bcf0b35efdef0e3cc3814dab0e43aa4589b2 Mon Sep 17 00:00:00 2001 From: Djordje Vlaisavljevic Date: Wed, 15 Jan 2025 14:33:08 +0000 Subject: [PATCH 41/90] Added table of contents widget to article modal (#22008) ref https://linear.app/ghost/issue/AP-634/table-of-contents-in-reader-view - Adds a table of contents widget to the right side of articles in reader view that let's you navigate between headings for easier navigation in long, complex articles - Enhanced popover component with configurable side positioning - Updated package version to 0.3.44 --- apps/admin-x-activitypub/package.json | 2 +- .../src/components/feed/ArticleModal.tsx | 400 ++++++++++++++---- .../src/global/Popover.tsx | 7 +- 3 files changed, 318 insertions(+), 91 deletions(-) diff --git a/apps/admin-x-activitypub/package.json b/apps/admin-x-activitypub/package.json index 15887166f8b..970dd9c0dd8 100644 --- a/apps/admin-x-activitypub/package.json +++ b/apps/admin-x-activitypub/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/admin-x-activitypub", - "version": "0.3.43", + "version": "0.3.44", "license": "MIT", "repository": { "type": "git", diff --git a/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx b/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx index e32490b370f..6183b5dacb9 100644 --- a/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx +++ b/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx @@ -1,7 +1,7 @@ import FeedItem from './FeedItem'; import FeedItemStats from './FeedItemStats'; import NiceModal from '@ebay/nice-modal-react'; -import React, {useEffect, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import articleBodyStyles from '../articleBodyStyles'; import getUsername from '../../utils/get-username'; import {OptionProps, SingleValueProps, components} from 'react-select'; @@ -37,14 +37,91 @@ interface IframeWindow extends Window { resizeIframe?: () => void; } -const ArticleBody: React.FC<{heading: string, image: string|undefined, excerpt: string|undefined, html: string, fontSize: FontSize, lineHeight: string, fontFamily: SelectOption}> = ({ +interface TOCItem { + id: string; + text: string; + level: number; + element?: HTMLElement; +} + +const TableOfContents: React.FC<{ + items: TOCItem[]; + activeId: string | null; + onItemClick: (id: string) => void; +}> = ({items, onItemClick}) => { + if (items.length === 0) { + return null; + } + + const getLineWidth = (level: number) => { + switch (level) { + case 1: + return 'w-5'; + case 2: + return 'w-3'; + default: + return 'w-2'; + } + }; + + return ( +
    + + {items.map(item => ( +
    + ))} +
    + } + > +
    + +
    +
    +
    + ); +}; + +const ArticleBody: React.FC<{ + heading: string; + image: string|undefined; + excerpt: string|undefined; + html: string; + fontSize: FontSize; + lineHeight: string; + fontFamily: SelectOption; + onHeadingsExtracted?: (headings: TOCItem[]) => void; + onIframeLoad?: (iframe: HTMLIFrameElement) => void; +}> = ({ heading, image, excerpt, html, fontSize, lineHeight, - fontFamily + fontFamily, + onHeadingsExtracted, + onIframeLoad }) => { const site = useBrowseSite(); const siteData = site.data?.site; @@ -112,7 +189,15 @@ const ArticleBody: React.FC<{heading: string, image: string|undefined, excerpt: window.addEventListener('DOMContentLoaded', initializeResize); window.addEventListener('load', resizeIframe); window.addEventListener('resize', resizeIframe); - new MutationObserver(resizeIframe).observe(document.body, { subtree: true, childList: true }); + + if (document.body) { + const observer = new MutationObserver(resizeIframe); + observer.observe(document.body, { + subtree: true, + childList: true, + attributes: true + }); + } window.addEventListener('message', (event) => { if (event.data.type === 'triggerResize') { @@ -198,6 +283,36 @@ const ArticleBody: React.FC<{heading: string, image: string|undefined, excerpt: } }, [fontSize, lineHeight, fontFamily]); + useEffect(() => { + const iframe = iframeRef.current; + if (!iframe) { + return; + } + + const handleLoad = () => { + if (!iframe.contentDocument) { + return; + } + + const headings = Array.from(iframe.contentDocument.querySelectorAll('h1:not(.gh-article-title), h2, h3, h4, h5, h6')).map((el, idx) => { + const id = `heading-${idx}`; + el.id = id; + return { + id, + text: el.textContent || '', + level: parseInt(el.tagName[1]), + element: el as HTMLElement + }; + }); + + onHeadingsExtracted?.(headings); + onIframeLoad?.(iframe); + }; + + iframe.addEventListener('load', handleLoad); + return () => iframe.removeEventListener('load', handleLoad); + }, [onHeadingsExtracted, onIframeLoad]); + return (
    @@ -480,6 +595,100 @@ const ArticleModal: React.FC = ({ return () => container?.removeEventListener('scroll', handleScroll); }, []); + const [tocItems, setTocItems] = useState([]); + const [activeHeadingId, setActiveHeadingId] = useState(null); + const [iframeElement, setIframeElement] = useState(null); + + const handleHeadingsExtracted = useCallback((headings: TOCItem[]) => { + setTocItems(headings); + }, []); + + const handleIframeLoad = useCallback((iframe: HTMLIFrameElement) => { + setIframeElement(iframe); + }, []); + + const scrollToHeading = useCallback((id: string) => { + if (!iframeElement?.contentDocument) { + return; + } + + const heading = iframeElement.contentDocument.getElementById(id); + if (heading) { + const container = document.querySelector('.overflow-y-auto'); + if (!container) { + return; + } + + // Use offsetTop for absolute position within the document + const headingOffset = heading.offsetTop; + + container.scrollTo({ + top: headingOffset - 120, + behavior: 'smooth' + }); + } + }, [iframeElement]); + + useEffect(() => { + if (!iframeElement?.contentDocument || !tocItems.length) { + return; + } + + const setupObserver = () => { + const container = document.querySelector('.overflow-y-auto'); + if (!container) { + return; + } + + const handleScroll = () => { + const doc = iframeElement.contentDocument; + if (!doc || !doc.documentElement) { + return; + } + + // Get all heading elements and their positions + const headings = tocItems + .map(item => doc.getElementById(item.id)) + .filter((el): el is HTMLElement => el !== null) + .map(el => ({ + element: el, + id: el.id, + position: el.getBoundingClientRect().top - container.getBoundingClientRect().top + })); + + if (!headings.length) { + return; + } + + // Find the last visible heading + const viewportCenter = container.clientHeight / 2; + const buffer = 100; + + // Find the last heading that's above the viewport center + const lastVisibleHeading = headings.reduce((last, current) => { + if (current.position < (viewportCenter + buffer)) { + return current; + } + return last; + }, headings[0]); + + if (lastVisibleHeading && lastVisibleHeading.element.id !== activeHeadingId) { + setActiveHeadingId(lastVisibleHeading.element.id); + } + }; + + container.addEventListener('scroll', handleScroll); + handleScroll(); + + return () => { + container.removeEventListener('scroll', handleScroll); + }; + }; + + const timeoutId = setTimeout(setupObserver, 100); + return () => clearTimeout(timeoutId); + }, [iframeElement, tocItems, activeHeadingId]); + return ( = ({
    -
    -
    - {activityThreadParents.map((item) => { - return ( - <> - { - navigateForward(item.id, item.object, item.actor, false); - }} - onCommentClick={() => { - navigateForward(item.id, item.object, item.actor, true); - }} - /> - - ); - })} - - {object.type === 'Note' && ( - 0)) ? true : false} - type='Note' - onCommentClick={() => { - repliesRef.current?.scrollIntoView({ - behavior: 'smooth', - block: 'center' - }); - }} - /> - )} - {object.type === 'Article' && ( -
    - + {modalSize === MODAL_SIZE_LG && object.type === 'Article' && tocItems.length > 0 && ( +
    +
    + -
    - { - repliesRef.current?.scrollIntoView({ - behavior: 'smooth', - block: 'center' - }); - }} - onLikeClick={onLikeClick} - /> -
    - )} - -
    -
    - - - {isLoadingThread && } - -
    - {activityThreadChildren.map((item, index) => { - const showDivider = index !== activityThreadChildren.length - 1; - + )} +
    +
    + {activityThreadParents.map((item) => { return ( <> = ({ navigateForward(item.id, item.object, item.actor, true); }} /> - {showDivider && } ); })} + + {object.type === 'Note' && ( + 0))} + type='Note' + onCommentClick={() => { + repliesRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'center' + }); + }} + /> + )} + {object.type === 'Article' && ( +
    + +
    + { + repliesRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'center' + }); + }} + onLikeClick={onLikeClick} + /> +
    +
    + )} + +
    + +
    + + + {isLoadingThread && } + +
    + {activityThreadChildren.map((item, index) => { + const showDivider = index !== activityThreadChildren.length - 1; + + return ( + + { + navigateForward(item.id, item.object, item.actor, false); + }} + onCommentClick={() => { + navigateForward(item.id, item.object, item.actor, true); + }} + /> + {showDivider && } + + ); + })} +
    {modalSize === MODAL_SIZE_LG && object.type === 'Article' && ( -
    +
    {getReadingTime(object.content ?? '')}
    diff --git a/apps/admin-x-design-system/src/global/Popover.tsx b/apps/admin-x-design-system/src/global/Popover.tsx index afb1755dcda..7e35ae50551 100644 --- a/apps/admin-x-design-system/src/global/Popover.tsx +++ b/apps/admin-x-design-system/src/global/Popover.tsx @@ -7,6 +7,7 @@ export interface PopoverProps { trigger: React.ReactNode; children: React.ReactNode; position?: PopoverPosition; + side?: PopoverPrimitive.PopoverContentProps['side']; closeOnItemClick?: boolean; open?: boolean; setOpen?: (value: boolean) => void; @@ -16,12 +17,13 @@ const Popover: React.FC = ({ trigger, children, position = 'start', + side = 'bottom', closeOnItemClick, open: openState, setOpen: setOpenState }) => { const [internalOpen, setInternalOpen] = useState(false); - + const open = openState !== undefined ? openState : internalOpen; const setOpen = setOpenState || setInternalOpen; @@ -38,7 +40,8 @@ const Popover: React.FC = ({ {trigger} - + {children} From 6bc164cb7c930f739ac85a8bd9c3ddcef2a45a1e Mon Sep 17 00:00:00 2001 From: Michael Barrett Date: Wed, 15 Jan 2025 16:43:51 +0000 Subject: [PATCH 42/90] Updated profile tab to use dedicated account endpoints in `admin-x-activitypub` (#22010) refs [AP-647](https://linear.app/ghost/issue/AP-648/refactor-profile-tab-to-use-account-and-follows) Updated the profile tab in `admin-x-activitypub` to use dedicated account endpoints. This is to remove coupling between the UI and the ActivityPub endpoints in preparation for the upcoming changes around storing `accounts` and `follows` in the database --- apps/admin-x-activitypub/package.json | 2 +- .../src/api/activitypub.ts | 96 +++++++++---- .../src/components/Profile.tsx | 135 ++++++++++-------- .../src/components/global/APAvatar.tsx | 7 +- .../src/hooks/useActivityPubQueries.ts | 77 +++++----- 5 files changed, 196 insertions(+), 121 deletions(-) diff --git a/apps/admin-x-activitypub/package.json b/apps/admin-x-activitypub/package.json index 970dd9c0dd8..f1bf23f2b7e 100644 --- a/apps/admin-x-activitypub/package.json +++ b/apps/admin-x-activitypub/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/admin-x-activitypub", - "version": "0.3.44", + "version": "0.3.45", "license": "MIT", "repository": { "type": "git", diff --git a/apps/admin-x-activitypub/src/api/activitypub.ts b/apps/admin-x-activitypub/src/api/activitypub.ts index 74eecb43a93..a8922b7da62 100644 --- a/apps/admin-x-activitypub/src/api/activitypub.ts +++ b/apps/admin-x-activitypub/src/api/activitypub.ts @@ -43,6 +43,34 @@ export interface GetPostsForProfileResponse { next: string | null; } +export type AccountFollowsType = 'following' | 'followers'; + +interface Account { + id: string; + name: string; + handle: string; + bio: string; + url: string; + avatarUrl: string; + bannerImageUrl: string | null; + customFields: Record; + postsCount: number; + likedCount: number; + followingCount: number; + followerCount: number; + followsMe: boolean; + followedByMe: boolean; +} + +type GetAccountResponse = Account + +export type MinimalAccount = Pick; + +export interface GetAccountFollowsResponse { + accounts: MinimalAccount[]; + next: string | null; +} + export class ActivityPubAPI { constructor( private readonly apiUrl: URL, @@ -114,20 +142,6 @@ export class ActivityPubAPI { }; } - private async getActivityPubCollectionCount(collectionUrl: URL): Promise { - const json = await this.fetchJSON(collectionUrl); - - if (json === null) { - return 0; - } - - if ('totalItems' in json && typeof json.totalItems === 'number') { - return json.totalItems; - } - - return 0; - } - get inboxApiUrl() { return new URL(`.ghost/activitypub/inbox/${this.handle}`, this.apiUrl); } @@ -162,10 +176,6 @@ export class ActivityPubAPI { return this.getActivityPubCollection(this.followingApiUrl, cursor); } - async getFollowingCount(): Promise { - return this.getActivityPubCollectionCount(this.followingApiUrl); - } - get followersApiUrl() { return new URL(`.ghost/activitypub/followers/${this.handle}`, this.apiUrl); } @@ -174,10 +184,6 @@ export class ActivityPubAPI { return this.getActivityPubCollection(this.followersApiUrl, cursor); } - async getFollowersCount(): Promise { - return this.getActivityPubCollectionCount(this.followersApiUrl); - } - async follow(username: string): Promise { const url = new URL(`.ghost/activitypub/actions/follow/${username}`, this.apiUrl); const json = await this.fetchJSON(url, 'POST'); @@ -192,10 +198,6 @@ export class ActivityPubAPI { return this.getActivityPubCollection(this.likedApiUrl, cursor); } - async getLikedCount(): Promise { - return this.getActivityPubCollectionCount(this.likedApiUrl); - } - async like(id: string): Promise { const url = new URL(`.ghost/activitypub/actions/like/${encodeURIComponent(id)}`, this.apiUrl); await this.fetchJSON(url, 'POST'); @@ -404,4 +406,46 @@ export class ActivityPubAPI { const json = await this.fetchJSON(url); return json as ActivityThread; } + + get accountApiUrl() { + return new URL(`.ghost/activitypub/account/${this.handle}`, this.apiUrl); + } + + async getAccount(): Promise { + const json = await this.fetchJSON(this.accountApiUrl); + + return json as GetAccountResponse; + } + + async getAccountFollows(type: AccountFollowsType, next?: string): Promise { + const url = new URL(`.ghost/activitypub/account/${this.handle}/follows/${type}`, this.apiUrl); + if (next) { + url.searchParams.set('next', next); + } + + const json = await this.fetchJSON(url); + + if (json === null) { + return { + accounts: [], + next: null + }; + } + + if (!('accounts' in json)) { + return { + accounts: [], + next: null + }; + } + + const accounts = Array.isArray(json.accounts) ? json.accounts : []; + const nextPage = 'next' in json && typeof json.next === 'string' ? json.next : null; + + return { + accounts, + next: nextPage + }; + } } + diff --git a/apps/admin-x-activitypub/src/components/Profile.tsx b/apps/admin-x-activitypub/src/components/Profile.tsx index ea851516e1a..541a16517b2 100644 --- a/apps/admin-x-activitypub/src/components/Profile.tsx +++ b/apps/admin-x-activitypub/src/components/Profile.tsx @@ -4,19 +4,15 @@ import NiceModal from '@ebay/nice-modal-react'; import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub'; import {Button, Heading, List, LoadingIndicator, NoValueLabel, Tab, TabView} from '@tryghost/admin-x-design-system'; -import getName from '../utils/get-name'; -import getUsername from '../utils/get-username'; import { + type AccountFollowsQueryResult, type ActivityPubCollectionQueryResult, - useFollowersCountForUser, - useFollowersForUser, - useFollowingCountForUser, - useFollowingForUser, - useLikedCountForUser, + useAccountFollowsForUser, + useAccountForUser, useLikedForUser, - useOutboxForUser, - useUserDataForUser + useOutboxForUser } from '../hooks/useActivityPubQueries'; +import {MinimalAccount} from '../api/activitypub'; import {handleViewContent} from '../utils/content-handlers'; import APAvatar from './global/APAvatar'; @@ -28,13 +24,13 @@ import ViewProfileModal from './modals/ViewProfileModal'; import {type Activity} from '../components/activities/ActivityItem'; interface UseInfiniteScrollTabProps { - useDataHook: (key: string) => ActivityPubCollectionQueryResult; + useDataHook: (key: string) => ActivityPubCollectionQueryResult | AccountFollowsQueryResult; emptyStateLabel: string; emptyStateIcon: string; } /** - * Hook to abstract away the common logic for infinite scroll in tabs + * Hook to abstract away the common logic for infinite scroll in the tabs */ const useInfiniteScrollTab = ({useDataHook, emptyStateLabel, emptyStateIcon}: UseInfiniteScrollTabProps) => { const { @@ -45,7 +41,15 @@ const useInfiniteScrollTab = ({useDataHook, emptyStateLabel, emptyStateI isLoading } = useDataHook('index'); - const items = (data?.pages.flatMap(page => page.data) ?? []); + const items = (data?.pages.flatMap((page) => { + if ('data' in page) { + return page.data; + } else if ('accounts' in page) { + return page.accounts as TData[]; + } + + return []; + }) ?? []); const observerRef = useRef(null); const loadMoreRef = useRef(null); @@ -172,15 +176,15 @@ const LikesTab: React.FC = () => { ); }; -const handleUserClick = (actor: ActorProperties) => { +const handleAccountClick = (handle: string) => { NiceModal.show(ViewProfileModal, { - profile: getUsername(actor) + profile: handle }); }; const FollowingTab: React.FC = () => { - const {items: following, EmptyState, LoadingState} = useInfiniteScrollTab({ - useDataHook: useFollowingForUser, + const {items: accounts, EmptyState, LoadingState} = useInfiniteScrollTab({ + useDataHook: handle => useAccountFollowsForUser(handle, 'following'), emptyStateLabel: 'You aren\'t following anyone yet.', emptyStateIcon: 'user-add' }); @@ -190,21 +194,26 @@ const FollowingTab: React.FC = () => { { - {following.map((item, index) => ( - + {accounts.map((account, index) => ( + handleUserClick(item)} + key={account.id} + onClick={() => handleAccountClick(account.handle)} > - +
    - {getName(item)} -
    {getUsername(item)}
    + {account.name} +
    {account.handle}
    - {index < following.length - 1 && } + {index < accounts.length - 1 && }
    ))}
    @@ -215,8 +224,8 @@ const FollowingTab: React.FC = () => { }; const FollowersTab: React.FC = () => { - const {items: followers, EmptyState, LoadingState} = useInfiniteScrollTab({ - useDataHook: useFollowersForUser, + const {items: accounts, EmptyState, LoadingState} = useInfiniteScrollTab({ + useDataHook: handle => useAccountFollowsForUser(handle, 'followers'), emptyStateLabel: 'Nobody\'s following you yet. Their loss!', emptyStateIcon: 'user-add' }); @@ -226,21 +235,26 @@ const FollowersTab: React.FC = () => { { - {followers.map((item, index) => ( - + {accounts.map((account, index) => ( + handleUserClick(item)} + key={account.id} + onClick={() => handleAccountClick(account.handle)} > - +
    - {item.name || getName(item) || 'Unknown'} -
    {getUsername(item)}
    + {account.name} +
    {account.handle}
    - {index < followers.length - 1 && } + {index < accounts.length - 1 && }
    ))}
    @@ -255,12 +269,7 @@ type ProfileTab = 'posts' | 'likes' | 'following' | 'followers'; interface ProfileProps {} const Profile: React.FC = ({}) => { - const {data: followersCount = 0, isLoading: isLoadingFollowersCount} = useFollowersCountForUser('index'); - const {data: followingCount = 0, isLoading: isLoadingFollowingCount} = useFollowingCountForUser('index'); - const {data: likedCount = 0, isLoading: isLoadingLikedCount} = useLikedCountForUser('index'); - const {data: userProfile, isLoading: isLoadingProfile} = useUserDataForUser('index') as {data: ActorProperties | null, isLoading: boolean}; - - const isInitialLoading = isLoadingProfile || isLoadingFollowersCount || isLoadingFollowingCount || isLoadingLikedCount; + const {data: account, isLoading: isLoadingAccount} = useAccountForUser('index'); const [selectedTab, setSelectedTab] = useState('posts'); @@ -282,7 +291,7 @@ const Profile: React.FC = ({}) => {
    ), - counter: likedCount + counter: account?.likedCount || 0 }, { id: 'following', @@ -292,7 +301,7 @@ const Profile: React.FC = ({}) => {
    ), - counter: followingCount + counter: account?.followingCount || 0 }, { id: 'followers', @@ -302,11 +311,16 @@ const Profile: React.FC = ({}) => {
    ), - counter: followersCount + counter: account?.followerCount || 0 } ].filter(Boolean) as Tab[]; - const attachments = (userProfile?.attachment || []); + const customFields = Object.keys(account?.customFields || {}).map((key) => { + return { + name: key, + value: account!.customFields[key] + }; + }) || []; const [isExpanded, setisExpanded] = useState(false); @@ -326,45 +340,50 @@ const Profile: React.FC = ({}) => { return ( <> - {isInitialLoading ? ( + {isLoadingAccount ? (
    ) : (
    - {userProfile?.image && ( + {account?.bannerImageUrl && (
    {userProfile?.name}
    )} -
    +
    - {userProfile?.name} + {account?.name} - {userProfile && getUsername(userProfile)} + {account?.handle} - {(userProfile?.summary || attachments.length > 0) && ( + {(account?.bio || customFields.length > 0) && (
    p]:mb-3 ${isExpanded ? 'max-h-none pb-7' : 'max-h-[160px] overflow-hidden'} relative`}>
    - {attachments.map(attachment => ( - - {attachment.name} - + {customFields.map(customField => ( + + {customField.name} + ))} {!isExpanded && isOverflowing && ( diff --git a/apps/admin-x-activitypub/src/components/global/APAvatar.tsx b/apps/admin-x-activitypub/src/components/global/APAvatar.tsx index 9de312ddaa5..5319a756802 100644 --- a/apps/admin-x-activitypub/src/components/global/APAvatar.tsx +++ b/apps/admin-x-activitypub/src/components/global/APAvatar.tsx @@ -9,7 +9,12 @@ import {Icon} from '@tryghost/admin-x-design-system'; type AvatarSize = '2xs' | 'xs' | 'sm' | 'lg' | 'notification'; interface APAvatarProps { - author: ActorProperties | undefined; + author: { + icon: { + url: string; + }; + name: string; + } | undefined; size?: AvatarSize; } diff --git a/apps/admin-x-activitypub/src/hooks/useActivityPubQueries.ts b/apps/admin-x-activitypub/src/hooks/useActivityPubQueries.ts index a8f18b37648..99c47e6c146 100644 --- a/apps/admin-x-activitypub/src/hooks/useActivityPubQueries.ts +++ b/apps/admin-x-activitypub/src/hooks/useActivityPubQueries.ts @@ -1,10 +1,25 @@ +import { + type AccountFollowsType, + ActivityPubAPI, + ActivityPubCollectionResponse, + ActivityThread, + type GetAccountFollowsResponse, + type Profile, + type SearchResults +} from '../api/activitypub'; import {Activity} from '../components/activities/ActivityItem'; -import {ActivityPubAPI, ActivityPubCollectionResponse, ActivityThread, type Profile, type SearchResults} from '../api/activitypub'; -import {type UseInfiniteQueryResult, useInfiniteQuery, useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; +import { + type UseInfiniteQueryResult, + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient +} from '@tanstack/react-query'; let SITE_URL: string; export type ActivityPubCollectionQueryResult = UseInfiniteQueryResult>; +export type AccountFollowsQueryResult = UseInfiniteQueryResult; async function getSiteUrl() { if (!SITE_URL) { @@ -52,17 +67,6 @@ export function useLikedForUser(handle: string) { }); } -export function useLikedCountForUser(handle: string) { - return useQuery({ - queryKey: [`likedCount:${handle}`], - async queryFn() { - const siteUrl = await getSiteUrl(); - const api = createActivityPubAPI(handle, siteUrl); - return api.getLikedCount(); - } - }); -} - export function useLikeMutationForUser(handle: string) { const queryClient = useQueryClient(); return useMutation({ @@ -183,17 +187,6 @@ export function useFollowersForUser(handle: string) { }); } -export function useFollowersCountForUser(handle: string) { - return useQuery({ - queryKey: [`followersCount:${handle}`], - async queryFn() { - const siteUrl = await getSiteUrl(); - const api = createActivityPubAPI(handle, siteUrl); - return api.getFollowersCount(); - } - }); -} - export function useFollowingForUser(handle: string) { return useInfiniteQuery({ queryKey: [`following:${handle}`], @@ -208,17 +201,6 @@ export function useFollowingForUser(handle: string) { }); } -export function useFollowingCountForUser(handle: string) { - return useQuery({ - queryKey: [`followingCount:${handle}`], - async queryFn() { - const siteUrl = await getSiteUrl(); - const api = createActivityPubAPI(handle, siteUrl); - return api.getFollowingCount(); - } - }); -} - export function useFollow(handle: string, onSuccess: () => void, onError: () => void) { const queryClient = useQueryClient(); return useMutation({ @@ -547,3 +529,28 @@ export function useNoteMutationForUser(handle: string) { } }); } + +export function useAccountForUser(handle: string) { + return useQuery({ + queryKey: [`account:${handle}`], + async queryFn() { + const siteUrl = await getSiteUrl(); + const api = createActivityPubAPI(handle, siteUrl); + return api.getAccount(); + } + }); +} + +export function useAccountFollowsForUser(handle: string, type: AccountFollowsType) { + return useInfiniteQuery({ + queryKey: [`follows:${handle}:${type}`], + async queryFn({pageParam}: {pageParam?: string}) { + const siteUrl = await getSiteUrl(); + const api = createActivityPubAPI(handle, siteUrl); + return api.getAccountFollows(type, pageParam); + }, + getNextPageParam(prevPage) { + return prevPage.next; + } + }); +} From 4ebf4dd1b079d5d727c5a6d615c7e39c72b5e8fa Mon Sep 17 00:00:00 2001 From: Michael Barrett Date: Wed, 15 Jan 2025 17:53:38 +0000 Subject: [PATCH 43/90] Fixed missing author handle in admin-x-activitypub (#22011) refs [AP-647](https://linear.app/ghost/issue/AP-648/refactor-profile-tab-to-use-account-and-follows) Fixed missing author handle in admin-x-activitypub --- apps/admin-x-activitypub/package.json | 2 +- apps/admin-x-activitypub/src/components/Profile.tsx | 9 ++++++--- .../src/components/global/APAvatar.tsx | 7 +++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/admin-x-activitypub/package.json b/apps/admin-x-activitypub/package.json index f1bf23f2b7e..aa3768927b0 100644 --- a/apps/admin-x-activitypub/package.json +++ b/apps/admin-x-activitypub/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/admin-x-activitypub", - "version": "0.3.45", + "version": "0.3.46", "license": "MIT", "repository": { "type": "git", diff --git a/apps/admin-x-activitypub/src/components/Profile.tsx b/apps/admin-x-activitypub/src/components/Profile.tsx index 541a16517b2..ffa3f9ea9b2 100644 --- a/apps/admin-x-activitypub/src/components/Profile.tsx +++ b/apps/admin-x-activitypub/src/components/Profile.tsx @@ -204,7 +204,8 @@ const FollowingTab: React.FC = () => { icon: { url: account.avatarUrl }, - name: account.name + name: account.name, + handle: account.handle }} />
    @@ -245,7 +246,8 @@ const FollowersTab: React.FC = () => { icon: { url: account.avatarUrl }, - name: account.name + name: account.name, + handle: account.handle }} />
    @@ -364,7 +366,8 @@ const Profile: React.FC = ({}) => { icon: { url: account?.avatarUrl }, - name: account?.name + name: account?.name, + handle: account?.handle }} size='lg' /> diff --git a/apps/admin-x-activitypub/src/components/global/APAvatar.tsx b/apps/admin-x-activitypub/src/components/global/APAvatar.tsx index 5319a756802..6f86771d7fb 100644 --- a/apps/admin-x-activitypub/src/components/global/APAvatar.tsx +++ b/apps/admin-x-activitypub/src/components/global/APAvatar.tsx @@ -14,6 +14,7 @@ interface APAvatarProps { url: string; }; name: string; + handle?: string; } | undefined; size?: AvatarSize; } @@ -64,14 +65,16 @@ const APAvatar: React.FC = ({author, size}) => { containerClass = clsx(containerClass, 'bg-grey-100'); } + const handle = author?.handle || getUsername(author as ActorProperties); + const onClick = (e: React.MouseEvent) => { e.stopPropagation(); NiceModal.show(ViewProfileModal, { - profile: getUsername(author as ActorProperties) + profile: handle }); }; - const title = `${author?.name} ${getUsername(author as ActorProperties)}`; + const title = `${author?.name} ${handle}`; if (iconUrl) { return ( From 7cf0e92d3ec8aa117b54ec9838dcada9b3edd5bf Mon Sep 17 00:00:00 2001 From: Michael Barrett Date: Wed, 15 Jan 2025 20:59:08 +0000 Subject: [PATCH 44/90] Changed profile modal to always remote load in `admin-x-activitypub` (#22012) no refs Changed profile modal to always remote load in `admin-x-activitypub` instead of both accepting an object or a string. This will allow for easier refactoring of the modal when we switch this area of the app to use `accounts` instead of `profiles` --- .../src/components/Profile.tsx | 4 +--- .../src/components/Search.tsx | 2 +- .../src/components/global/APAvatar.tsx | 4 +--- .../components/modals/ViewProfileModal.tsx | 19 +++---------------- .../src/utils/handle-profile-click.ts | 2 +- 5 files changed, 7 insertions(+), 24 deletions(-) diff --git a/apps/admin-x-activitypub/src/components/Profile.tsx b/apps/admin-x-activitypub/src/components/Profile.tsx index ffa3f9ea9b2..e45d8f5228e 100644 --- a/apps/admin-x-activitypub/src/components/Profile.tsx +++ b/apps/admin-x-activitypub/src/components/Profile.tsx @@ -177,9 +177,7 @@ const LikesTab: React.FC = () => { }; const handleAccountClick = (handle: string) => { - NiceModal.show(ViewProfileModal, { - profile: handle - }); + NiceModal.show(ViewProfileModal, {handle}); }; const FollowingTab: React.FC = () => { diff --git a/apps/admin-x-activitypub/src/components/Search.tsx b/apps/admin-x-activitypub/src/components/Search.tsx index 39451a20ea1..78a6c797388 100644 --- a/apps/admin-x-activitypub/src/components/Search.tsx +++ b/apps/admin-x-activitypub/src/components/Search.tsx @@ -48,7 +48,7 @@ const SearchResult: React.FC = ({result, update}) => { { - NiceModal.show(ViewProfileModal, {profile: result, onFollow, onUnfollow}); + NiceModal.show(ViewProfileModal, {handle: result.handle, onFollow, onUnfollow}); }} > diff --git a/apps/admin-x-activitypub/src/components/global/APAvatar.tsx b/apps/admin-x-activitypub/src/components/global/APAvatar.tsx index 6f86771d7fb..62190fe86fa 100644 --- a/apps/admin-x-activitypub/src/components/global/APAvatar.tsx +++ b/apps/admin-x-activitypub/src/components/global/APAvatar.tsx @@ -69,9 +69,7 @@ const APAvatar: React.FC = ({author, size}) => { const onClick = (e: React.MouseEvent) => { e.stopPropagation(); - NiceModal.show(ViewProfileModal, { - profile: handle - }); + NiceModal.show(ViewProfileModal, {handle}); }; const title = `${author?.name} ${handle}`; diff --git a/apps/admin-x-activitypub/src/components/modals/ViewProfileModal.tsx b/apps/admin-x-activitypub/src/components/modals/ViewProfileModal.tsx index 7e6a7f399ce..22d4314e467 100644 --- a/apps/admin-x-activitypub/src/components/modals/ViewProfileModal.tsx +++ b/apps/admin-x-activitypub/src/components/modals/ViewProfileModal.tsx @@ -1,7 +1,6 @@ import React, {useEffect, useRef, useState} from 'react'; import NiceModal, {useModal} from '@ebay/nice-modal-react'; -import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub'; import {Button, Heading, Icon, List, LoadingIndicator, Modal, NoValueLabel, Tab,TabView} from '@tryghost/admin-x-design-system'; import {UseInfiniteQueryResult} from '@tanstack/react-query'; @@ -217,13 +216,7 @@ const FollowersTab: React.FC<{handle: string}> = ({handle}) => { }; interface ViewProfileModalProps { - profile: { - actor: ActorProperties; - handle: string; - followerCount: number; - followingCount: number; - isFollowing: boolean; - } | string; + handle: string; onFollow?: () => void; onUnfollow?: () => void; } @@ -231,20 +224,14 @@ interface ViewProfileModalProps { type ProfileTab = 'posts' | 'following' | 'followers'; const ViewProfileModal: React.FC = ({ - profile: initialProfile, + handle, onFollow = noop, onUnfollow = noop }) => { const modal = useModal(); const [selectedTab, setSelectedTab] = useState('posts'); - const willLoadProfile = typeof initialProfile === 'string'; - let {data: profile, isInitialLoading: isLoading} = useProfileForUser('index', initialProfile as string, willLoadProfile); - - if (!willLoadProfile) { - profile = initialProfile; - isLoading = false; - } + const {data: profile, isLoading} = useProfileForUser('index', handle); const attachments = (profile?.actor.attachment || []); diff --git a/apps/admin-x-activitypub/src/utils/handle-profile-click.ts b/apps/admin-x-activitypub/src/utils/handle-profile-click.ts index 947e7a04a31..758315e30cf 100644 --- a/apps/admin-x-activitypub/src/utils/handle-profile-click.ts +++ b/apps/admin-x-activitypub/src/utils/handle-profile-click.ts @@ -6,6 +6,6 @@ import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub'; export const handleProfileClick = (actor: ActorProperties, e?: React.MouseEvent) => { e?.stopPropagation(); NiceModal.show(ViewProfileModal, { - profile: getUsername(actor) + handle: getUsername(actor) }); }; From 2b335e8c3787494425ca509f170db79b5bc74431 Mon Sep 17 00:00:00 2001 From: Peter Zimon Date: Thu, 16 Jan 2025 12:03:01 +0100 Subject: [PATCH 45/90] Fix main navigation default visibility (#22016) ref https://linear.app/ghost/issue/DES-797/admin-visual-design-improvements - With the current user setting initialization the main navigation was to be closed/hidden by default. This change makes sure that if the menu toggle wasn't used it's going to use the default value (`visible: true`) --- ghost/admin/app/services/navigation.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ghost/admin/app/services/navigation.js b/ghost/admin/app/services/navigation.js index c7de0233965..cb0019f9a0d 100644 --- a/ghost/admin/app/services/navigation.js +++ b/ghost/admin/app/services/navigation.js @@ -33,7 +33,7 @@ export default class NavigationService extends Service { } let userSettings = JSON.parse(this.session.user.accessibility || '{}') || {}; - this.settings = userSettings.navigation || Object.assign({}, DEFAULT_SETTINGS); + this.settings = {...DEFAULT_SETTINGS, ...userSettings.navigation}; } @action From a983bf07919fd629b2baef61de01a6911871d252 Mon Sep 17 00:00:00 2001 From: Princi Vershwal Date: Thu, 16 Jan 2025 18:17:41 +0530 Subject: [PATCH 46/90] =?UTF-8?q?=F0=9F=8E=A8=20Optimised=20SQL=20query=20?= =?UTF-8?q?for=20exporting=20members=20(#22017)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref https://linear.app/ghost/issue/ONC-699/lever-member-export-unresponsive - Split large SQL queries into smaller, focused queries to improve performance and reduce database load. - Shifted aggregation logic from database to in-memory processing for improved query efficiency and faster execution. - Added temp logging to identify performance bottlenecks and measure execution time for each step in production environment as things are pretty fast in local setup and staging. - No updates in the test, this API already has snapshot tests and unit tests --- .../server/services/members/exporter/query.js | 142 +++++++++++++----- 1 file changed, 102 insertions(+), 40 deletions(-) diff --git a/ghost/core/core/server/services/members/exporter/query.js b/ghost/core/core/server/services/members/exporter/query.js index 81594279acf..80243a3c927 100644 --- a/ghost/core/core/server/services/members/exporter/query.js +++ b/ghost/core/core/server/services/members/exporter/query.js @@ -1,9 +1,11 @@ const models = require('../../../models'); const {knex} = require('../../../data/db'); const moment = require('moment'); +const logging = require('@tryghost/logging'); module.exports = async function (options) { const hasFilter = options.limit !== 'all' || options.filter || options.search; + const start = Date.now(); let ids = null; if (hasFilter) { @@ -31,58 +33,118 @@ module.exports = async function (options) { */ } - const allProducts = await models.Product.fetchAll(); - const allLabels = await models.Label.fetchAll(); + const startFetchingProducts = Date.now(); - let query = knex('members') + const allProducts = await knex('products').select('id', 'name').then(rows => rows.reduce((acc, product) => { + acc[product.id] = product.name; + return acc; + }, {}) + ); + + const allLabels = await knex('labels').select('id', 'name').then(rows => rows.reduce((acc, label) => { + acc[label.id] = label.name; + return acc; + }, {}) + ); + + logging.info('[MembersExporter] Fetched products and labels in ' + (Date.now() - startFetchingProducts) + 'ms'); + + const startFetchingMembers = Date.now(); + + const members = await knex('members') .select('id', 'email', 'name', 'note', 'status', 'created_at') - .select(knex.raw(` - (CASE WHEN EXISTS (SELECT 1 FROM members_newsletters n WHERE n.member_id = members.id) - THEN TRUE ELSE FALSE - END) as subscribed - `)) - .select(knex.raw(` - (SELECT GROUP_CONCAT(product_id) FROM members_products f WHERE f.member_id = members.id) as tiers - `)) - .select(knex.raw(` - (SELECT GROUP_CONCAT(label_id) FROM members_labels f WHERE f.member_id = members.id) as labels - `)) - .select(knex.raw(` - (SELECT customer_id FROM members_stripe_customers f WHERE f.member_id = members.id limit 1) as stripe_customer_id - `)); + .modify((query) => { + if (hasFilter) { + query.whereIn('id', ids); + } + }); - if (hasFilter) { - query = query.whereIn('id', ids); - } + logging.info('[MembersExporter] Fetched members in ' + (Date.now() - startFetchingMembers) + 'ms'); + + const startFetchingTiers = Date.now(); + + const tiers = await knex('members_products') + .select('member_id', knex.raw('GROUP_CONCAT(product_id) as tiers')) + .groupBy('member_id') + .modify((query) => { + if (hasFilter) { + query.whereIn('member_id', ids); + } + }); + + logging.info('[MembersExporter] Fetched tiers in ' + (Date.now() - startFetchingTiers) + 'ms'); + + const startFetchingLabels = Date.now(); + + const labels = await knex('members_labels') + .select('member_id', knex.raw('GROUP_CONCAT(label_id) as labels')) + .groupBy('member_id') + .modify((query) => { + if (hasFilter) { + query.whereIn('member_id', ids); + } + }); - const rows = await query; - for (const row of rows) { - const tierIds = row.tiers ? row.tiers.split(',') : []; - const tiers = tierIds.map((id) => { - const tier = allProducts.find(p => p.id === id); + logging.info('[MembersExporter] Fetched labels in ' + (Date.now() - startFetchingLabels) + 'ms'); + + const startFetchingStripeCustomers = Date.now(); + + const stripeCustomers = await knex('members_stripe_customers') + .select('member_id', knex.raw('MIN(customer_id) as stripe_customer_id')) + .groupBy('member_id') + .modify((query) => { + if (hasFilter) { + query.whereIn('member_id', ids); + } + }); + + logging.info('[MembersExporter] Fetched stripe customers in ' + (Date.now() - startFetchingStripeCustomers) + 'ms'); + + const startFetchingSubscriptions = Date.now(); + + const subscriptions = await knex('members_newsletters') + .distinct('member_id') + .modify((query) => { + if (hasFilter) { + query.whereIn('member_id', ids); + } + }); + + logging.info('[MembersExporter] Fetched subscriptions in ' + (Date.now() - startFetchingSubscriptions) + 'ms'); + + const startInMemoryProcessing = Date.now(); + + const tiersMap = new Map(tiers.map(row => [row.member_id, row.tiers])); + const labelsMap = new Map(labels.map(row => [row.member_id, row.labels])); + const stripeCustomerMap = new Map(stripeCustomers.map(row => [row.member_id, row.stripe_customer_id])); + const subscribedSet = new Set(subscriptions.map(row => row.member_id)); + + for (const row of members) { + const tierIds = tiersMap.get(row.id) ? tiersMap.get(row.id).split(',') : []; + const tierDetails = tierIds.map((id) => { return { - name: tier.get('name') + name: allProducts[id] }; }); - row.tiers = tiers; + row.tiers = tierDetails; - const labelIds = row.labels ? row.labels.split(',') : []; - const labels = labelIds.map((id) => { - const label = allLabels.find(l => l.id === id); + const labelIds = labelsMap.get(row.id) ? labelsMap.get(row.id).split(',') : []; + const labelDetails = labelIds.map((id) => { return { - name: label.get('name') + name: allLabels[id] }; }); - row.labels = labels; - } + row.labels = labelDetails; - for (const member of rows) { - // Note: we don't modify the array or change/duplicate objects - // to increase performance - member.subscribed = !!member.subscribed; - member.comped = member.status === 'comped'; - member.created_at = moment(member.created_at).toISOString(); + row.subscribed = subscribedSet.has(row.id); + row.comped = row.status === 'comped'; + row.stripe_customer_id = stripeCustomerMap.get(row.id) || null; + row.created_at = moment(row.created_at).toISOString(); } - return rows; + logging.info('[MembersExporter] In memory processing finished in ' + (Date.now() - startInMemoryProcessing) + 'ms'); + + logging.info('[MembersExporter] Total time taken for member export: ' + (Date.now() - start) / 1000 + 's'); + + return members; }; From 7bc1102cc6f4fa00fbfdce3eac29eae2b509a996 Mon Sep 17 00:00:00 2001 From: Djordje Vlaisavljevic Date: Thu, 16 Jan 2025 12:57:07 +0000 Subject: [PATCH 47/90] Improved how we represent unusual article heading structures ref https://linear.app/ghost/issue/AP-634/table-of-contents-in-reader-view - Sometimes publishers use headings in unusual ways (for example, using just `h3`s). This means we can't rely on headings always being structured in the expected way (`h1`, `h2`, `h3`...) Now after we scan the article for headings, we find the highest level heading and then calculate normalized levels for all other headings. This helps the widget look good even in these edge cases. --- apps/admin-x-activitypub/package.json | 2 +- .../src/components/feed/ArticleModal.tsx | 50 +++++++++++++++---- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/apps/admin-x-activitypub/package.json b/apps/admin-x-activitypub/package.json index aa3768927b0..93c824e18db 100644 --- a/apps/admin-x-activitypub/package.json +++ b/apps/admin-x-activitypub/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/admin-x-activitypub", - "version": "0.3.46", + "version": "0.3.47", "license": "MIT", "repository": { "type": "git", diff --git a/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx b/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx index 6183b5dacb9..2eb6b2cd74c 100644 --- a/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx +++ b/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx @@ -56,16 +56,27 @@ const TableOfContents: React.FC<{ const getLineWidth = (level: number) => { switch (level) { case 1: - return 'w-5'; - case 2: return 'w-3'; - default: + case 2: return 'w-2'; + default: + return 'w-1'; + } + }; + + const getHeadingPadding = (level: number) => { + switch (level) { + case 1: + return 'pl-2'; + case 2: + return 'pl-6'; + default: + return 'pl-10'; } }; return ( -
    +
    (
    ))}
    @@ -85,10 +96,7 @@ const TableOfContents: React.FC<{ {items.map(item => ( - ))} - -
    - -
    - ); -}; - const ArticleBody: React.FC<{ heading: string; image: string|undefined; @@ -859,7 +787,6 @@ const ArticleModal: React.FC = ({
    diff --git a/apps/admin-x-activitypub/src/components/feed/TableOfContents.tsx b/apps/admin-x-activitypub/src/components/feed/TableOfContents.tsx new file mode 100644 index 00000000000..6814723b396 --- /dev/null +++ b/apps/admin-x-activitypub/src/components/feed/TableOfContents.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import {Popover} from '@tryghost/admin-x-design-system'; + +export interface TOCItem { + id: string; + text: string; + level: number; + element?: HTMLElement; +} + +interface TableOfContentsProps { + items: TOCItem[]; + onItemClick: (id: string) => void; +} + +const LINE_WIDTHS = { + 1: 'w-3', + 2: 'w-2', + 3: 'w-1' +} as const; + +const HEADING_PADDINGS = { + 1: 'pl-2', + 2: 'pl-6', + 3: 'pl-10' +} as const; + +const TableOfContents: React.FC = ({items, onItemClick}) => { + if (items.length === 0) { + return null; + } + + const getNormalizedLevel = (level: number) => { + return Math.min(level, 3); + }; + + const getLineWidth = (level: number) => { + return LINE_WIDTHS[getNormalizedLevel(level) as keyof typeof LINE_WIDTHS]; + }; + + const getHeadingPadding = (level: number) => { + return HEADING_PADDINGS[getNormalizedLevel(level) as keyof typeof HEADING_PADDINGS]; + }; + + return ( +
    + + {items.map(item => ( +
    + ))} +
    + } + > +
    + +
    +
    +
    + ); +}; + +export default TableOfContents; From 3e806ca7619f5a3316f649cebf210b9ecc5d8526 Mon Sep 17 00:00:00 2001 From: Djordje Vlaisavljevic Date: Thu, 16 Jan 2025 18:39:02 +0000 Subject: [PATCH 49/90] Updated copy to match terminology we're using ref https://linear.app/ghost/issue/AP-646/update-terminology-on-search-page - "Account" instead of "profile", "handle" instead of "username" --- apps/admin-x-activitypub/src/components/Search.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/admin-x-activitypub/src/components/Search.tsx b/apps/admin-x-activitypub/src/components/Search.tsx index 78a6c797388..663d3242677 100644 --- a/apps/admin-x-activitypub/src/components/Search.tsx +++ b/apps/admin-x-activitypub/src/components/Search.tsx @@ -156,7 +156,7 @@ const Search: React.FC = ({}) => { className='mb-6 mr-12 flex h-10 w-full items-center rounded-lg border border-transparent bg-grey-100 px-[33px] py-1.5 transition-colors focus:border-green focus:bg-white focus:outline-2 dark:border-transparent dark:bg-grey-925 dark:text-white dark:placeholder:text-grey-800 dark:focus:border-green dark:focus:bg-grey-950 tablet:mr-0' containerClassName='w-100' inputRef={queryInputRef} - placeholder='Enter a username...' + placeholder='Enter a handle or account URL...' title="Search" type='text' value={query} @@ -183,7 +183,7 @@ const Search: React.FC = ({}) => { {showNoResults && ( - No users matching this username + No users matching this handle or account URL )} From e5ea3a0a8c2ff88541504c51483745ea7848ba73 Mon Sep 17 00:00:00 2001 From: Djordje Vlaisavljevic Date: Thu, 16 Jan 2025 18:50:47 +0000 Subject: [PATCH 50/90] Fixed "Show all" button not appearing on profile summaries no ref --- apps/admin-x-activitypub/package.json | 2 +- .../src/components/modals/ViewProfileModal.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/admin-x-activitypub/package.json b/apps/admin-x-activitypub/package.json index c8b0ce83910..b287246df96 100644 --- a/apps/admin-x-activitypub/package.json +++ b/apps/admin-x-activitypub/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/admin-x-activitypub", - "version": "0.3.48", + "version": "0.3.49", "license": "MIT", "repository": { "type": "git", diff --git a/apps/admin-x-activitypub/src/components/modals/ViewProfileModal.tsx b/apps/admin-x-activitypub/src/components/modals/ViewProfileModal.tsx index 22d4314e467..1b054cb27fa 100644 --- a/apps/admin-x-activitypub/src/components/modals/ViewProfileModal.tsx +++ b/apps/admin-x-activitypub/src/components/modals/ViewProfileModal.tsx @@ -274,7 +274,7 @@ const ViewProfileModal: React.FC = ({ if (contentRef.current) { setIsOverflowing(contentRef.current.scrollHeight > 160); // Compare content height to max height } - }, [isExpanded]); + }, [isExpanded, profile]); return ( = ({
    )} {isOverflowing && + + + + + + + Edit post + ⇧⌘E + + + View in browser + ⇧⌘O + + + Delete + + +
    +
    +

    The Evolution of Basketball: From Pastime to Professional and One of the Most Popular Sports

    +
    + ); +}; + +export default Header; diff --git a/apps/posts/src/components/layout/Header.tsx b/apps/posts/src/components/layout/Header.tsx deleted file mode 100644 index 5fd1ba21268..00000000000 --- a/apps/posts/src/components/layout/Header.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import {Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, H1} from '@tryghost/shade'; - -const Header = () => { - return ( -
    - - - - - Posts - - - - - - Analytics - - - - -

    The Evolution of Basketball: From Pastime to Professional and One of the Most Popular Sports

    -
    - ); -}; - -export default Header; diff --git a/apps/posts/src/components/post-analytics/overview/ClickPerformance.tsx b/apps/posts/src/components/post-analytics/overview/ClickPerformance.tsx deleted file mode 100644 index 50189e293a0..00000000000 --- a/apps/posts/src/components/post-analytics/overview/ClickPerformance.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import * as React from 'react'; -import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@tryghost/shade'; - -interface ClickPerformanceProps extends React.ComponentProps {}; - -const ClickPerformance: React.FC = (props) => { - return ( - - - Click performance - - Links in this newsletter - - - - Card contents - - - ); -}; - -export default ClickPerformance; diff --git a/apps/posts/src/components/post-analytics/overview/Conversions.tsx b/apps/posts/src/components/post-analytics/overview/Conversions.tsx deleted file mode 100644 index ec3f2ff00ba..00000000000 --- a/apps/posts/src/components/post-analytics/overview/Conversions.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import * as React from 'react'; -import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@tryghost/shade'; - -interface ConversionsProps extends React.ComponentProps {}; - -const Conversions: React.FC = (props) => { - return ( - - - Conversions - - 3 members signed up on this post - - - - Card contents - - - ); -}; - -export default Conversions; diff --git a/apps/posts/src/components/post-analytics/overview/Feedback.tsx b/apps/posts/src/components/post-analytics/overview/Feedback.tsx deleted file mode 100644 index 0e0b7d48e84..00000000000 --- a/apps/posts/src/components/post-analytics/overview/Feedback.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import * as React from 'react'; -import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@tryghost/shade'; - -interface FeedbackProps extends React.ComponentProps {}; - -const Feedback: React.FC = (props) => { - return ( - - - Feedback - - 188 reactions - - - - Card contents - - - ); -}; - -export default Feedback; diff --git a/apps/posts/src/components/post-analytics/overview/NewsletterPerformance.tsx b/apps/posts/src/components/post-analytics/overview/NewsletterPerformance.tsx deleted file mode 100644 index 9f4397cf8b9..00000000000 --- a/apps/posts/src/components/post-analytics/overview/NewsletterPerformance.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import * as React from 'react'; -import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@tryghost/shade'; - -interface NewsletterPerformanceProps extends React.ComponentProps {}; - -const NewsletterPerformance: React.FC = (props) => { - return ( - - - Newsletter performance - - Sent 19 Sept 2024 - - - - Card contents - - - ); -}; - -export default NewsletterPerformance; diff --git a/apps/posts/src/pages/PostAnalytics.tsx b/apps/posts/src/pages/PostAnalytics.tsx deleted file mode 100644 index 34a0d75610b..00000000000 --- a/apps/posts/src/pages/PostAnalytics.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import Header from '../components/layout/Header'; -import Overview from '../components/post-analytics/Overview'; -import {Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuTrigger, Icon, Page, Tabs, TabsContent, TabsList, TabsTrigger} from '@tryghost/shade'; - -const PostAnalytics = () => { - return ( - -
    - -
    - - Overview - Newsletter - -
    - - - - - - - - Edit post - ⇧⌘E - - - View in browser - ⇧⌘O - - - Delete - - -
    -
    - - - - - Newsletter details - -
    - - ); -}; - -export default PostAnalytics; diff --git a/apps/posts/src/views/post-analytics/PostAnalytics.tsx b/apps/posts/src/views/post-analytics/PostAnalytics.tsx new file mode 100644 index 00000000000..90cb9a8c64e --- /dev/null +++ b/apps/posts/src/views/post-analytics/PostAnalytics.tsx @@ -0,0 +1,29 @@ +import Header from '../../components/Header'; +import Newsletter from './components/Newsletter'; +import Overview from './components/Overview'; +import {LucideIcon, Page, Tabs, TabsContent, TabsList, TabsTrigger} from '@tryghost/shade'; + +interface postAnalyticsProps {}; + +const PostAnalytics: React.FC = () => { + return ( + +
    + + + Overview + Newsletter + Web + + + + + + + + + + ); +}; + +export default PostAnalytics; diff --git a/apps/posts/src/views/post-analytics/components/Newsletter.tsx b/apps/posts/src/views/post-analytics/components/Newsletter.tsx new file mode 100644 index 00000000000..8982064470e --- /dev/null +++ b/apps/posts/src/views/post-analytics/components/Newsletter.tsx @@ -0,0 +1,104 @@ +import OpenedList from './newsletter/OpenedList'; +import React from 'react'; +import SentList from './newsletter/SentList'; +import {Badge} from '@tryghost/shade'; +import {StatsTabItem, StatsTabTitle, StatsTabValue, StatsTabs, StatsTabsGroup} from './StatsTabs'; + +interface newsletterProps {}; + +const Newsletter: React.FC = () => { + const tabs = [ + [ + { + key: 'sent', + title: 'Sent', + value: '1,697', + badge: '', + content: + }, + { + key: 'opened', + title: 'Opened', + value: '560', + badge: '75%', + content: + }, + { + key: 'clicked', + title: 'Clicked', + value: '21', + badge: '18%', + content: + } + ], + [ + { + key: 'unsubscribed', + title: 'Unsubscribed', + value: '21', + badge: '', + content: + }, + { + key: 'feedback', + title: 'Feedback', + value: '5', + badge: '', + content: + }, + { + key: 'spam', + title: 'Marked as spam', + value: '17', + badge: '', + content: + }, + { + key: 'bounced', + title: 'Bounced', + value: '81', + badge: '', + content: + } + ] + ]; + + const [currentTab, setCurrentTab] = React.useState(tabs[0][0].key); + + const Content: React.FC = () => { + return tabs.map((tabGroup) => { + const selectedTab = tabGroup.find(tab => tab.key === currentTab); + return selectedTab?.content; + }); + }; + + return ( +
    +
    + +
    +
    + + {tabs.map(group => ( + + {group.map(item => ( + { + setCurrentTab(item.key); + }}> + + {item.title} + {item.badge && {item.badge}} + + {item.value} + + ))} + + ) + )} + +
    +
    + ); +}; + +export default Newsletter; diff --git a/apps/posts/src/components/post-analytics/Overview.tsx b/apps/posts/src/views/post-analytics/components/Overview.tsx similarity index 76% rename from apps/posts/src/components/post-analytics/Overview.tsx rename to apps/posts/src/views/post-analytics/components/Overview.tsx index 73f086c174c..c1392fc7f98 100644 --- a/apps/posts/src/components/post-analytics/Overview.tsx +++ b/apps/posts/src/views/post-analytics/components/Overview.tsx @@ -3,9 +3,11 @@ import Conversions from './overview/Conversions'; import Feedback from './overview/Feedback'; import NewsletterPerformance from './overview/NewsletterPerformance'; -const Overview = () => { +interface overviewProps {}; + +const Overview: React.FC = () => { return ( -
    +
    diff --git a/apps/posts/src/views/post-analytics/components/StatsTabs.tsx b/apps/posts/src/views/post-analytics/components/StatsTabs.tsx new file mode 100644 index 00000000000..3a79d034bcd --- /dev/null +++ b/apps/posts/src/views/post-analytics/components/StatsTabs.tsx @@ -0,0 +1,57 @@ +import * as React from 'react'; +import {Button, ButtonProps} from '@tryghost/shade'; +import {cn} from '@tryghost/shade'; + +interface statsTabsProps + extends React.HTMLAttributes {} + +const StatsTabs: React.FC = ({className, ...props}) => { + return
    ; +}; + +interface statsTabsGroupProps + extends React.HTMLAttributes {}; + +const StatsTabsGroup: React.FC = ({className, ...props}) => { + return
    ; +}; + +interface subNavItemProps extends ButtonProps { + isActive?: boolean; +} + +const StatsTabItem: React.FC = ({isActive, ...props}) => { + const subNavItemClasses = cn( + 'flex flex-col items-start h-auto py-3 gap-0 border border-border group/item', + isActive ? 'bg-muted/70' : 'border-gray-200 hover:bg-muted/50' + ); + return ( + + + + ); +}; + +export default ClickPerformance; diff --git a/apps/posts/src/views/post-analytics/components/overview/Conversions.tsx b/apps/posts/src/views/post-analytics/components/overview/Conversions.tsx new file mode 100644 index 00000000000..4d6cd21b1bb --- /dev/null +++ b/apps/posts/src/views/post-analytics/components/overview/Conversions.tsx @@ -0,0 +1,72 @@ +import * as React from 'react'; +import {Avatar, AvatarFallback, AvatarImage, Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@tryghost/shade'; + +interface ConversionsProps extends React.ComponentProps {}; + +const Conversions: React.FC = (props) => { + const mockData = [ + { + name: 'Gustavo Kenter', + tier: 'Gold', + avatarImage: 'https://i.pravatar.cc/150?img=1', + avatarFallback: 'GK', + receiveDate: 'A month ago' + }, + { + name: 'Kadin Botosh', + tier: 'Free', + avatarImage: '', + avatarFallback: 'KB', + receiveDate: 'A month ago' + }, + { + name: 'Skylar Lipshutz', + tier: 'Free', + avatarImage: 'https://i.pravatar.cc/150?img=2', + avatarFallback: 'SL', + receiveDate: 'A month ago' + } + ]; + + return ( + + + Conversions + + 3 members signed up on this post + + + + + + + Member + Tier + + + + {mockData.map(member => ( + + +
    + + + {member.avatarFallback} + + {member.name} +
    +
    + {member.tier} +
    + ))} +
    +
    +
    + + + +
    + ); +}; + +export default Conversions; diff --git a/apps/posts/src/views/post-analytics/components/overview/Feedback.tsx b/apps/posts/src/views/post-analytics/components/overview/Feedback.tsx new file mode 100644 index 00000000000..b6c6bd1f96f --- /dev/null +++ b/apps/posts/src/views/post-analytics/components/overview/Feedback.tsx @@ -0,0 +1,107 @@ +import * as React from 'react'; +import {Card, CardContent, CardDescription, CardHeader, CardTitle, ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, Recharts, Separator} from '@tryghost/shade'; +import {Metric, MetricLabel, MetricValue} from './Metric'; + +interface FeedbackProps extends React.ComponentProps {}; + +const Feedback: React.FC = (props) => { + const chartData = React.useMemo(() => { + return [ + {browser: 'chrome', visitors: 98, fill: 'var(--color-chrome)'}, + {browser: 'safari', visitors: 17, fill: 'var(--color-safari)'} + ]; + }, []); + + const chartConfig = { + visitors: { + label: 'Reactions' + }, + chrome: { + label: 'More like this', + color: 'hsl(var(--chart-1))' + }, + safari: { + label: 'Less like this', + color: 'hsl(var(--chart-5))' + } + } satisfies ChartConfig; + + const totalVisitors = React.useMemo(() => { + return chartData.reduce((acc, curr) => acc + curr.visitors, 0); + }, [chartData]); + + return ( + + + Feedback + + 188 reactions + + + + +
    + + More like this + 98 + + + + Less like this + 17 + +
    + + + } + cursor={false} + /> + + { + if (viewBox && 'cx' in viewBox && 'cy' in viewBox) { + return ( + + + {totalVisitors.toLocaleString()} + + + Reactions + + + ); + } + }} + /> + + + +
    +
    + ); +}; + +export default Feedback; diff --git a/apps/posts/src/views/post-analytics/components/overview/Metric.tsx b/apps/posts/src/views/post-analytics/components/overview/Metric.tsx new file mode 100644 index 00000000000..5ce2f874637 --- /dev/null +++ b/apps/posts/src/views/post-analytics/components/overview/Metric.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import {cn} from '@tryghost/shade'; + +export interface metricDivProps + extends React.HTMLAttributes {} + +const Metric = ({className, ...props}: metricDivProps) => { + return ( +
    + ); +}; + +const MetricLabel = ({className, ...props}: metricDivProps) => { + return ( +
    + ); +}; + +const MetricValue = ({className, ...props}: metricDivProps) => { + return ( +
    + ); +}; + +const MetricPercentage = ({className, ...props}: metricDivProps) => { + return ( +
    + ); +}; + +export {Metric, MetricLabel, MetricValue, MetricPercentage}; diff --git a/apps/posts/src/views/post-analytics/components/overview/NewsletterPerformance.tsx b/apps/posts/src/views/post-analytics/components/overview/NewsletterPerformance.tsx new file mode 100644 index 00000000000..db7a7f94cb3 --- /dev/null +++ b/apps/posts/src/views/post-analytics/components/overview/NewsletterPerformance.tsx @@ -0,0 +1,72 @@ +import * as React from 'react'; +import {Badge, Card, CardContent, CardDescription, CardHeader, CardTitle, ChartConfig, ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, Recharts, Separator} from '@tryghost/shade'; +import {Metric, MetricLabel, MetricPercentage, MetricValue} from './Metric'; + +interface NewsletterPerformanceProps extends React.ComponentProps {}; + +const NewsletterPerformance: React.FC = (props) => { + const chartData = [ + {metric: 'Sent', current: 1697, avg: 1524}, + {metric: 'Opened', current: 1184, avg: 867}, + {metric: 'Clicked', current: 750, avg: 478} + ]; + + const chartConfig = { + current: { + label: 'This post', + color: 'hsl(var(--chart-1))' + }, + avg: { + label: 'Your average post', + color: 'hsl(var(--chart-5))' + } + } satisfies ChartConfig; + + return ( + + + Newsletter performance + + Sent 19 Sept 2024 + + + + +
    + + Sent + 1,697 + + + + Opened + 1,184 69% + + + + Clicked + 750 44% + +
    + + + + + } /> + } /> + + + + +
    +
    + ); +}; + +export default NewsletterPerformance; diff --git a/apps/shade/package.json b/apps/shade/package.json index 084b146a2b2..f140fc9e9f0 100644 --- a/apps/shade/package.json +++ b/apps/shade/package.json @@ -67,28 +67,30 @@ "@ebay/nice-modal-react": "1.2.13", "@radix-ui/react-avatar": "1.1.0", "@radix-ui/react-checkbox": "1.1.1", + "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dropdown-menu": "2.1.3", "@radix-ui/react-form": "0.0.3", "@radix-ui/react-popover": "1.1.1", "@radix-ui/react-radio-group": "1.2.0", - "@radix-ui/react-separator": "1.1.0", - "@radix-ui/react-slot": "1.1.1", + "@radix-ui/react-separator": "^1.1.1", + "@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-switch": "1.1.0", "@radix-ui/react-tabs": "1.1.2", - "@radix-ui/react-tooltip": "1.1.2", + "@radix-ui/react-tooltip": "^1.1.6", "@sentry/react": "7.119.2", "@tailwindcss/forms": "0.5.9", "@tailwindcss/line-clamp": "0.4.4", "@uiw/react-codemirror": "4.23.7", "autoprefixer": "10.4.19", - "class-variance-authority": "0.7.1", + "class-variance-authority": "^0.7.1", "clsx": "2.1.1", - "lucide-react": "0.468.0", + "lucide-react": "^0.471.1", "postcss": "8.4.39", "postcss-import": "16.1.0", "react-colorful": "5.6.1", "react-hot-toast": "2.5.1", "react-select": "5.8.2", + "recharts": "^2.15.0", "tailwind-merge": "2.6.0", "tailwindcss": "3.4.14", "tailwindcss-animate": "1.0.7" @@ -97,4 +99,4 @@ "react": "^18.2.0", "react-dom": "^18.2.0" } -} \ No newline at end of file +} diff --git a/apps/shade/src/assets/icons/dotdotdot.svg b/apps/shade/src/assets/icons/dotdotdot.svg index 5a6a5fbe2a5..743cb1af5e4 100644 --- a/apps/shade/src/assets/icons/dotdotdot.svg +++ b/apps/shade/src/assets/icons/dotdotdot.svg @@ -1,10 +1,10 @@ - - - - \ No newline at end of file + + + + diff --git a/apps/shade/src/components/layout/page.tsx b/apps/shade/src/components/layout/page.tsx index c11775bed3d..50db1085e0e 100644 --- a/apps/shade/src/components/layout/page.tsx +++ b/apps/shade/src/components/layout/page.tsx @@ -9,7 +9,7 @@ const Page = React.forwardRef( return (
    ); diff --git a/apps/shade/src/components/ui/avatar.tsx b/apps/shade/src/components/ui/avatar.tsx new file mode 100644 index 00000000000..18bd105b114 --- /dev/null +++ b/apps/shade/src/components/ui/avatar.tsx @@ -0,0 +1,48 @@ +import * as React from 'react'; +import * as AvatarPrimitive from '@radix-ui/react-avatar'; + +import {cn} from '@/lib/utils'; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({className, ...props}, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({className, ...props}, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({className, ...props}, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export {Avatar, AvatarImage, AvatarFallback}; diff --git a/apps/shade/src/components/ui/badge.tsx b/apps/shade/src/components/ui/badge.tsx new file mode 100644 index 00000000000..7d860f70c14 --- /dev/null +++ b/apps/shade/src/components/ui/badge.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import {cva, type VariantProps} from 'class-variance-authority'; + +import {cn} from '@/lib/utils'; + +const badgeVariants = cva( + 'inline-flex items-center rounded-sm border px-1.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + { + variants: { + variant: { + default: + 'border-transparent bg-primary text-primary-foreground', + secondary: + 'border-transparent bg-secondary text-secondary-foreground/70', + destructive: + 'border-transparent bg-destructive/20 text-destructive', + success: + 'border-transparent bg-green/20 text-green', + outline: 'text-foreground' + } + }, + defaultVariants: { + variant: 'default' + } + } +); + +export interface BadgeProps +extends React.HTMLAttributes, + VariantProps {} + +function Badge({className, variant, ...props}: BadgeProps) { + return ( +
    + ); +} + +export {Badge, badgeVariants}; diff --git a/apps/shade/src/components/ui/card.tsx b/apps/shade/src/components/ui/card.tsx index 88ffb88313c..9f252bb9825 100644 --- a/apps/shade/src/components/ui/card.tsx +++ b/apps/shade/src/components/ui/card.tsx @@ -9,7 +9,7 @@ const Card = React.forwardRef<
    (({className, ...props}, ref) => (
    )); @@ -35,7 +35,7 @@ const CardTitle = React.forwardRef< >(({className, ...props}, ref) => (
    )); @@ -65,11 +65,13 @@ const CardFooter = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({className, ...props}, ref) => ( -
    +
    +
    +
    )); CardFooter.displayName = 'CardFooter'; diff --git a/apps/shade/src/components/ui/chart.tsx b/apps/shade/src/components/ui/chart.tsx new file mode 100644 index 00000000000..1b3ffc33701 --- /dev/null +++ b/apps/shade/src/components/ui/chart.tsx @@ -0,0 +1,363 @@ +import * as React from 'react'; +import * as RechartsPrimitive from 'recharts'; + +import {cn} from '@/lib/utils'; + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = {light: '', dark: '.dark'} as const; + +export type ChartConfig = { +[k in string]: { + label?: React.ReactNode + icon?: React.ComponentType +} & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } +) +} + +type ChartContextProps = { +config: ChartConfig +} + +const ChartContext = React.createContext(null); + +function useChart() { + const context = React.useContext(ChartContext); + + if (!context) { + throw new Error('useChart must be used within a '); + } + + return context; +} + +const ChartContainer = React.forwardRef< +HTMLDivElement, +React.ComponentProps<'div'> & { + config: ChartConfig + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >['children'] +} +>(({id, className, children, config, ...props}, ref) => { + const uniqueId = React.useId(); + const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`; + + return ( + +
    + + + {children} + +
    +
    + ); +}); +ChartContainer.displayName = 'Chart'; + +const ChartStyle = ({id, config}: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, themeConfig]) => themeConfig.theme || themeConfig.color + ); + + if (!colorConfig.length) { + return null; + } + + return ( + - - - - \ No newline at end of file + + + + diff --git a/yarn.lock b/yarn.lock index 4622e76993f..882f25dc332 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4351,6 +4351,26 @@ resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.1.1.tgz#82074aa83a472353bb22e86f11bcbd1c61c4c71a" integrity sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q== +"@radix-ui/react-dialog@^1.1.4": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.1.4.tgz#d68e977acfcc0d044b9dab47b6dd2c179d2b3191" + integrity sha512-Ur7EV1IwQGCyaAuyDRiOLA5JIUZxELJljF+MbM/2NC0BYwfuRrbpS30BiQBJrVruscgUkieKkqXYDOoByaxIoA== + dependencies: + "@radix-ui/primitive" "1.1.1" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-dismissable-layer" "1.1.3" + "@radix-ui/react-focus-guards" "1.1.1" + "@radix-ui/react-focus-scope" "1.1.1" + "@radix-ui/react-id" "1.1.0" + "@radix-ui/react-portal" "1.1.3" + "@radix-ui/react-presence" "1.1.2" + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-slot" "1.1.1" + "@radix-ui/react-use-controllable-state" "1.1.0" + aria-hidden "^1.1.1" + react-remove-scroll "^2.6.1" + "@radix-ui/react-direction@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.0.1.tgz#9cb61bf2ccf568f3421422d182637b7f47596c9b" @@ -4397,6 +4417,17 @@ "@radix-ui/react-use-callback-ref" "1.1.0" "@radix-ui/react-use-escape-keydown" "1.1.0" +"@radix-ui/react-dismissable-layer@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.3.tgz#4ee0f0f82d53bf5bd9db21665799bb0d1bad5ed8" + integrity sha512-onrWn/72lQoEucDmJnr8uczSNTujT0vJnA/X5+3AkChVPowr8n1yvIKIabhWyMQeMvvmdpsvcyDqx3X1LEXCPg== + dependencies: + "@radix-ui/primitive" "1.1.1" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-use-callback-ref" "1.1.0" + "@radix-ui/react-use-escape-keydown" "1.1.0" + "@radix-ui/react-dropdown-menu@2.1.3": version "2.1.3" resolved "https://registry.yarnpkg.com/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.3.tgz#02665f99bfdcefc33a8a15dc130e9b98ebdf7671" @@ -4752,6 +4783,13 @@ dependencies: "@radix-ui/react-primitive" "2.0.0" +"@radix-ui/react-separator@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-separator/-/react-separator-1.1.1.tgz#dd60621553c858238d876be9b0702287424866d2" + integrity sha512-RRiNRSrD8iUiXriq/Y5n4/3iE8HzqgLHsusUSg5jVpU2+3tqcUFPJXHDymwEypunc2sWxDUS3UC+rkZRlHedsw== + dependencies: + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-slot@1.0.2": version "1.0.2" resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.0.2.tgz#a9ff4423eade67f501ffb32ec22064bc9d3099ab" @@ -4767,7 +4805,7 @@ dependencies: "@radix-ui/react-compose-refs" "1.1.0" -"@radix-ui/react-slot@1.1.1": +"@radix-ui/react-slot@1.1.1", "@radix-ui/react-slot@^1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.1.1.tgz#ab9a0ffae4027db7dc2af503c223c978706affc3" integrity sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g== @@ -4871,6 +4909,24 @@ "@radix-ui/react-use-controllable-state" "1.1.0" "@radix-ui/react-visually-hidden" "1.1.0" +"@radix-ui/react-tooltip@^1.1.6": + version "1.1.6" + resolved "https://registry.yarnpkg.com/@radix-ui/react-tooltip/-/react-tooltip-1.1.6.tgz#eab98e9a5c876ef0abfae3cfeee229870528ed06" + integrity sha512-TLB5D8QLExS1uDn7+wH/bjEmRurNMTzNrtq7IjaS4kjion9NtzsTGkvR5+i7yc9q01Pi2KMM2cN3f8UG4IvvXA== + dependencies: + "@radix-ui/primitive" "1.1.1" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-dismissable-layer" "1.1.3" + "@radix-ui/react-id" "1.1.0" + "@radix-ui/react-popper" "1.2.1" + "@radix-ui/react-portal" "1.1.3" + "@radix-ui/react-presence" "1.1.2" + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-slot" "1.1.1" + "@radix-ui/react-use-controllable-state" "1.1.0" + "@radix-ui/react-visually-hidden" "1.1.1" + "@radix-ui/react-use-callback-ref@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz#f4bb1f27f2023c984e6534317ebc411fc181107a" @@ -4982,6 +5038,13 @@ dependencies: "@radix-ui/react-primitive" "2.0.0" +"@radix-ui/react-visually-hidden@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.1.tgz#f7b48c1af50dfdc366e92726aee6d591996c5752" + integrity sha512-vVfA2IZ9q/J+gEamvj761Oq1FpWgCDaNOOIfbPVp2MVPLEomUr5+Vf7kJGwQ24YxZSlQVar7Bes8kyTo5Dshpg== + dependencies: + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/rect@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.0.1.tgz#bf8e7d947671996da2e30f4904ece343bc4a883f" @@ -8240,6 +8303,57 @@ dependencies: "@types/node" "*" +"@types/d3-array@^3.0.3": + version "3.2.1" + resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.2.1.tgz#1f6658e3d2006c4fceac53fde464166859f8b8c5" + integrity sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg== + +"@types/d3-color@*": + version "3.1.3" + resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.3.tgz#368c961a18de721da8200e80bf3943fb53136af2" + integrity sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A== + +"@types/d3-ease@^3.0.0": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-ease/-/d3-ease-3.0.2.tgz#e28db1bfbfa617076f7770dd1d9a48eaa3b6c51b" + integrity sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA== + +"@types/d3-interpolate@^3.0.1": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c" + integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA== + dependencies: + "@types/d3-color" "*" + +"@types/d3-path@*": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-3.1.0.tgz#2b907adce762a78e98828f0b438eaca339ae410a" + integrity sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ== + +"@types/d3-scale@^4.0.2": + version "4.0.8" + resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.8.tgz#d409b5f9dcf63074464bf8ddfb8ee5a1f95945bb" + integrity sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ== + dependencies: + "@types/d3-time" "*" + +"@types/d3-shape@^3.1.0": + version "3.1.7" + resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.7.tgz#2b7b423dc2dfe69c8c93596e673e37443348c555" + integrity sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg== + dependencies: + "@types/d3-path" "*" + +"@types/d3-time@*", "@types/d3-time@^3.0.0": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.4.tgz#8472feecd639691450dd8000eb33edd444e1323f" + integrity sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g== + +"@types/d3-timer@^3.0.0": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-timer/-/d3-timer-3.0.2.tgz#70bbda77dc23aa727413e22e214afa3f0e852f70" + integrity sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw== + "@types/detect-port@^1.3.0": version "1.3.2" resolved "https://registry.yarnpkg.com/@types/detect-port/-/detect-port-1.3.2.tgz#8c06a975e472803b931ee73740aeebd0a2eb27ae" @@ -12795,7 +12909,7 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" -class-variance-authority@0.7.1: +class-variance-authority@^0.7.1: version "0.7.1" resolved "https://registry.yarnpkg.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz#4008a798a0e4553a781a57ac5177c9fb5d043787" integrity sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg== @@ -12986,7 +13100,7 @@ clone@~0.1.9: resolved "https://registry.yarnpkg.com/clone/-/clone-0.1.19.tgz#613fb68639b26a494ac53253e15b1a6bd88ada85" integrity sha512-IO78I0y6JcSpEPHzK4obKdsL7E7oLdRVDVOLwr2Hkbjsb+Eoz0dxW6tef0WizoKu0gLC4oZSZuEF4U2K6w1WQw== -clsx@2.1.1, clsx@^2.1.1: +clsx@2.1.1, clsx@^2.0.0, clsx@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== @@ -14057,6 +14171,77 @@ cyclist@^1.0.1: resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9" integrity sha512-NJGVKPS81XejHcLhaLJS7plab0fK3slPh11mESeeDq2W4ZI5kUKK/LRRdVDvjJseojbPB7ZwjnyOybg3Igea/A== +"d3-array@2 - 3", "d3-array@2.10.0 - 3", d3-array@^3.1.6: + version "3.2.4" + resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.4.tgz#15fec33b237f97ac5d7c986dc77da273a8ed0bb5" + integrity sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg== + dependencies: + internmap "1 - 2" + +"d3-color@1 - 3": + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2" + integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA== + +d3-ease@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4" + integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w== + +"d3-format@1 - 3": + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.0.tgz#9260e23a28ea5cb109e93b21a06e24e2ebd55641" + integrity sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA== + +"d3-interpolate@1.2.0 - 3", d3-interpolate@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d" + integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g== + dependencies: + d3-color "1 - 3" + +d3-path@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-3.1.0.tgz#22df939032fb5a71ae8b1800d61ddb7851c42526" + integrity sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ== + +d3-scale@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-4.0.2.tgz#82b38e8e8ff7080764f8dcec77bd4be393689396" + integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ== + dependencies: + d3-array "2.10.0 - 3" + d3-format "1 - 3" + d3-interpolate "1.2.0 - 3" + d3-time "2.1.1 - 3" + d3-time-format "2 - 4" + +d3-shape@^3.1.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-3.2.0.tgz#a1a839cbd9ba45f28674c69d7f855bcf91dfc6a5" + integrity sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA== + dependencies: + d3-path "^3.1.0" + +"d3-time-format@2 - 4": + version "4.1.0" + resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-4.1.0.tgz#7ab5257a5041d11ecb4fe70a5c7d16a195bb408a" + integrity sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg== + dependencies: + d3-time "1 - 3" + +"d3-time@1 - 3", "d3-time@2.1.1 - 3", d3-time@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-3.1.0.tgz#9310db56e992e3c0175e1ef385e545e48a9bb5c7" + integrity sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q== + dependencies: + d3-array "2 - 3" + +d3-timer@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0" + integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA== + dag-map@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/dag-map/-/dag-map-2.0.2.tgz#9714b472de82a1843de2fba9b6876938cab44c68" @@ -14187,6 +14372,11 @@ decamelize@^5.0.0: resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-5.0.1.tgz#db11a92e58c741ef339fb0a2868d8a06a9a7b1e9" integrity sha512-VfxadyCECXgQlkoEAjeghAr5gY3Hf+IKjKb+X8tGVDtveCjN+USwprd2q3QXBR9T1+x2DG0XZF5/w+7HAtSaXA== +decimal.js-light@^2.4.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz#134fd32508f19e208f4fb2f8dac0d2626a867934" + integrity sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg== + decimal.js@^10.2.1, decimal.js@^10.4.3: version "10.4.3" resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.3.tgz#1044092884d245d1b7f65725fa4ad4c6f781cc23" @@ -17185,7 +17375,7 @@ etag@~1.8.1: resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== -eventemitter3@^4.0.0: +eventemitter3@^4.0.0, eventemitter3@^4.0.1: version "4.0.7" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== @@ -17621,6 +17811,11 @@ fast-deep-equal@^3, fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== +fast-equals@^5.0.1: + version "5.2.2" + resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-5.2.2.tgz#885d7bfb079fac0ce0e8450374bce29e9b742484" + integrity sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw== + fast-fifo@^1.1.0, fast-fifo@^1.2.0: version "1.3.2" resolved "https://registry.yarnpkg.com/fast-fifo/-/fast-fifo-1.3.2.tgz#286e31de96eb96d38a97899815740ba2a4f3640c" @@ -19978,6 +20173,11 @@ internal-slot@^1.0.3, internal-slot@^1.0.4, internal-slot@^1.0.5: has "^1.0.3" side-channel "^1.0.4" +"internmap@1 - 2": + version "2.0.3" + resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009" + integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg== + interpret@^2.0.0, interpret@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9" @@ -22791,10 +22991,10 @@ ltgt@^2.1.2: resolved "https://registry.yarnpkg.com/ltgt/-/ltgt-2.2.1.tgz#f35ca91c493f7b73da0e07495304f17b31f87ee5" integrity sha512-AI2r85+4MquTw9ZYqabu4nMwy9Oftlfa/e/52t9IjtfG+mGBbTNdAoZ3RQKLHR6r0wQnwZnPIEh/Ya6XTWAKNA== -lucide-react@0.468.0: - version "0.468.0" - resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.468.0.tgz#830c1bfd905575ddd23b832baa420c87db166910" - integrity sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA== +lucide-react@^0.471.1: + version "0.471.1" + resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.471.1.tgz#16f29cd65c7c847eceab0bbf8592443104423249" + integrity sha512-syOxwPhf62gg2YOsz72HRn+CIpeudFy67AeKnSR8Hn/fIIF4ubhNbRF+pQ2CaJrl+X9Os4PL87z2DXQi3DVeDA== luxon@3.5.0, luxon@^3.5.0: version "3.5.0" @@ -27310,6 +27510,11 @@ react-is@^18.0.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== +react-is@^18.3.1: + version "18.3.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" + integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== + react-refresh@^0.14.0: version "0.14.0" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.0.tgz#4e02825378a5f227079554d4284889354e5f553e" @@ -27323,6 +27528,14 @@ react-remove-scroll-bar@^2.3.3, react-remove-scroll-bar@^2.3.4, react-remove-scr react-style-singleton "^2.2.1" tslib "^2.0.0" +react-remove-scroll-bar@^2.3.7: + version "2.3.8" + resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz#99c20f908ee467b385b68a3469b4a3e750012223" + integrity sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q== + dependencies: + react-style-singleton "^2.2.2" + tslib "^2.0.0" + react-remove-scroll@2.5.5: version "2.5.5" resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz#1e31a1260df08887a8a0e46d09271b52b3a37e77" @@ -27356,6 +27569,17 @@ react-remove-scroll@2.6.0: use-callback-ref "^1.3.0" use-sidecar "^1.1.2" +react-remove-scroll@^2.6.1: + version "2.6.2" + resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.6.2.tgz#2518d2c5112e71ea8928f1082a58459b5c7a2a97" + integrity sha512-KmONPx5fnlXYJQqC62Q+lwIeAk64ws/cUw6omIumRzMRPqgnYqhSSti99nbj0Ry13bv7dF+BKn7NB+OqkdZGTw== + dependencies: + react-remove-scroll-bar "^2.3.7" + react-style-singleton "^2.2.1" + tslib "^2.1.0" + use-callback-ref "^1.3.3" + use-sidecar "^1.1.2" + react-select@5.8.2: version "5.8.2" resolved "https://registry.yarnpkg.com/react-select/-/react-select-5.8.2.tgz#0d7ccb1895d61aafcd090fbf65aa9e506225a854" @@ -27371,6 +27595,15 @@ react-select@5.8.2: react-transition-group "^4.3.0" use-isomorphic-layout-effect "^1.1.2" +react-smooth@^4.0.0: + version "4.0.4" + resolved "https://registry.yarnpkg.com/react-smooth/-/react-smooth-4.0.4.tgz#a5875f8bb61963ca61b819cedc569dc2453894b4" + integrity sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q== + dependencies: + fast-equals "^5.0.1" + prop-types "^15.8.1" + react-transition-group "^4.4.5" + react-string-replace@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/react-string-replace/-/react-string-replace-1.1.1.tgz#8413a598c60e397fe77df3464f2889f00ba25989" @@ -27385,7 +27618,15 @@ react-style-singleton@^2.2.1: invariant "^2.2.4" tslib "^2.0.0" -react-transition-group@^4.3.0: +react-style-singleton@^2.2.2: + version "2.2.3" + resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz#4265608be69a4d70cfe3047f2c6c88b2c3ace388" + integrity sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ== + dependencies: + get-nonce "^1.0.0" + tslib "^2.0.0" + +react-transition-group@^4.3.0, react-transition-group@^4.4.5: version "4.4.5" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1" integrity sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g== @@ -27548,6 +27789,27 @@ recast@^0.23.1, recast@^0.23.3: source-map "~0.6.1" tslib "^2.0.1" +recharts-scale@^0.4.4: + version "0.4.5" + resolved "https://registry.yarnpkg.com/recharts-scale/-/recharts-scale-0.4.5.tgz#0969271f14e732e642fcc5bd4ab270d6e87dd1d9" + integrity sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w== + dependencies: + decimal.js-light "^2.4.1" + +recharts@^2.15.0: + version "2.15.0" + resolved "https://registry.yarnpkg.com/recharts/-/recharts-2.15.0.tgz#0b77bff57a43885df9769ae649a14cb1a7fe19aa" + integrity sha512-cIvMxDfpAmqAmVgc4yb7pgm/O1tmmkl/CjrvXuW+62/+7jj/iF9Ykm+hb/UJt42TREHMyd3gb+pkgoa2MxgDIw== + dependencies: + clsx "^2.0.0" + eventemitter3 "^4.0.1" + lodash "^4.17.21" + react-is "^18.3.1" + react-smooth "^4.0.0" + recharts-scale "^0.4.4" + tiny-invariant "^1.3.1" + victory-vendor "^36.6.8" + rechoir@^0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384" @@ -31331,6 +31593,13 @@ use-callback-ref@^1.3.0: dependencies: tslib "^2.0.0" +use-callback-ref@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz#98d9fab067075841c5b2c6852090d5d0feabe2bf" + integrity sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg== + dependencies: + tslib "^2.0.0" + use-debounce@10.0.4: version "10.0.4" resolved "https://registry.yarnpkg.com/use-debounce/-/use-debounce-10.0.4.tgz#2135be498ad855416c4495cfd8e0e130bd33bb24" @@ -31557,6 +31826,26 @@ vfile@^4.0.0: unist-util-stringify-position "^2.0.0" vfile-message "^2.0.0" +victory-vendor@^36.6.8: + version "36.9.2" + resolved "https://registry.yarnpkg.com/victory-vendor/-/victory-vendor-36.9.2.tgz#668b02a448fa4ea0f788dbf4228b7e64669ff801" + integrity sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ== + dependencies: + "@types/d3-array" "^3.0.3" + "@types/d3-ease" "^3.0.0" + "@types/d3-interpolate" "^3.0.1" + "@types/d3-scale" "^4.0.2" + "@types/d3-shape" "^3.1.0" + "@types/d3-time" "^3.0.0" + "@types/d3-timer" "^3.0.0" + d3-array "^3.1.6" + d3-ease "^3.0.1" + d3-interpolate "^3.0.1" + d3-scale "^4.0.2" + d3-shape "^3.1.0" + d3-time "^3.0.0" + d3-timer "^3.0.1" + video-extensions@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/video-extensions/-/video-extensions-1.2.0.tgz#62f449f403b853f02da40964cbf34143f7d96731" From 970741cf5def06c0314480432da07f3df8373a46 Mon Sep 17 00:00:00 2001 From: Sag Date: Mon, 20 Jan 2025 22:12:55 +0700 Subject: [PATCH 54/90] =?UTF-8?q?=F0=9F=94=92=20Blocked=20spammy=20email?= =?UTF-8?q?=20domains=20in=20member=20signups=20(#22027)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref https://linear.app/ghost/issue/ONC-721 ref https://app.incident.io/ghost/incidents/132 - added a blocklist at the email domain level for free member signups - for example, if `blocked-domain.com` is blocked, `thomas@blocked-domain.com` cannot sign up as free member - the blocklist is configurable: `"spam.blocked_email_domains": ["blocked-domain.com"]` --- apps/portal/src/utils/errors.js | 1 + .../core/core/server/services/members/api.js | 4 +- .../newsletters/NewslettersService.js | 4 +- .../services/settings/SettingsBREADService.js | 4 +- ghost/core/core/shared/config/defaults.json | 3 +- .../send-magic-link.test.js.snap | 18 +++++++ .../e2e-api/members/send-magic-link.test.js | 20 ++++++++ ghost/i18n/locales/af/portal.json | 1 + ghost/i18n/locales/ar/portal.json | 1 + ghost/i18n/locales/bg/portal.json | 1 + ghost/i18n/locales/bn/portal.json | 1 + ghost/i18n/locales/bs/portal.json | 1 + ghost/i18n/locales/ca/portal.json | 1 + ghost/i18n/locales/context.json | 1 + ghost/i18n/locales/cs/portal.json | 1 + ghost/i18n/locales/da/portal.json | 1 + ghost/i18n/locales/de-CH/portal.json | 1 + ghost/i18n/locales/de/portal.json | 1 + ghost/i18n/locales/el/portal.json | 1 + ghost/i18n/locales/en/portal.json | 1 + ghost/i18n/locales/eo/portal.json | 1 + ghost/i18n/locales/es/portal.json | 1 + ghost/i18n/locales/et/portal.json | 1 + ghost/i18n/locales/fa/portal.json | 1 + ghost/i18n/locales/fi/portal.json | 1 + ghost/i18n/locales/fr/portal.json | 1 + ghost/i18n/locales/gd/portal.json | 1 + ghost/i18n/locales/he/portal.json | 1 + ghost/i18n/locales/hi/portal.json | 1 + ghost/i18n/locales/hr/portal.json | 1 + ghost/i18n/locales/hu/portal.json | 1 + ghost/i18n/locales/id/portal.json | 1 + ghost/i18n/locales/is/portal.json | 1 + ghost/i18n/locales/it/portal.json | 1 + ghost/i18n/locales/ja/portal.json | 1 + ghost/i18n/locales/ko/portal.json | 1 + ghost/i18n/locales/kz/portal.json | 1 + ghost/i18n/locales/lt/portal.json | 1 + ghost/i18n/locales/lv/portal.json | 1 + ghost/i18n/locales/mk/portal.json | 1 + ghost/i18n/locales/mn/portal.json | 1 + ghost/i18n/locales/ms/portal.json | 1 + ghost/i18n/locales/ne/portal.json | 1 + ghost/i18n/locales/nl/portal.json | 1 + ghost/i18n/locales/nn/portal.json | 1 + ghost/i18n/locales/no/portal.json | 1 + ghost/i18n/locales/pl/portal.json | 1 + ghost/i18n/locales/pt-BR/portal.json | 1 + ghost/i18n/locales/pt/portal.json | 1 + ghost/i18n/locales/ro/portal.json | 1 + ghost/i18n/locales/ru/portal.json | 1 + ghost/i18n/locales/si/portal.json | 1 + ghost/i18n/locales/sk/portal.json | 1 + ghost/i18n/locales/sl/portal.json | 1 + ghost/i18n/locales/sq/portal.json | 1 + ghost/i18n/locales/sr-Cyrl/portal.json | 1 + ghost/i18n/locales/sr/portal.json | 1 + ghost/i18n/locales/sv/portal.json | 1 + ghost/i18n/locales/sw/portal.json | 1 + ghost/i18n/locales/ta/portal.json | 1 + ghost/i18n/locales/th/portal.json | 1 + ghost/i18n/locales/tr/portal.json | 1 + ghost/i18n/locales/uk/portal.json | 1 + ghost/i18n/locales/ur/portal.json | 1 + ghost/i18n/locales/uz/portal.json | 1 + ghost/i18n/locales/vi/portal.json | 1 + ghost/i18n/locales/zh-Hant/portal.json | 1 + ghost/i18n/locales/zh/portal.json | 1 + ghost/magic-link/lib/MagicLink.js | 32 +++++++++++- ghost/magic-link/test/index.test.js | 51 +++++++++++++++++++ ghost/members-api/lib/members-api.js | 6 ++- 71 files changed, 196 insertions(+), 8 deletions(-) diff --git a/apps/portal/src/utils/errors.js b/apps/portal/src/utils/errors.js index c48e3716bfc..93034e1cc98 100644 --- a/apps/portal/src/utils/errors.js +++ b/apps/portal/src/utils/errors.js @@ -59,6 +59,7 @@ export function chooseBestErrorMessage(error, alreadyTranslatedDefaultMessage, t t('Too many different sign-in attempts, try again in {{number}} days'); t('Failed to send magic link email'); t('This site only accepts paid members.'); + t('This email domain is not accepted, try again with a different email address'); } }; diff --git a/ghost/core/core/server/services/members/api.js b/ghost/core/core/server/services/members/api.js index 6b65cf6bd8f..26f5d46d7ca 100644 --- a/ghost/core/core/server/services/members/api.js +++ b/ghost/core/core/server/services/members/api.js @@ -20,6 +20,7 @@ const memberAttributionService = require('../member-attribution'); const emailSuppressionList = require('../email-suppression-list'); const {t} = require('../i18n'); const sentry = require('../../../shared/sentry'); +const sharedConfig = require('../../../shared/config'); const MAGIC_LINK_TOKEN_VALIDITY = 24 * 60 * 60 * 1000; const MAGIC_LINK_TOKEN_VALIDITY_AFTER_USAGE = 10 * 60 * 1000; @@ -238,7 +239,8 @@ function createApiInstance(config) { emailSuppressionList, settingsCache, sentry, - settingsHelpers + settingsHelpers, + config: sharedConfig }); return membersApiInstance; diff --git a/ghost/core/core/server/services/newsletters/NewslettersService.js b/ghost/core/core/server/services/newsletters/NewslettersService.js index 5247c69c93b..bf9082e96a7 100644 --- a/ghost/core/core/server/services/newsletters/NewslettersService.js +++ b/ghost/core/core/server/services/newsletters/NewslettersService.js @@ -6,6 +6,7 @@ const debug = require('@tryghost/debug')('services:newsletters'); const tpl = require('@tryghost/tpl'); const errors = require('@tryghost/errors'); const sentry = require('../../../shared/sentry'); +const config = require('../../../shared/config'); const messages = { nameAlreadyExists: 'A newsletter with the same name already exists', @@ -86,7 +87,8 @@ class NewslettersService { getText, getHTML, getSubject, - sentry + sentry, + config }); } diff --git a/ghost/core/core/server/services/settings/SettingsBREADService.js b/ghost/core/core/server/services/settings/SettingsBREADService.js index 0638920c510..bd93c0d7d9a 100644 --- a/ghost/core/core/server/services/settings/SettingsBREADService.js +++ b/ghost/core/core/server/services/settings/SettingsBREADService.js @@ -6,6 +6,7 @@ const logging = require('@tryghost/logging'); const MagicLink = require('@tryghost/magic-link'); const verifyEmailTemplate = require('./emails/verify-email'); const sentry = require('../../../shared/sentry'); +const config = require('../../../shared/config'); const EMAIL_KEYS = ['members_support_address']; const messages = { @@ -82,7 +83,8 @@ class SettingsBREADService { getText, getHTML, getSubject, - sentry + sentry, + config }); } diff --git a/ghost/core/core/shared/config/defaults.json b/ghost/core/core/shared/config/defaults.json index 04966562649..2c1220b8d81 100644 --- a/ghost/core/core/shared/config/defaults.json +++ b/ghost/core/core/shared/config/defaults.json @@ -126,7 +126,8 @@ "maxWait": 360000, "lifetime": 3600, "freeRetries": 10 - } + }, + "blocked_email_domains": [] }, "caching": { "frontend": { diff --git a/ghost/core/test/e2e-api/members/__snapshots__/send-magic-link.test.js.snap b/ghost/core/test/e2e-api/members/__snapshots__/send-magic-link.test.js.snap index e8e0a5499ec..1305fc1af3d 100644 --- a/ghost/core/test/e2e-api/members/__snapshots__/send-magic-link.test.js.snap +++ b/ghost/core/test/e2e-api/members/__snapshots__/send-magic-link.test.js.snap @@ -107,3 +107,21 @@ Object { ], } `; + +exports[`sendMagicLink blocks signups from blocked email domains 1: [body] 1`] = ` +Object { + "errors": Array [ + Object { + "code": null, + "context": null, + "details": null, + "ghostErrorCode": null, + "help": null, + "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "message": "This email domain is not accepted, try again with a different email address", + "property": null, + "type": "BadRequestError", + }, + ], +} +`; diff --git a/ghost/core/test/e2e-api/members/send-magic-link.test.js b/ghost/core/test/e2e-api/members/send-magic-link.test.js index f353f37fb13..88eefe3869d 100644 --- a/ghost/core/test/e2e-api/members/send-magic-link.test.js +++ b/ghost/core/test/e2e-api/members/send-magic-link.test.js @@ -4,6 +4,7 @@ const settingsCache = require('../../../core/shared/settings-cache'); const DomainEvents = require('@tryghost/domain-events'); const {anyErrorId} = matchers; const spamPrevention = require('../../../core/server/web/shared/middleware/api/spam-prevention'); +const configUtils = require('../../utils/configUtils'); let membersAgent, membersService; @@ -29,6 +30,7 @@ describe('sendMagicLink', function () { afterEach(function () { mockManager.restore(); + configUtils.restore(); }); it('Errors when passed multiple emails', async function () { @@ -285,4 +287,22 @@ describe('sendMagicLink', function () { } }); }); + + it('blocks signups from blocked email domains', async function () { + configUtils.set('spam:blocked_email_domains', ['blocked-domain.com']); + + const email = 'this-member-does-not-exist@blocked-domain.com'; + await membersAgent.post('/api/send-magic-link') + .body({ + email, + emailType: 'signup' + }) + .expectStatus(400) + .matchBodySnapshot({ + errors: [{ + id: anyErrorId, + message: 'This email domain is not accepted, try again with a different email address' + }] + }); + }); }); diff --git a/ghost/i18n/locales/af/portal.json b/ghost/i18n/locales/af/portal.json index bacfa41c0da..90cc1959603 100644 --- a/ghost/i18n/locales/af/portal.json +++ b/ghost/i18n/locales/af/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Hierdie webwerf is slegs op uitnodiging, kontak die eienaar vir toegang.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/ar/portal.json b/ghost/i18n/locales/ar/portal.json index 2be918d4fe9..e781b56499e 100644 --- a/ghost/i18n/locales/ar/portal.json +++ b/ghost/i18n/locales/ar/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": ".حدث خطأ أثناء الاشتراك، يرجى المحاولة مرة أخرى", "There was an error processing your payment. Please try again.": ".حدث خطأ أثناء معالجة دفعك، يرجى المحاولة مرة أخرى", "There was an error sending the email, please try again": ".حدث خطأ أثناء إرسال البريد الاكتروني، يرجى المحاولة مرة أخرى", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "هذا الموقع للمشتركين فقط، تواصل مع ادارة الموقع للحصول على اشتراك.", "This site is not accepting payments at the moment.": "هذا الموقع لا يقبل المدفوعات في الوقت الحالي", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/bg/portal.json b/ghost/i18n/locales/bg/portal.json index 6df78718e14..3960c1dd0f2 100644 --- a/ghost/i18n/locales/bg/portal.json +++ b/ghost/i18n/locales/bg/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "Възникна грешка при продължаването на абонамента ви, опитайте отново.", "There was an error processing your payment. Please try again.": "Възникна грешка при обработката на вашето плащане. Моля, опитайте отново.", "There was an error sending the email, please try again": "Възникна грешка при изпращане на имейл, опитайте отново", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Сайтът е само с покани. Свържете се със собственика за да получите достъп.", "This site is not accepting payments at the moment.": "В момента сайтът не приема плащания.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/bn/portal.json b/ghost/i18n/locales/bn/portal.json index 4dbf4485a4e..a89cfc89682 100644 --- a/ghost/i18n/locales/bn/portal.json +++ b/ghost/i18n/locales/bn/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "এই সাইটটি কেবল আমন্ত্রণের মাধ্যমে, প্রবেশাধিকার পেতে মালিকের সাথে যোগাযোগ করুন।", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/bs/portal.json b/ghost/i18n/locales/bs/portal.json index 5d2bfa3bd84..0d2da7661ca 100644 --- a/ghost/i18n/locales/bs/portal.json +++ b/ghost/i18n/locales/bs/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Ova je stranica samo na poziv, kontaktiraj vlasnika za pristup.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/ca/portal.json b/ghost/i18n/locales/ca/portal.json index d17417d828a..16f7031facd 100644 --- a/ghost/i18n/locales/ca/portal.json +++ b/ghost/i18n/locales/ca/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Aquest llog és només per invitació, contacta amb el propietari per obtenir accés.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/context.json b/ghost/i18n/locales/context.json index c9ae0468fde..9d845830c9c 100644 --- a/ghost/i18n/locales/context.json +++ b/ghost/i18n/locales/context.json @@ -242,6 +242,7 @@ "This comment has been hidden.": "Text for a comment thas was hidden", "This comment has been removed.": "Text for a comment thas was removed", "This email address will not be used.": "This is in the footer of signup verification emails, and comes right after 'If you did not make this request, you can simply delete this message.'", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "A message on the member login screen indicating that a site is not-open to public signups", "This site is not accepting payments at the moment.": "An error message shown when a tips or donations link is opened but the site has donations disabled", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/cs/portal.json b/ghost/i18n/locales/cs/portal.json index 36f707e7d81..f4a806b1607 100644 --- a/ghost/i18n/locales/cs/portal.json +++ b/ghost/i18n/locales/cs/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "Při zpracování vaší platby došlo k chybě. Zkuste to prosím znovu.", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Tento web je pouze pro pozvané, kontaktujte provozovatele pro přístup.", "This site is not accepting payments at the moment.": "Tento web momentálně nepřijímá platby.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/da/portal.json b/ghost/i18n/locales/da/portal.json index b8f3f329f89..03c1eb8c788 100644 --- a/ghost/i18n/locales/da/portal.json +++ b/ghost/i18n/locales/da/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "Der opstod en fejl under forlængelsen af dit abonnement, prøv venligst igen.", "There was an error processing your payment. Please try again.": "Der opstod en fejl under behandlingen af din betaling. Prøv venligst igen.", "There was an error sending the email, please try again": "Der opstod en fejl under afsendelse af e-mailen, prøv venligst igen.", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Denne sider kræver at du skal være inviteret. Kontakt ejeres for at få adgang.", "This site is not accepting payments at the moment.": "Denne side accepterer ikke betalinger i øjeblikket.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/de-CH/portal.json b/ghost/i18n/locales/de-CH/portal.json index 8d3bb954825..03895b4d89d 100644 --- a/ghost/i18n/locales/de-CH/portal.json +++ b/ghost/i18n/locales/de-CH/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Der Zugang zu diesem Inhalt ist eingeschränkt. Bitte kontaktieren Sie uns, wenn Sie Zugang wünschen.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/de/portal.json b/ghost/i18n/locales/de/portal.json index 83d7591b7d4..7fe5353ff08 100644 --- a/ghost/i18n/locales/de/portal.json +++ b/ghost/i18n/locales/de/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "Beim Erneuern deines Abonnements ist ein Fehler aufgetreten. Bitte versuche es erneut.", "There was an error processing your payment. Please try again.": "Bei der Verarbeitung deiner Zahlung gab es einen Fehler. Bitte versuche es noch einmal.", "There was an error sending the email, please try again": "Beim Versand der E-Mail ist ein Fehler aufgetreten. Bitte versuche es erneut.", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Für diese Seite benötigst du eine Einladung. Bitte kontaktiere den Inhaber.", "This site is not accepting payments at the moment.": "Diese Website nimmt zur Zeit keine Zahlungen entgegen.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/el/portal.json b/ghost/i18n/locales/el/portal.json index bc56a279929..98a6143e0b4 100644 --- a/ghost/i18n/locales/el/portal.json +++ b/ghost/i18n/locales/el/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Αυτός ο ιστότοπος είναι μόνο με πρόσκληση, επικοινωνήστε με τον ιδιοκτήτη για πρόσβαση.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/en/portal.json b/ghost/i18n/locales/en/portal.json index 54638995cf5..508f16c3466 100644 --- a/ghost/i18n/locales/en/portal.json +++ b/ghost/i18n/locales/en/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/eo/portal.json b/ghost/i18n/locales/eo/portal.json index 760b2ebe312..7f5724bdf9a 100644 --- a/ghost/i18n/locales/eo/portal.json +++ b/ghost/i18n/locales/eo/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Ĉi tiu retejo estas nur por invitiĝuloj, kontaktu la proprietulo por alireblo.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/es/portal.json b/ghost/i18n/locales/es/portal.json index 5e50595327c..0dd42ceae6f 100644 --- a/ghost/i18n/locales/es/portal.json +++ b/ghost/i18n/locales/es/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "Hubo un error en continuar la suscripción, inténtalo de nuevo por favor.", "There was an error processing your payment. Please try again.": "Hubo un error procesando tu pago. Intentalo de nuevvo por favor.", "There was an error sending the email, please try again": "Hubo un error enviando el correo electrónico, intentalo de nuevo por favor.", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Este sitio es solo por invitación, contacta al propietario para obtener acceso.", "This site is not accepting payments at the moment.": "Este sitio no acepta pagos en este momento.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/et/portal.json b/ghost/i18n/locales/et/portal.json index 7eb3a3485d1..5d70c09a7db 100644 --- a/ghost/i18n/locales/et/portal.json +++ b/ghost/i18n/locales/et/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "See sait on ainult kutsetega, juurdepääsu saamiseks võtke ühendust omanikuga.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/fa/portal.json b/ghost/i18n/locales/fa/portal.json index c73468136c5..c8d227e1b70 100644 --- a/ghost/i18n/locales/fa/portal.json +++ b/ghost/i18n/locales/fa/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "دسترسی به این وب\u200cسایت نیازمند دعوت\u200cنامه است، با مالک آن برای دریافت دسترسی تماس بگیرید.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/fi/portal.json b/ghost/i18n/locales/fi/portal.json index 62ec5e3b4ed..a7714935494 100644 --- a/ghost/i18n/locales/fi/portal.json +++ b/ghost/i18n/locales/fi/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Tämä sivu on vain kutsutuille, ota yhteyttä omistajaan saadaksesi pääsyoikeuden.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/fr/portal.json b/ghost/i18n/locales/fr/portal.json index 22f7eb2a228..21e6f2b607e 100644 --- a/ghost/i18n/locales/fr/portal.json +++ b/ghost/i18n/locales/fr/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "Une erreur s'est produite lors de la prolongation de votre abonnement, veuillez réessayer.", "There was an error processing your payment. Please try again.": "Une erreur s'est produite lors du traitement de votre paiement. Veuillez réessayer.", "There was an error sending the email, please try again": "Une erreur s'est produite lors de l'envoi de l'e-mail, veuillez réessayer.", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Ce site est réservé aux invités. Veuillez écrire au propriétaire pour en demander l'accès.", "This site is not accepting payments at the moment.": "Ce site n'accepte pas les paiements pour le moment.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/gd/portal.json b/ghost/i18n/locales/gd/portal.json index 90092761438..007908c4bab 100644 --- a/ghost/i18n/locales/gd/portal.json +++ b/ghost/i18n/locales/gd/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "Thachair mearachd fhad 's a bhathar a' làimhseachadh a' phàighidh agad. Feuch a-rithist an ceann greis.", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Feumar cuireadh airson an làrach-lìn seo, leig fios dhan rianaire ma tha thu ag iarraidh cothrom-inntrigidh.", "This site is not accepting payments at the moment.": "Chan eil an làrach seo a' gabhail ri phàighidhean an-dràsta.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/he/portal.json b/ghost/i18n/locales/he/portal.json index 1241bbd5093..19c9163cf73 100644 --- a/ghost/i18n/locales/he/portal.json +++ b/ghost/i18n/locales/he/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "שגיאה בהמשך המנוי שלכם, נסו שוב.", "There was an error processing your payment. Please try again.": "שגיאה בעיבוד התשלום שלכם. נסו שוב.", "There was an error sending the email, please try again": "שגיאה בשליחת המייל, נסו שוב", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "אתר זה פתוח למוזמנים בלבד, פנו לבעל האתר לגישה.", "This site is not accepting payments at the moment.": "אתר זה לא מקבל תשלומים כרגע.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/hi/portal.json b/ghost/i18n/locales/hi/portal.json index e5c6927dcab..c3cb361240e 100644 --- a/ghost/i18n/locales/hi/portal.json +++ b/ghost/i18n/locales/hi/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "यह साइट केवल निमंत्रण द्वारा है, पहुँच के लिए मालिक से संपर्क करें।", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/hr/portal.json b/ghost/i18n/locales/hr/portal.json index 69135f7de32..937fb337dd9 100644 --- a/ghost/i18n/locales/hr/portal.json +++ b/ghost/i18n/locales/hr/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Ove stranice su samo za članove, kontaktirajte vlasnika kako biste dobili pristup.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/hu/portal.json b/ghost/i18n/locales/hu/portal.json index de9f7d87bd3..f304651bcbb 100644 --- a/ghost/i18n/locales/hu/portal.json +++ b/ghost/i18n/locales/hu/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "A website csak meghívóval látogatható. Meghívóért lépjen kapcsolatba az oldal tulajdonosával!", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/id/portal.json b/ghost/i18n/locales/id/portal.json index bd9c652e083..5de578cae55 100644 --- a/ghost/i18n/locales/id/portal.json +++ b/ghost/i18n/locales/id/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "Terjadi kesalahan saat melanjutkan langganan Anda, harap coba lagi.", "There was an error processing your payment. Please try again.": "Terjadi kesalahan saat memproses pembayaran Anda. Harap coba lagi.", "There was an error sending the email, please try again": "Terjadi kesalahan saat mengirim email, harap coba lagi", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Situs ini hanya untuk yang diundang, hubungi pemiliknya untuk mendapatkan akses.", "This site is not accepting payments at the moment.": "Situs ini tidak menerima pembayaran saat ini.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/is/portal.json b/ghost/i18n/locales/is/portal.json index ef6598309e0..9b5b2a17b8b 100644 --- a/ghost/i18n/locales/is/portal.json +++ b/ghost/i18n/locales/is/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Aðgangur krefst boðsmiða, hafið samband við eiganda síðunnar til að fá aðgang.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/it/portal.json b/ghost/i18n/locales/it/portal.json index c99667b5331..2e5738f4051 100644 --- a/ghost/i18n/locales/it/portal.json +++ b/ghost/i18n/locales/it/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "C'è stato un errore nella continuazione del tuo abbonamento, riprova per favore.", "There was an error processing your payment. Please try again.": "C'è stato un errore durante l’elaborazione del tuo pagamento. Riprova per favore.", "There was an error sending the email, please try again": "C'è stato un errore nell'invio dell'e-mail, per favore riprova", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Questo sito è accessibile solo su invito, contatta il proprietario per poter accedere.", "This site is not accepting payments at the moment.": "Questo sito non accetta pagamenti al momento.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/ja/portal.json b/ghost/i18n/locales/ja/portal.json index 4e9b53d0f18..9b3182cca72 100644 --- a/ghost/i18n/locales/ja/portal.json +++ b/ghost/i18n/locales/ja/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "このサイトは招待制です。アクセスするにはオーナーに連絡してください。", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/ko/portal.json b/ghost/i18n/locales/ko/portal.json index e71b36ac98e..2fd55603b09 100644 --- a/ghost/i18n/locales/ko/portal.json +++ b/ghost/i18n/locales/ko/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "구독 계속하기 중 오류가 발생했어요. 다시 시도해 주세요.", "There was an error processing your payment. Please try again.": "결제 처리 중 오류가 발생했어요. 다시 시도해 주세요.", "There was an error sending the email, please try again": "이메일 전송 중 오류가 발생했어요. 다시 시도해 주세요", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "위 사이트는 초대된 사용자만 사용이 가능해요. 접근을 위해서는 관리자에게 연락해 주세요.", "This site is not accepting payments at the moment.": "현재 이 사이트는 결제를 받지 않고 있어요.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/kz/portal.json b/ghost/i18n/locales/kz/portal.json index 8414afa9f02..ce82ba45e3d 100644 --- a/ghost/i18n/locales/kz/portal.json +++ b/ghost/i18n/locales/kz/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Бұл сайтқа тек шақырту бойынша кіруге болады, рұқсат алу үшін иесіне хабарласыңыз.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/lt/portal.json b/ghost/i18n/locales/lt/portal.json index d09d5df89d0..e05538188dd 100644 --- a/ghost/i18n/locales/lt/portal.json +++ b/ghost/i18n/locales/lt/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Ši svetainė pasiekiama tik su pakvietimu, susisiekite su savininku dėl prieigos. ", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/lv/portal.json b/ghost/i18n/locales/lv/portal.json index a0b1eff46da..190adb36072 100644 --- a/ghost/i18n/locales/lv/portal.json +++ b/ghost/i18n/locales/lv/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "Turpinot abonementu, radās kļūda. Lūdzu, mēģiniet vēlreiz.", "There was an error processing your payment. Please try again.": "Apstrādājot jūsu maksājumu, radās kļūda. Lūdzu, mēģiniet vēlreiz.", "There was an error sending the email, please try again": "Nosūtot e-pasta ziņojumu, radās kļūda. Lūdzu, mēģiniet vēlreiz", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Šī vietne ir paredzēta tikai ielūgumam. Lai iegūtu piekļuvi, sazinieties ar īpašnieku.", "This site is not accepting payments at the moment.": "Šī vietne pašlaik nepieņem maksājumus.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/mk/portal.json b/ghost/i18n/locales/mk/portal.json index 0c9831b3116..3451e7300d6 100644 --- a/ghost/i18n/locales/mk/portal.json +++ b/ghost/i18n/locales/mk/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Оваа страница е достапна само со покана. За пристап контактирајте го сопственикот.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/mn/portal.json b/ghost/i18n/locales/mn/portal.json index e949b202743..97d23717539 100644 --- a/ghost/i18n/locales/mn/portal.json +++ b/ghost/i18n/locales/mn/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Энэхүү сайт руу зөвхөн урилгаар нэвтрэх боломжтой тул та админд нь хандана уу.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/ms/portal.json b/ghost/i18n/locales/ms/portal.json index a9c86031367..bd2111dea79 100644 --- a/ghost/i18n/locales/ms/portal.json +++ b/ghost/i18n/locales/ms/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Laman web ini hanya untuk jemputan, hubungi pemilik untuk akses.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/ne/portal.json b/ghost/i18n/locales/ne/portal.json index 54638995cf5..508f16c3466 100644 --- a/ghost/i18n/locales/ne/portal.json +++ b/ghost/i18n/locales/ne/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/nl/portal.json b/ghost/i18n/locales/nl/portal.json index 20843b69737..e16b198b621 100644 --- a/ghost/i18n/locales/nl/portal.json +++ b/ghost/i18n/locales/nl/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "Er was een fout bij het voortzetten van je abonnement, probeer het opnieuw.", "There was an error processing your payment. Please try again.": "Er was een fout bij het verwerken van je betaling, probeer het opnieuw.", "There was an error sending the email, please try again": "Er was een fout bij het verzenden van de e-mail, probeer het opnieuw", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Deze site is alleen toegankelijk op uitnodiging, neem contact op met de eigenaar.", "This site is not accepting payments at the moment.": "Deze site accepteert momenteel geen betalingen.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/nn/portal.json b/ghost/i18n/locales/nn/portal.json index 84f77247b85..d3c06a17261 100644 --- a/ghost/i18n/locales/nn/portal.json +++ b/ghost/i18n/locales/nn/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Denne sida er kun for inviterte, ta kontakt med eigaren for tilgang.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/no/portal.json b/ghost/i18n/locales/no/portal.json index 283b19c68da..55f85c8d646 100644 --- a/ghost/i18n/locales/no/portal.json +++ b/ghost/i18n/locales/no/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "En feil oppstod ved fornyelse av abonnementet, vennligst prøv igjen.", "There was an error processing your payment. Please try again.": "Det oppsto en feil under behandling av betalingen din. Vennligst prøv igjen.", "There was an error sending the email, please try again": "En feil oppstod ved sending av e-posten, vennligst prøv igjen", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Denne nettsiden er kun for inviterte. Kontakt eieren for invitasjon.", "This site is not accepting payments at the moment.": "Denne nettsiden godtar ikke betalinger for øyeblikket.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/pl/portal.json b/ghost/i18n/locales/pl/portal.json index 8b28b867e66..518af111ec0 100644 --- a/ghost/i18n/locales/pl/portal.json +++ b/ghost/i18n/locales/pl/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Ta strona posiada zamknięty dostęp. Skontaktuj się z właścicielem, aby uzyskać dostęp.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/pt-BR/portal.json b/ghost/i18n/locales/pt-BR/portal.json index f631eead816..94cbdeb8c25 100644 --- a/ghost/i18n/locales/pt-BR/portal.json +++ b/ghost/i18n/locales/pt-BR/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "Houve um erro ao continuar sua assinatura, por favor, tente novamente.", "There was an error processing your payment. Please try again.": "Houve um erro ao processar seu pagamento. Por favor, tente novamente.", "There was an error sending the email, please try again": "Houve um erro ao enviar o e-mail, por favor, tente novamente.", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Este site é apenas para convidados. Contate o proprietário para obter acesso.", "This site is not accepting payments at the moment.": "Este site não está aceitando pagamentos no momento.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/pt/portal.json b/ghost/i18n/locales/pt/portal.json index 62d46bca91e..8d776679170 100644 --- a/ghost/i18n/locales/pt/portal.json +++ b/ghost/i18n/locales/pt/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "Houve um problema ao processar o seu pagamento. Tente novamente por favor.", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "O acesso a este site é feito apenas por convite. Entre em contacto com o proprietário para obter acesso.", "This site is not accepting payments at the moment.": "Este site não está a aceitar pagamentos de momento", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/ro/portal.json b/ghost/i18n/locales/ro/portal.json index 8788c457616..dce5d968ca7 100644 --- a/ghost/i18n/locales/ro/portal.json +++ b/ghost/i18n/locales/ro/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Acest site este disponibil doar pe bază de invitație, contactează proprietarul pentru acces.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/ru/portal.json b/ghost/i18n/locales/ru/portal.json index 4b0d228a237..c9525ab915e 100644 --- a/ghost/i18n/locales/ru/portal.json +++ b/ghost/i18n/locales/ru/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "Произошла ошибка при обработке вашего платежа. Попробуйте ещё раз.", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Доступ к материалам этого сайта возможен только по приглашению. Для получения доступа свяжитесь с владельцем сайта.", "This site is not accepting payments at the moment.": "В данный момент сайт не принимает платежи.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/si/portal.json b/ghost/i18n/locales/si/portal.json index a5d2505aabc..4ccedfa27b9 100644 --- a/ghost/i18n/locales/si/portal.json +++ b/ghost/i18n/locales/si/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "මෙම වෙබ් අඩවිය ආරාධිතයන් සඳහා පමණි, ප්\u200dරවේශ වීම සඳහා හිමිකරු අමතන්න.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/sk/portal.json b/ghost/i18n/locales/sk/portal.json index 79c1cf5192c..36486b2eb3b 100644 --- a/ghost/i18n/locales/sk/portal.json +++ b/ghost/i18n/locales/sk/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Táto stránka je iba pre pozvaných úžívateľov, kontaktujte vlastníka stránky.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/sl/portal.json b/ghost/i18n/locales/sl/portal.json index 4fc4285f4f3..9121d377990 100644 --- a/ghost/i18n/locales/sl/portal.json +++ b/ghost/i18n/locales/sl/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "To spletno mesto je dostopno samo s povabilom, obrnite se na lastnika.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/sq/portal.json b/ghost/i18n/locales/sq/portal.json index ab63e66bd55..e42367d1446 100644 --- a/ghost/i18n/locales/sq/portal.json +++ b/ghost/i18n/locales/sq/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Kjo faqe eshte vetem me ftesa, kontaktoni zoteruesin per akses.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/sr-Cyrl/portal.json b/ghost/i18n/locales/sr-Cyrl/portal.json index 7e969ce11a7..858400556f2 100644 --- a/ghost/i18n/locales/sr-Cyrl/portal.json +++ b/ghost/i18n/locales/sr-Cyrl/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "Дошло је до грешке при обради ваше уплате. Молимо вас покушајте поново.", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Овај сајт је само на позив, контактирајте власника ради приступа.", "This site is not accepting payments at the moment.": "Овај сајт тренутно не прихвата уплате.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/sr/portal.json b/ghost/i18n/locales/sr/portal.json index e300ae10a44..586908dcbfa 100644 --- a/ghost/i18n/locales/sr/portal.json +++ b/ghost/i18n/locales/sr/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Ovaj sajt je samo za članove, kontaktirajte vlasnika kako bi dobili pristup.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/sv/portal.json b/ghost/i18n/locales/sv/portal.json index d56cf1651f8..55aeecc8e41 100644 --- a/ghost/i18n/locales/sv/portal.json +++ b/ghost/i18n/locales/sv/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "Det blev fel när din prenumeration skulle fortsättas, vänligen försök igen", "There was an error processing your payment. Please try again.": "Det blev fel när din betalning skulle behandlas, vänligen försök igen", "There was an error sending the email, please try again": "Det blev ett fel när e-posten skulle skickas, vänligen försök igen", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Den här sidan är endast för inbjudna, kontakta ägaren för åtkomst.", "This site is not accepting payments at the moment.": "Den här webbsidan accepterar inte betalningar för tillfället", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/sw/portal.json b/ghost/i18n/locales/sw/portal.json index 046013638c2..218f70a3df2 100644 --- a/ghost/i18n/locales/sw/portal.json +++ b/ghost/i18n/locales/sw/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Tovuti hii ni ya mialiko pekee, wasiliana na mmiliki kupata ufikiaji.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/ta/portal.json b/ghost/i18n/locales/ta/portal.json index 4b5c92473f4..b7289ef5487 100644 --- a/ghost/i18n/locales/ta/portal.json +++ b/ghost/i18n/locales/ta/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "உங்கள் சந்தாவைத் தொடர்வதில் பிழை ஏற்பட்டது, மீண்டும் முயற்சிக்கவும்.", "There was an error processing your payment. Please try again.": "உங்கள் கட்டணத்தை செயலாக்குவதில் பிழை ஏற்பட்டது. மீண்டும் முயற்சிக்கவும்.", "There was an error sending the email, please try again": "மின்னஞ்சலை அனுப்புவதில் பிழை ஏற்பட்டது, மீண்டும் முயற்சிக்கவும்", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "இந்த தளம் அழைப்பு மட்டுமே, அணுகலுக்கு உரிமையாளரைத் தொடர்பு கொள்ளவும்.", "This site is not accepting payments at the moment.": "இந்த தளம் தற்போது கட்டணங்களை ஏற்கவில்லை.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/th/portal.json b/ghost/i18n/locales/th/portal.json index 13a3b082bff..92f4be14b6f 100644 --- a/ghost/i18n/locales/th/portal.json +++ b/ghost/i18n/locales/th/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "เว็บไซต์นี้สำหรับผู้ได้รับเชิญเท่านั้น โปรดติดต่อเจ้าของเพื่อเข้าถึง", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/tr/portal.json b/ghost/i18n/locales/tr/portal.json index 20047bab660..9d3059588bd 100644 --- a/ghost/i18n/locales/tr/portal.json +++ b/ghost/i18n/locales/tr/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "Aboneliğinizi devam ettirirken bir hata oluştu, lütfen tekrar deneyin.", "There was an error processing your payment. Please try again.": "Ödemeniz işlenirken bir hata oluştu. Lütfen tekrar deneyiniz.", "There was an error sending the email, please try again": "E-posta gönderilirken bir hata oluştu, lütfen tekrar deneyin.", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Bu site sadece davetiyesi olanlar içindir, erişim için site sahibiyle iletişime geç.", "This site is not accepting payments at the moment.": "Bu site şu anda ödeme kabul etmemektedir.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/uk/portal.json b/ghost/i18n/locales/uk/portal.json index 7eae5dd5038..4dc2eb04d08 100644 --- a/ghost/i18n/locales/uk/portal.json +++ b/ghost/i18n/locales/uk/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "Під час продовження підписки сталася помилка. Спробуйте ще раз.", "There was an error processing your payment. Please try again.": "Під час обробки вашого платежу сталася помилка. Спробуйте ще раз.", "There was an error sending the email, please try again": "Під час надсилання листа сталася помилка. Повторіть спробу", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Цей сайт доступний тільки за запрошенням, звернись до власника сайта для доступу.", "This site is not accepting payments at the moment.": "Цей сайт на даний момент не приймає платежі.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/ur/portal.json b/ghost/i18n/locales/ur/portal.json index c65c245264b..041dea35fca 100644 --- a/ghost/i18n/locales/ur/portal.json +++ b/ghost/i18n/locales/ur/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "یہ سائٹ صرف دعوتی ہے، دستیابی کے لئے مالک سے رابطہ کریں۔", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/uz/portal.json b/ghost/i18n/locales/uz/portal.json index b08485cac70..6e569adb550 100644 --- a/ghost/i18n/locales/uz/portal.json +++ b/ghost/i18n/locales/uz/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Bu saytda faqat taklif qilinadi, kirish uchun egasiga murojaat qiling.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/vi/portal.json b/ghost/i18n/locales/vi/portal.json index ce097a1e323..4c655fd9151 100644 --- a/ghost/i18n/locales/vi/portal.json +++ b/ghost/i18n/locales/vi/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "Xảy ra lỗi khi tiếp tục gói thành viên, vui lòng thử lại", "There was an error processing your payment. Please try again.": "Xảy ra lỗi khi tiến hành thanh toán. Hãy thử lại sau.", "There was an error sending the email, please try again": "Xảy ra lỗi khi gửi email, vui lòng thử lại", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Trang web này chỉ dành cho những người được mời, hãy liên hệ với chủ sở hữu để cấp quyền truy cập.", "This site is not accepting payments at the moment.": "Trang web này hiện chưa chấp nhận thanh toán.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/zh-Hant/portal.json b/ghost/i18n/locales/zh-Hant/portal.json index 38be03f724e..812f54e0b4d 100644 --- a/ghost/i18n/locales/zh-Hant/portal.json +++ b/ghost/i18n/locales/zh-Hant/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "續約您的訂閱時發生錯誤,請您再試一次。", "There was an error processing your payment. Please try again.": "處理您的付款時發生錯誤,請您再試一次。", "There was an error sending the email, please try again": "寄送 email 時發生錯誤,請您再試一次。", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "此網站僅限受邀請者觀看,請聯繫網站擁有者取得存取權限。", "This site is not accepting payments at the moment.": "此網站目前無付款方式。", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/zh/portal.json b/ghost/i18n/locales/zh/portal.json index 1db54a5c467..016e303c01c 100644 --- a/ghost/i18n/locales/zh/portal.json +++ b/ghost/i18n/locales/zh/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "您的付款处理失败,请重试。", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "此网站仅限邀请,联系网站所有者以获取访问", "This site is not accepting payments at the moment.": "本网站目前暂不接受付款。", "This site only accepts paid members.": "", diff --git a/ghost/magic-link/lib/MagicLink.js b/ghost/magic-link/lib/MagicLink.js index b49b25a20b8..f05030a6ee0 100644 --- a/ghost/magic-link/lib/MagicLink.js +++ b/ghost/magic-link/lib/MagicLink.js @@ -2,7 +2,8 @@ const {IncorrectUsageError, BadRequestError} = require('@tryghost/errors'); const {isEmail} = require('@tryghost/validator'); const tpl = require('@tryghost/tpl'); const messages = { - invalidEmail: 'Email is not valid' + invalidEmail: 'Email is not valid', + unsupportedEmailDomain: 'This email domain is not accepted, try again with a different email address' }; /** @@ -34,6 +35,7 @@ class MagicLink { * @param {typeof defaultGetHTML} [options.getHTML] * @param {typeof defaultGetSubject} [options.getSubject] * @param {object} [options.sentry] + * @param {object} [options.config] */ constructor(options) { if (!options || !options.transporter || !options.tokenProvider || !options.getSigninURL) { @@ -46,6 +48,7 @@ class MagicLink { this.getHTML = options.getHTML || defaultGetHTML; this.getSubject = options.getSubject || defaultGetSubject; this.sentry = options.sentry || undefined; + this.config = options.config || {}; } /** @@ -60,12 +63,19 @@ class MagicLink { */ async sendMagicLink(options) { this.sentry?.captureMessage?.(`[Magic Link] Generating magic link`, {extra: options}); - + if (!isEmail(options.email)) { throw new BadRequestError({ message: tpl(messages.invalidEmail) }); } + + if (this.isEmailDomainBlocked(options.email)) { + throw new BadRequestError({ + message: tpl(messages.unsupportedEmailDomain) + }); + } + const token = await this.tokenProvider.create(options.tokenData); const type = options.type || 'signin'; @@ -108,6 +118,24 @@ class MagicLink { const tokenData = await this.tokenProvider.validate(token); return tokenData; } + + /** + * Check if the email domain is blocked, based on the `spam.blocked_email_domains` config + * + * @param {string} email + * @returns {boolean} + */ + isEmailDomainBlocked(email) { + const emailDomain = email.split('@')[1]?.toLowerCase(); + const blockedDomains = this.config?.get('spam:blocked_email_domains'); + + // Config is not set properly: skip check + if (!blockedDomains || !Array.isArray(blockedDomains)) { + return false; + } + + return blockedDomains.includes(emailDomain); + } } /** diff --git a/ghost/magic-link/test/index.test.js b/ghost/magic-link/test/index.test.js index 22520d2671c..b7e2de246b0 100644 --- a/ghost/magic-link/test/index.test.js +++ b/ghost/magic-link/test/index.test.js @@ -21,6 +21,9 @@ describe('MagicLink', function () { getSubject: sandbox.stub().returns('SOMESUBJECT'), transporter: { sendMail: sandbox.stub().resolves() + }, + config: { + get: sandbox.stub().resolves() } }; const service = new MagicLink(options); @@ -53,6 +56,9 @@ describe('MagicLink', function () { getSubject: sandbox.stub().returns('SOMESUBJECT'), transporter: { sendMail: sandbox.stub().resolves() + }, + config: { + get: sandbox.stub().resolves() } }; const service = new MagicLink(options); @@ -85,6 +91,48 @@ describe('MagicLink', function () { assert.equal(options.transporter.sendMail.firstCall.args[0].text, options.getText.firstCall.returnValue); assert.equal(options.transporter.sendMail.firstCall.args[0].html, options.getHTML.firstCall.returnValue); }); + + it('Blocks signups from blocked email domains', async function () { + const options = { + tokenProvider: new MagicLink.JWTTokenProvider(secret), + getSigninURL: sandbox.stub().returns('FAKEURL'), + getText: sandbox.stub().returns('SOMETEXT'), + getHTML: sandbox.stub().returns('SOMEHTML'), + getSubject: sandbox.stub().returns('SOMESUBJECT'), + transporter: { + sendMail: sandbox.stub().resolves() + }, + config: { + get: sandbox.stub().withArgs('spam:blocked_email_domains').returns(['blocked-domain.com']) + } + }; + const service = new MagicLink(options); + + const blockedArgs = { + email: 'test@blocked-domain.com', + tokenData: { + id: '420' + } + }; + + await assert.rejects( + () => service.sendMagicLink(blockedArgs), + { + name: 'BadRequestError', + message: 'This email domain is not accepted, try again with a different email address' + } + ); + + // Verify non-blocked domain is allowed + const allowedArgs = { + email: 'test@allowed-domain.com', + tokenData: { + id: '420' + } + }; + + await assert.doesNotReject(() => service.sendMagicLink(allowedArgs)); + }); }); describe('#getDataFromToken', function () { @@ -96,6 +144,9 @@ describe('MagicLink', function () { getHTML: sandbox.stub().returns('SOMEHTML'), transporter: { sendMail: sandbox.stub().resolves() + }, + config: { + get: sandbox.stub().resolves() } }; const service = new MagicLink(options); diff --git a/ghost/members-api/lib/members-api.js b/ghost/members-api/lib/members-api.js index 6bacee6d92a..660bc6cdca7 100644 --- a/ghost/members-api/lib/members-api.js +++ b/ghost/members-api/lib/members-api.js @@ -73,7 +73,8 @@ module.exports = function MembersAPI({ emailSuppressionList, settingsCache, sentry, - settingsHelpers + settingsHelpers, + config }) { const tokenService = new TokenService({ privateKey, @@ -158,7 +159,8 @@ module.exports = function MembersAPI({ getText, getHTML, getSubject, - sentry + sentry, + config }); const paymentsService = new PaymentsService({ From 77af93be6a7bc6403b42ee2e4d34a69fab281c6a Mon Sep 17 00:00:00 2001 From: Sag Date: Mon, 20 Jan 2025 22:12:55 +0700 Subject: [PATCH 55/90] =?UTF-8?q?=F0=9F=94=92=20Blocked=20spammy=20email?= =?UTF-8?q?=20domains=20in=20member=20signups=20(#22027)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref https://linear.app/ghost/issue/ONC-721 ref https://app.incident.io/ghost/incidents/132 - added a blocklist at the email domain level for free member signups - for example, if `blocked-domain.com` is blocked, `thomas@blocked-domain.com` cannot sign up as free member - the blocklist is configurable: `"spam.blocked_email_domains": ["blocked-domain.com"]` --- apps/portal/src/utils/errors.js | 1 + .../core/core/server/services/members/api.js | 4 +- .../newsletters/NewslettersService.js | 4 +- .../services/settings/SettingsBREADService.js | 4 +- ghost/core/core/shared/config/defaults.json | 3 +- .../send-magic-link.test.js.snap | 18 +++++++ .../e2e-api/members/send-magic-link.test.js | 20 ++++++++ ghost/i18n/locales/af/portal.json | 1 + ghost/i18n/locales/ar/portal.json | 1 + ghost/i18n/locales/bg/portal.json | 1 + ghost/i18n/locales/bn/portal.json | 1 + ghost/i18n/locales/bs/portal.json | 1 + ghost/i18n/locales/ca/portal.json | 1 + ghost/i18n/locales/context.json | 1 + ghost/i18n/locales/cs/portal.json | 1 + ghost/i18n/locales/da/portal.json | 1 + ghost/i18n/locales/de-CH/portal.json | 1 + ghost/i18n/locales/de/portal.json | 1 + ghost/i18n/locales/el/portal.json | 1 + ghost/i18n/locales/en/portal.json | 1 + ghost/i18n/locales/eo/portal.json | 1 + ghost/i18n/locales/es/portal.json | 1 + ghost/i18n/locales/et/portal.json | 1 + ghost/i18n/locales/fa/portal.json | 1 + ghost/i18n/locales/fi/portal.json | 1 + ghost/i18n/locales/fr/portal.json | 1 + ghost/i18n/locales/gd/portal.json | 1 + ghost/i18n/locales/he/portal.json | 1 + ghost/i18n/locales/hi/portal.json | 1 + ghost/i18n/locales/hr/portal.json | 1 + ghost/i18n/locales/hu/portal.json | 1 + ghost/i18n/locales/id/portal.json | 1 + ghost/i18n/locales/is/portal.json | 1 + ghost/i18n/locales/it/portal.json | 1 + ghost/i18n/locales/ja/portal.json | 1 + ghost/i18n/locales/ko/portal.json | 1 + ghost/i18n/locales/kz/portal.json | 1 + ghost/i18n/locales/lt/portal.json | 1 + ghost/i18n/locales/lv/portal.json | 1 + ghost/i18n/locales/mk/portal.json | 1 + ghost/i18n/locales/mn/portal.json | 1 + ghost/i18n/locales/ms/portal.json | 1 + ghost/i18n/locales/ne/portal.json | 1 + ghost/i18n/locales/nl/portal.json | 1 + ghost/i18n/locales/nn/portal.json | 1 + ghost/i18n/locales/no/portal.json | 1 + ghost/i18n/locales/pl/portal.json | 1 + ghost/i18n/locales/pt-BR/portal.json | 1 + ghost/i18n/locales/pt/portal.json | 1 + ghost/i18n/locales/ro/portal.json | 1 + ghost/i18n/locales/ru/portal.json | 1 + ghost/i18n/locales/si/portal.json | 1 + ghost/i18n/locales/sk/portal.json | 1 + ghost/i18n/locales/sl/portal.json | 1 + ghost/i18n/locales/sq/portal.json | 1 + ghost/i18n/locales/sr-Cyrl/portal.json | 1 + ghost/i18n/locales/sr/portal.json | 1 + ghost/i18n/locales/sv/portal.json | 1 + ghost/i18n/locales/sw/portal.json | 1 + ghost/i18n/locales/ta/portal.json | 1 + ghost/i18n/locales/th/portal.json | 1 + ghost/i18n/locales/tr/portal.json | 1 + ghost/i18n/locales/uk/portal.json | 1 + ghost/i18n/locales/ur/portal.json | 1 + ghost/i18n/locales/uz/portal.json | 1 + ghost/i18n/locales/vi/portal.json | 1 + ghost/i18n/locales/zh-Hant/portal.json | 1 + ghost/i18n/locales/zh/portal.json | 1 + ghost/magic-link/lib/MagicLink.js | 32 +++++++++++- ghost/magic-link/test/index.test.js | 51 +++++++++++++++++++ ghost/members-api/lib/members-api.js | 6 ++- 71 files changed, 196 insertions(+), 8 deletions(-) diff --git a/apps/portal/src/utils/errors.js b/apps/portal/src/utils/errors.js index c48e3716bfc..93034e1cc98 100644 --- a/apps/portal/src/utils/errors.js +++ b/apps/portal/src/utils/errors.js @@ -59,6 +59,7 @@ export function chooseBestErrorMessage(error, alreadyTranslatedDefaultMessage, t t('Too many different sign-in attempts, try again in {{number}} days'); t('Failed to send magic link email'); t('This site only accepts paid members.'); + t('This email domain is not accepted, try again with a different email address'); } }; diff --git a/ghost/core/core/server/services/members/api.js b/ghost/core/core/server/services/members/api.js index 6b65cf6bd8f..26f5d46d7ca 100644 --- a/ghost/core/core/server/services/members/api.js +++ b/ghost/core/core/server/services/members/api.js @@ -20,6 +20,7 @@ const memberAttributionService = require('../member-attribution'); const emailSuppressionList = require('../email-suppression-list'); const {t} = require('../i18n'); const sentry = require('../../../shared/sentry'); +const sharedConfig = require('../../../shared/config'); const MAGIC_LINK_TOKEN_VALIDITY = 24 * 60 * 60 * 1000; const MAGIC_LINK_TOKEN_VALIDITY_AFTER_USAGE = 10 * 60 * 1000; @@ -238,7 +239,8 @@ function createApiInstance(config) { emailSuppressionList, settingsCache, sentry, - settingsHelpers + settingsHelpers, + config: sharedConfig }); return membersApiInstance; diff --git a/ghost/core/core/server/services/newsletters/NewslettersService.js b/ghost/core/core/server/services/newsletters/NewslettersService.js index 5247c69c93b..bf9082e96a7 100644 --- a/ghost/core/core/server/services/newsletters/NewslettersService.js +++ b/ghost/core/core/server/services/newsletters/NewslettersService.js @@ -6,6 +6,7 @@ const debug = require('@tryghost/debug')('services:newsletters'); const tpl = require('@tryghost/tpl'); const errors = require('@tryghost/errors'); const sentry = require('../../../shared/sentry'); +const config = require('../../../shared/config'); const messages = { nameAlreadyExists: 'A newsletter with the same name already exists', @@ -86,7 +87,8 @@ class NewslettersService { getText, getHTML, getSubject, - sentry + sentry, + config }); } diff --git a/ghost/core/core/server/services/settings/SettingsBREADService.js b/ghost/core/core/server/services/settings/SettingsBREADService.js index 0638920c510..bd93c0d7d9a 100644 --- a/ghost/core/core/server/services/settings/SettingsBREADService.js +++ b/ghost/core/core/server/services/settings/SettingsBREADService.js @@ -6,6 +6,7 @@ const logging = require('@tryghost/logging'); const MagicLink = require('@tryghost/magic-link'); const verifyEmailTemplate = require('./emails/verify-email'); const sentry = require('../../../shared/sentry'); +const config = require('../../../shared/config'); const EMAIL_KEYS = ['members_support_address']; const messages = { @@ -82,7 +83,8 @@ class SettingsBREADService { getText, getHTML, getSubject, - sentry + sentry, + config }); } diff --git a/ghost/core/core/shared/config/defaults.json b/ghost/core/core/shared/config/defaults.json index 04966562649..2c1220b8d81 100644 --- a/ghost/core/core/shared/config/defaults.json +++ b/ghost/core/core/shared/config/defaults.json @@ -126,7 +126,8 @@ "maxWait": 360000, "lifetime": 3600, "freeRetries": 10 - } + }, + "blocked_email_domains": [] }, "caching": { "frontend": { diff --git a/ghost/core/test/e2e-api/members/__snapshots__/send-magic-link.test.js.snap b/ghost/core/test/e2e-api/members/__snapshots__/send-magic-link.test.js.snap index e8e0a5499ec..1305fc1af3d 100644 --- a/ghost/core/test/e2e-api/members/__snapshots__/send-magic-link.test.js.snap +++ b/ghost/core/test/e2e-api/members/__snapshots__/send-magic-link.test.js.snap @@ -107,3 +107,21 @@ Object { ], } `; + +exports[`sendMagicLink blocks signups from blocked email domains 1: [body] 1`] = ` +Object { + "errors": Array [ + Object { + "code": null, + "context": null, + "details": null, + "ghostErrorCode": null, + "help": null, + "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "message": "This email domain is not accepted, try again with a different email address", + "property": null, + "type": "BadRequestError", + }, + ], +} +`; diff --git a/ghost/core/test/e2e-api/members/send-magic-link.test.js b/ghost/core/test/e2e-api/members/send-magic-link.test.js index f353f37fb13..88eefe3869d 100644 --- a/ghost/core/test/e2e-api/members/send-magic-link.test.js +++ b/ghost/core/test/e2e-api/members/send-magic-link.test.js @@ -4,6 +4,7 @@ const settingsCache = require('../../../core/shared/settings-cache'); const DomainEvents = require('@tryghost/domain-events'); const {anyErrorId} = matchers; const spamPrevention = require('../../../core/server/web/shared/middleware/api/spam-prevention'); +const configUtils = require('../../utils/configUtils'); let membersAgent, membersService; @@ -29,6 +30,7 @@ describe('sendMagicLink', function () { afterEach(function () { mockManager.restore(); + configUtils.restore(); }); it('Errors when passed multiple emails', async function () { @@ -285,4 +287,22 @@ describe('sendMagicLink', function () { } }); }); + + it('blocks signups from blocked email domains', async function () { + configUtils.set('spam:blocked_email_domains', ['blocked-domain.com']); + + const email = 'this-member-does-not-exist@blocked-domain.com'; + await membersAgent.post('/api/send-magic-link') + .body({ + email, + emailType: 'signup' + }) + .expectStatus(400) + .matchBodySnapshot({ + errors: [{ + id: anyErrorId, + message: 'This email domain is not accepted, try again with a different email address' + }] + }); + }); }); diff --git a/ghost/i18n/locales/af/portal.json b/ghost/i18n/locales/af/portal.json index bacfa41c0da..90cc1959603 100644 --- a/ghost/i18n/locales/af/portal.json +++ b/ghost/i18n/locales/af/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Hierdie webwerf is slegs op uitnodiging, kontak die eienaar vir toegang.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/ar/portal.json b/ghost/i18n/locales/ar/portal.json index 2be918d4fe9..e781b56499e 100644 --- a/ghost/i18n/locales/ar/portal.json +++ b/ghost/i18n/locales/ar/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": ".حدث خطأ أثناء الاشتراك، يرجى المحاولة مرة أخرى", "There was an error processing your payment. Please try again.": ".حدث خطأ أثناء معالجة دفعك، يرجى المحاولة مرة أخرى", "There was an error sending the email, please try again": ".حدث خطأ أثناء إرسال البريد الاكتروني، يرجى المحاولة مرة أخرى", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "هذا الموقع للمشتركين فقط، تواصل مع ادارة الموقع للحصول على اشتراك.", "This site is not accepting payments at the moment.": "هذا الموقع لا يقبل المدفوعات في الوقت الحالي", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/bg/portal.json b/ghost/i18n/locales/bg/portal.json index 6df78718e14..3960c1dd0f2 100644 --- a/ghost/i18n/locales/bg/portal.json +++ b/ghost/i18n/locales/bg/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "Възникна грешка при продължаването на абонамента ви, опитайте отново.", "There was an error processing your payment. Please try again.": "Възникна грешка при обработката на вашето плащане. Моля, опитайте отново.", "There was an error sending the email, please try again": "Възникна грешка при изпращане на имейл, опитайте отново", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Сайтът е само с покани. Свържете се със собственика за да получите достъп.", "This site is not accepting payments at the moment.": "В момента сайтът не приема плащания.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/bn/portal.json b/ghost/i18n/locales/bn/portal.json index 4dbf4485a4e..a89cfc89682 100644 --- a/ghost/i18n/locales/bn/portal.json +++ b/ghost/i18n/locales/bn/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "এই সাইটটি কেবল আমন্ত্রণের মাধ্যমে, প্রবেশাধিকার পেতে মালিকের সাথে যোগাযোগ করুন।", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/bs/portal.json b/ghost/i18n/locales/bs/portal.json index 5d2bfa3bd84..0d2da7661ca 100644 --- a/ghost/i18n/locales/bs/portal.json +++ b/ghost/i18n/locales/bs/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Ova je stranica samo na poziv, kontaktiraj vlasnika za pristup.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/ca/portal.json b/ghost/i18n/locales/ca/portal.json index d17417d828a..16f7031facd 100644 --- a/ghost/i18n/locales/ca/portal.json +++ b/ghost/i18n/locales/ca/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Aquest llog és només per invitació, contacta amb el propietari per obtenir accés.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/context.json b/ghost/i18n/locales/context.json index c9ae0468fde..9d845830c9c 100644 --- a/ghost/i18n/locales/context.json +++ b/ghost/i18n/locales/context.json @@ -242,6 +242,7 @@ "This comment has been hidden.": "Text for a comment thas was hidden", "This comment has been removed.": "Text for a comment thas was removed", "This email address will not be used.": "This is in the footer of signup verification emails, and comes right after 'If you did not make this request, you can simply delete this message.'", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "A message on the member login screen indicating that a site is not-open to public signups", "This site is not accepting payments at the moment.": "An error message shown when a tips or donations link is opened but the site has donations disabled", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/cs/portal.json b/ghost/i18n/locales/cs/portal.json index 36f707e7d81..f4a806b1607 100644 --- a/ghost/i18n/locales/cs/portal.json +++ b/ghost/i18n/locales/cs/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "Při zpracování vaší platby došlo k chybě. Zkuste to prosím znovu.", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Tento web je pouze pro pozvané, kontaktujte provozovatele pro přístup.", "This site is not accepting payments at the moment.": "Tento web momentálně nepřijímá platby.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/da/portal.json b/ghost/i18n/locales/da/portal.json index b8f3f329f89..03c1eb8c788 100644 --- a/ghost/i18n/locales/da/portal.json +++ b/ghost/i18n/locales/da/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "Der opstod en fejl under forlængelsen af dit abonnement, prøv venligst igen.", "There was an error processing your payment. Please try again.": "Der opstod en fejl under behandlingen af din betaling. Prøv venligst igen.", "There was an error sending the email, please try again": "Der opstod en fejl under afsendelse af e-mailen, prøv venligst igen.", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Denne sider kræver at du skal være inviteret. Kontakt ejeres for at få adgang.", "This site is not accepting payments at the moment.": "Denne side accepterer ikke betalinger i øjeblikket.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/de-CH/portal.json b/ghost/i18n/locales/de-CH/portal.json index 8d3bb954825..03895b4d89d 100644 --- a/ghost/i18n/locales/de-CH/portal.json +++ b/ghost/i18n/locales/de-CH/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Der Zugang zu diesem Inhalt ist eingeschränkt. Bitte kontaktieren Sie uns, wenn Sie Zugang wünschen.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/de/portal.json b/ghost/i18n/locales/de/portal.json index 83d7591b7d4..7fe5353ff08 100644 --- a/ghost/i18n/locales/de/portal.json +++ b/ghost/i18n/locales/de/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "Beim Erneuern deines Abonnements ist ein Fehler aufgetreten. Bitte versuche es erneut.", "There was an error processing your payment. Please try again.": "Bei der Verarbeitung deiner Zahlung gab es einen Fehler. Bitte versuche es noch einmal.", "There was an error sending the email, please try again": "Beim Versand der E-Mail ist ein Fehler aufgetreten. Bitte versuche es erneut.", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Für diese Seite benötigst du eine Einladung. Bitte kontaktiere den Inhaber.", "This site is not accepting payments at the moment.": "Diese Website nimmt zur Zeit keine Zahlungen entgegen.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/el/portal.json b/ghost/i18n/locales/el/portal.json index bc56a279929..98a6143e0b4 100644 --- a/ghost/i18n/locales/el/portal.json +++ b/ghost/i18n/locales/el/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Αυτός ο ιστότοπος είναι μόνο με πρόσκληση, επικοινωνήστε με τον ιδιοκτήτη για πρόσβαση.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/en/portal.json b/ghost/i18n/locales/en/portal.json index 54638995cf5..508f16c3466 100644 --- a/ghost/i18n/locales/en/portal.json +++ b/ghost/i18n/locales/en/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/eo/portal.json b/ghost/i18n/locales/eo/portal.json index 760b2ebe312..7f5724bdf9a 100644 --- a/ghost/i18n/locales/eo/portal.json +++ b/ghost/i18n/locales/eo/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Ĉi tiu retejo estas nur por invitiĝuloj, kontaktu la proprietulo por alireblo.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/es/portal.json b/ghost/i18n/locales/es/portal.json index 5e50595327c..0dd42ceae6f 100644 --- a/ghost/i18n/locales/es/portal.json +++ b/ghost/i18n/locales/es/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "Hubo un error en continuar la suscripción, inténtalo de nuevo por favor.", "There was an error processing your payment. Please try again.": "Hubo un error procesando tu pago. Intentalo de nuevvo por favor.", "There was an error sending the email, please try again": "Hubo un error enviando el correo electrónico, intentalo de nuevo por favor.", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Este sitio es solo por invitación, contacta al propietario para obtener acceso.", "This site is not accepting payments at the moment.": "Este sitio no acepta pagos en este momento.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/et/portal.json b/ghost/i18n/locales/et/portal.json index 7eb3a3485d1..5d70c09a7db 100644 --- a/ghost/i18n/locales/et/portal.json +++ b/ghost/i18n/locales/et/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "See sait on ainult kutsetega, juurdepääsu saamiseks võtke ühendust omanikuga.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/fa/portal.json b/ghost/i18n/locales/fa/portal.json index c73468136c5..c8d227e1b70 100644 --- a/ghost/i18n/locales/fa/portal.json +++ b/ghost/i18n/locales/fa/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "دسترسی به این وب\u200cسایت نیازمند دعوت\u200cنامه است، با مالک آن برای دریافت دسترسی تماس بگیرید.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/fi/portal.json b/ghost/i18n/locales/fi/portal.json index 62ec5e3b4ed..a7714935494 100644 --- a/ghost/i18n/locales/fi/portal.json +++ b/ghost/i18n/locales/fi/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Tämä sivu on vain kutsutuille, ota yhteyttä omistajaan saadaksesi pääsyoikeuden.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/fr/portal.json b/ghost/i18n/locales/fr/portal.json index 22f7eb2a228..21e6f2b607e 100644 --- a/ghost/i18n/locales/fr/portal.json +++ b/ghost/i18n/locales/fr/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "Une erreur s'est produite lors de la prolongation de votre abonnement, veuillez réessayer.", "There was an error processing your payment. Please try again.": "Une erreur s'est produite lors du traitement de votre paiement. Veuillez réessayer.", "There was an error sending the email, please try again": "Une erreur s'est produite lors de l'envoi de l'e-mail, veuillez réessayer.", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Ce site est réservé aux invités. Veuillez écrire au propriétaire pour en demander l'accès.", "This site is not accepting payments at the moment.": "Ce site n'accepte pas les paiements pour le moment.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/gd/portal.json b/ghost/i18n/locales/gd/portal.json index 90092761438..007908c4bab 100644 --- a/ghost/i18n/locales/gd/portal.json +++ b/ghost/i18n/locales/gd/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "Thachair mearachd fhad 's a bhathar a' làimhseachadh a' phàighidh agad. Feuch a-rithist an ceann greis.", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Feumar cuireadh airson an làrach-lìn seo, leig fios dhan rianaire ma tha thu ag iarraidh cothrom-inntrigidh.", "This site is not accepting payments at the moment.": "Chan eil an làrach seo a' gabhail ri phàighidhean an-dràsta.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/he/portal.json b/ghost/i18n/locales/he/portal.json index 1241bbd5093..19c9163cf73 100644 --- a/ghost/i18n/locales/he/portal.json +++ b/ghost/i18n/locales/he/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "שגיאה בהמשך המנוי שלכם, נסו שוב.", "There was an error processing your payment. Please try again.": "שגיאה בעיבוד התשלום שלכם. נסו שוב.", "There was an error sending the email, please try again": "שגיאה בשליחת המייל, נסו שוב", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "אתר זה פתוח למוזמנים בלבד, פנו לבעל האתר לגישה.", "This site is not accepting payments at the moment.": "אתר זה לא מקבל תשלומים כרגע.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/hi/portal.json b/ghost/i18n/locales/hi/portal.json index e5c6927dcab..c3cb361240e 100644 --- a/ghost/i18n/locales/hi/portal.json +++ b/ghost/i18n/locales/hi/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "यह साइट केवल निमंत्रण द्वारा है, पहुँच के लिए मालिक से संपर्क करें।", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/hr/portal.json b/ghost/i18n/locales/hr/portal.json index 69135f7de32..937fb337dd9 100644 --- a/ghost/i18n/locales/hr/portal.json +++ b/ghost/i18n/locales/hr/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Ove stranice su samo za članove, kontaktirajte vlasnika kako biste dobili pristup.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/hu/portal.json b/ghost/i18n/locales/hu/portal.json index de9f7d87bd3..f304651bcbb 100644 --- a/ghost/i18n/locales/hu/portal.json +++ b/ghost/i18n/locales/hu/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "A website csak meghívóval látogatható. Meghívóért lépjen kapcsolatba az oldal tulajdonosával!", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/id/portal.json b/ghost/i18n/locales/id/portal.json index bd9c652e083..5de578cae55 100644 --- a/ghost/i18n/locales/id/portal.json +++ b/ghost/i18n/locales/id/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "Terjadi kesalahan saat melanjutkan langganan Anda, harap coba lagi.", "There was an error processing your payment. Please try again.": "Terjadi kesalahan saat memproses pembayaran Anda. Harap coba lagi.", "There was an error sending the email, please try again": "Terjadi kesalahan saat mengirim email, harap coba lagi", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Situs ini hanya untuk yang diundang, hubungi pemiliknya untuk mendapatkan akses.", "This site is not accepting payments at the moment.": "Situs ini tidak menerima pembayaran saat ini.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/is/portal.json b/ghost/i18n/locales/is/portal.json index ef6598309e0..9b5b2a17b8b 100644 --- a/ghost/i18n/locales/is/portal.json +++ b/ghost/i18n/locales/is/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Aðgangur krefst boðsmiða, hafið samband við eiganda síðunnar til að fá aðgang.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/it/portal.json b/ghost/i18n/locales/it/portal.json index c99667b5331..2e5738f4051 100644 --- a/ghost/i18n/locales/it/portal.json +++ b/ghost/i18n/locales/it/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "C'è stato un errore nella continuazione del tuo abbonamento, riprova per favore.", "There was an error processing your payment. Please try again.": "C'è stato un errore durante l’elaborazione del tuo pagamento. Riprova per favore.", "There was an error sending the email, please try again": "C'è stato un errore nell'invio dell'e-mail, per favore riprova", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Questo sito è accessibile solo su invito, contatta il proprietario per poter accedere.", "This site is not accepting payments at the moment.": "Questo sito non accetta pagamenti al momento.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/ja/portal.json b/ghost/i18n/locales/ja/portal.json index 4e9b53d0f18..9b3182cca72 100644 --- a/ghost/i18n/locales/ja/portal.json +++ b/ghost/i18n/locales/ja/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "このサイトは招待制です。アクセスするにはオーナーに連絡してください。", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/ko/portal.json b/ghost/i18n/locales/ko/portal.json index e71b36ac98e..2fd55603b09 100644 --- a/ghost/i18n/locales/ko/portal.json +++ b/ghost/i18n/locales/ko/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "구독 계속하기 중 오류가 발생했어요. 다시 시도해 주세요.", "There was an error processing your payment. Please try again.": "결제 처리 중 오류가 발생했어요. 다시 시도해 주세요.", "There was an error sending the email, please try again": "이메일 전송 중 오류가 발생했어요. 다시 시도해 주세요", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "위 사이트는 초대된 사용자만 사용이 가능해요. 접근을 위해서는 관리자에게 연락해 주세요.", "This site is not accepting payments at the moment.": "현재 이 사이트는 결제를 받지 않고 있어요.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/kz/portal.json b/ghost/i18n/locales/kz/portal.json index 8414afa9f02..ce82ba45e3d 100644 --- a/ghost/i18n/locales/kz/portal.json +++ b/ghost/i18n/locales/kz/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Бұл сайтқа тек шақырту бойынша кіруге болады, рұқсат алу үшін иесіне хабарласыңыз.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/lt/portal.json b/ghost/i18n/locales/lt/portal.json index d09d5df89d0..e05538188dd 100644 --- a/ghost/i18n/locales/lt/portal.json +++ b/ghost/i18n/locales/lt/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Ši svetainė pasiekiama tik su pakvietimu, susisiekite su savininku dėl prieigos. ", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/lv/portal.json b/ghost/i18n/locales/lv/portal.json index a0b1eff46da..190adb36072 100644 --- a/ghost/i18n/locales/lv/portal.json +++ b/ghost/i18n/locales/lv/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "Turpinot abonementu, radās kļūda. Lūdzu, mēģiniet vēlreiz.", "There was an error processing your payment. Please try again.": "Apstrādājot jūsu maksājumu, radās kļūda. Lūdzu, mēģiniet vēlreiz.", "There was an error sending the email, please try again": "Nosūtot e-pasta ziņojumu, radās kļūda. Lūdzu, mēģiniet vēlreiz", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Šī vietne ir paredzēta tikai ielūgumam. Lai iegūtu piekļuvi, sazinieties ar īpašnieku.", "This site is not accepting payments at the moment.": "Šī vietne pašlaik nepieņem maksājumus.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/mk/portal.json b/ghost/i18n/locales/mk/portal.json index 0c9831b3116..3451e7300d6 100644 --- a/ghost/i18n/locales/mk/portal.json +++ b/ghost/i18n/locales/mk/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Оваа страница е достапна само со покана. За пристап контактирајте го сопственикот.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/mn/portal.json b/ghost/i18n/locales/mn/portal.json index e949b202743..97d23717539 100644 --- a/ghost/i18n/locales/mn/portal.json +++ b/ghost/i18n/locales/mn/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Энэхүү сайт руу зөвхөн урилгаар нэвтрэх боломжтой тул та админд нь хандана уу.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/ms/portal.json b/ghost/i18n/locales/ms/portal.json index a9c86031367..bd2111dea79 100644 --- a/ghost/i18n/locales/ms/portal.json +++ b/ghost/i18n/locales/ms/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Laman web ini hanya untuk jemputan, hubungi pemilik untuk akses.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/ne/portal.json b/ghost/i18n/locales/ne/portal.json index 54638995cf5..508f16c3466 100644 --- a/ghost/i18n/locales/ne/portal.json +++ b/ghost/i18n/locales/ne/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/nl/portal.json b/ghost/i18n/locales/nl/portal.json index 20843b69737..e16b198b621 100644 --- a/ghost/i18n/locales/nl/portal.json +++ b/ghost/i18n/locales/nl/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "Er was een fout bij het voortzetten van je abonnement, probeer het opnieuw.", "There was an error processing your payment. Please try again.": "Er was een fout bij het verwerken van je betaling, probeer het opnieuw.", "There was an error sending the email, please try again": "Er was een fout bij het verzenden van de e-mail, probeer het opnieuw", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Deze site is alleen toegankelijk op uitnodiging, neem contact op met de eigenaar.", "This site is not accepting payments at the moment.": "Deze site accepteert momenteel geen betalingen.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/nn/portal.json b/ghost/i18n/locales/nn/portal.json index 84f77247b85..d3c06a17261 100644 --- a/ghost/i18n/locales/nn/portal.json +++ b/ghost/i18n/locales/nn/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Denne sida er kun for inviterte, ta kontakt med eigaren for tilgang.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/no/portal.json b/ghost/i18n/locales/no/portal.json index 283b19c68da..55f85c8d646 100644 --- a/ghost/i18n/locales/no/portal.json +++ b/ghost/i18n/locales/no/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "En feil oppstod ved fornyelse av abonnementet, vennligst prøv igjen.", "There was an error processing your payment. Please try again.": "Det oppsto en feil under behandling av betalingen din. Vennligst prøv igjen.", "There was an error sending the email, please try again": "En feil oppstod ved sending av e-posten, vennligst prøv igjen", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Denne nettsiden er kun for inviterte. Kontakt eieren for invitasjon.", "This site is not accepting payments at the moment.": "Denne nettsiden godtar ikke betalinger for øyeblikket.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/pl/portal.json b/ghost/i18n/locales/pl/portal.json index 8b28b867e66..518af111ec0 100644 --- a/ghost/i18n/locales/pl/portal.json +++ b/ghost/i18n/locales/pl/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Ta strona posiada zamknięty dostęp. Skontaktuj się z właścicielem, aby uzyskać dostęp.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/pt-BR/portal.json b/ghost/i18n/locales/pt-BR/portal.json index f631eead816..94cbdeb8c25 100644 --- a/ghost/i18n/locales/pt-BR/portal.json +++ b/ghost/i18n/locales/pt-BR/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "Houve um erro ao continuar sua assinatura, por favor, tente novamente.", "There was an error processing your payment. Please try again.": "Houve um erro ao processar seu pagamento. Por favor, tente novamente.", "There was an error sending the email, please try again": "Houve um erro ao enviar o e-mail, por favor, tente novamente.", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Este site é apenas para convidados. Contate o proprietário para obter acesso.", "This site is not accepting payments at the moment.": "Este site não está aceitando pagamentos no momento.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/pt/portal.json b/ghost/i18n/locales/pt/portal.json index 62d46bca91e..8d776679170 100644 --- a/ghost/i18n/locales/pt/portal.json +++ b/ghost/i18n/locales/pt/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "Houve um problema ao processar o seu pagamento. Tente novamente por favor.", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "O acesso a este site é feito apenas por convite. Entre em contacto com o proprietário para obter acesso.", "This site is not accepting payments at the moment.": "Este site não está a aceitar pagamentos de momento", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/ro/portal.json b/ghost/i18n/locales/ro/portal.json index 8788c457616..dce5d968ca7 100644 --- a/ghost/i18n/locales/ro/portal.json +++ b/ghost/i18n/locales/ro/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Acest site este disponibil doar pe bază de invitație, contactează proprietarul pentru acces.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/ru/portal.json b/ghost/i18n/locales/ru/portal.json index 4b0d228a237..c9525ab915e 100644 --- a/ghost/i18n/locales/ru/portal.json +++ b/ghost/i18n/locales/ru/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "Произошла ошибка при обработке вашего платежа. Попробуйте ещё раз.", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Доступ к материалам этого сайта возможен только по приглашению. Для получения доступа свяжитесь с владельцем сайта.", "This site is not accepting payments at the moment.": "В данный момент сайт не принимает платежи.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/si/portal.json b/ghost/i18n/locales/si/portal.json index a5d2505aabc..4ccedfa27b9 100644 --- a/ghost/i18n/locales/si/portal.json +++ b/ghost/i18n/locales/si/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "මෙම වෙබ් අඩවිය ආරාධිතයන් සඳහා පමණි, ප්\u200dරවේශ වීම සඳහා හිමිකරු අමතන්න.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/sk/portal.json b/ghost/i18n/locales/sk/portal.json index 79c1cf5192c..36486b2eb3b 100644 --- a/ghost/i18n/locales/sk/portal.json +++ b/ghost/i18n/locales/sk/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Táto stránka je iba pre pozvaných úžívateľov, kontaktujte vlastníka stránky.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/sl/portal.json b/ghost/i18n/locales/sl/portal.json index 4fc4285f4f3..9121d377990 100644 --- a/ghost/i18n/locales/sl/portal.json +++ b/ghost/i18n/locales/sl/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "To spletno mesto je dostopno samo s povabilom, obrnite se na lastnika.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/sq/portal.json b/ghost/i18n/locales/sq/portal.json index ab63e66bd55..e42367d1446 100644 --- a/ghost/i18n/locales/sq/portal.json +++ b/ghost/i18n/locales/sq/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Kjo faqe eshte vetem me ftesa, kontaktoni zoteruesin per akses.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/sr-Cyrl/portal.json b/ghost/i18n/locales/sr-Cyrl/portal.json index 7e969ce11a7..858400556f2 100644 --- a/ghost/i18n/locales/sr-Cyrl/portal.json +++ b/ghost/i18n/locales/sr-Cyrl/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "Дошло је до грешке при обради ваше уплате. Молимо вас покушајте поново.", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Овај сајт је само на позив, контактирајте власника ради приступа.", "This site is not accepting payments at the moment.": "Овај сајт тренутно не прихвата уплате.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/sr/portal.json b/ghost/i18n/locales/sr/portal.json index e300ae10a44..586908dcbfa 100644 --- a/ghost/i18n/locales/sr/portal.json +++ b/ghost/i18n/locales/sr/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Ovaj sajt je samo za članove, kontaktirajte vlasnika kako bi dobili pristup.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/sv/portal.json b/ghost/i18n/locales/sv/portal.json index d56cf1651f8..55aeecc8e41 100644 --- a/ghost/i18n/locales/sv/portal.json +++ b/ghost/i18n/locales/sv/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "Det blev fel när din prenumeration skulle fortsättas, vänligen försök igen", "There was an error processing your payment. Please try again.": "Det blev fel när din betalning skulle behandlas, vänligen försök igen", "There was an error sending the email, please try again": "Det blev ett fel när e-posten skulle skickas, vänligen försök igen", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Den här sidan är endast för inbjudna, kontakta ägaren för åtkomst.", "This site is not accepting payments at the moment.": "Den här webbsidan accepterar inte betalningar för tillfället", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/sw/portal.json b/ghost/i18n/locales/sw/portal.json index 046013638c2..218f70a3df2 100644 --- a/ghost/i18n/locales/sw/portal.json +++ b/ghost/i18n/locales/sw/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Tovuti hii ni ya mialiko pekee, wasiliana na mmiliki kupata ufikiaji.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/ta/portal.json b/ghost/i18n/locales/ta/portal.json index 4b5c92473f4..b7289ef5487 100644 --- a/ghost/i18n/locales/ta/portal.json +++ b/ghost/i18n/locales/ta/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "உங்கள் சந்தாவைத் தொடர்வதில் பிழை ஏற்பட்டது, மீண்டும் முயற்சிக்கவும்.", "There was an error processing your payment. Please try again.": "உங்கள் கட்டணத்தை செயலாக்குவதில் பிழை ஏற்பட்டது. மீண்டும் முயற்சிக்கவும்.", "There was an error sending the email, please try again": "மின்னஞ்சலை அனுப்புவதில் பிழை ஏற்பட்டது, மீண்டும் முயற்சிக்கவும்", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "இந்த தளம் அழைப்பு மட்டுமே, அணுகலுக்கு உரிமையாளரைத் தொடர்பு கொள்ளவும்.", "This site is not accepting payments at the moment.": "இந்த தளம் தற்போது கட்டணங்களை ஏற்கவில்லை.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/th/portal.json b/ghost/i18n/locales/th/portal.json index 13a3b082bff..92f4be14b6f 100644 --- a/ghost/i18n/locales/th/portal.json +++ b/ghost/i18n/locales/th/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "เว็บไซต์นี้สำหรับผู้ได้รับเชิญเท่านั้น โปรดติดต่อเจ้าของเพื่อเข้าถึง", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/tr/portal.json b/ghost/i18n/locales/tr/portal.json index 20047bab660..9d3059588bd 100644 --- a/ghost/i18n/locales/tr/portal.json +++ b/ghost/i18n/locales/tr/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "Aboneliğinizi devam ettirirken bir hata oluştu, lütfen tekrar deneyin.", "There was an error processing your payment. Please try again.": "Ödemeniz işlenirken bir hata oluştu. Lütfen tekrar deneyiniz.", "There was an error sending the email, please try again": "E-posta gönderilirken bir hata oluştu, lütfen tekrar deneyin.", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Bu site sadece davetiyesi olanlar içindir, erişim için site sahibiyle iletişime geç.", "This site is not accepting payments at the moment.": "Bu site şu anda ödeme kabul etmemektedir.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/uk/portal.json b/ghost/i18n/locales/uk/portal.json index 7eae5dd5038..4dc2eb04d08 100644 --- a/ghost/i18n/locales/uk/portal.json +++ b/ghost/i18n/locales/uk/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "Під час продовження підписки сталася помилка. Спробуйте ще раз.", "There was an error processing your payment. Please try again.": "Під час обробки вашого платежу сталася помилка. Спробуйте ще раз.", "There was an error sending the email, please try again": "Під час надсилання листа сталася помилка. Повторіть спробу", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Цей сайт доступний тільки за запрошенням, звернись до власника сайта для доступу.", "This site is not accepting payments at the moment.": "Цей сайт на даний момент не приймає платежі.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/ur/portal.json b/ghost/i18n/locales/ur/portal.json index c65c245264b..041dea35fca 100644 --- a/ghost/i18n/locales/ur/portal.json +++ b/ghost/i18n/locales/ur/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "یہ سائٹ صرف دعوتی ہے، دستیابی کے لئے مالک سے رابطہ کریں۔", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/uz/portal.json b/ghost/i18n/locales/uz/portal.json index b08485cac70..6e569adb550 100644 --- a/ghost/i18n/locales/uz/portal.json +++ b/ghost/i18n/locales/uz/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Bu saytda faqat taklif qilinadi, kirish uchun egasiga murojaat qiling.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/vi/portal.json b/ghost/i18n/locales/vi/portal.json index ce097a1e323..4c655fd9151 100644 --- a/ghost/i18n/locales/vi/portal.json +++ b/ghost/i18n/locales/vi/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "Xảy ra lỗi khi tiếp tục gói thành viên, vui lòng thử lại", "There was an error processing your payment. Please try again.": "Xảy ra lỗi khi tiến hành thanh toán. Hãy thử lại sau.", "There was an error sending the email, please try again": "Xảy ra lỗi khi gửi email, vui lòng thử lại", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Trang web này chỉ dành cho những người được mời, hãy liên hệ với chủ sở hữu để cấp quyền truy cập.", "This site is not accepting payments at the moment.": "Trang web này hiện chưa chấp nhận thanh toán.", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/zh-Hant/portal.json b/ghost/i18n/locales/zh-Hant/portal.json index 38be03f724e..812f54e0b4d 100644 --- a/ghost/i18n/locales/zh-Hant/portal.json +++ b/ghost/i18n/locales/zh-Hant/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "續約您的訂閱時發生錯誤,請您再試一次。", "There was an error processing your payment. Please try again.": "處理您的付款時發生錯誤,請您再試一次。", "There was an error sending the email, please try again": "寄送 email 時發生錯誤,請您再試一次。", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "此網站僅限受邀請者觀看,請聯繫網站擁有者取得存取權限。", "This site is not accepting payments at the moment.": "此網站目前無付款方式。", "This site only accepts paid members.": "", diff --git a/ghost/i18n/locales/zh/portal.json b/ghost/i18n/locales/zh/portal.json index 1db54a5c467..016e303c01c 100644 --- a/ghost/i18n/locales/zh/portal.json +++ b/ghost/i18n/locales/zh/portal.json @@ -165,6 +165,7 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "您的付款处理失败,请重试。", "There was an error sending the email, please try again": "", + "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "此网站仅限邀请,联系网站所有者以获取访问", "This site is not accepting payments at the moment.": "本网站目前暂不接受付款。", "This site only accepts paid members.": "", diff --git a/ghost/magic-link/lib/MagicLink.js b/ghost/magic-link/lib/MagicLink.js index b49b25a20b8..f05030a6ee0 100644 --- a/ghost/magic-link/lib/MagicLink.js +++ b/ghost/magic-link/lib/MagicLink.js @@ -2,7 +2,8 @@ const {IncorrectUsageError, BadRequestError} = require('@tryghost/errors'); const {isEmail} = require('@tryghost/validator'); const tpl = require('@tryghost/tpl'); const messages = { - invalidEmail: 'Email is not valid' + invalidEmail: 'Email is not valid', + unsupportedEmailDomain: 'This email domain is not accepted, try again with a different email address' }; /** @@ -34,6 +35,7 @@ class MagicLink { * @param {typeof defaultGetHTML} [options.getHTML] * @param {typeof defaultGetSubject} [options.getSubject] * @param {object} [options.sentry] + * @param {object} [options.config] */ constructor(options) { if (!options || !options.transporter || !options.tokenProvider || !options.getSigninURL) { @@ -46,6 +48,7 @@ class MagicLink { this.getHTML = options.getHTML || defaultGetHTML; this.getSubject = options.getSubject || defaultGetSubject; this.sentry = options.sentry || undefined; + this.config = options.config || {}; } /** @@ -60,12 +63,19 @@ class MagicLink { */ async sendMagicLink(options) { this.sentry?.captureMessage?.(`[Magic Link] Generating magic link`, {extra: options}); - + if (!isEmail(options.email)) { throw new BadRequestError({ message: tpl(messages.invalidEmail) }); } + + if (this.isEmailDomainBlocked(options.email)) { + throw new BadRequestError({ + message: tpl(messages.unsupportedEmailDomain) + }); + } + const token = await this.tokenProvider.create(options.tokenData); const type = options.type || 'signin'; @@ -108,6 +118,24 @@ class MagicLink { const tokenData = await this.tokenProvider.validate(token); return tokenData; } + + /** + * Check if the email domain is blocked, based on the `spam.blocked_email_domains` config + * + * @param {string} email + * @returns {boolean} + */ + isEmailDomainBlocked(email) { + const emailDomain = email.split('@')[1]?.toLowerCase(); + const blockedDomains = this.config?.get('spam:blocked_email_domains'); + + // Config is not set properly: skip check + if (!blockedDomains || !Array.isArray(blockedDomains)) { + return false; + } + + return blockedDomains.includes(emailDomain); + } } /** diff --git a/ghost/magic-link/test/index.test.js b/ghost/magic-link/test/index.test.js index 22520d2671c..b7e2de246b0 100644 --- a/ghost/magic-link/test/index.test.js +++ b/ghost/magic-link/test/index.test.js @@ -21,6 +21,9 @@ describe('MagicLink', function () { getSubject: sandbox.stub().returns('SOMESUBJECT'), transporter: { sendMail: sandbox.stub().resolves() + }, + config: { + get: sandbox.stub().resolves() } }; const service = new MagicLink(options); @@ -53,6 +56,9 @@ describe('MagicLink', function () { getSubject: sandbox.stub().returns('SOMESUBJECT'), transporter: { sendMail: sandbox.stub().resolves() + }, + config: { + get: sandbox.stub().resolves() } }; const service = new MagicLink(options); @@ -85,6 +91,48 @@ describe('MagicLink', function () { assert.equal(options.transporter.sendMail.firstCall.args[0].text, options.getText.firstCall.returnValue); assert.equal(options.transporter.sendMail.firstCall.args[0].html, options.getHTML.firstCall.returnValue); }); + + it('Blocks signups from blocked email domains', async function () { + const options = { + tokenProvider: new MagicLink.JWTTokenProvider(secret), + getSigninURL: sandbox.stub().returns('FAKEURL'), + getText: sandbox.stub().returns('SOMETEXT'), + getHTML: sandbox.stub().returns('SOMEHTML'), + getSubject: sandbox.stub().returns('SOMESUBJECT'), + transporter: { + sendMail: sandbox.stub().resolves() + }, + config: { + get: sandbox.stub().withArgs('spam:blocked_email_domains').returns(['blocked-domain.com']) + } + }; + const service = new MagicLink(options); + + const blockedArgs = { + email: 'test@blocked-domain.com', + tokenData: { + id: '420' + } + }; + + await assert.rejects( + () => service.sendMagicLink(blockedArgs), + { + name: 'BadRequestError', + message: 'This email domain is not accepted, try again with a different email address' + } + ); + + // Verify non-blocked domain is allowed + const allowedArgs = { + email: 'test@allowed-domain.com', + tokenData: { + id: '420' + } + }; + + await assert.doesNotReject(() => service.sendMagicLink(allowedArgs)); + }); }); describe('#getDataFromToken', function () { @@ -96,6 +144,9 @@ describe('MagicLink', function () { getHTML: sandbox.stub().returns('SOMEHTML'), transporter: { sendMail: sandbox.stub().resolves() + }, + config: { + get: sandbox.stub().resolves() } }; const service = new MagicLink(options); diff --git a/ghost/members-api/lib/members-api.js b/ghost/members-api/lib/members-api.js index 6bacee6d92a..660bc6cdca7 100644 --- a/ghost/members-api/lib/members-api.js +++ b/ghost/members-api/lib/members-api.js @@ -73,7 +73,8 @@ module.exports = function MembersAPI({ emailSuppressionList, settingsCache, sentry, - settingsHelpers + settingsHelpers, + config }) { const tokenService = new TokenService({ privateKey, @@ -158,7 +159,8 @@ module.exports = function MembersAPI({ getText, getHTML, getSubject, - sentry + sentry, + config }); const paymentsService = new PaymentsService({ From ff4545939c98fddb36dfb7144f201a198325014b Mon Sep 17 00:00:00 2001 From: Ghost CI <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 20 Jan 2025 16:07:50 +0000 Subject: [PATCH 56/90] v5.107.1 --- ghost/admin/package.json | 2 +- ghost/core/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ghost/admin/package.json b/ghost/admin/package.json index 52061fc8d4b..e5ea2645583 100644 --- a/ghost/admin/package.json +++ b/ghost/admin/package.json @@ -1,6 +1,6 @@ { "name": "ghost-admin", - "version": "5.107.0", + "version": "5.107.1", "description": "Ember.js admin client for Ghost", "author": "Ghost Foundation", "homepage": "http://ghost.org", diff --git a/ghost/core/package.json b/ghost/core/package.json index 76e0d79632c..f5b948a7886 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -1,6 +1,6 @@ { "name": "ghost", - "version": "5.107.0", + "version": "5.107.1", "description": "The professional publishing platform", "author": "Ghost Foundation", "homepage": "https://ghost.org", From c1d7a46599b770743f7307fdec967b0a2118b97e Mon Sep 17 00:00:00 2001 From: Djordje Vlaisavljevic Date: Mon, 20 Jan 2025 15:08:37 +0000 Subject: [PATCH 57/90] Added truncation and "Show more" button for long notes ref https://linear.app/ghost/issue/AP-618/show-only-excerpts-for-very-long-notes-in-the-feed - Notes can be pretty long and we used to show them in their entirety, so they could take up a large chunk of the viewport. Now we're limiting the displayed text in notes to 10 lines, and we show a "Show more" button to indicate there is more content. --- .../src/components/feed/FeedItem.tsx | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/apps/admin-x-activitypub/src/components/feed/FeedItem.tsx b/apps/admin-x-activitypub/src/components/feed/FeedItem.tsx index c857f16fe35..6a2ca1fdc56 100644 --- a/apps/admin-x-activitypub/src/components/feed/FeedItem.tsx +++ b/apps/admin-x-activitypub/src/components/feed/FeedItem.tsx @@ -1,4 +1,4 @@ -import React, {useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub'; import {Button, Heading, Icon, Menu, MenuItem, showToast} from '@tryghost/admin-x-design-system'; @@ -166,6 +166,16 @@ const FeedItem: React.FC = ({actor, object, layout, type, comment const [, setIsCopied] = useState(false); + const contentRef = useRef(null); + const [isTruncated, setIsTruncated] = useState(false); + + useEffect(() => { + const element = contentRef.current; + if (element) { + setIsTruncated(element.scrollHeight > element.clientHeight); + } + }, [object.content]); + const onLikeClick = () => { // Do API req or smth // Don't need to know about setting timeouts or anything like that @@ -264,10 +274,23 @@ const FeedItem: React.FC = ({actor, object, layout, type, comment
    {(object.type === 'Article') && renderFeedAttachment(object, layout)} {object.name && {object.name}} - {(object.preview && object.type === 'Article') ?
    {object.preview.content}
    :
    } + {(object.preview && object.type === 'Article') ? ( +
    {object.preview.content}
    + ) : ( +
    +
    + {isTruncated && ( + + )} +
    + )} {(object.type === 'Note') && renderFeedAttachment(object, layout)} {(object.type === 'Article') && )} + {renderFeedAttachment(object, layout)}
    - )} - {(object.type === 'Note') && renderFeedAttachment(object, layout)} - {(object.type === 'Article') &&
    Date: Mon, 20 Jan 2025 19:03:55 +0000 Subject: [PATCH 59/90] Fixed posts not opening when clicked from profile feed no ref --- apps/admin-x-activitypub/package.json | 2 +- .../src/components/modals/ViewProfileModal.tsx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/admin-x-activitypub/package.json b/apps/admin-x-activitypub/package.json index 4104da51684..d5be231920a 100644 --- a/apps/admin-x-activitypub/package.json +++ b/apps/admin-x-activitypub/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/admin-x-activitypub", - "version": "0.3.51", + "version": "0.3.52", "license": "MIT", "repository": { "type": "git", diff --git a/apps/admin-x-activitypub/src/components/modals/ViewProfileModal.tsx b/apps/admin-x-activitypub/src/components/modals/ViewProfileModal.tsx index 1b054cb27fa..616030b4b99 100644 --- a/apps/admin-x-activitypub/src/components/modals/ViewProfileModal.tsx +++ b/apps/admin-x-activitypub/src/components/modals/ViewProfileModal.tsx @@ -16,6 +16,7 @@ import Separator from '../global/Separator'; import getName from '../../utils/get-name'; import getUsername from '../../utils/get-username'; import {handleProfileClick} from '../../utils/handle-profile-click'; +import {handleViewContent} from '../../utils/content-handlers'; const noop = () => {}; @@ -173,7 +174,8 @@ const PostsTab: React.FC<{handle: string}> = ({handle}) => { layout='feed' object={post.object} type={post.type} - onCommentClick={() => {}} + onClick={() => handleViewContent(post, false)} + onCommentClick={() => handleViewContent(post, true)} /> {index < posts.length - 1 && }
    From 6b00bdecb0c259de2c887acf1f91925fd41507bf Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Mon, 20 Jan 2025 17:34:04 -0800 Subject: [PATCH 60/90] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20degraded=20databas?= =?UTF-8?q?e=20performance=20when=20using=20the=20Post=20Analytics=20scree?= =?UTF-8?q?n=20(#22031)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref https://linear.app/ghost/issue/ONC-717/support-escalation-re-dashboard-unresponsive This reverts commit 9082a9f1db1d44026013a8cc8f0dee27e06e1486, which introduced an automatic refresh interval on the Post Analytics screen in Admin. This change led to an increase in the number of requests to the `/ghost/api/admin/members/events/` endpoint, which is a particularly database intensive endpoint. Ultimately this led to significantly higher load on the database which degraded performance for sites with a large `email_recipients` table. --- ghost/admin/app/components/posts/analytics.hbs | 8 ++++++++ ghost/admin/app/components/posts/analytics.js | 13 +------------ 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/ghost/admin/app/components/posts/analytics.hbs b/ghost/admin/app/components/posts/analytics.hbs index 3accdb48da9..64d91bbaa17 100644 --- a/ghost/admin/app/components/posts/analytics.hbs +++ b/ghost/admin/app/components/posts/analytics.hbs @@ -35,6 +35,14 @@ {{/let}}
    + {{#unless this.post.emailOnly}}
    + {{#unless this.post.emailOnly}} + + + + + + + Share + + This is a dialog, lets see how it works. + + + + diff --git a/apps/posts/src/routes.ts b/apps/posts/src/routes.ts new file mode 100644 index 00000000000..8e1f20afd82 --- /dev/null +++ b/apps/posts/src/routes.ts @@ -0,0 +1,30 @@ +import Newsletter from './views/post-analytics/components/Newsletter'; +import Overview from './views/post-analytics/components/Overview'; +import PostAnalytics from './views/post-analytics/PostAnalytics'; +import {createHashRouter} from 'react-router'; + +export const BASE_PATH = '/posts-x'; +export const ANALYTICS = `${BASE_PATH}/analytics`; + +const postAnalyticsRoutes = [ + { + path: `${BASE_PATH}/analytics/:postId`, + Component: PostAnalytics, + children: [ + { + path: '', + Component: Overview + }, + { + path: 'overview', + Component: Overview + }, + { + path: 'newsletter', + Component: Newsletter + } + ] + } +]; + +export const router = createHashRouter(postAnalyticsRoutes); diff --git a/apps/posts/src/views/post-analytics/PostAnalytics.tsx b/apps/posts/src/views/post-analytics/PostAnalytics.tsx index 90cb9a8c64e..ebb78a58f63 100644 --- a/apps/posts/src/views/post-analytics/PostAnalytics.tsx +++ b/apps/posts/src/views/post-analytics/PostAnalytics.tsx @@ -1,26 +1,44 @@ import Header from '../../components/Header'; -import Newsletter from './components/Newsletter'; -import Overview from './components/Overview'; -import {LucideIcon, Page, Tabs, TabsContent, TabsList, TabsTrigger} from '@tryghost/shade'; +import {ANALYTICS} from '../../routes'; +import {Outlet, useLocation, useNavigate, useParams} from 'react-router'; +import {Page, Tabs, TabsList, TabsTrigger} from '@tryghost/shade'; interface postAnalyticsProps {}; const PostAnalytics: React.FC = () => { + const navigate = useNavigate(); + const {postId} = useParams(); + const location = useLocation(); + + let currentTab = location.pathname.split('/').pop(); + if (currentTab === postId || !currentTab) { + currentTab = 'overview'; + } + + const handleTabChange = (value: string) => { + if (value === 'overview') { + navigate(`${ANALYTICS}/${postId}`); + } else { + navigate(`${ANALYTICS}/${postId}/${value}`); + } + }; + return (
    - + - Overview - Newsletter - Web + Overview + Newsletter - - - - - - +
    + +
    ); diff --git a/apps/posts/src/views/post-analytics/components/Newsletter.tsx b/apps/posts/src/views/post-analytics/components/Newsletter.tsx index 8982064470e..4181824d121 100644 --- a/apps/posts/src/views/post-analytics/components/Newsletter.tsx +++ b/apps/posts/src/views/post-analytics/components/Newsletter.tsx @@ -1,7 +1,7 @@ import OpenedList from './newsletter/OpenedList'; import React from 'react'; import SentList from './newsletter/SentList'; -import {Badge} from '@tryghost/shade'; +import {Badge, Card, CardContent} from '@tryghost/shade'; import {StatsTabItem, StatsTabTitle, StatsTabValue, StatsTabs, StatsTabsGroup} from './StatsTabs'; interface newsletterProps {}; @@ -13,7 +13,7 @@ const Newsletter: React.FC = () => { key: 'sent', title: 'Sent', value: '1,697', - badge: '', + badge: '100%', content: }, { @@ -73,10 +73,12 @@ const Newsletter: React.FC = () => { }; return ( -
    -
    - -
    +
    + + + + +
    {tabs.map(group => ( diff --git a/apps/posts/src/views/post-analytics/components/StatsTabs.tsx b/apps/posts/src/views/post-analytics/components/StatsTabs.tsx index 3a79d034bcd..22e49f89159 100644 --- a/apps/posts/src/views/post-analytics/components/StatsTabs.tsx +++ b/apps/posts/src/views/post-analytics/components/StatsTabs.tsx @@ -6,7 +6,7 @@ interface statsTabsProps extends React.HTMLAttributes {} const StatsTabs: React.FC = ({className, ...props}) => { - return
    ; + return
    ; }; interface statsTabsGroupProps diff --git a/apps/posts/src/views/post-analytics/components/overview/NewsletterPerformance.tsx b/apps/posts/src/views/post-analytics/components/overview/NewsletterPerformance.tsx index db7a7f94cb3..2c28bb02e7c 100644 --- a/apps/posts/src/views/post-analytics/components/overview/NewsletterPerformance.tsx +++ b/apps/posts/src/views/post-analytics/components/overview/NewsletterPerformance.tsx @@ -35,7 +35,7 @@ const NewsletterPerformance: React.FC = (props) => {
    Sent - 1,697 + 1,697 100% diff --git a/apps/shade/src/components/ui/dialog.tsx b/apps/shade/src/components/ui/dialog.tsx new file mode 100644 index 00000000000..176b9b0bc97 --- /dev/null +++ b/apps/shade/src/components/ui/dialog.tsx @@ -0,0 +1,122 @@ +import * as React from 'react'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import {X} from 'lucide-react'; + +import {cn} from '@/lib/utils'; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({className, ...props}, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({className, children, ...props}, ref) => ( + +
    + + + {children} + + + Close + + +
    +
    +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
    +); +DialogHeader.displayName = 'DialogHeader'; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
    +); +DialogFooter.displayName = 'DialogFooter'; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({className, ...props}, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({className, ...props}, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription +}; diff --git a/apps/shade/src/components/ui/tabs.tsx b/apps/shade/src/components/ui/tabs.tsx index a69b8bdfe92..ab17bf57f3e 100644 --- a/apps/shade/src/components/ui/tabs.tsx +++ b/apps/shade/src/components/ui/tabs.tsx @@ -76,7 +76,7 @@ const tabsTriggerVariants = cva( variant: { segmented: 'h-7 rounded-md text-sm font-medium data-[state=active]:shadow-md', button: 'h-[34px] gap-1.5 rounded-md border border-input py-2 text-sm font-medium hover:bg-muted/50 data-[state=active]:bg-muted/70 data-[state=active]:font-semibold', - underline: 'relative h-[34px] px-0 text-md font-medium text-gray-700 data-[state=active]:font-semibold data-[state=active]:text-black data-[state=active]:after:absolute data-[state=active]:after:inset-x-0 data-[state=active]:after:bottom-[-5px] data-[state=active]:after:h-px data-[state=active]:after:bg-black data-[state=active]:after:content-[""]' + underline: 'relative h-[34px] px-0 text-md font-medium text-gray-700 data-[state=active]:font-semibold data-[state=active]:text-black data-[state=active]:after:absolute data-[state=active]:after:inset-x-0 data-[state=active]:after:bottom-[-5px] data-[state=active]:after:h-0.5 data-[state=active]:after:bg-black data-[state=active]:after:content-[""]' } }, defaultVariants: { diff --git a/apps/shade/src/index.ts b/apps/shade/src/index.ts index 7574e40f260..dab7abfff2a 100644 --- a/apps/shade/src/index.ts +++ b/apps/shade/src/index.ts @@ -5,6 +5,7 @@ export * from './components/ui/breadcrumb'; export * from './components/ui/button'; export * from './components/ui/card'; export * from './components/ui/chart'; +export * from './components/ui/dialog'; export * from './components/ui/dropdown-menu'; export * from './components/ui/input'; export * from './components/ui/separator'; diff --git a/ghost/admin/app/components/gh-nav-menu/main.hbs b/ghost/admin/app/components/gh-nav-menu/main.hbs index b1d75ec9836..1a5392416fa 100644 --- a/ghost/admin/app/components/gh-nav-menu/main.hbs +++ b/ghost/admin/app/components/gh-nav-menu/main.hbs @@ -164,7 +164,7 @@ {{/if}} {{#if (feature "postsX")}}
  • - {{svg-jar "chart"}}Post analytics + {{svg-jar "chart"}}Post analytics
  • {{/if}} diff --git a/ghost/admin/app/router.js b/ghost/admin/app/router.js index 5fa7d260a08..5c0a1352a1a 100644 --- a/ghost/admin/app/router.js +++ b/ghost/admin/app/router.js @@ -54,6 +54,9 @@ Router.map(function () { this.route('posts-x', function () { this.route('posts-x', {path: '/*sub'}); + this.route('analytics', function () { + this.route('123'); + }); }); this.route('settings-x', {path: '/settings'}, function () { diff --git a/yarn.lock b/yarn.lock index 882f25dc332..5ba170a3f82 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8276,6 +8276,11 @@ resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.1.tgz#bfd02c1f2224567676c1545199f87c3a861d878d" integrity sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q== +"@types/cookie@^0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.6.0.tgz#eac397f28bf1d6ae0ae081363eca2f425bedf0d5" + integrity sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA== + "@types/cookiejar@^2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@types/cookiejar/-/cookiejar-2.1.5.tgz#14a3e83fa641beb169a2dd8422d91c3c345a9a78" @@ -13562,6 +13567,11 @@ cookie@0.7.2: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== +cookie@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-1.0.2.tgz#27360701532116bd3f1f9416929d176afe1e4610" + integrity sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA== + cookie@~0.4.1: version "0.4.2" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432" @@ -27580,6 +27590,16 @@ react-remove-scroll@^2.6.1: use-callback-ref "^1.3.3" use-sidecar "^1.1.2" +react-router@^7.1.3: + version "7.1.3" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.1.3.tgz#6c15c28838b799cb3058943e8e8015dbd6c16c7b" + integrity sha512-EezYymLY6Guk/zLQ2vRA8WvdUhWFEj5fcE3RfWihhxXBW7+cd1LsIiA3lmx+KCmneAGQuyBv820o44L2+TtkSA== + dependencies: + "@types/cookie" "^0.6.0" + cookie "^1.0.1" + set-cookie-parser "^2.6.0" + turbo-stream "2.4.0" + react-select@5.8.2: version "5.8.2" resolved "https://registry.yarnpkg.com/react-select/-/react-select-5.8.2.tgz#0d7ccb1895d61aafcd090fbf65aa9e506225a854" @@ -28871,6 +28891,11 @@ set-blocking@^2.0.0, set-blocking@~2.0.0: resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== +set-cookie-parser@^2.6.0: + version "2.7.1" + resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz#3016f150072202dfbe90fadee053573cc89d2943" + integrity sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ== + set-function-length@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" @@ -31071,6 +31096,11 @@ tunnel@^0.0.6: resolved "https://registry.yarnpkg.com/tunnel/-/tunnel-0.0.6.tgz#72f1314b34a5b192db012324df2cc587ca47f92c" integrity sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg== +turbo-stream@2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/turbo-stream/-/turbo-stream-2.4.0.tgz#1e4fca6725e90fa14ac4adb782f2d3759a5695f0" + integrity sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g== + tweetnacl@^0.14.3, tweetnacl@~0.14.0: version "0.14.5" resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" From 713e75838ae7db4e4b8865946703e78684680419 Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Tue, 21 Jan 2025 11:22:45 -0800 Subject: [PATCH 65/90] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20newsletters=20not?= =?UTF-8?q?=20rendering=20in=20Portal=20Email=20Preferences=20(#22037)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref https://linear.app/ghost/issue/ONC-723/support-escalation-re-fwd-email-preferences - On sites where the Default recipients setting was set to anything other than "Whoever has access to the post", the list of newsletters and the toggle to subscribe/unsubscribe would not be rendered on the Portal "Email Preferences" page. - The bug was introduced in v5.106.0, and intended to hide the newsletter list if Newsletter sending were disabled completely, but there was bug in the logic - This commit has a breaking test to prevent this in the future, and fixes the logic to only hide the newsletter list if `editor_default_email_recipients` is explicitly set to 'disabled'. --- .../components/pages/AccountEmailPage.test.js | 24 +++++++++++++++++++ apps/portal/src/utils/helpers.js | 2 +- apps/portal/src/utils/helpers.test.js | 19 ++++++++++++++- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/apps/portal/src/components/pages/AccountEmailPage.test.js b/apps/portal/src/components/pages/AccountEmailPage.test.js index c5cb88f762f..1c47bc1713e 100644 --- a/apps/portal/src/components/pages/AccountEmailPage.test.js +++ b/apps/portal/src/components/pages/AccountEmailPage.test.js @@ -134,4 +134,28 @@ describe('Account Email Page', () => { expect(unsubscribeBtns).toHaveLength(1); expect(unsubscribeBtns[0].textContent).toContain('Get notified when someone replies to your comment'); }); + + test('newsletters are visible when editor default email recipients is set to visibility', async () => { + const newsletterData = getNewslettersData({numOfNewsletters: 2}); + const siteData = getSiteData({ + newsletters: newsletterData, + editorDefaultEmailRecipients: 'visibility', + member: getMemberData({newsletters: newsletterData}) + }); + const {getAllByTestId} = setup({site: siteData}); + const unsubscribeBtns = getAllByTestId(`toggle-wrapper`); + expect(unsubscribeBtns).toHaveLength(3); + }); + + test('newsletters are visible when editor default email recipients is set to filter', async () => { + const newsletterData = getNewslettersData({numOfNewsletters: 2}); + const siteData = getSiteData({ + newsletters: newsletterData, + editorDefaultEmailRecipients: 'filter', + member: getMemberData({newsletters: newsletterData}) + }); + const {getAllByTestId} = setup({site: siteData}); + const unsubscribeBtns = getAllByTestId(`toggle-wrapper`); + expect(unsubscribeBtns).toHaveLength(3); + }); }); diff --git a/apps/portal/src/utils/helpers.js b/apps/portal/src/utils/helpers.js index 27d1e0bf15f..ce70d3b9a3d 100644 --- a/apps/portal/src/utils/helpers.js +++ b/apps/portal/src/utils/helpers.js @@ -87,7 +87,7 @@ export function getNewsletterFromUuid({site, uuid}) { } export function hasNewsletterSendingEnabled({site}) { - return site?.editor_default_email_recipients === 'visibility'; + return site?.editor_default_email_recipients !== 'disabled'; } export function allowCompMemberUpgrade({member}) { diff --git a/apps/portal/src/utils/helpers.test.js b/apps/portal/src/utils/helpers.test.js index b58af2868eb..a21301b59f7 100644 --- a/apps/portal/src/utils/helpers.test.js +++ b/apps/portal/src/utils/helpers.test.js @@ -1,4 +1,4 @@ -import {hasAvailablePrices, getAllProductsForSite, getAvailableProducts, getCurrencySymbol, getFreeProduct, getMemberName, getMemberSubscription, getPriceFromSubscription, getPriceIdFromPageQuery, getSupportAddress, getDefaultNewsletterSender, getUrlHistory, hasMultipleProducts, isActiveOffer, isInviteOnly, isPaidMember, isPaidMembersOnly, isSameCurrency, transformApiTiersData, isSigninAllowed, isSignupAllowed, getCompExpiry, isInThePast} from './helpers'; +import {hasAvailablePrices, getAllProductsForSite, getAvailableProducts, getCurrencySymbol, getFreeProduct, getMemberName, getMemberSubscription, getPriceFromSubscription, getPriceIdFromPageQuery, getSupportAddress, getDefaultNewsletterSender, getUrlHistory, hasMultipleProducts, isActiveOffer, isInviteOnly, isPaidMember, isPaidMembersOnly, isSameCurrency, transformApiTiersData, isSigninAllowed, isSignupAllowed, getCompExpiry, isInThePast, hasNewsletterSendingEnabled} from './helpers'; import * as Fixtures from './fixtures-generator'; import {site as FixturesSite, member as FixtureMember, offer as FixtureOffer, transformTierFixture as TransformFixtureTiers} from '../utils/test-fixtures'; import {isComplimentaryMember} from '../utils/helpers'; @@ -539,4 +539,21 @@ describe('Helpers - ', () => { expect(isInThePast(futureDate)).toEqual(false); }); }); + + describe('hasNewsletterSendingEnabled', () => { + test('returns true when editor default email recipients is set to visibility', () => { + const site = {editor_default_email_recipients: 'visibility'}; + expect(hasNewsletterSendingEnabled({site})).toBe(true); + }); + + test('returns false when editor default email recipients is set to disabled', () => { + const site = {editor_default_email_recipients: 'disabled'}; + expect(hasNewsletterSendingEnabled({site})).toBe(false); + }); + + test('returns true when editor default email recipients is set to filter', () => { + const site = {editor_default_email_recipients: 'filter'}; + expect(hasNewsletterSendingEnabled({site})).toBe(true); + }); + }); }); From 669da1cfb1caed2d5367ff8e4dabbc050a342f5a Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Tue, 21 Jan 2025 13:04:23 -0800 Subject: [PATCH 66/90] Shipped portal@2.48.1 (#22039) Patch update including this bug fix: https://github.com/TryGhost/Ghost/commit/713e75838ae7db4e4b8865946703e78684680419 --- apps/portal/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/portal/package.json b/apps/portal/package.json index 0ebc1a99eed..2e3d5040e8a 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/portal", - "version": "2.48.0", + "version": "2.48.1", "license": "MIT", "repository": { "type": "git", From 3ca419bcbce4acf27354f269a9b3b6b311666089 Mon Sep 17 00:00:00 2001 From: Sag Date: Wed, 22 Jan 2025 11:40:22 +0700 Subject: [PATCH 67/90] Improved error message when email provider is blocked (#22040) ref https://linear.app/ghost/issue/ONC-721 ref https://linear.app/ghost/issue/PRO-1349 - also added the rate limit error message into the translate-able strings in Portal --- apps/portal/src/utils/errors.js | 3 ++- .../e2e-api/members/__snapshots__/send-magic-link.test.js.snap | 2 +- ghost/core/test/e2e-api/members/send-magic-link.test.js | 2 +- ghost/i18n/locales/af/portal.json | 3 ++- ghost/i18n/locales/ar/portal.json | 3 ++- ghost/i18n/locales/bg/portal.json | 3 ++- ghost/i18n/locales/bn/portal.json | 3 ++- ghost/i18n/locales/bs/portal.json | 3 ++- ghost/i18n/locales/ca/portal.json | 3 ++- ghost/i18n/locales/context.json | 3 ++- ghost/i18n/locales/cs/portal.json | 3 ++- ghost/i18n/locales/da/portal.json | 3 ++- ghost/i18n/locales/de-CH/portal.json | 3 ++- ghost/i18n/locales/de/portal.json | 3 ++- ghost/i18n/locales/el/portal.json | 3 ++- ghost/i18n/locales/en/portal.json | 3 ++- ghost/i18n/locales/eo/portal.json | 3 ++- ghost/i18n/locales/es/portal.json | 3 ++- ghost/i18n/locales/et/portal.json | 3 ++- ghost/i18n/locales/fa/portal.json | 3 ++- ghost/i18n/locales/fi/portal.json | 3 ++- ghost/i18n/locales/fr/portal.json | 3 ++- ghost/i18n/locales/gd/portal.json | 3 ++- ghost/i18n/locales/he/portal.json | 3 ++- ghost/i18n/locales/hi/portal.json | 3 ++- ghost/i18n/locales/hr/portal.json | 3 ++- ghost/i18n/locales/hu/portal.json | 3 ++- ghost/i18n/locales/id/portal.json | 3 ++- ghost/i18n/locales/is/portal.json | 3 ++- ghost/i18n/locales/it/portal.json | 3 ++- ghost/i18n/locales/ja/portal.json | 3 ++- ghost/i18n/locales/ko/portal.json | 3 ++- ghost/i18n/locales/kz/portal.json | 3 ++- ghost/i18n/locales/lt/portal.json | 3 ++- ghost/i18n/locales/lv/portal.json | 3 ++- ghost/i18n/locales/mk/portal.json | 3 ++- ghost/i18n/locales/mn/portal.json | 3 ++- ghost/i18n/locales/ms/portal.json | 3 ++- ghost/i18n/locales/ne/portal.json | 3 ++- ghost/i18n/locales/nl/portal.json | 3 ++- ghost/i18n/locales/nn/portal.json | 3 ++- ghost/i18n/locales/no/portal.json | 3 ++- ghost/i18n/locales/pl/portal.json | 3 ++- ghost/i18n/locales/pt-BR/portal.json | 3 ++- ghost/i18n/locales/pt/portal.json | 3 ++- ghost/i18n/locales/ro/portal.json | 3 ++- ghost/i18n/locales/ru/portal.json | 3 ++- ghost/i18n/locales/si/portal.json | 3 ++- ghost/i18n/locales/sk/portal.json | 3 ++- ghost/i18n/locales/sl/portal.json | 3 ++- ghost/i18n/locales/sq/portal.json | 3 ++- ghost/i18n/locales/sr-Cyrl/portal.json | 3 ++- ghost/i18n/locales/sr/portal.json | 3 ++- ghost/i18n/locales/sv/portal.json | 3 ++- ghost/i18n/locales/sw/portal.json | 3 ++- ghost/i18n/locales/ta/portal.json | 3 ++- ghost/i18n/locales/th/portal.json | 3 ++- ghost/i18n/locales/tr/portal.json | 3 ++- ghost/i18n/locales/uk/portal.json | 3 ++- ghost/i18n/locales/ur/portal.json | 3 ++- ghost/i18n/locales/uz/portal.json | 3 ++- ghost/i18n/locales/vi/portal.json | 3 ++- ghost/i18n/locales/zh-Hant/portal.json | 3 ++- ghost/i18n/locales/zh/portal.json | 3 ++- ghost/magic-link/lib/MagicLink.js | 2 +- ghost/magic-link/test/index.test.js | 2 +- 66 files changed, 128 insertions(+), 66 deletions(-) diff --git a/apps/portal/src/utils/errors.js b/apps/portal/src/utils/errors.js index 93034e1cc98..a3ad55d0881 100644 --- a/apps/portal/src/utils/errors.js +++ b/apps/portal/src/utils/errors.js @@ -59,7 +59,8 @@ export function chooseBestErrorMessage(error, alreadyTranslatedDefaultMessage, t t('Too many different sign-in attempts, try again in {{number}} days'); t('Failed to send magic link email'); t('This site only accepts paid members.'); - t('This email domain is not accepted, try again with a different email address'); + t('Signups from this email provider are not allowed'); + t('Too many sign-up attempts, try again later'); } }; diff --git a/ghost/core/test/e2e-api/members/__snapshots__/send-magic-link.test.js.snap b/ghost/core/test/e2e-api/members/__snapshots__/send-magic-link.test.js.snap index 1305fc1af3d..2a27b32c1a8 100644 --- a/ghost/core/test/e2e-api/members/__snapshots__/send-magic-link.test.js.snap +++ b/ghost/core/test/e2e-api/members/__snapshots__/send-magic-link.test.js.snap @@ -118,7 +118,7 @@ Object { "ghostErrorCode": null, "help": null, "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, - "message": "This email domain is not accepted, try again with a different email address", + "message": "Signups from this email provider are not allowed", "property": null, "type": "BadRequestError", }, diff --git a/ghost/core/test/e2e-api/members/send-magic-link.test.js b/ghost/core/test/e2e-api/members/send-magic-link.test.js index 88eefe3869d..f10faaf718f 100644 --- a/ghost/core/test/e2e-api/members/send-magic-link.test.js +++ b/ghost/core/test/e2e-api/members/send-magic-link.test.js @@ -301,7 +301,7 @@ describe('sendMagicLink', function () { .matchBodySnapshot({ errors: [{ id: anyErrorId, - message: 'This email domain is not accepted, try again with a different email address' + message: 'Signups from this email provider are not allowed' }] }); }); diff --git a/ghost/i18n/locales/af/portal.json b/ghost/i18n/locales/af/portal.json index 90cc1959603..253ab1b4141 100644 --- a/ghost/i18n/locales/af/portal.json +++ b/ghost/i18n/locales/af/portal.json @@ -138,6 +138,7 @@ "Sign out": "Teken uit", "Sign up": "Registreer", "Signup error: Invalid link": "Aanmelding fout: Ongeldige skakel", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "Jammer, dit het nie gewerk nie.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Hierdie webwerf is slegs op uitnodiging, kontak die eienaar vir toegang.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Probeer gratis vir {{amount}} dae, dan {{originalPrice}}.", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "Ontsluit toegang tot alle nuusbriewe deur 'n betaalde intekenaar te word.", diff --git a/ghost/i18n/locales/ar/portal.json b/ghost/i18n/locales/ar/portal.json index e781b56499e..c4108b6d24a 100644 --- a/ghost/i18n/locales/ar/portal.json +++ b/ghost/i18n/locales/ar/portal.json @@ -138,6 +138,7 @@ "Sign out": "تسجيل الخروج", "Sign up": "إنشاء حساب", "Signup error: Invalid link": "خطأ في التسجيل: الرابط غير صالح", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": ".حدث خطأ ما، يرجى المحاولة مرة أخرى لاحقًا", "Sorry, no recommendations are available right now.": ".عذرًا، لا توجد توصيات متاحة حاليًا", "Sorry, that didn’t work.": ".عذرًا، لم ينجح ذلك", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": ".حدث خطأ أثناء الاشتراك، يرجى المحاولة مرة أخرى", "There was an error processing your payment. Please try again.": ".حدث خطأ أثناء معالجة دفعك، يرجى المحاولة مرة أخرى", "There was an error sending the email, please try again": ".حدث خطأ أثناء إرسال البريد الاكتروني، يرجى المحاولة مرة أخرى", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "هذا الموقع للمشتركين فقط، تواصل مع ادارة الموقع للحصول على اشتراك.", "This site is not accepting payments at the moment.": "هذا الموقع لا يقبل المدفوعات في الوقت الحالي", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": ".أيام{{number}} محاولات تسجيل الدخول متعددة جدًا، حاول مرة أخري بعد ", "Too many different sign-in attempts, try again in {{number}} hours": ".ساعة {{number}} محاولات تسجيل الدخول متعددة جدًا، حاول مرة أخري بعد ", "Too many different sign-in attempts, try again in {{number}} minutes": ".دقيقة {{number}} محاولات تسجيل الدخول متعددة جدًا، حاول مرة أخري بعد ", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": " .{{originalPrice}} يوما، ثم {{amount}} جرب مجانًا لمدة", "Unable to initiate checkout session": "غير قادر على بدء جلسة الدفع", "Unlock access to all newsletters by becoming a paid subscriber.": ".افتح الوصول إلي جميع النشرات الإخبارية من خلال الاشتراك المدفوع", diff --git a/ghost/i18n/locales/bg/portal.json b/ghost/i18n/locales/bg/portal.json index 3960c1dd0f2..168eef1693a 100644 --- a/ghost/i18n/locales/bg/portal.json +++ b/ghost/i18n/locales/bg/portal.json @@ -138,6 +138,7 @@ "Sign out": "Изход", "Sign up": "Регистриране", "Signup error: Invalid link": "Грешка при влизане: Невалиден линк", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "Нещо се обърка, опитайте отново.", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "Жалко, така не става.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "Възникна грешка при продължаването на абонамента ви, опитайте отново.", "There was an error processing your payment. Please try again.": "Възникна грешка при обработката на вашето плащане. Моля, опитайте отново.", "There was an error sending the email, please try again": "Възникна грешка при изпращане на имейл, опитайте отново", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Сайтът е само с покани. Свържете се със собственика за да получите достъп.", "This site is not accepting payments at the moment.": "В момента сайтът не приема плащания.", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "Твърде много различни опити за влизане, опитайте отново след {{number}} дни", "Too many different sign-in attempts, try again in {{number}} hours": "Твърде много различни опити за влизане, опитайте отново след {{number}} часа", "Too many different sign-in attempts, try again in {{number}} minutes": "Твърде много различни опити за влизане, опитайте отново след {{number}} минути", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Тествайте безплатно за {{amount}} дни, след това {{originalPrice}}.", "Unable to initiate checkout session": "Невъзможност за започване на сесия за плащане", "Unlock access to all newsletters by becoming a paid subscriber.": "Отключете достъпа до всички бюлетини, като станете платен абонат.", diff --git a/ghost/i18n/locales/bn/portal.json b/ghost/i18n/locales/bn/portal.json index a89cfc89682..2e19743fceb 100644 --- a/ghost/i18n/locales/bn/portal.json +++ b/ghost/i18n/locales/bn/portal.json @@ -138,6 +138,7 @@ "Sign out": "সাইন আউট করুন", "Sign up": "সাইন আপ করুন", "Signup error: Invalid link": "সাইন আপ ত্রুটি: অবৈধ লিঙ্ক", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "দুঃখিত, এটি কাজ করেনি।", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "এই সাইটটি কেবল আমন্ত্রণের মাধ্যমে, প্রবেশাধিকার পেতে মালিকের সাথে যোগাযোগ করুন।", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "{{amount}} দিনের জন্য ফ্রি চেষ্টা করুন, তারপর {{originalPrice}}।", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "পেইড সাবস্ক্রাইবার হয়ে সমস্ত নিউজলেটারে প্রবেশাধিকার আনলক করুন।", diff --git a/ghost/i18n/locales/bs/portal.json b/ghost/i18n/locales/bs/portal.json index 0d2da7661ca..f6eb714e94e 100644 --- a/ghost/i18n/locales/bs/portal.json +++ b/ghost/i18n/locales/bs/portal.json @@ -138,6 +138,7 @@ "Sign out": "Odjavi se", "Sign up": "Registracija", "Signup error: Invalid link": "Greška pri prijavi: Neispravan link", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "Žao nam je, to nije uspjelo.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Ova je stranica samo na poziv, kontaktiraj vlasnika za pristup.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Isprobaj besplatno {{amount}} dana, a zatim plati {{originalPrice}}.", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "Otključaj pristup svim newsletterima tako što ćete postati plaćeni član.", diff --git a/ghost/i18n/locales/ca/portal.json b/ghost/i18n/locales/ca/portal.json index 16f7031facd..9e8ba84060f 100644 --- a/ghost/i18n/locales/ca/portal.json +++ b/ghost/i18n/locales/ca/portal.json @@ -138,6 +138,7 @@ "Sign out": "Finalitza la sessió", "Sign up": "Registrar-se", "Signup error: Invalid link": "Error de registre: Enllaç no vàlid", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "Em sap greu, pero no ha funcionat.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Aquest llog és només per invitació, contacta amb el propietari per obtenir accés.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Prova gratis durant {{amount}} dies i després {{originalPrice}}.", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "Desbloqueja l'accés a tots els butlletins de notícies fent-te un subscriptor de pagament.", diff --git a/ghost/i18n/locales/context.json b/ghost/i18n/locales/context.json index 9d845830c9c..efe8d12d2fb 100644 --- a/ghost/i18n/locales/context.json +++ b/ghost/i18n/locales/context.json @@ -202,6 +202,7 @@ "Sign up": "A button to sign up", "Sign up now": "Button text to sign up in order to post a comment", "Signup error: Invalid link": "Notification text when an invalid / expired signup link is used", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "", "Something went wrong, please try again.": "Error message when subscribing to a newsletter fails in the signup form embed", "Sorry, no recommendations are available right now.": "", @@ -242,7 +243,6 @@ "This comment has been hidden.": "Text for a comment thas was hidden", "This comment has been removed.": "Text for a comment thas was removed", "This email address will not be used.": "This is in the footer of signup verification emails, and comes right after 'If you did not make this request, you can simply delete this message.'", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "A message on the member login screen indicating that a site is not-open to public signups", "This site is not accepting payments at the moment.": "An error message shown when a tips or donations link is opened but the site has donations disabled", "This site only accepts paid members.": "", @@ -254,6 +254,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "A label for an offer with a free trial", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "A message to encourage members to upgrade to a paid subscription", diff --git a/ghost/i18n/locales/cs/portal.json b/ghost/i18n/locales/cs/portal.json index f4a806b1607..eef5865cdfe 100644 --- a/ghost/i18n/locales/cs/portal.json +++ b/ghost/i18n/locales/cs/portal.json @@ -138,6 +138,7 @@ "Sign out": "Odhlásit se", "Sign up": "Registrovat se", "Signup error: Invalid link": "Chyba registrace: Neplatný odkaz", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "Něco se pokazilo, zkuste to prosím později.", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "Omlouváme se, to nefungovalo.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "Při zpracování vaší platby došlo k chybě. Zkuste to prosím znovu.", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Tento web je pouze pro pozvané, kontaktujte provozovatele pro přístup.", "This site is not accepting payments at the moment.": "Tento web momentálně nepřijímá platby.", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Vyzkoušejte zdarma na {{amount}} dní, poté {{originalPrice}}.", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "Odemkněte přístup ke všem newsletterům tím, že se stanete placeným odběratelem.", diff --git a/ghost/i18n/locales/da/portal.json b/ghost/i18n/locales/da/portal.json index 03c1eb8c788..6c0dbe0ac54 100644 --- a/ghost/i18n/locales/da/portal.json +++ b/ghost/i18n/locales/da/portal.json @@ -138,6 +138,7 @@ "Sign out": "Log ud", "Sign up": "Bliv medlem", "Signup error: Invalid link": "Tilmeldingsfejl: Ugyldigt link", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "Noget gik galt, prøv venligst igen senere.", "Sorry, no recommendations are available right now.": "Beklager, der er ingen anbefalinger tilgængelige lige nu.", "Sorry, that didn’t work.": "Beklager, det virkede ikke.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "Der opstod en fejl under forlængelsen af dit abonnement, prøv venligst igen.", "There was an error processing your payment. Please try again.": "Der opstod en fejl under behandlingen af din betaling. Prøv venligst igen.", "There was an error sending the email, please try again": "Der opstod en fejl under afsendelse af e-mailen, prøv venligst igen.", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Denne sider kræver at du skal være inviteret. Kontakt ejeres for at få adgang.", "This site is not accepting payments at the moment.": "Denne side accepterer ikke betalinger i øjeblikket.", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "For mange forskellige log-in forsøg, prøv igen om {{number}} dage.", "Too many different sign-in attempts, try again in {{number}} hours": "For mange forskellige log-in forsøg, prøv igen om {{number}} timer.", "Too many different sign-in attempts, try again in {{number}} minutes": "For mange forskellige log-in forsøg, prøv igen om {{number}} minutter.", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Prøv gratis i {{amount}} dage, derefter {{original Price}}.", "Unable to initiate checkout session": "Kan ikke starte betalings-session", "Unlock access to all newsletters by becoming a paid subscriber.": "Lås op for adgang til alle nyhedsbreve ved at blive en betalingsabonnent.", diff --git a/ghost/i18n/locales/de-CH/portal.json b/ghost/i18n/locales/de-CH/portal.json index 03895b4d89d..e871df48fbe 100644 --- a/ghost/i18n/locales/de-CH/portal.json +++ b/ghost/i18n/locales/de-CH/portal.json @@ -138,6 +138,7 @@ "Sign out": "Abmelden", "Sign up": "Registrieren", "Signup error: Invalid link": "Fehler bei der Registrierung: Ungültiger Link.", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "Entschuldigen Sie, das hat leider nicht funktioniert.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Der Zugang zu diesem Inhalt ist eingeschränkt. Bitte kontaktieren Sie uns, wenn Sie Zugang wünschen.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Probieren Sie uns für {{amount}} Tage kostenlos aus. Danach folgt ein Abo zum Preis von {{originalPrice}}.", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "Schalten Sie mit einem Abo den Zugang zu allen Newslettern frei.", diff --git a/ghost/i18n/locales/de/portal.json b/ghost/i18n/locales/de/portal.json index 7fe5353ff08..57b1828f9eb 100644 --- a/ghost/i18n/locales/de/portal.json +++ b/ghost/i18n/locales/de/portal.json @@ -138,6 +138,7 @@ "Sign out": "Abmelden", "Sign up": "Registrieren", "Signup error: Invalid link": "Fehler bei der Registrierung: Ungültiger Link", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "Etwas ist schief gelaufen, bitte versuche es später noch einmal.", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "Entschuldige, das hat nicht funktioniert.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "Beim Erneuern deines Abonnements ist ein Fehler aufgetreten. Bitte versuche es erneut.", "There was an error processing your payment. Please try again.": "Bei der Verarbeitung deiner Zahlung gab es einen Fehler. Bitte versuche es noch einmal.", "There was an error sending the email, please try again": "Beim Versand der E-Mail ist ein Fehler aufgetreten. Bitte versuche es erneut.", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Für diese Seite benötigst du eine Einladung. Bitte kontaktiere den Inhaber.", "This site is not accepting payments at the moment.": "Diese Website nimmt zur Zeit keine Zahlungen entgegen.", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "Zu viele verschiedene Anmeldeversuche. Versuche es in {{number}} Tagen erneut.", "Too many different sign-in attempts, try again in {{number}} hours": "Zu viele verschiedene Anmeldeversuche. Versuche es in {{number}} Stunden erneut.", "Too many different sign-in attempts, try again in {{number}} minutes": "Zu viele verschiedene Anmeldeversuche. Versuche es in {{number}} Minuten erneut.", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Kostenfreier Testzugang für {{amount}} Tage, danach {{originalPrice}}.", "Unable to initiate checkout session": "Zahlungsabwicklung konnte nicht gestartet werden", "Unlock access to all newsletters by becoming a paid subscriber.": "Schalte den Zugang zu allen Newslettern frei, indem du zahlende/r Abonnent*in wirst.", diff --git a/ghost/i18n/locales/el/portal.json b/ghost/i18n/locales/el/portal.json index 98a6143e0b4..882755e96ee 100644 --- a/ghost/i18n/locales/el/portal.json +++ b/ghost/i18n/locales/el/portal.json @@ -138,6 +138,7 @@ "Sign out": "Αποσύνδεση", "Sign up": "Εγγραφή", "Signup error: Invalid link": "Σφάλμα εγγραφής: Μη έγκυρος σύνδεσμος", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "Συγγνώμη, αυτό δεν λειτούργησε.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Αυτός ο ιστότοπος είναι μόνο με πρόσκληση, επικοινωνήστε με τον ιδιοκτήτη για πρόσβαση.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Δοκιμάστε δωρεάν για {{amount}} ημέρες, μετά {{originalPrice}}.", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "Ξεκλειδώστε την πρόσβαση σε όλα τα ενημερωτικά δελτία, ενεργοποιόντας την premium συνδρομή.", diff --git a/ghost/i18n/locales/en/portal.json b/ghost/i18n/locales/en/portal.json index 508f16c3466..3d23a769b02 100644 --- a/ghost/i18n/locales/en/portal.json +++ b/ghost/i18n/locales/en/portal.json @@ -138,6 +138,7 @@ "Sign out": "", "Sign up": "", "Signup error: Invalid link": "", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "", diff --git a/ghost/i18n/locales/eo/portal.json b/ghost/i18n/locales/eo/portal.json index 7f5724bdf9a..80d6099ceaa 100644 --- a/ghost/i18n/locales/eo/portal.json +++ b/ghost/i18n/locales/eo/portal.json @@ -138,6 +138,7 @@ "Sign out": "", "Sign up": "Aliĝu", "Signup error: Invalid link": "", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Ĉi tiu retejo estas nur por invitiĝuloj, kontaktu la proprietulo por alireblo.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "", diff --git a/ghost/i18n/locales/es/portal.json b/ghost/i18n/locales/es/portal.json index 0dd42ceae6f..02d4053f782 100644 --- a/ghost/i18n/locales/es/portal.json +++ b/ghost/i18n/locales/es/portal.json @@ -138,6 +138,7 @@ "Sign out": "Cerrar sesión", "Sign up": "Registrarse", "Signup error: Invalid link": "Error de registro: Enlace inválido", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "Lo siento, eso no funcionó.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "Hubo un error en continuar la suscripción, inténtalo de nuevo por favor.", "There was an error processing your payment. Please try again.": "Hubo un error procesando tu pago. Intentalo de nuevvo por favor.", "There was an error sending the email, please try again": "Hubo un error enviando el correo electrónico, intentalo de nuevo por favor.", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Este sitio es solo por invitación, contacta al propietario para obtener acceso.", "This site is not accepting payments at the moment.": "Este sitio no acepta pagos en este momento.", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "Demasiados intentos de iniciar sesión, intentalo de nuevo en {{number}} días", "Too many different sign-in attempts, try again in {{number}} hours": "Demasiados intentos de iniciar sesión, intentalo de nuevo en {{number}} horas", "Too many different sign-in attempts, try again in {{number}} minutes": "Demasiados intentos de iniciar sesión, intentalo de nuevo en {{number}} minutos", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Prueba gratis por {{amount}} dias, luego {{originalPrice}}.", "Unable to initiate checkout session": "No se pudo iniciar la sesión de pago", "Unlock access to all newsletters by becoming a paid subscriber.": "Desbloquea el acceso a todos los boletines convirtiéndote en un suscriptor pago.", diff --git a/ghost/i18n/locales/et/portal.json b/ghost/i18n/locales/et/portal.json index 5d70c09a7db..09f1d4a4e0c 100644 --- a/ghost/i18n/locales/et/portal.json +++ b/ghost/i18n/locales/et/portal.json @@ -138,6 +138,7 @@ "Sign out": "Logi välja", "Sign up": "Registreeru", "Signup error: Invalid link": "Registreerimise viga: Vigane link", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "Midagi läks valesti, palun proovige hiljem uuesti.", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "See sait on ainult kutsetega, juurdepääsu saamiseks võtke ühendust omanikuga.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Proovige tasuta {{amount}} päeva, seejärel {{originalPrice}}.", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "Avage juurdepääs kõigile uudiskirjadele, hakates tasuliseks tellijaks.", diff --git a/ghost/i18n/locales/fa/portal.json b/ghost/i18n/locales/fa/portal.json index c8d227e1b70..a3a894cc097 100644 --- a/ghost/i18n/locales/fa/portal.json +++ b/ghost/i18n/locales/fa/portal.json @@ -138,6 +138,7 @@ "Sign out": "بیرون رفتن", "Sign up": "ثبت نام", "Signup error: Invalid link": "خطای ثبت نام: پیوند معتبر نیست", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "پوزش می\u200cخواهیم، آن کار انجام نشد.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "دسترسی به این وب\u200cسایت نیازمند دعوت\u200cنامه است، با مالک آن برای دریافت دسترسی تماس بگیرید.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "برای {{amount}} روز به صورت رایگان امتحان کنید، سپس با قیمت {{originalPrice}}.", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "با دریافت اشتراک پولی به شما دسترسی به تمامی خبرنامه\u200cها داده می\u200cشود.", diff --git a/ghost/i18n/locales/fi/portal.json b/ghost/i18n/locales/fi/portal.json index a7714935494..818edb3e33e 100644 --- a/ghost/i18n/locales/fi/portal.json +++ b/ghost/i18n/locales/fi/portal.json @@ -138,6 +138,7 @@ "Sign out": "Kirjaudu ulos", "Sign up": "Rekisteröidy", "Signup error: Invalid link": "Virhe rekisteröinnissä: Linkki ei toimi", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "Anteeksi, tämä ei onnistunut", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Tämä sivu on vain kutsutuille, ota yhteyttä omistajaan saadaksesi pääsyoikeuden.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Kokeile ilmaiseksi {{amount}} päivää, sen jälkeen hinta on {{originalPrice}}.", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "Avaa pääsy kaikkiin uutiskirjeisiin maksullisella tilauksella.", diff --git a/ghost/i18n/locales/fr/portal.json b/ghost/i18n/locales/fr/portal.json index 21e6f2b607e..6898beb1078 100644 --- a/ghost/i18n/locales/fr/portal.json +++ b/ghost/i18n/locales/fr/portal.json @@ -138,6 +138,7 @@ "Sign out": "Se déconnecter", "Sign up": "S’inscrire", "Signup error: Invalid link": "Erreur lors de l'inscription : le lien est invalide", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "Quelque chose s'est mal passé, veuillez réessayer plus tard.", "Sorry, no recommendations are available right now.": "Désolé, aucune recommandation n'est disponible pour le moment.", "Sorry, that didn’t work.": "Désolé, cela n'a pas fonctionné.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "Une erreur s'est produite lors de la prolongation de votre abonnement, veuillez réessayer.", "There was an error processing your payment. Please try again.": "Une erreur s'est produite lors du traitement de votre paiement. Veuillez réessayer.", "There was an error sending the email, please try again": "Une erreur s'est produite lors de l'envoi de l'e-mail, veuillez réessayer.", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Ce site est réservé aux invités. Veuillez écrire au propriétaire pour en demander l'accès.", "This site is not accepting payments at the moment.": "Ce site n'accepte pas les paiements pour le moment.", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "Trop de tentatives de connexion différentes, réessayez dans {{number}} jours", "Too many different sign-in attempts, try again in {{number}} hours": "Trop de tentatives de connexion différentes, réessayez dans {{number}} heures", "Too many different sign-in attempts, try again in {{number}} minutes": "Trop de tentatives de connexion différentes, réessayez dans {{number}} minutes", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Essayez gratuitement pendant {{amount}} jours, puis {{originalPrice}}.", "Unable to initiate checkout session": "Impossible d'initier une session de paiement", "Unlock access to all newsletters by becoming a paid subscriber.": "Débloquez l'accès à toutes les newsletters en souscrivant un abonnement payant.", diff --git a/ghost/i18n/locales/gd/portal.json b/ghost/i18n/locales/gd/portal.json index 007908c4bab..880ef6d5834 100644 --- a/ghost/i18n/locales/gd/portal.json +++ b/ghost/i18n/locales/gd/portal.json @@ -138,6 +138,7 @@ "Sign out": "Clàraich a-mach", "Sign up": "Clàraich", "Signup error: Invalid link": "Mearachd: Ceangal mì-dhligheach", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "Thachair mearachd, feuch a-rithist an ceann greis.", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "Duilich, cha do dh’obraich sin.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "Thachair mearachd fhad 's a bhathar a' làimhseachadh a' phàighidh agad. Feuch a-rithist an ceann greis.", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Feumar cuireadh airson an làrach-lìn seo, leig fios dhan rianaire ma tha thu ag iarraidh cothrom-inntrigidh.", "This site is not accepting payments at the moment.": "Chan eil an làrach seo a' gabhail ri phàighidhean an-dràsta.", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "An-asgaidh airson {{amount}}l, agus {{originalPrice}} an dèidh sin ", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "Tig nad bhall phaighte gus cothrom fhaighinn air na cuairt-litrichean gu lèir.", diff --git a/ghost/i18n/locales/he/portal.json b/ghost/i18n/locales/he/portal.json index 19c9163cf73..f5664720632 100644 --- a/ghost/i18n/locales/he/portal.json +++ b/ghost/i18n/locales/he/portal.json @@ -138,6 +138,7 @@ "Sign out": "יציאה", "Sign up": "הרשמה", "Signup error: Invalid link": "שגיאת הרשמה: לינק לא תקין", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "משהו השתבש, נסו שוב מאוחר יותר.", "Sorry, no recommendations are available right now.": "מצטערים אך אין המלצות זמינות כעת.", "Sorry, that didn’t work.": "מצטערים, זה לא עבד", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "שגיאה בהמשך המנוי שלכם, נסו שוב.", "There was an error processing your payment. Please try again.": "שגיאה בעיבוד התשלום שלכם. נסו שוב.", "There was an error sending the email, please try again": "שגיאה בשליחת המייל, נסו שוב", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "אתר זה פתוח למוזמנים בלבד, פנו לבעל האתר לגישה.", "This site is not accepting payments at the moment.": "אתר זה לא מקבל תשלומים כרגע.", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "יותר מדי ניסיונות כניסה שונים, נסו שוב בעוד {{number}} ימים", "Too many different sign-in attempts, try again in {{number}} hours": "יותר מדי ניסיונות כניסה שונים, נסו שוב בעוד {{number}} שעות", "Too many different sign-in attempts, try again in {{number}} minutes": "יותר מדי ניסיונות כניסה שונים, נסו שוב בעוד {{number}} דקות", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "נסו בחינם למשך {{amount}} ימים, ואז {{originalPrice}}.", "Unable to initiate checkout session": "בעיה בהתחלת סשן קופה", "Unlock access to all newsletters by becoming a paid subscriber.": "פתחו גישה לכל הניוזלטרים על ידי הפכתכם למנוי בתשלום.", diff --git a/ghost/i18n/locales/hi/portal.json b/ghost/i18n/locales/hi/portal.json index c3cb361240e..10195c9a7f5 100644 --- a/ghost/i18n/locales/hi/portal.json +++ b/ghost/i18n/locales/hi/portal.json @@ -138,6 +138,7 @@ "Sign out": "साइन आउट करें", "Sign up": "साइन अप करें", "Signup error: Invalid link": "साइनअप त्रुटि: अमान्य लिंक", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "क्षमा करें, वह काम नहीं किया।", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "यह साइट केवल निमंत्रण द्वारा है, पहुँच के लिए मालिक से संपर्क करें।", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "{{amount}} दिनों के लिए मुफ्त प्रयास करें, फिर {{originalPrice}}।", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "एक सशुल्क सदस्य बनकर सभी न्यूज़लेटर्स तक पहुंच अनलॉक करें।", diff --git a/ghost/i18n/locales/hr/portal.json b/ghost/i18n/locales/hr/portal.json index 937fb337dd9..d7a413e2d71 100644 --- a/ghost/i18n/locales/hr/portal.json +++ b/ghost/i18n/locales/hr/portal.json @@ -138,6 +138,7 @@ "Sign out": "Odjava", "Sign up": "Registracija", "Signup error: Invalid link": "Greška prilikom prijave. Neispravan link", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "Žao nam je, to nije uspjelo.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Ove stranice su samo za članove, kontaktirajte vlasnika kako biste dobili pristup.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Probajte besplatno na {{amount}} dana, zatim {{originalPrice}}.", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "Otključajte pristup svim newsletterima plaćanjem pretplate.", diff --git a/ghost/i18n/locales/hu/portal.json b/ghost/i18n/locales/hu/portal.json index f304651bcbb..0eda99b161b 100644 --- a/ghost/i18n/locales/hu/portal.json +++ b/ghost/i18n/locales/hu/portal.json @@ -138,6 +138,7 @@ "Sign out": "Kijelentkezés", "Sign up": "Regisztráció", "Signup error: Invalid link": "Regisztrációs hiba: érvénytelen link", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "Sajnáljuk, ez nem működött.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "A website csak meghívóval látogatható. Meghívóért lépjen kapcsolatba az oldal tulajdonosával!", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Próbálja ki ingyen {{amount}} napig, utána {{originalPrice}}", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "Előfizetéssel hozzáférhet minden hírlevélhez!", diff --git a/ghost/i18n/locales/id/portal.json b/ghost/i18n/locales/id/portal.json index 5de578cae55..eb91e95a638 100644 --- a/ghost/i18n/locales/id/portal.json +++ b/ghost/i18n/locales/id/portal.json @@ -138,6 +138,7 @@ "Sign out": "Keluar", "Sign up": "Daftar", "Signup error: Invalid link": "Kesalahan pendaftaran: Tautan tidak valid", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "Terjadi kesalahan, silakan coba lagi nanti.", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "Maaf, itu tidak berhasil.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "Terjadi kesalahan saat melanjutkan langganan Anda, harap coba lagi.", "There was an error processing your payment. Please try again.": "Terjadi kesalahan saat memproses pembayaran Anda. Harap coba lagi.", "There was an error sending the email, please try again": "Terjadi kesalahan saat mengirim email, harap coba lagi", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Situs ini hanya untuk yang diundang, hubungi pemiliknya untuk mendapatkan akses.", "This site is not accepting payments at the moment.": "Situs ini tidak menerima pembayaran saat ini.", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "Terlalu banyak percobaan masuk, coba lagi dalam {{number}} hari", "Too many different sign-in attempts, try again in {{number}} hours": "Terlalu banyak percobaan masuk, coba lagi dalam {{number}} jam", "Too many different sign-in attempts, try again in {{number}} minutes": "Terlalu banyak percobaan masuk, coba lagi dalam {{number}} menit", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Coba gratis selama {{amount}} hari, kemudian {{originalPrice}}.", "Unable to initiate checkout session": "Tidak dapat memulai sesi checkout", "Unlock access to all newsletters by becoming a paid subscriber.": "Buka akses ke semua buletin dengan menjadi pelanggan berbayar.", diff --git a/ghost/i18n/locales/is/portal.json b/ghost/i18n/locales/is/portal.json index 9b5b2a17b8b..825efe7547e 100644 --- a/ghost/i18n/locales/is/portal.json +++ b/ghost/i18n/locales/is/portal.json @@ -138,6 +138,7 @@ "Sign out": "Útskráning", "Sign up": "Nýskráning", "Signup error: Invalid link": "Villa við nýskráningu: Ógildur hlekkur", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "Þetta virkaði því miður ekki.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Aðgangur krefst boðsmiða, hafið samband við eiganda síðunnar til að fá aðgang.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Prófaðu í {{amount}} daga án endurgjalds og síðan fyrir {{originalPrice}}", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "Fáðu aðgang að öllum fréttabréfum með því að gerast áskrifandi.", diff --git a/ghost/i18n/locales/it/portal.json b/ghost/i18n/locales/it/portal.json index 2e5738f4051..730c303fd24 100644 --- a/ghost/i18n/locales/it/portal.json +++ b/ghost/i18n/locales/it/portal.json @@ -138,6 +138,7 @@ "Sign out": "Esci", "Sign up": "Iscriviti", "Signup error: Invalid link": "Errore di accesso: link invalido", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "Qualcosa è andato storto, riprova più tardi.", "Sorry, no recommendations are available right now.": "Ci dispiace, al momento non ci sono consigli disponibili.", "Sorry, that didn’t work.": "Ci dispiace, non ha funzionato.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "C'è stato un errore nella continuazione del tuo abbonamento, riprova per favore.", "There was an error processing your payment. Please try again.": "C'è stato un errore durante l’elaborazione del tuo pagamento. Riprova per favore.", "There was an error sending the email, please try again": "C'è stato un errore nell'invio dell'e-mail, per favore riprova", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Questo sito è accessibile solo su invito, contatta il proprietario per poter accedere.", "This site is not accepting payments at the moment.": "Questo sito non accetta pagamenti al momento.", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "Troppi tentativi di accesso, riprova fra {{number}} giorni", "Too many different sign-in attempts, try again in {{number}} hours": "Troppi tentativi di accesso, riprova fra {{number}} ore", "Too many different sign-in attempts, try again in {{number}} minutes": "Troppi tentativi di accesso, riprova fra {{number}} minuti", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Prova gratis per {{amount}} giorni, poi {{originalPrice}}.", "Unable to initiate checkout session": "Impossibile iniziare il checkout", "Unlock access to all newsletters by becoming a paid subscriber.": "Abbonati per sbloccare l'accesso a tutte le newsletter.", diff --git a/ghost/i18n/locales/ja/portal.json b/ghost/i18n/locales/ja/portal.json index 9b3182cca72..e28a0fc58a2 100644 --- a/ghost/i18n/locales/ja/portal.json +++ b/ghost/i18n/locales/ja/portal.json @@ -138,6 +138,7 @@ "Sign out": "ログアウト", "Sign up": "新規登録", "Signup error: Invalid link": "エラー: 無効なリンク", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "申し訳ありませんが、うまくいきませんでした。", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "このサイトは招待制です。アクセスするにはオーナーに連絡してください。", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "{{amount}}日間無料でお試しください、その後は{{originalPrice}}です。", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "有料の購読者になることで、すべてのニュースレターへのアクセスが可能になります。", diff --git a/ghost/i18n/locales/ko/portal.json b/ghost/i18n/locales/ko/portal.json index 2fd55603b09..ba7b21427e1 100644 --- a/ghost/i18n/locales/ko/portal.json +++ b/ghost/i18n/locales/ko/portal.json @@ -138,6 +138,7 @@ "Sign out": "로그아웃", "Sign up": "가입", "Signup error: Invalid link": "가입 오류: 잘못된 링크", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "문제가 발생했어요. 나중에 다시 시도해 주세요.", "Sorry, no recommendations are available right now.": "죄송해요. 현재 추천할 만한 콘텐츠가 없어요.", "Sorry, that didn’t work.": "죄송해요. 작동하지 않았어요.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "구독 계속하기 중 오류가 발생했어요. 다시 시도해 주세요.", "There was an error processing your payment. Please try again.": "결제 처리 중 오류가 발생했어요. 다시 시도해 주세요.", "There was an error sending the email, please try again": "이메일 전송 중 오류가 발생했어요. 다시 시도해 주세요", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "위 사이트는 초대된 사용자만 사용이 가능해요. 접근을 위해서는 관리자에게 연락해 주세요.", "This site is not accepting payments at the moment.": "현재 이 사이트는 결제를 받지 않고 있어요.", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "너무 많은 로그인 시도를 하셨어요. {{number}}일 후에 다시 시도해 주세요", "Too many different sign-in attempts, try again in {{number}} hours": "너무 많은 로그인 시도를 하셨어요. {{number}}시간 후에 다시 시도해 주세요", "Too many different sign-in attempts, try again in {{number}} minutes": "너무 많은 로그인 시도를 하셨어요. {{number}}분 후에 다시 시도해 주세요", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "{{amount}}일 동안 무료로 사용한 후 {{originalPrice}}로 결제해 주세요.", "Unable to initiate checkout session": "결제 세션을 시작할 수 없어요", "Unlock access to all newsletters by becoming a paid subscriber.": "유료 구독자가 되어 모든 뉴스레터에 접근해 주세요.", diff --git a/ghost/i18n/locales/kz/portal.json b/ghost/i18n/locales/kz/portal.json index ce82ba45e3d..21571bf5716 100644 --- a/ghost/i18n/locales/kz/portal.json +++ b/ghost/i18n/locales/kz/portal.json @@ -138,6 +138,7 @@ "Sign out": "Шығу", "Sign up": "Тіркелу", "Signup error: Invalid link": "Тіркелу қатесі: Жарамсыз сілтеме", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "Өкінішті, бұдан ештеңе шықпады.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Бұл сайтқа тек шақырту бойынша кіруге болады, рұқсат алу үшін иесіне хабарласыңыз.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "{{amount}} күн тегін қолданып көріңіз, содан кейінгі бағасы {{originalPrice}}.", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "Ақылы түрде жазылу арқылы барлық ақпараттық бюллетеньдерге қол жеткізіңіз.", diff --git a/ghost/i18n/locales/lt/portal.json b/ghost/i18n/locales/lt/portal.json index e05538188dd..c3ab17cf9d7 100644 --- a/ghost/i18n/locales/lt/portal.json +++ b/ghost/i18n/locales/lt/portal.json @@ -138,6 +138,7 @@ "Sign out": "Atsijungti", "Sign up": "Registruotis", "Signup error: Invalid link": "Registracijos klaida: negaliojanti nuoroda", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "Atsiprašome, nepavyko.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Ši svetainė pasiekiama tik su pakvietimu, susisiekite su savininku dėl prieigos. ", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Išbandykite {{amount}} d. nemokamai, vėliau {{originalPrice}}.", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "Gaukite prieigą prie visų naujienlaiškių įsigiję mokamą prenumeratą.", diff --git a/ghost/i18n/locales/lv/portal.json b/ghost/i18n/locales/lv/portal.json index 190adb36072..b6dfdc82287 100644 --- a/ghost/i18n/locales/lv/portal.json +++ b/ghost/i18n/locales/lv/portal.json @@ -138,6 +138,7 @@ "Sign out": "Izrakstīties", "Sign up": "Pierakstīties", "Signup error: Invalid link": "Reģistrācijas kļūda: nederīga saite", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "Radās problēma. Lūdzu, vēlāk mēģiniet vēlreiz.", "Sorry, no recommendations are available right now.": "Diemžēl pašlaik ieteikumi nav pieejami.", "Sorry, that didn’t work.": "Atvainojiet, tas nedarbojās.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "Turpinot abonementu, radās kļūda. Lūdzu, mēģiniet vēlreiz.", "There was an error processing your payment. Please try again.": "Apstrādājot jūsu maksājumu, radās kļūda. Lūdzu, mēģiniet vēlreiz.", "There was an error sending the email, please try again": "Nosūtot e-pasta ziņojumu, radās kļūda. Lūdzu, mēģiniet vēlreiz", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Šī vietne ir paredzēta tikai ielūgumam. Lai iegūtu piekļuvi, sazinieties ar īpašnieku.", "This site is not accepting payments at the moment.": "Šī vietne pašlaik nepieņem maksājumus.", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "Pārāk daudz dažādu pierakstīšanās mēģinājumu, mēģiniet vēlreiz pēc {{number}}\u00a0dienām", "Too many different sign-in attempts, try again in {{number}} hours": "Pārāk daudz dažādu pierakstīšanās mēģinājumu. Mēģiniet vēlreiz pēc {{number}}\u00a0stundām", "Too many different sign-in attempts, try again in {{number}} minutes": "Pārāk daudz dažādu pierakstīšanās mēģinājumu, mēģiniet vēlreiz pēc {{number}}\u00a0minūtēm", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Mēģiniet bez maksas {{amount}}\u00a0dienas, pēc tam {{original Price}}.", "Unable to initiate checkout session": "Nevar uzsākt norēķināšanās sesiju", "Unlock access to all newsletters by becoming a paid subscriber.": "Atbloķējiet piekļuvi visiem biļeteniem, kļūstot par maksas abonentu.", diff --git a/ghost/i18n/locales/mk/portal.json b/ghost/i18n/locales/mk/portal.json index 3451e7300d6..4b38c32ee05 100644 --- a/ghost/i18n/locales/mk/portal.json +++ b/ghost/i18n/locales/mk/portal.json @@ -138,6 +138,7 @@ "Sign out": "Одјавете се", "Sign up": "Регистрирајте се", "Signup error: Invalid link": "Грешка при регистрација: Невалиден линк", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "Се извинуваме, тоа не проработи.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Оваа страница е достапна само со покана. За пристап контактирајте го сопственикот.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Пробајте бесплатно за {{amount}} денови, потоа по цена од {{originalPrice}}.", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "Добијте пристап до сите билтени преку платена претплата.", diff --git a/ghost/i18n/locales/mn/portal.json b/ghost/i18n/locales/mn/portal.json index 97d23717539..6737f41fde5 100644 --- a/ghost/i18n/locales/mn/portal.json +++ b/ghost/i18n/locales/mn/portal.json @@ -138,6 +138,7 @@ "Sign out": "", "Sign up": "Бүртгүүлэх", "Signup error: Invalid link": "", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Энэхүү сайт руу зөвхөн урилгаар нэвтрэх боломжтой тул та админд нь хандана уу.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "", diff --git a/ghost/i18n/locales/ms/portal.json b/ghost/i18n/locales/ms/portal.json index bd2111dea79..313d902d2d7 100644 --- a/ghost/i18n/locales/ms/portal.json +++ b/ghost/i18n/locales/ms/portal.json @@ -138,6 +138,7 @@ "Sign out": "Log keluar", "Sign up": "Daftar", "Signup error: Invalid link": "Ralat daftar: Pautan tidak sah", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "Maaf, itu tidak berfungsi.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Laman web ini hanya untuk jemputan, hubungi pemilik untuk akses.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Cuba secara percuma selama {{amount}} hari, kemudian {{originalPrice}}.", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "Buka akses ke semua newsletter dengan menjadi pelanggan berbayar.", diff --git a/ghost/i18n/locales/ne/portal.json b/ghost/i18n/locales/ne/portal.json index 508f16c3466..3d23a769b02 100644 --- a/ghost/i18n/locales/ne/portal.json +++ b/ghost/i18n/locales/ne/portal.json @@ -138,6 +138,7 @@ "Sign out": "", "Sign up": "", "Signup error: Invalid link": "", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "", diff --git a/ghost/i18n/locales/nl/portal.json b/ghost/i18n/locales/nl/portal.json index e16b198b621..37a12fc49eb 100644 --- a/ghost/i18n/locales/nl/portal.json +++ b/ghost/i18n/locales/nl/portal.json @@ -138,6 +138,7 @@ "Sign out": "Uitloggen", "Sign up": "Registreren", "Signup error: Invalid link": "Registratiefout: Ongeldige link", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "Er ging iets mis, probeer het later opnieuw.", "Sorry, no recommendations are available right now.": "Sorry, er zijn momenteel geen aanbevelingen beschikbaar.", "Sorry, that didn’t work.": "Sorry, dat werkte niet.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "Er was een fout bij het voortzetten van je abonnement, probeer het opnieuw.", "There was an error processing your payment. Please try again.": "Er was een fout bij het verwerken van je betaling, probeer het opnieuw.", "There was an error sending the email, please try again": "Er was een fout bij het verzenden van de e-mail, probeer het opnieuw", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Deze site is alleen toegankelijk op uitnodiging, neem contact op met de eigenaar.", "This site is not accepting payments at the moment.": "Deze site accepteert momenteel geen betalingen.", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "Te veel verschillende inlogpogingen, probeer het opnieuw over {{number}} dagen.", "Too many different sign-in attempts, try again in {{number}} hours": "Te veel verschillende inlogpogingen, probeer het opnieuw over {{number}} uur.", "Too many different sign-in attempts, try again in {{number}} minutes": "Te veel verschillende inlogpogingen, probeer het opnieuw over {{number}} minuten.", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Probeer gratis voor {{amount}} dagen, daarna {{originalPrice}}.", "Unable to initiate checkout session": "Kan afrekeningssessie niet starten", "Unlock access to all newsletters by becoming a paid subscriber.": "Ontgrendel toegang tot alle nieuwsbrieven door een betalende abonnee te worden.", diff --git a/ghost/i18n/locales/nn/portal.json b/ghost/i18n/locales/nn/portal.json index d3c06a17261..62eff84c477 100644 --- a/ghost/i18n/locales/nn/portal.json +++ b/ghost/i18n/locales/nn/portal.json @@ -138,6 +138,7 @@ "Sign out": "Logg ut", "Sign up": "Registrer deg", "Signup error: Invalid link": "Registreringsfeil: Ugyldig lenke", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "Beklagar, det verka ikkje.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Denne sida er kun for inviterte, ta kontakt med eigaren for tilgang.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Prøv gratis i {{amount}} dagar, deretter {{originalPrice}}.", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "Få tilgang til alle nyheitsbreva med å bli ein betalande abonnent.", diff --git a/ghost/i18n/locales/no/portal.json b/ghost/i18n/locales/no/portal.json index 55f85c8d646..07dc34a9251 100644 --- a/ghost/i18n/locales/no/portal.json +++ b/ghost/i18n/locales/no/portal.json @@ -138,6 +138,7 @@ "Sign out": "Logg ut", "Sign up": "Opprett bruker", "Signup error: Invalid link": "Feil ved registrering: Ugyldig lenke", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "Noe gikk galt. Prøv igjen senere.", "Sorry, no recommendations are available right now.": "Beklager, ingen anbefalinger er tilgjengelige for øyeblikket.", "Sorry, that didn’t work.": "Beklager, det fungerte ikke", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "En feil oppstod ved fornyelse av abonnementet, vennligst prøv igjen.", "There was an error processing your payment. Please try again.": "Det oppsto en feil under behandling av betalingen din. Vennligst prøv igjen.", "There was an error sending the email, please try again": "En feil oppstod ved sending av e-posten, vennligst prøv igjen", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Denne nettsiden er kun for inviterte. Kontakt eieren for invitasjon.", "This site is not accepting payments at the moment.": "Denne nettsiden godtar ikke betalinger for øyeblikket.", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "For mange påloggingsforsøk, prøv igjen om {{number}} dager.", "Too many different sign-in attempts, try again in {{number}} hours": "For mange påloggingsforsøk, prøv igjen om {{number}} timer.", "Too many different sign-in attempts, try again in {{number}} minutes": "For mange påloggingsforsøk, prøv igjen om {{number}} minutter.", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Prøv gratis i {{amount}} dager, deretter {{originalPrice}}.", "Unable to initiate checkout session": "Kunne ikke starte utsjekkingssesjon", "Unlock access to all newsletters by becoming a paid subscriber.": "Få tilgang til alle nyhetsbrev ved å bli betalende abonnent.", diff --git a/ghost/i18n/locales/pl/portal.json b/ghost/i18n/locales/pl/portal.json index 518af111ec0..ac4dbb3d05e 100644 --- a/ghost/i18n/locales/pl/portal.json +++ b/ghost/i18n/locales/pl/portal.json @@ -138,6 +138,7 @@ "Sign out": "Wyloguj się", "Sign up": "Zarejestruj się", "Signup error: Invalid link": "Błąd rejestracji: Nieprawidłowy link", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "Przepraszamy, to nie zadziałało.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Ta strona posiada zamknięty dostęp. Skontaktuj się z właścicielem, aby uzyskać dostęp.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Wypróbuj za darmo przez {{amount}} dni, później {{originalPrice}}.", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "Zostań płatnym subskrybentem i odblokuj dostęp do wszystkich biuletynów.", diff --git a/ghost/i18n/locales/pt-BR/portal.json b/ghost/i18n/locales/pt-BR/portal.json index 94cbdeb8c25..21efbb24567 100644 --- a/ghost/i18n/locales/pt-BR/portal.json +++ b/ghost/i18n/locales/pt-BR/portal.json @@ -138,6 +138,7 @@ "Sign out": "Sair", "Sign up": "Cadastrar", "Signup error: Invalid link": "Erro de inscrição: link inválido", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "Algo deu errado, tente novamente mais tarde.", "Sorry, no recommendations are available right now.": "Desculpe, não há recomendações disponíveis no momento.", "Sorry, that didn’t work.": "Desculpe, isso não funcionou.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "Houve um erro ao continuar sua assinatura, por favor, tente novamente.", "There was an error processing your payment. Please try again.": "Houve um erro ao processar seu pagamento. Por favor, tente novamente.", "There was an error sending the email, please try again": "Houve um erro ao enviar o e-mail, por favor, tente novamente.", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Este site é apenas para convidados. Contate o proprietário para obter acesso.", "This site is not accepting payments at the moment.": "Este site não está aceitando pagamentos no momento.", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "Muitas tentativas de login diferentes, tente novamente em {{number}} dias.", "Too many different sign-in attempts, try again in {{number}} hours": "Muitas tentativas de login diferentes, tente novamente em {{number}} horas.", "Too many different sign-in attempts, try again in {{number}} minutes": "Muitas tentativas de login diferentes, tente novamente em {{number}} minutos.", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Experimente grátis por {{amount}} dias, depois {{originalPrice}}.", "Unable to initiate checkout session": "Não foi possível iniciar a sessão de pagamento.", "Unlock access to all newsletters by becoming a paid subscriber.": "Desbloqueie o acesso a todas as newsletters se tornando um assinante pago.", diff --git a/ghost/i18n/locales/pt/portal.json b/ghost/i18n/locales/pt/portal.json index 8d776679170..8dff1af3cc4 100644 --- a/ghost/i18n/locales/pt/portal.json +++ b/ghost/i18n/locales/pt/portal.json @@ -138,6 +138,7 @@ "Sign out": "Sair", "Sign up": "Registar", "Signup error: Invalid link": "Erro de inscrição: ligação inválida", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "Temos um erro em mãos, tente por favor mais tarde.", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "Desculpe, mas isso não funcionou.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "Houve um problema ao processar o seu pagamento. Tente novamente por favor.", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "O acesso a este site é feito apenas por convite. Entre em contacto com o proprietário para obter acesso.", "This site is not accepting payments at the moment.": "Este site não está a aceitar pagamentos de momento", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Experimente grátis por {{amount}} dias, depois {{originalPrice}}.", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "Desbloqueie o acesso a todas as newsletters tornando-se um assinante pago.", diff --git a/ghost/i18n/locales/ro/portal.json b/ghost/i18n/locales/ro/portal.json index dce5d968ca7..979cb438c13 100644 --- a/ghost/i18n/locales/ro/portal.json +++ b/ghost/i18n/locales/ro/portal.json @@ -138,6 +138,7 @@ "Sign out": "Deconectare", "Sign up": "Înregistrare", "Signup error: Invalid link": "Eroare la înregistrare: Link invalid", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "Ne pare rău, nu a funcționat.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Acest site este disponibil doar pe bază de invitație, contactează proprietarul pentru acces.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "Prea multe încercări de autentificare diferite, încearcă din nou în {{number}} zile.", "Too many different sign-in attempts, try again in {{number}} hours": "Prea multe încercări de autentificare diferite, încearcă din nou în {{number}} ore.", "Too many different sign-in attempts, try again in {{number}} minutes": "Prea multe încercări de autentificare diferite, încearcă din nou în {{number}} minute.", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Încearcă gratuit pentru {{amount}} zile, apoi {{originalPrice}}.", "Unable to initiate checkout session": "Nu pot iniția sesiunea de plată.", "Unlock access to all newsletters by becoming a paid subscriber.": "Deblochează accesul la toate buletinele informative devenind un abonat plătit.", diff --git a/ghost/i18n/locales/ru/portal.json b/ghost/i18n/locales/ru/portal.json index c9525ab915e..4f9150710ea 100644 --- a/ghost/i18n/locales/ru/portal.json +++ b/ghost/i18n/locales/ru/portal.json @@ -138,6 +138,7 @@ "Sign out": "Выйти", "Sign up": "Зарегистрироваться", "Signup error: Invalid link": "Ошибка регистрации: Неверная или просроченная ссылка", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "Что-то пошло не так, попробуйте ещё раз позже.", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "Извините, это не сработало.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "Произошла ошибка при обработке вашего платежа. Попробуйте ещё раз.", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Доступ к материалам этого сайта возможен только по приглашению. Для получения доступа свяжитесь с владельцем сайта.", "This site is not accepting payments at the moment.": "В данный момент сайт не принимает платежи.", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Попробуйте бесплатно в течение {{amount}} дня(ей), затем за {{original Price}}.", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "Получите доступ ко всем рассылкам, оформив платную подписку.", diff --git a/ghost/i18n/locales/si/portal.json b/ghost/i18n/locales/si/portal.json index 4ccedfa27b9..d815680b02a 100644 --- a/ghost/i18n/locales/si/portal.json +++ b/ghost/i18n/locales/si/portal.json @@ -138,6 +138,7 @@ "Sign out": "Sign out වෙන්න", "Sign up": "ලියාපදිංචි වෙන්න", "Signup error: Invalid link": "Signup වීම අසාර්ථකයි: වැරදි link එකකි", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "සමාවෙන්න, නමුත් එය සාර්ථක වූයේ නැත.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "මෙම වෙබ් අඩවිය ආරාධිතයන් සඳහා පමණි, ප්\u200dරවේශ වීම සඳහා හිමිකරු අමතන්න.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "දින {{amount}}ක් නොමිලයේ භාවිතා කරන්න, ඉන් පසුව {{originalPrice}}ක් පමණි.", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "Paid subscriber කෙනෙකු වීම හරහා සියළුම newsletters වලට access ලබාගන්න.", diff --git a/ghost/i18n/locales/sk/portal.json b/ghost/i18n/locales/sk/portal.json index 36486b2eb3b..da435dfc834 100644 --- a/ghost/i18n/locales/sk/portal.json +++ b/ghost/i18n/locales/sk/portal.json @@ -138,6 +138,7 @@ "Sign out": "Odhlásiť", "Sign up": "Registrovať", "Signup error: Invalid link": "Chyba registrácie: Neplatný odkaz", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "Prepáčte, toto nezafungovalo.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Táto stránka je iba pre pozvaných úžívateľov, kontaktujte vlastníka stránky.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Vyskúšajte zadarmo na {{amount}} dní, potom {{originalPrice}}", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "", diff --git a/ghost/i18n/locales/sl/portal.json b/ghost/i18n/locales/sl/portal.json index 9121d377990..43021afa793 100644 --- a/ghost/i18n/locales/sl/portal.json +++ b/ghost/i18n/locales/sl/portal.json @@ -138,6 +138,7 @@ "Sign out": "", "Sign up": "Registracija", "Signup error: Invalid link": "", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "To spletno mesto je dostopno samo s povabilom, obrnite se na lastnika.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "", diff --git a/ghost/i18n/locales/sq/portal.json b/ghost/i18n/locales/sq/portal.json index e42367d1446..91491e65032 100644 --- a/ghost/i18n/locales/sq/portal.json +++ b/ghost/i18n/locales/sq/portal.json @@ -138,6 +138,7 @@ "Sign out": "Dil", "Sign up": "Rregjistrohu", "Signup error: Invalid link": "Gabim ne rregjistrim: Link i pavlefshem", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "Na vjen keq, kjo nuk funksionoi.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Kjo faqe eshte vetem me ftesa, kontaktoni zoteruesin per akses.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Provo falas per {{amount}} dite, pastaj {{originalPrice}}.", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "Zhbllokoni aksesin per te gjitha buletinet duke u bere nje abonues me pagese.", diff --git a/ghost/i18n/locales/sr-Cyrl/portal.json b/ghost/i18n/locales/sr-Cyrl/portal.json index 858400556f2..6377cca66c0 100644 --- a/ghost/i18n/locales/sr-Cyrl/portal.json +++ b/ghost/i18n/locales/sr-Cyrl/portal.json @@ -138,6 +138,7 @@ "Sign out": "Одјавите се", "Sign up": "Пријавите се", "Signup error: Invalid link": "Грешка при пријави: Неважећи линк", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "Нешто је пошло наопако, молимо вас покушајте касније.", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "Извините, то није успело.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "Дошло је до грешке при обради ваше уплате. Молимо вас покушајте поново.", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Овај сајт је само на позив, контактирајте власника ради приступа.", "This site is not accepting payments at the moment.": "Овај сајт тренутно не прихвата уплате.", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Пробајте бесплатно за {{amount}} дана, након тога {{originalPrice}}.", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "Откључајте приступ свим билтенима тако што ћете постати плаћени претплатник.", diff --git a/ghost/i18n/locales/sr/portal.json b/ghost/i18n/locales/sr/portal.json index 586908dcbfa..7f8db176862 100644 --- a/ghost/i18n/locales/sr/portal.json +++ b/ghost/i18n/locales/sr/portal.json @@ -138,6 +138,7 @@ "Sign out": "", "Sign up": "Registracija", "Signup error: Invalid link": "", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Ovaj sajt je samo za članove, kontaktirajte vlasnika kako bi dobili pristup.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "", diff --git a/ghost/i18n/locales/sv/portal.json b/ghost/i18n/locales/sv/portal.json index 55aeecc8e41..13e91e9e30e 100644 --- a/ghost/i18n/locales/sv/portal.json +++ b/ghost/i18n/locales/sv/portal.json @@ -138,6 +138,7 @@ "Sign out": "Logga ut", "Sign up": "Få uppdateringar", "Signup error: Invalid link": "Registreringsfel. Länken fungerade inte.", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "Något gick fel, vänligen försök igen senare.", "Sorry, no recommendations are available right now.": "Ursäkta, inga rekommendationer finns tillgängliga just nu.", "Sorry, that didn’t work.": "Ursäkta, det fungerande inte.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "Det blev fel när din prenumeration skulle fortsättas, vänligen försök igen", "There was an error processing your payment. Please try again.": "Det blev fel när din betalning skulle behandlas, vänligen försök igen", "There was an error sending the email, please try again": "Det blev ett fel när e-posten skulle skickas, vänligen försök igen", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Den här sidan är endast för inbjudna, kontakta ägaren för åtkomst.", "This site is not accepting payments at the moment.": "Den här webbsidan accepterar inte betalningar för tillfället", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "För många olika inloggningsförsök, testa igen om {{number}} dagar.", "Too many different sign-in attempts, try again in {{number}} hours": "För många olika inloggningsförsök, testa igen om {{number}} timmar.", "Too many different sign-in attempts, try again in {{number}} minutes": "För många olika inloggningsförsök, testa igen om {{number}} minuter.", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Prova gratis i {{amount}} dagar, sen betalar du {{originalPrice}}.", "Unable to initiate checkout session": "Kan inte initiera utcheckningssession", "Unlock access to all newsletters by becoming a paid subscriber.": "Få tillgång till alla nyhetsbrev genom att bli betalande prenumerant", diff --git a/ghost/i18n/locales/sw/portal.json b/ghost/i18n/locales/sw/portal.json index 218f70a3df2..50257f89412 100644 --- a/ghost/i18n/locales/sw/portal.json +++ b/ghost/i18n/locales/sw/portal.json @@ -138,6 +138,7 @@ "Sign out": "Toka", "Sign up": "Jisajili", "Signup error: Invalid link": "Kosa la usajili: Kiungo batili", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "Samahani, hiyo haikufanya kazi.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Tovuti hii ni ya mialiko pekee, wasiliana na mmiliki kupata ufikiaji.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Jaribu bila malipo kwa siku {{amount}}, kisha {{originalPrice}}.", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "Fungua ufikiaji wa majarida yote kwa kuwa mwanachama anayelipa.", diff --git a/ghost/i18n/locales/ta/portal.json b/ghost/i18n/locales/ta/portal.json index b7289ef5487..434c4c3649b 100644 --- a/ghost/i18n/locales/ta/portal.json +++ b/ghost/i18n/locales/ta/portal.json @@ -138,6 +138,7 @@ "Sign out": "வெளியேறு", "Sign up": "பதிவு செய்", "Signup error: Invalid link": "பதிவு பிழை: தவறான இணைப்பு", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "ஏதோ தவறு நடந்துவிட்டது, பிறகு மீண்டும் முயற்சிக்கவும்.", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "மன்னிக்கவும், அது வேலை செய்யவில்லை.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "உங்கள் சந்தாவைத் தொடர்வதில் பிழை ஏற்பட்டது, மீண்டும் முயற்சிக்கவும்.", "There was an error processing your payment. Please try again.": "உங்கள் கட்டணத்தை செயலாக்குவதில் பிழை ஏற்பட்டது. மீண்டும் முயற்சிக்கவும்.", "There was an error sending the email, please try again": "மின்னஞ்சலை அனுப்புவதில் பிழை ஏற்பட்டது, மீண்டும் முயற்சிக்கவும்", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "இந்த தளம் அழைப்பு மட்டுமே, அணுகலுக்கு உரிமையாளரைத் தொடர்பு கொள்ளவும்.", "This site is not accepting payments at the moment.": "இந்த தளம் தற்போது கட்டணங்களை ஏற்கவில்லை.", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "மிக அதிகமான வேறுபட்ட உள்நுழைவு முயற்சிகள், {{number}} நாட்களில் மீண்டும் முயற்சிக்கவும்", "Too many different sign-in attempts, try again in {{number}} hours": "மிக அதிகமான வேறுபட்ட உள்நுழைவு முயற்சிகள், {{number}} மணிநேரங்களில் மீண்டும் முயற்சிக்கவும்", "Too many different sign-in attempts, try again in {{number}} minutes": "மிக அதிகமான வேறுபட்ட உள்நுழைவு முயற்சிகள், {{number}} நிமிடங்களில் மீண்டும் முயற்சிக்கவும்", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "{{amount}} நாட்களுக்கு இலவசமாக முயற்சிக்கவும், பின்னர் {{originalPrice}}.", "Unable to initiate checkout session": "செக்அவுட் அமர்வைத் தொடங்க முடியவில்லை", "Unlock access to all newsletters by becoming a paid subscriber.": "பணம் செலுத்தும் சந்தாதாரராக மாறி அனைத்து செய்திமடல்களுக்கும் அணுகலைத் திறக்கவும்.", diff --git a/ghost/i18n/locales/th/portal.json b/ghost/i18n/locales/th/portal.json index 92f4be14b6f..c1290922a32 100644 --- a/ghost/i18n/locales/th/portal.json +++ b/ghost/i18n/locales/th/portal.json @@ -138,6 +138,7 @@ "Sign out": "ออกจากระบบ", "Sign up": "สมัครใช้งาน", "Signup error: Invalid link": "มีข้อผิดพลาดในการสมัครใช้งาน: ลิงก์ไม่ถูกต้อง", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "ขออภัย, ไม่สามารถส่งได้", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "เว็บไซต์นี้สำหรับผู้ได้รับเชิญเท่านั้น โปรดติดต่อเจ้าของเพื่อเข้าถึง", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "ทดลองใช้ฟรี {{amount}} วัน จากนั้นจ่ายเป็น {{ราคาเดิม}}", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "ปลดล็อกการเข้าถึงจดหมายข่าวทั้งหมดโดยสมัครเป็นสมาชิกแบบชำระเงิน", diff --git a/ghost/i18n/locales/tr/portal.json b/ghost/i18n/locales/tr/portal.json index 9d3059588bd..2e8f82975fd 100644 --- a/ghost/i18n/locales/tr/portal.json +++ b/ghost/i18n/locales/tr/portal.json @@ -138,6 +138,7 @@ "Sign out": "Çıkış yap", "Sign up": "Kayıt ol", "Signup error: Invalid link": "Kayıt hatası: Geçersiz bağlantı", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "Bir şeyler ters gitti, lütfen daha sonra tekrar deneyin.", "Sorry, no recommendations are available right now.": "Üzgünüz, şu anda öneri bulunmamaktadır", "Sorry, that didn’t work.": "Üzgünüm, bu işe yaramadı.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "Aboneliğinizi devam ettirirken bir hata oluştu, lütfen tekrar deneyin.", "There was an error processing your payment. Please try again.": "Ödemeniz işlenirken bir hata oluştu. Lütfen tekrar deneyiniz.", "There was an error sending the email, please try again": "E-posta gönderilirken bir hata oluştu, lütfen tekrar deneyin.", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Bu site sadece davetiyesi olanlar içindir, erişim için site sahibiyle iletişime geç.", "This site is not accepting payments at the moment.": "Bu site şu anda ödeme kabul etmemektedir.", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "Çok fazla farklı giriş denemesi yapıldı, lütfen {{number}} gün sonra tekrar deneyin", "Too many different sign-in attempts, try again in {{number}} hours": "Çok fazla farklı giriş denemesi yapıldı, lütfen {{number}} saat sonra tekrar deneyin", "Too many different sign-in attempts, try again in {{number}} minutes": "Çok fazla farklı giriş denemesi yapıldı, lütfen {{number}} dakika sonra tekrar deneyin", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "{{amount}} gün ücretsiz deneyin, ardından {{originalPrice}}.", "Unable to initiate checkout session": "Ödeme oturumu başlatılamadı", "Unlock access to all newsletters by becoming a paid subscriber.": "Tüm bültenlere erişimi açmak için ücretli bir abone olun.", diff --git a/ghost/i18n/locales/uk/portal.json b/ghost/i18n/locales/uk/portal.json index 4dc2eb04d08..dbccf59b1b8 100644 --- a/ghost/i18n/locales/uk/portal.json +++ b/ghost/i18n/locales/uk/portal.json @@ -138,6 +138,7 @@ "Sign out": "Вихід", "Sign up": "Реєстрація", "Signup error: Invalid link": "Помилка реєстрації: недійсне посилання", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "Щось пішло не так, спробуйте пізніше.", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "Вибачте, це не спрацювало.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "Під час продовження підписки сталася помилка. Спробуйте ще раз.", "There was an error processing your payment. Please try again.": "Під час обробки вашого платежу сталася помилка. Спробуйте ще раз.", "There was an error sending the email, please try again": "Під час надсилання листа сталася помилка. Повторіть спробу", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Цей сайт доступний тільки за запрошенням, звернись до власника сайта для доступу.", "This site is not accepting payments at the moment.": "Цей сайт на даний момент не приймає платежі.", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "Забагато різних спроб входу. Повторіть спробу через {{number}} днів", "Too many different sign-in attempts, try again in {{number}} hours": "Забагато різних спроб входу. Повторіть спробу через {{number}} годин", "Too many different sign-in attempts, try again in {{number}} minutes": "Забагато різних спроб входу. Повторіть спробу через {{number}} хвилин", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Спробуйте безкоштовно протягом {{amount}} днів, надалі за {{originalPrice}}.", "Unable to initiate checkout session": "Не вдалося розпочати сеанс оформлення замовлення", "Unlock access to all newsletters by becoming a paid subscriber.": "Розблокуйте доступ до всіх розсилок, ставши платним підписником.", diff --git a/ghost/i18n/locales/ur/portal.json b/ghost/i18n/locales/ur/portal.json index 041dea35fca..437b9b96ce8 100644 --- a/ghost/i18n/locales/ur/portal.json +++ b/ghost/i18n/locales/ur/portal.json @@ -138,6 +138,7 @@ "Sign out": "لاگ آؤٹ", "Sign up": "سائن اپ", "Signup error: Invalid link": "سائن اپ خطا: غیر معتبر لنک", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "معاف کریں، یہ کام نہیں کیا گیا۔", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "یہ سائٹ صرف دعوتی ہے، دستیابی کے لئے مالک سے رابطہ کریں۔", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "مفت ٹرائل کے لئے کوشش کریں {{amount}} دن، پھر {{originalPrice}}۔", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "ایک ادائیگی چکچکی بن کر تمام نیوزلیٹرز کا رسائی کھولیں۔", diff --git a/ghost/i18n/locales/uz/portal.json b/ghost/i18n/locales/uz/portal.json index 6e569adb550..0dd70d28b2a 100644 --- a/ghost/i18n/locales/uz/portal.json +++ b/ghost/i18n/locales/uz/portal.json @@ -138,6 +138,7 @@ "Sign out": "", "Sign up": "Ro'yxatdan o'tmoq", "Signup error: Invalid link": "", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Bu saytda faqat taklif qilinadi, kirish uchun egasiga murojaat qiling.", "This site is not accepting payments at the moment.": "", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "", diff --git a/ghost/i18n/locales/vi/portal.json b/ghost/i18n/locales/vi/portal.json index 4c655fd9151..eea2c801481 100644 --- a/ghost/i18n/locales/vi/portal.json +++ b/ghost/i18n/locales/vi/portal.json @@ -138,6 +138,7 @@ "Sign out": "Đăng xuất", "Sign up": "Đăng ký", "Signup error: Invalid link": "Lỗi đăng ký: Liên kết không hợp lệ", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "Xảy ra lỗi, hãy thử lại sau.", "Sorry, no recommendations are available right now.": "Rất tiếc, chưa có đề xuất nào vào lúc này.", "Sorry, that didn’t work.": "Rất tiếc, không dùng được.", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "Xảy ra lỗi khi tiếp tục gói thành viên, vui lòng thử lại", "There was an error processing your payment. Please try again.": "Xảy ra lỗi khi tiến hành thanh toán. Hãy thử lại sau.", "There was an error sending the email, please try again": "Xảy ra lỗi khi gửi email, vui lòng thử lại", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "Trang web này chỉ dành cho những người được mời, hãy liên hệ với chủ sở hữu để cấp quyền truy cập.", "This site is not accepting payments at the moment.": "Trang web này hiện chưa chấp nhận thanh toán.", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "Thử đăng nhập quá nhiều, hãy thử lại sau {{number}} ngày.", "Too many different sign-in attempts, try again in {{number}} hours": "Thử đăng nhập quá nhiều, hãy thử lại sau {{number}} giờ.", "Too many different sign-in attempts, try again in {{number}} minutes": "Thử đăng nhập quá nhiều, hãy thử lại sau {{number}} phút.", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "Đọc thử {{amount}} ngày, phí sau đọc thử là {{originalPrice}}.", "Unable to initiate checkout session": "Không thể bắt đầu phiên thanh toán", "Unlock access to all newsletters by becoming a paid subscriber.": "Trở thành thành viên trả phí để mở khóa truy cập toàn bộ bản tin.", diff --git a/ghost/i18n/locales/zh-Hant/portal.json b/ghost/i18n/locales/zh-Hant/portal.json index 812f54e0b4d..0e2b8b2de12 100644 --- a/ghost/i18n/locales/zh-Hant/portal.json +++ b/ghost/i18n/locales/zh-Hant/portal.json @@ -138,6 +138,7 @@ "Sign out": "登出", "Sign up": "註冊", "Signup error: Invalid link": "註冊錯誤:連結無效", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "伺服器錯誤,請稍後重試。", "Sorry, no recommendations are available right now.": "抱歉,目前沒有其他的推薦。", "Sorry, that didn’t work.": "抱歉,該操作無法完成。", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "續約您的訂閱時發生錯誤,請您再試一次。", "There was an error processing your payment. Please try again.": "處理您的付款時發生錯誤,請您再試一次。", "There was an error sending the email, please try again": "寄送 email 時發生錯誤,請您再試一次。", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "此網站僅限受邀請者觀看,請聯繫網站擁有者取得存取權限。", "This site is not accepting payments at the moment.": "此網站目前無付款方式。", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "嘗試過多不同的登入,請於 {{number}} 日後再試一次。", "Too many different sign-in attempts, try again in {{number}} hours": "嘗試過多不同的登入,請於 {{number}} 小時後再試一次。", "Too many different sign-in attempts, try again in {{number}} minutes": "嘗試過多不同的登入,請於 {{number}} 分鐘後再試一次。", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "免費試用 {{amount}} 天,然後以 {{originalPrice}} 開始訂閱。", "Unable to initiate checkout session": "無法建立結帳", "Unlock access to all newsletters by becoming a paid subscriber.": "成為付費會員以解鎖所有電子報內容。", diff --git a/ghost/i18n/locales/zh/portal.json b/ghost/i18n/locales/zh/portal.json index 016e303c01c..de65aabd011 100644 --- a/ghost/i18n/locales/zh/portal.json +++ b/ghost/i18n/locales/zh/portal.json @@ -138,6 +138,7 @@ "Sign out": "退出", "Sign up": "注册", "Signup error: Invalid link": "注册错误:链接无效", + "Signups from this email provider are not allowed": "", "Something went wrong, please try again later.": "出了点问题,请稍后再试。", "Sorry, no recommendations are available right now.": "", "Sorry, that didn’t work.": "抱歉,该操作无法完成。", @@ -165,7 +166,6 @@ "There was an error continuing your subscription, please try again.": "", "There was an error processing your payment. Please try again.": "您的付款处理失败,请重试。", "There was an error sending the email, please try again": "", - "This email domain is not accepted, try again with a different email address": "", "This site is invite-only, contact the owner for access.": "此网站仅限邀请,联系网站所有者以获取访问", "This site is not accepting payments at the moment.": "本网站目前暂不接受付款。", "This site only accepts paid members.": "", @@ -177,6 +177,7 @@ "Too many different sign-in attempts, try again in {{number}} days": "", "Too many different sign-in attempts, try again in {{number}} hours": "", "Too many different sign-in attempts, try again in {{number}} minutes": "", + "Too many sign-up attempts, try again later": "", "Try free for {{amount}} days, then {{originalPrice}}.": "{{amount}}天免费试用,之后{{originalPrice}}。", "Unable to initiate checkout session": "", "Unlock access to all newsletters by becoming a paid subscriber.": "成为付费订阅用户以解锁全部快报。", diff --git a/ghost/magic-link/lib/MagicLink.js b/ghost/magic-link/lib/MagicLink.js index f05030a6ee0..d0285eaca52 100644 --- a/ghost/magic-link/lib/MagicLink.js +++ b/ghost/magic-link/lib/MagicLink.js @@ -3,7 +3,7 @@ const {isEmail} = require('@tryghost/validator'); const tpl = require('@tryghost/tpl'); const messages = { invalidEmail: 'Email is not valid', - unsupportedEmailDomain: 'This email domain is not accepted, try again with a different email address' + unsupportedEmailDomain: 'Signups from this email provider are not allowed' }; /** diff --git a/ghost/magic-link/test/index.test.js b/ghost/magic-link/test/index.test.js index b7e2de246b0..b05ebb30ef6 100644 --- a/ghost/magic-link/test/index.test.js +++ b/ghost/magic-link/test/index.test.js @@ -119,7 +119,7 @@ describe('MagicLink', function () { () => service.sendMagicLink(blockedArgs), { name: 'BadRequestError', - message: 'This email domain is not accepted, try again with a different email address' + message: 'Signups from this email provider are not allowed' } ); From c8e76fb4988b2651a4a8c3cf5a2f4b1131e3c0dd Mon Sep 17 00:00:00 2001 From: Sag Date: Wed, 22 Jan 2025 11:48:58 +0700 Subject: [PATCH 68/90] Released Portal v2.48.2 (#22041) no issue - changelog v2.48.1 -> v2.48.2: - https://github.com/TryGhost/Ghost/commit/3ca419bcbce4acf27354f269a9b3b6b311666089 --- apps/portal/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/portal/package.json b/apps/portal/package.json index 2e3d5040e8a..46160c52c58 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/portal", - "version": "2.48.1", + "version": "2.48.2", "license": "MIT", "repository": { "type": "git", From 3a38aef9b23c75402b9100f70a8bf4f66cc2bb99 Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Wed, 22 Jan 2025 07:09:08 +0000 Subject: [PATCH 69/90] Added `contentVisibilityAlpha` flag no issue - flag to allow internal testing of content visibility developments without unintentional early release to beta testers --- .../settings/advanced/labs/AlphaFeatures.tsx | 10 +++++++--- ghost/admin/app/components/koenig-lexical-editor.js | 3 ++- ghost/admin/app/services/feature.js | 1 + ghost/core/core/shared/labs.js | 3 ++- .../e2e-api/admin/__snapshots__/config.test.js.snap | 1 + 5 files changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx b/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx index 94760d8736f..ce189d0dae1 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx @@ -40,10 +40,14 @@ const features = [{ description: '(Highly) Experimental support for ActivityPub.', flag: 'ActivityPub' },{ - title: 'Content Visibility', - description: 'Enables content visibility in Emails', + title: 'Content Visibility (Beta)', + description: 'Enables content visibility in Emails - Changes already released to beta testers', flag: 'contentVisibility' -}, { +},{ + title: 'Content Visibility (Alpha)', + description: 'Enables content visibility in Emails - Additional changes for internal testing. NOTE: requires `contentVisibility` to also be enabled', + flag: 'contentVisibilityAlpha' +},{ title: 'Post analytics redesign', description: 'Enables redesigned Post analytics page', flag: 'postsX' diff --git a/ghost/admin/app/components/koenig-lexical-editor.js b/ghost/admin/app/components/koenig-lexical-editor.js index 490d5ac364c..4906370f7c6 100644 --- a/ghost/admin/app/components/koenig-lexical-editor.js +++ b/ghost/admin/app/components/koenig-lexical-editor.js @@ -441,7 +441,8 @@ export default class KoenigLexicalEditor extends Component { renderLabels: !this.session.user.isContributor, feature: { collectionsCard: this.feature.collectionsCard, - contentVisibility: this.feature.contentVisibility + contentVisibility: this.feature.contentVisibility, + contentVisibilityAlpha: this.feature.contentVisibilityAlpha }, deprecated: { // todo fix typo headerV1: true // if false, shows header v1 in the menu diff --git a/ghost/admin/app/services/feature.js b/ghost/admin/app/services/feature.js index 38e4f662c92..b5d0b9e8654 100644 --- a/ghost/admin/app/services/feature.js +++ b/ghost/admin/app/services/feature.js @@ -74,6 +74,7 @@ export default class FeatureService extends Service { @feature('ActivityPub') ActivityPub; @feature('editorExcerpt') editorExcerpt; @feature('contentVisibility') contentVisibility; + @feature('contentVisibilityAlpha') contentVisibilityAlpha; @feature('postsX') postsX; _user = null; diff --git a/ghost/core/core/shared/labs.js b/ghost/core/core/shared/labs.js index 4ba96958bfe..8b0d2e00309 100644 --- a/ghost/core/core/shared/labs.js +++ b/ghost/core/core/shared/labs.js @@ -51,7 +51,8 @@ const ALPHA_FEATURES = [ 'lexicalIndicators', 'adminXDemo', 'postsX', - 'captcha' + 'captcha', + 'contentVisibilityAlpha' ]; module.exports.GA_KEYS = [...GA_FEATURES]; diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap index ef614cda16d..ad7ee1480d9 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap @@ -18,6 +18,7 @@ Object { "captcha": true, "collectionsCard": true, "contentVisibility": true, + "contentVisibilityAlpha": true, "customFonts": true, "editorExcerpt": true, "emailCustomization": true, From f07291b72cb00de591fa1b0df6c7f60eba8d5074 Mon Sep 17 00:00:00 2001 From: Sag Date: Wed, 22 Jan 2025 14:26:49 +0700 Subject: [PATCH 70/90] Added missing error message handler for the integrity token endpoint (#22043) ref https://linear.app/ghost/issue/PRO-1349 - the integrity token endpoint can return a json response with an error message (for example, when rate limited) - added the standard response handler to the integrity token endpoint in Portal, to render the error message sent by the backend --- apps/portal/src/utils/api.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/portal/src/utils/api.js b/apps/portal/src/utils/api.js index aabea5aa7a8..55bd0a8536a 100644 --- a/apps/portal/src/utils/api.js +++ b/apps/portal/src/utils/api.js @@ -254,6 +254,10 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}) { if (res.ok) { return res.text(); } else { + const humanError = await HumanReadableError.fromApiResponse(res); + if (humanError) { + throw humanError; + } throw new Error('Failed to start a members session'); } }, @@ -290,7 +294,6 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}) { if (res.ok) { return 'Success'; } else { - // Try to read body error message that is human readable and should be shown to the user const humanError = await HumanReadableError.fromApiResponse(res); if (humanError) { throw humanError; From 5409ae1c68104d1edeb324b52223de43102bbe06 Mon Sep 17 00:00:00 2001 From: Sag Date: Wed, 22 Jan 2025 14:35:54 +0700 Subject: [PATCH 71/90] Released Portal v2.48.3 (#22044) no issue - changelog v2.48.2 -> v2.48.3: - https://github.com/TryGhost/Ghost/commit/f07291b72cb00de591fa1b0df6c7f60eba8d5074 --- apps/portal/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/portal/package.json b/apps/portal/package.json index 46160c52c58..598b7dd3c47 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/portal", - "version": "2.48.2", + "version": "2.48.3", "license": "MIT", "repository": { "type": "git", From 2d0f6568fa87f48f6b1408df4622632549fc1833 Mon Sep 17 00:00:00 2001 From: Djordje Vlaisavljevic Date: Wed, 22 Jan 2025 17:49:11 +0000 Subject: [PATCH 72/90] Fixed reading progress indicator for very short articles (#22036) ref https://linear.app/ghost/issue/AP-653/scroll-percentage-remains-at-0percent-when-no-content-to-scroll - When an entire article fits into the viewport height, we used to show`0%` in the reading progress indicator. Now we check if that's the case, and then show `100%` if it is. --- apps/admin-x-activitypub/package.json | 2 +- .../src/components/feed/ArticleModal.tsx | 58 ++++++++++++++++--- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/apps/admin-x-activitypub/package.json b/apps/admin-x-activitypub/package.json index d5be231920a..1d51e5499d6 100644 --- a/apps/admin-x-activitypub/package.json +++ b/apps/admin-x-activitypub/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/admin-x-activitypub", - "version": "0.3.52", + "version": "0.3.53", "license": "MIT", "repository": { "type": "git", diff --git a/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx b/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx index 3bd797faf48..d1a2ee92cc6 100644 --- a/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx +++ b/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx @@ -18,6 +18,7 @@ import APAvatar from '../global/APAvatar'; import APReplyBox from '../global/APReplyBox'; import TableOfContents, {TOCItem} from './TableOfContents'; import getReadingTime from '../../utils/get-reading-time'; +import {useDebounce} from 'use-debounce'; interface ArticleModalProps { activityId: string; @@ -48,6 +49,7 @@ const ArticleBody: React.FC<{ fontFamily: SelectOption; onHeadingsExtracted?: (headings: TOCItem[]) => void; onIframeLoad?: (iframe: HTMLIFrameElement) => void; + onLoadingChange?: (isLoading: boolean) => void; }> = ({ heading, image, @@ -57,7 +59,8 @@ const ArticleBody: React.FC<{ lineHeight, fontFamily, onHeadingsExtracted, - onIframeLoad + onIframeLoad, + onLoadingChange }) => { const site = useBrowseSite(); const siteData = site.data?.site; @@ -213,7 +216,6 @@ const ArticleBody: React.FC<{ if (iframeWindow && typeof iframeWindow.resizeIframe === 'function') { iframeWindow.resizeIframe(); } else { - // Fallback: trigger a resize event const resizeEvent = new Event('resize'); iframeDocument.dispatchEvent(resizeEvent); } @@ -269,6 +271,11 @@ const ArticleBody: React.FC<{ return () => iframe.removeEventListener('load', handleLoad); }, [onHeadingsExtracted, onIframeLoad]); + // Update parent when loading state changes + useEffect(() => { + onLoadingChange?.(isLoading); + }, [isLoading, onLoadingChange]); + return (
    @@ -526,30 +533,66 @@ const ArticleModal: React.FC = ({ const currentGridWidth = `${parseInt(currentMaxWidth) - 64}px`; const [readingProgress, setReadingProgress] = useState(0); + const [isLoading, setIsLoading] = useState(true); + + // Add debounced version of setReadingProgress + const [debouncedSetReadingProgress] = useDebounce(setReadingProgress, 100); + + const PROGRESS_INCREMENT = 5; // Progress is shown in 5% increments (0%, 5%, 10%, etc.) useEffect(() => { const container = document.querySelector('.overflow-y-auto'); const article = document.getElementById('object-content'); const handleScroll = () => { + if (isLoading) { + return; + } + if (!container || !article) { return; } const articleRect = article.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); + + const isContentShorterThanViewport = articleRect.height <= containerRect.height; + + if (isContentShorterThanViewport) { + debouncedSetReadingProgress(100); + return; + } + const scrolledPast = Math.max(0, containerRect.top - articleRect.top); const totalHeight = (article as HTMLElement).offsetHeight - (container as HTMLElement).offsetHeight; const rawProgress = Math.min(Math.max((scrolledPast / totalHeight) * 100, 0), 100); - const progress = Math.round(rawProgress / 5) * 5; + const progress = Math.round(rawProgress / PROGRESS_INCREMENT) * PROGRESS_INCREMENT; - setReadingProgress(progress); + debouncedSetReadingProgress(progress); }; + if (isLoading) { + return; + } + + const observer = new MutationObserver(handleScroll); + if (article) { + observer.observe(article, { + childList: true, + subtree: true, + characterData: true + }); + } + container?.addEventListener('scroll', handleScroll); - return () => container?.removeEventListener('scroll', handleScroll); - }, []); + handleScroll(); + + return () => { + container?.removeEventListener('scroll', handleScroll); + observer.disconnect(); + }; + }, [isLoading, debouncedSetReadingProgress]); const [tocItems, setTocItems] = useState([]); const [activeHeadingId, setActiveHeadingId] = useState(null); @@ -575,7 +618,6 @@ const ArticleModal: React.FC = ({ return; } - // Use offsetTop for absolute position within the document const headingOffset = heading.offsetTop; container.scrollTo({ @@ -602,7 +644,6 @@ const ArticleModal: React.FC = ({ return; } - // Get all heading elements and their positions const headings = tocItems .map(item => doc.getElementById(item.id)) .filter((el): el is HTMLElement => el !== null) @@ -845,6 +886,7 @@ const ArticleModal: React.FC = ({ lineHeight={LINE_HEIGHTS[currentLineHeightIndex]} onHeadingsExtracted={handleHeadingsExtracted} onIframeLoad={handleIframeLoad} + onLoadingChange={setIsLoading} />
    Date: Thu, 23 Jan 2025 08:21:37 +0700 Subject: [PATCH 73/90] Added missing Vietnamese translation for portal (#21948) Translating "This site only accepts paid members." Co-authored-by: Chris Raible --- ghost/i18n/locales/vi/portal.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ghost/i18n/locales/vi/portal.json b/ghost/i18n/locales/vi/portal.json index eea2c801481..231ab2b5dcb 100644 --- a/ghost/i18n/locales/vi/portal.json +++ b/ghost/i18n/locales/vi/portal.json @@ -168,7 +168,7 @@ "There was an error sending the email, please try again": "Xảy ra lỗi khi gửi email, vui lòng thử lại", "This site is invite-only, contact the owner for access.": "Trang web này chỉ dành cho những người được mời, hãy liên hệ với chủ sở hữu để cấp quyền truy cập.", "This site is not accepting payments at the moment.": "Trang web này hiện chưa chấp nhận thanh toán.", - "This site only accepts paid members.": "", + "This site only accepts paid members.": "Trang web này chỉ chấp nhận người dùng trả phí.", "To complete signup, click the confirmation link in your inbox. If it doesn't arrive within 3 minutes, check your spam folder!": "Để hoàn tất đăng ký, nhấn vào liên kết xác nhận được gửi tới email của bạn. Sau 3 phút mà không thấy, hãy kiểm tra hộp thư spam!", "To continue to stay up to date, subscribe to {{publication}} below.": "Để tiếp tục được cập nhật, hãy đăng ký {{publication}} bên dưới.", "Too many attempts try again in {{number}} days.": "Thử quá nhiều, hãy thử lại sau {{number}} ngày.", From 568322c378b762bf695d6c3e1e6fa7fd11b5e757 Mon Sep 17 00:00:00 2001 From: Sag Date: Thu, 23 Jan 2025 11:12:29 +0700 Subject: [PATCH 74/90] Added new setting in the database: blocked_email_domains [migration] (#22046) ref https://linear.app/ghost/issue/ENG-1973 ref https://app.incident.io/ghost/incidents/132 - added a new database setting: `blocked_email_domains` (array, default: `[]`) - this setting will allow publishers to block additional email domains during member signups, on top of the ones blocklisted at a config level (follow-up PR) --- .../api/endpoints/utils/serializers/input/settings.js | 3 ++- ...25-01-23-02-51-10-add-blocked-email-domains-setting.js | 8 ++++++++ .../data/schema/default-settings/default-settings.json | 4 ++++ ghost/core/test/unit/server/data/exporter/index.test.js | 2 +- ghost/core/test/unit/server/data/schema/integrity.test.js | 2 +- 5 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 ghost/core/core/server/data/migrations/versions/5.108/2025-01-23-02-51-10-add-blocked-email-domains-setting.js diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/input/settings.js b/ghost/core/core/server/api/endpoints/utils/serializers/input/settings.js index 4bda40ceda4..709e41adfc4 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/input/settings.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/input/settings.js @@ -74,7 +74,8 @@ const EDITABLE_SETTINGS = [ 'donations_suggested_amount', 'recommendations_enabled', 'body_font', - 'heading_font' + 'heading_font', + 'blocked_email_domains' ]; module.exports = { diff --git a/ghost/core/core/server/data/migrations/versions/5.108/2025-01-23-02-51-10-add-blocked-email-domains-setting.js b/ghost/core/core/server/data/migrations/versions/5.108/2025-01-23-02-51-10-add-blocked-email-domains-setting.js new file mode 100644 index 00000000000..2404d4b9cab --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/5.108/2025-01-23-02-51-10-add-blocked-email-domains-setting.js @@ -0,0 +1,8 @@ +const {addSetting} = require('../../utils'); + +module.exports = addSetting({ + key: 'blocked_email_domains', + value: '[]', + type: 'array', + group: 'members' +}); diff --git a/ghost/core/core/server/data/schema/default-settings/default-settings.json b/ghost/core/core/server/data/schema/default-settings/default-settings.json index 2d036a745c2..7094706acd4 100644 --- a/ghost/core/core/server/data/schema/default-settings/default-settings.json +++ b/ghost/core/core/server/data/schema/default-settings/default-settings.json @@ -315,6 +315,10 @@ "isIn": [["true", "false"]] }, "type": "boolean" + }, + "blocked_email_domains": { + "defaultValue": "[]", + "type": "array" } }, "portal": { diff --git a/ghost/core/test/unit/server/data/exporter/index.test.js b/ghost/core/test/unit/server/data/exporter/index.test.js index 40bcae2e4e0..0a790cf6121 100644 --- a/ghost/core/test/unit/server/data/exporter/index.test.js +++ b/ghost/core/test/unit/server/data/exporter/index.test.js @@ -236,7 +236,7 @@ describe('Exporter', function () { // NOTE: if default settings changed either modify the settings keys blocklist or increase allowedKeysLength // This is a reminder to think about the importer/exporter scenarios ;) - const allowedKeysLength = 88; + const allowedKeysLength = 89; totalKeysLength.should.eql(SETTING_KEYS_BLOCKLIST.length + allowedKeysLength); }); }); diff --git a/ghost/core/test/unit/server/data/schema/integrity.test.js b/ghost/core/test/unit/server/data/schema/integrity.test.js index c05139ed5de..9485f954938 100644 --- a/ghost/core/test/unit/server/data/schema/integrity.test.js +++ b/ghost/core/test/unit/server/data/schema/integrity.test.js @@ -37,7 +37,7 @@ describe('DB version integrity', function () { // Only these variables should need updating const currentSchemaHash = 'b26690fb57ffd0edbddb4cd9e02b17d6'; const currentFixturesHash = '80e79d1efd5da275e19cb375afb4ad04'; - const currentSettingsHash = '80387fdbda0102ab4995660d5d98007c'; + const currentSettingsHash = '05366d793079c93b87477ec0404301c6'; const currentRoutesHash = '3d180d52c663d173a6be791ef411ed01'; // If this test is failing, then it is likely a change has been made that requires a DB version bump, From e41fc2c4d581764b9707ccf5d8b2ea5f765305ab Mon Sep 17 00:00:00 2001 From: Peter Zimon Date: Thu, 23 Jan 2025 08:22:04 +0100 Subject: [PATCH 75/90] Shade updates (#22045) ref https://linear.app/ghost/issue/DES-1085/update-shade-to-be-used-in-activitypub - Shade so far was just used in our playground (Post analytics). It needed to be prepared so that it can be integrated in real projects like ActivityPub. This means cleaning up everything related to it like conventions, file structure, documentation etc. --- apps/shade/src/boilerplate.stories.tsx | 18 --- apps/shade/src/boilerplate.tsx | 15 -- .../src/components/ui/avatar.stories.tsx | 35 +++++ .../shade/src/components/ui/badge.stories.tsx | 17 +++ .../shade/src/components/ui/chart.stories.tsx | 102 +++++++++++++ .../src/components/ui/dialog.stories.tsx | 34 +++++ apps/shade/src/components/ui/icon.ts | 3 +- .../src/components/ui/separator.stories.tsx | 15 ++ .../shade/src/components/ui/table.stories.tsx | 50 +++++++ .../src/components/ui/tooltip.stories.tsx | 37 +++++ apps/shade/src/components/ui/tooltip.tsx | 20 +-- apps/shade/src/docs/Conventions.mdx | 22 ++- apps/shade/src/docs/CreatingComponents.mdx | 26 +++- apps/shade/src/docs/Environment.mdx | 4 +- apps/shade/src/docs/UsingComponents.mdx | 2 +- apps/shade/src/docs/Welcome.mdx | 8 +- apps/shade/src/docs/assets/tech-stack.png | Bin 0 -> 117912 bytes ...tyState.tsx => use-global-dirty-state.tsx} | 0 apps/shade/src/index.ts | 6 +- apps/shade/src/lib/utils.ts | 136 ++++++++++++++++++ apps/shade/src/providers/ShadeProvider.tsx | 2 +- apps/shade/src/utils/debounce.ts | 24 ---- apps/shade/src/utils/formatText.ts | 6 - apps/shade/src/utils/formatUrl.ts | 100 ------------- apps/shade/test/unit/utils/formatUrl.test.ts | 2 +- apps/shade/tsconfig.json | 4 +- 26 files changed, 497 insertions(+), 191 deletions(-) delete mode 100644 apps/shade/src/boilerplate.stories.tsx delete mode 100644 apps/shade/src/boilerplate.tsx create mode 100644 apps/shade/src/components/ui/avatar.stories.tsx create mode 100644 apps/shade/src/components/ui/badge.stories.tsx create mode 100644 apps/shade/src/components/ui/chart.stories.tsx create mode 100644 apps/shade/src/components/ui/dialog.stories.tsx create mode 100644 apps/shade/src/components/ui/separator.stories.tsx create mode 100644 apps/shade/src/components/ui/table.stories.tsx create mode 100644 apps/shade/src/components/ui/tooltip.stories.tsx create mode 100644 apps/shade/src/docs/assets/tech-stack.png rename apps/shade/src/hooks/{useGlobalDirtyState.tsx => use-global-dirty-state.tsx} (100%) delete mode 100644 apps/shade/src/utils/debounce.ts delete mode 100644 apps/shade/src/utils/formatText.ts delete mode 100644 apps/shade/src/utils/formatUrl.ts diff --git a/apps/shade/src/boilerplate.stories.tsx b/apps/shade/src/boilerplate.stories.tsx deleted file mode 100644 index 37064139442..00000000000 --- a/apps/shade/src/boilerplate.stories.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import type {Meta, StoryObj} from '@storybook/react'; - -import BoilerPlate from './boilerplate'; - -const meta = { - title: 'Meta / Boilerplate', - component: BoilerPlate, - tags: ['autodocs'] -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - children: 'This is a boilerplate component. Use as a basis to create new components.' - } -}; diff --git a/apps/shade/src/boilerplate.tsx b/apps/shade/src/boilerplate.tsx deleted file mode 100644 index 6aa687cc9d4..00000000000 --- a/apps/shade/src/boilerplate.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; - -interface BoilerPlateProps { - children?: React.ReactNode; -} - -const BoilerPlate: React.FC = ({children}) => { - return ( - <> - {children} - - ); -}; - -export default BoilerPlate; diff --git a/apps/shade/src/components/ui/avatar.stories.tsx b/apps/shade/src/components/ui/avatar.stories.tsx new file mode 100644 index 00000000000..9cacd9262ba --- /dev/null +++ b/apps/shade/src/components/ui/avatar.stories.tsx @@ -0,0 +1,35 @@ +import type {Meta, StoryObj} from '@storybook/react'; +import {Avatar, AvatarFallback, AvatarImage} from './avatar'; + +const meta = { + title: 'Components / Avatar', + component: Avatar, + tags: ['autodocs'], + argTypes: { + children: { + table: { + disable: true + } + } + } +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: AG + } +}; + +export const WithImage: Story = { + args: { + children: ( + <> + + AG + + ) + } +}; diff --git a/apps/shade/src/components/ui/badge.stories.tsx b/apps/shade/src/components/ui/badge.stories.tsx new file mode 100644 index 00000000000..65e1bf9b0e4 --- /dev/null +++ b/apps/shade/src/components/ui/badge.stories.tsx @@ -0,0 +1,17 @@ +import type {Meta, StoryObj} from '@storybook/react'; +import {Badge} from './badge'; + +const meta = { + title: 'Components / Badge', + component: Badge, + tags: ['autodocs'] +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: 'Badge' + } +}; diff --git a/apps/shade/src/components/ui/chart.stories.tsx b/apps/shade/src/components/ui/chart.stories.tsx new file mode 100644 index 00000000000..d70fdaaa9c7 --- /dev/null +++ b/apps/shade/src/components/ui/chart.stories.tsx @@ -0,0 +1,102 @@ +import type {Meta} from '@storybook/react'; +import {ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent} from './chart'; +import React from 'react'; +import {Label, Pie, PieChart} from 'recharts'; + +const meta = { + title: 'Components / Charts', + component: ChartContainer, + tags: ['autodocs'], + argTypes: { + children: { + control: false + } + } +} satisfies Meta; + +export default meta; + +export const Default = { + render: function ChartStory() { + const chartData = React.useMemo(() => { + return [ + {browser: 'chrome', visitors: 98, fill: 'var(--color-chrome)'}, + {browser: 'safari', visitors: 17, fill: 'var(--color-safari)'} + ]; + }, []); + + const chartConfig = { + visitors: { + label: 'Reactions' + }, + chrome: { + label: 'More like this', + color: 'hsl(var(--chart-1))' + }, + safari: { + label: 'Less like this', + color: 'hsl(var(--chart-5))' + } + } satisfies ChartConfig; + + const totalVisitors = React.useMemo(() => { + return chartData.reduce((acc, curr) => acc + curr.visitors, 0); + }, [chartData]); + + return ( + <> + + + } + cursor={false} + /> + + + + +
    + Visit ShadCN/UI Charts docs for usage details. +
    + + ); + } +}; \ No newline at end of file diff --git a/apps/shade/src/components/ui/dialog.stories.tsx b/apps/shade/src/components/ui/dialog.stories.tsx new file mode 100644 index 00000000000..4a4727b8fce --- /dev/null +++ b/apps/shade/src/components/ui/dialog.stories.tsx @@ -0,0 +1,34 @@ +import type {Meta, StoryObj} from '@storybook/react'; +import {Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle} from './dialog'; +import {Button} from './button'; + +const meta = { + title: 'Components / Dialog', + component: Dialog, + tags: ['autodocs'], + argTypes: { + children: { + table: { + disable: true + } + } + } +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: ( + <> + + + + Are you absolutely sure? + + + + ) + } +}; diff --git a/apps/shade/src/components/ui/icon.ts b/apps/shade/src/components/ui/icon.ts index 6d76addedd8..c08f5156101 100644 --- a/apps/shade/src/components/ui/icon.ts +++ b/apps/shade/src/components/ui/icon.ts @@ -1,7 +1,6 @@ import React from 'react'; import {cva, type VariantProps} from 'class-variance-authority'; -import {cn} from '@/lib/utils'; -import {kebabToPascalCase} from '@/utils/formatText'; +import {cn, kebabToPascalCase} from '@/lib/utils'; const iconVariants = cva('', { variants: { diff --git a/apps/shade/src/components/ui/separator.stories.tsx b/apps/shade/src/components/ui/separator.stories.tsx new file mode 100644 index 00000000000..5708b376f43 --- /dev/null +++ b/apps/shade/src/components/ui/separator.stories.tsx @@ -0,0 +1,15 @@ +import type {Meta, StoryObj} from '@storybook/react'; +import {Separator} from './separator'; + +const meta = { + title: 'Components / Separator', + component: Separator, + tags: ['autodocs'] +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {} +}; diff --git a/apps/shade/src/components/ui/table.stories.tsx b/apps/shade/src/components/ui/table.stories.tsx new file mode 100644 index 00000000000..83915c8adf6 --- /dev/null +++ b/apps/shade/src/components/ui/table.stories.tsx @@ -0,0 +1,50 @@ +import type {Meta, StoryObj} from '@storybook/react'; +import {Table, TableCaption, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell} from './table'; + +const meta = { + title: 'Components / Table', + component: Table, + tags: ['autodocs'], + argTypes: { + children: { + table: { + disable: true + } + } + } +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: ( + <> + A list of your recent invoices. + + + Invoice + Status + Method + Amount + + + + + ABC-123 + Paid + Card + $2,500.00 + + + + + Total + $2,500.00 + + + + ) + } +}; diff --git a/apps/shade/src/components/ui/tooltip.stories.tsx b/apps/shade/src/components/ui/tooltip.stories.tsx new file mode 100644 index 00000000000..ef9e0d3e5bf --- /dev/null +++ b/apps/shade/src/components/ui/tooltip.stories.tsx @@ -0,0 +1,37 @@ +import type {Meta, StoryObj} from '@storybook/react'; +import {Tooltip, TooltipTrigger, TooltipContent} from './tooltip'; +import {TooltipProvider} from '@radix-ui/react-tooltip'; + +const meta = { + title: 'Components / Tooltip', + component: Tooltip, + tags: ['autodocs'], + decorators: [ + Story => ( + + + + ) + ], + argTypes: { + children: { + table: { + disable: true + } + } + } +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: ( + <> + Hover me + Tooltip content + + ) + } +}; diff --git a/apps/shade/src/components/ui/tooltip.tsx b/apps/shade/src/components/ui/tooltip.tsx index 7bdc8a9edd5..5ac524a159b 100644 --- a/apps/shade/src/components/ui/tooltip.tsx +++ b/apps/shade/src/components/ui/tooltip.tsx @@ -14,15 +14,17 @@ const TooltipContent = React.forwardRef< React.ComponentPropsWithoutRef >(({className, sideOffset = 4, ...props}, ref) => ( - +
    + +
    )); TooltipContent.displayName = TooltipPrimitive.Content.displayName; diff --git a/apps/shade/src/docs/Conventions.mdx b/apps/shade/src/docs/Conventions.mdx index b34dd603ef8..0796dcd032f 100644 --- a/apps/shade/src/docs/Conventions.mdx +++ b/apps/shade/src/docs/Conventions.mdx @@ -26,6 +26,26 @@ className={cn(buttonVariants({variant, size, className}))} ## TailwindCSS color naming -[TK] +For gray colors up until now we've used `grey`. ShadCN follows TailwindCSS naming conventions and uses `gray` when you install a component. We also decided to go with this mainly to avoid having to manually override color names and risking manual errors in the design system. + +## Filenames + +Our generic naming conventions is: + +- `PascalCase` for React component names +- `kebab-case` for non-React-components like utilities, hooks and so on +- `camelCase` for functions, objects, types etc. + +When you install a ShadCN component via the CLI it'll create files with kebab-case. This is _not_ following our standards. Unfortunately changing _only_ filename casing in MacOS and Github is a **massive** PITA so for now we're accepting this inconsistency and let ShadCN create its files as is. + +## File structure + +- `/src/components/` — Root directory for components + - `/src/components/ui/` — Directory for atomic UI components (foundational UI elements such as buttons, dropdowns etc.) + - `/src/components/layout` — Complex, globally reusable components (such as a page or header container etc.) +- `/src/hooks/` — Custom Reach hooks +- `/src/providers/` — Context providers +- `/src/ib/utils.ts` — Utilities +- `/src/docs/` — Shade documentation
    diff --git a/apps/shade/src/docs/CreatingComponents.mdx b/apps/shade/src/docs/CreatingComponents.mdx index 183fb83fc72..a852cdbf08f 100644 --- a/apps/shade/src/docs/CreatingComponents.mdx +++ b/apps/shade/src/docs/CreatingComponents.mdx @@ -6,12 +6,18 @@ import { Meta } from '@storybook/blocks'; # Adding new components -ShadCN/UI is actually not a library, it's a starting point — this means that whenever you install a new ShadCN/UI component it adds a new React `tsx` file with the default implementation for the given UI component. Then you can customize the implementation by applying TW classes, creating new variants and so on. +

    ShadCN/UI is not a library, it's a starting point — this means that whenever you install a new ShadCN/UI component it adds new React component(s) with the default implementation for the given UI component. Then you can customize the implementation by applying TW classes, creating new variants and so on.

    As an example, let's see how to add a "Button" component. +--- + **Step 1:** Go to [ShadCN/UI component list](https://ui.shadcn.com/docs/components) and find **Button** +> ⚠️ Sometimes the npx command fails with a massive error. This usually happens if ShadCN tries to reinstall an already existing third party library or component (e.g. `radix-ui/dialog` or `lucide-react`). In these cases, find out which package causes the issue (usually it's the one indicated under the manual installation of the given component), remove it from Shade's `package.json` and retry adding the ShadCN component via the CLI command. + +--- + **Step 2:** Install the component In the `shade` folder follow the ShadCN/UI CLI install instructions: @@ -35,6 +41,8 @@ If you take a look at the source of this component you'll see that the whole but - You can't "update" ShadCN/UI components, you can only reinstall. This means that if you re-run the install command it overwrites the existing implementation including all your changes, so be careful. - You can change the implementation as you wish. You can add, remove or CSS classes, create new variants and add UI logic if you want. +--- + **Step 3:** Create stories for the new component In order to make the new component appear in Storybook, you need to create at least one story for it. You can use the Boilerplate story as a starting point: copy it as `[component].stories.tsx` to the same folder as your component: @@ -53,6 +61,8 @@ For a story, at a minimum you'll need to: - Update the imported component (for the button example: `import {Button} from './button'`), and update all references in the stories file - Change the meta data (most imporantly, `title: 'Components / Button'`, where "Components" will be the parent directory of Button in Storybook — this is a meta directory it doesn't have to exist in the file system) +--- + **Step 4:** (Optional) Add or remove component variants ShadCN/UI uses [Class Variance Authority](https://cva.style/docs) to manage variants for UI components. If you look at the Button component, you'll see an example implementation of this: @@ -83,6 +93,8 @@ export interface ButtonProps } ``` +--- + **Step 5:** Export the component In order to be able to import the new component in apps, you'll need to export it in the `index.ts` file of Shade, like this: @@ -93,6 +105,12 @@ export * from './components/ui/button'; That's it — you've just added a new component to Shade and made it ready to use in other React apps. +--- + +**Note:** ShadCN uses a few third party libraries. Since we export everything from Shade under the project `@tryghost/shade` there might be conflicts if a third party library uses similar component names as some other components in Shade. For example, ShadCN uses Recharts to display charts. Recharts has a `` component and Shade also has one. To overcome this issue we alias all third party exports (e.g. `export * as Recharts from "recharts"`). + +--- + ## Stories With the automatic variant method, most of the _style related_ documentation is handled automatically in Shade. New stories should be created for non-stylistic use cases which are imporant to be able check. For the Button component an example could be related to the contents of the button: text-only, icon-only and icon-text buttons should be separated into different stories. @@ -113,6 +131,10 @@ The `components.json` file contains the ShadCN/UI default configuration, so when ## Custom components -[TK] +It is of course possible to create our own components. Examine how ShadCN components are structured and follow their patterns: + +- Create a single file for each UI component +- Create [composable components](https://blog.tomaszgil.me/choosing-the-right-path-composable-vs-configurable-components-in-react) (multiple React components), _not_ configurable ones (lots of props). Create all corresponding React components in the same file (take a look at the Dropdown Menu implementation for an example). +- Export custom components the same way as you would with ShadCN ones.
    diff --git a/apps/shade/src/docs/Environment.mdx b/apps/shade/src/docs/Environment.mdx index 4a0551f5269..1b826dac0af 100644 --- a/apps/shade/src/docs/Environment.mdx +++ b/apps/shade/src/docs/Environment.mdx @@ -13,7 +13,7 @@ In order to make the proper TailwindCSS classes appear in code completion, you n Then, verify your VSCode settings: -1. Open settings.json (Cmd+Shift+P, then "Preferences: Open Settings (JSON)") +1. Open `settings.json` (Cmd+Shift+P, then "Preferences: Open Settings (JSON)") 2. Add these settings: ``` @@ -26,7 +26,7 @@ Then, verify your VSCode settings: } ``` -Also, ensure your project has a postcss.config.js file: +Also, ensure your project has a `postcss.config.js` file: ``` module.exports = { diff --git a/apps/shade/src/docs/UsingComponents.mdx b/apps/shade/src/docs/UsingComponents.mdx index 66e48ef3477..cfef2089a0e 100644 --- a/apps/shade/src/docs/UsingComponents.mdx +++ b/apps/shade/src/docs/UsingComponents.mdx @@ -6,7 +6,7 @@ import { Meta } from '@storybook/blocks'; # Component usage -The simplest way to use a component is to select the variant in Storybook and copy + paste the code from it. All components have their `className` prop forwarded to their rendered HTML component, which allows you to override any default classes. +

    The simplest way to use a component is to select the variant in Storybook and copy + paste the code from it. All components have their `className` prop forwarded to their rendered HTML component, which allows you to override any default classes.

    ## UI component API's diff --git a/apps/shade/src/docs/Welcome.mdx b/apps/shade/src/docs/Welcome.mdx index 95182399dd4..c5598da4911 100644 --- a/apps/shade/src/docs/Welcome.mdx +++ b/apps/shade/src/docs/Welcome.mdx @@ -1,4 +1,5 @@ import { Meta } from '@storybook/blocks'; +import techStackImage from './assets/tech-stack.png'; @@ -8,9 +9,10 @@ import { Meta } from '@storybook/blocks';

    **Shade** is Ghost's design system for product design. It contains all the necessary components and their usage that help you design a great product experience.

    -## Stack - Shade uses the RadixUI based open source UI library, [ShadCN/UI](https://ui.shadcn.com/) as its foundation which does the heavy lifting and gives a great starting point for any UI component. Additionally Shade is built using [TailwindCSS](https://www.tailwindcss.com). Before diving deep into working with Shade, please get familiar with both of these libraries. -Shade uses [Storybook](https://storybook.js.org/) to document components and best practices. +Technology Stack + +Shade uses [Storybook](https://storybook.js.org/) to test and document components and best practices. For every component used in production, there must be a Storybook entry with at least one story that showcases the component. +
    diff --git a/apps/shade/src/docs/assets/tech-stack.png b/apps/shade/src/docs/assets/tech-stack.png new file mode 100644 index 0000000000000000000000000000000000000000..7bf53082fdcb79100b23036ec8b345157ed51fe4 GIT binary patch literal 117912 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 literal 0 HcmV?d00001 diff --git a/apps/shade/src/hooks/useGlobalDirtyState.tsx b/apps/shade/src/hooks/use-global-dirty-state.tsx similarity index 100% rename from apps/shade/src/hooks/useGlobalDirtyState.tsx rename to apps/shade/src/hooks/use-global-dirty-state.tsx diff --git a/apps/shade/src/index.ts b/apps/shade/src/index.ts index dab7abfff2a..f45cd4a989f 100644 --- a/apps/shade/src/index.ts +++ b/apps/shade/src/index.ts @@ -35,13 +35,11 @@ export {ReactComponent as GoogleLogo} from './assets/images/google-logo.svg'; export {ReactComponent as TwitterLogo} from './assets/images/twitter-logo.svg'; export {ReactComponent as XLogo} from './assets/images/x-logo.svg'; -export {default as useGlobalDirtyState} from './hooks/useGlobalDirtyState'; +export {default as useGlobalDirtyState} from './hooks/use-global-dirty-state'; // Utils export * from '@/lib/utils'; -export {cn} from '@/lib/utils'; -export {debounce} from './utils/debounce'; -export {formatUrl} from './utils/formatUrl'; +export {cn, debounce, kebabToPascalCase, formatUrl} from '@/lib/utils'; export {default as ShadeApp} from './ShadeApp'; export type {ShadeAppProps} from './ShadeApp'; diff --git a/apps/shade/src/lib/utils.ts b/apps/shade/src/lib/utils.ts index 45b87de67cd..d526eeed371 100644 --- a/apps/shade/src/lib/utils.ts +++ b/apps/shade/src/lib/utils.ts @@ -1,6 +1,142 @@ import {clsx, type ClassValue} from 'clsx'; +import isEmail from 'validator/es/lib/isEmail'; import {twMerge} from 'tailwind-merge'; +// Helper to merge Tailwind classes export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +// Helper to debounce a function +export function debounce(func: (...args: T) => void, wait: number, immediate: boolean = false): (...args: T) => void { + let timeoutId: ReturnType | null; + + return function (this: unknown, ...args: T): void { + const later = () => { + timeoutId = null; + if (!immediate) { + func.apply(this, args); + } + }; + + const callNow = immediate && !timeoutId; + + if (timeoutId) { + clearTimeout(timeoutId); + } + + timeoutId = setTimeout(later, wait); + + if (callNow) { + func.apply(this, args); + } + }; +} + +// Helper to convert kebab-case to PascalCase with numbers +export const kebabToPascalCase = (str: string): string => { + const processed = str + .replace(/[-_]([a-z0-9])/gi, (_, char) => char.toUpperCase()); + return processed.charAt(0).toUpperCase() + processed.slice(1); +}; + +// Helper to format a URL +export const formatUrl = (value: string, baseUrl?: string, nullable?: boolean) => { + if (nullable && !value) { + return {save: null, display: ''}; + } + + let url = value.trim(); + + if (!url) { + if (baseUrl) { + return {save: '/', display: baseUrl}; + } + return {save: '', display: ''}; + } + + // if we have an email address, add the mailto: + if (isEmail(url)) { + return {save: `mailto:${url}`, display: `mailto:${url}`}; + } + + const isAnchorLink = url.match(/^#/); + if (isAnchorLink) { + return {save: url, display: url}; + } + + const isProtocolRelative = url.match(/^(\/\/)/); + if (isProtocolRelative) { + return {save: url, display: url}; + } + + if (!baseUrl) { + // Absolute URL with no base URL + if (!url.startsWith('http')) { + url = `https://${url}`; + } + } + + // If it doesn't look like a URL, leave it as is rather than assuming it's a pathname etc + if (!url.match(/^[a-zA-Z0-9-]+:/) && !url.match(/^(\/|\?)/)) { + return {save: url, display: url}; + } + + let parsedUrl: URL; + + try { + parsedUrl = new URL(url, baseUrl); + } catch (e) { + return {save: url, display: url}; + } + + if (!baseUrl) { + return {save: parsedUrl.toString(), display: parsedUrl.toString()}; + } + const parsedBaseUrl = new URL(baseUrl); + + let isRelativeToBasePath = parsedUrl.pathname && parsedUrl.pathname.indexOf(parsedBaseUrl.pathname) === 0; + + // if our path is only missing a trailing / mark it as relative + if (`${parsedUrl.pathname}/` === parsedBaseUrl.pathname) { + isRelativeToBasePath = true; + } + + const isOnSameHost = parsedUrl.host === parsedBaseUrl.host; + + // if relative to baseUrl, remove the base url before sending to action + if (isOnSameHost && isRelativeToBasePath) { + url = url.replace(/^[a-zA-Z0-9-]+:/, ''); + url = url.replace(/^\/\//, ''); + url = url.replace(parsedBaseUrl.host, ''); + url = url.replace(parsedBaseUrl.pathname, ''); + + if (!url.match(/^\//)) { + url = `/${url}`; + } + } + + if (!url.match(/\/$/) && !url.match(/[.#?]/)) { + url = `${url}/`; + } + + // we update with the relative URL but then transform it back to absolute + // for the input value. This avoids problems where the underlying relative + // value hasn't changed even though the input value has + return {save: url, display: displayFromBase(url, baseUrl)}; +}; + +// Helper to display a URL from a base URL +const displayFromBase = (url: string, baseUrl: string) => { + // Ensure base url has a trailing slash + if (!baseUrl.endsWith('/')) { + baseUrl += '/'; + } + + // Remove leading slash from url + if (url.startsWith('/')) { + url = url.substring(1); + } + + return new URL(url, baseUrl).toString(); +}; diff --git a/apps/shade/src/providers/ShadeProvider.tsx b/apps/shade/src/providers/ShadeProvider.tsx index 3d50e0558c7..49d03a416ae 100644 --- a/apps/shade/src/providers/ShadeProvider.tsx +++ b/apps/shade/src/providers/ShadeProvider.tsx @@ -2,7 +2,7 @@ import NiceModal from '@ebay/nice-modal-react'; import React, {createContext, useContext, useState} from 'react'; import {Toaster} from 'react-hot-toast'; // import {FetchKoenigLexical} from '../global/form/HtmlEditor'; -import {GlobalDirtyStateProvider} from '../hooks/useGlobalDirtyState'; +import {GlobalDirtyStateProvider} from '../hooks/use-global-dirty-state'; interface ShadeContextType { isAnyTextFieldFocused: boolean; diff --git a/apps/shade/src/utils/debounce.ts b/apps/shade/src/utils/debounce.ts deleted file mode 100644 index add7c3d5f16..00000000000 --- a/apps/shade/src/utils/debounce.ts +++ /dev/null @@ -1,24 +0,0 @@ -export function debounce(func: (...args: T) => void, wait: number, immediate: boolean = false): (...args: T) => void { - let timeoutId: ReturnType | null; - - return function (this: unknown, ...args: T): void { - const later = () => { - timeoutId = null; - if (!immediate) { - func.apply(this, args); - } - }; - - const callNow = immediate && !timeoutId; - - if (timeoutId) { - clearTimeout(timeoutId); - } - - timeoutId = setTimeout(later, wait); - - if (callNow) { - func.apply(this, args); - } - }; -} diff --git a/apps/shade/src/utils/formatText.ts b/apps/shade/src/utils/formatText.ts deleted file mode 100644 index 9b761f9c418..00000000000 --- a/apps/shade/src/utils/formatText.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Helper to convert kebab-case to PascalCase with numbers -export const kebabToPascalCase = (str: string): string => { - const processed = str - .replace(/[-_]([a-z0-9])/gi, (_, char) => char.toUpperCase()); - return processed.charAt(0).toUpperCase() + processed.slice(1); -}; diff --git a/apps/shade/src/utils/formatUrl.ts b/apps/shade/src/utils/formatUrl.ts deleted file mode 100644 index b05852be6c3..00000000000 --- a/apps/shade/src/utils/formatUrl.ts +++ /dev/null @@ -1,100 +0,0 @@ -import isEmail from 'validator/es/lib/isEmail'; - -export const formatUrl = (value: string, baseUrl?: string, nullable?: boolean) => { - if (nullable && !value) { - return {save: null, display: ''}; - } - - let url = value.trim(); - - if (!url) { - if (baseUrl) { - return {save: '/', display: baseUrl}; - } - return {save: '', display: ''}; - } - - // if we have an email address, add the mailto: - if (isEmail(url)) { - return {save: `mailto:${url}`, display: `mailto:${url}`}; - } - - const isAnchorLink = url.match(/^#/); - if (isAnchorLink) { - return {save: url, display: url}; - } - - const isProtocolRelative = url.match(/^(\/\/)/); - if (isProtocolRelative) { - return {save: url, display: url}; - } - - if (!baseUrl) { - // Absolute URL with no base URL - if (!url.startsWith('http')) { - url = `https://${url}`; - } - } - - // If it doesn't look like a URL, leave it as is rather than assuming it's a pathname etc - if (!url.match(/^[a-zA-Z0-9-]+:/) && !url.match(/^(\/|\?)/)) { - return {save: url, display: url}; - } - - let parsedUrl: URL; - - try { - parsedUrl = new URL(url, baseUrl); - } catch (e) { - return {save: url, display: url}; - } - - if (!baseUrl) { - return {save: parsedUrl.toString(), display: parsedUrl.toString()}; - } - const parsedBaseUrl = new URL(baseUrl); - - let isRelativeToBasePath = parsedUrl.pathname && parsedUrl.pathname.indexOf(parsedBaseUrl.pathname) === 0; - - // if our path is only missing a trailing / mark it as relative - if (`${parsedUrl.pathname}/` === parsedBaseUrl.pathname) { - isRelativeToBasePath = true; - } - - const isOnSameHost = parsedUrl.host === parsedBaseUrl.host; - - // if relative to baseUrl, remove the base url before sending to action - if (isOnSameHost && isRelativeToBasePath) { - url = url.replace(/^[a-zA-Z0-9-]+:/, ''); - url = url.replace(/^\/\//, ''); - url = url.replace(parsedBaseUrl.host, ''); - url = url.replace(parsedBaseUrl.pathname, ''); - - if (!url.match(/^\//)) { - url = `/${url}`; - } - } - - if (!url.match(/\/$/) && !url.match(/[.#?]/)) { - url = `${url}/`; - } - - // we update with the relative URL but then transform it back to absolute - // for the input value. This avoids problems where the underlying relative - // value hasn't changed even though the input value has - return {save: url, display: displayFromBase(url, baseUrl)}; -}; - -const displayFromBase = (url: string, baseUrl: string) => { - // Ensure base url has a trailing slash - if (!baseUrl.endsWith('/')) { - baseUrl += '/'; - } - - // Remove leading slash from url - if (url.startsWith('/')) { - url = url.substring(1); - } - - return new URL(url, baseUrl).toString(); -}; diff --git a/apps/shade/test/unit/utils/formatUrl.test.ts b/apps/shade/test/unit/utils/formatUrl.test.ts index 2f61dd649b6..e3c958ede3b 100644 --- a/apps/shade/test/unit/utils/formatUrl.test.ts +++ b/apps/shade/test/unit/utils/formatUrl.test.ts @@ -1,5 +1,5 @@ import * as assert from 'assert/strict'; -import {formatUrl} from '../../../src/utils/formatUrl'; +import {formatUrl} from '@/lib/utils'; describe('formatUrl', function () { it('displays empty string if the input is empty and nullable is true', function () { diff --git a/apps/shade/tsconfig.json b/apps/shade/tsconfig.json index 8c59ec214bc..5ed4b63be5d 100644 --- a/apps/shade/tsconfig.json +++ b/apps/shade/tsconfig.json @@ -4,7 +4,7 @@ "lib": ["DOM", "DOM.Iterable", "ESNext"], "module": "ESNext", "skipLibCheck": true, - "types": ["vite/client"], + "types": ["vite/client", "mocha"], /* Bundler mode */ "moduleResolution": "bundler", @@ -23,6 +23,6 @@ "@/*": ["./src/*"] } }, - "include": ["src"], + "include": ["src", "test"], "references": [{ "path": "./tsconfig.node.json" }] } From c0ccdbe280081560d2d095ea570492252447e1bf Mon Sep 17 00:00:00 2001 From: Sam Lord Date: Thu, 23 Jan 2025 12:02:53 +0000 Subject: [PATCH 76/90] Portal: Added HCaptcha element to signup/signin pages ref BAE-371 Added the HCaptcha react component & related utils to enable it / disable it based on the Captcha labs flag. At the moment this does not include the same functionality on forms using the data-attributes. --- apps/portal/package.json | 3 ++ .../portal/src/components/pages/SigninPage.js | 34 ++++++++++++++++--- .../portal/src/components/pages/SignupPage.js | 32 ++++++++++++++--- .../src/components/pages/SignupPage.test.js | 19 ++++++++++- apps/portal/src/utils/api.js | 6 ++-- apps/portal/src/utils/fixtures-generator.js | 8 +++-- apps/portal/src/utils/helpers.js | 8 +++++ yarn.lock | 20 +++++++++++ 8 files changed, 116 insertions(+), 14 deletions(-) diff --git a/apps/portal/package.json b/apps/portal/package.json index 598b7dd3c47..26ae06d50d9 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -97,5 +97,8 @@ "vite-plugin-css-injected-by-js": "3.3.0", "vite-plugin-svgr": "3.3.0", "vitest": "0.34.3" + }, + "dependencies": { + "@hcaptcha/react-hcaptcha": "1.11.1" } } diff --git a/apps/portal/src/components/pages/SigninPage.js b/apps/portal/src/components/pages/SigninPage.js index 9adb3a08ffa..0647af916e6 100644 --- a/apps/portal/src/components/pages/SigninPage.js +++ b/apps/portal/src/components/pages/SigninPage.js @@ -5,8 +5,9 @@ import CloseButton from '../common/CloseButton'; import AppContext from '../../AppContext'; import InputForm from '../common/InputForm'; import {ValidateInputForm} from '../../utils/form'; -import {hasAvailablePrices, isSigninAllowed, isSignupAllowed} from '../../utils/helpers'; +import {hasAvailablePrices, isSigninAllowed, isSignupAllowed, hasCaptchaEnabled, getCaptchaSitekey} from '../../utils/helpers'; import {ReactComponent as InvitationIcon} from '../../images/icons/invitation.svg'; +import HCaptcha from '@hcaptcha/react-hcaptcha'; export default class SigninPage extends React.Component { static contextType = AppContext; @@ -14,8 +15,12 @@ export default class SigninPage extends React.Component { constructor(props) { super(props); this.state = { - email: '' + email: '', + captchaLoaded: false, + token: undefined }; + + this.captchaRef = React.createRef(); } componentDidMount() { @@ -29,16 +34,27 @@ export default class SigninPage extends React.Component { handleSignin(e) { e.preventDefault(); + + const {site} = this.context; + if (hasCaptchaEnabled({site})) { + // hCaptcha's callback will call doSignin + return this.captchaRef.current.execute(); + } else { + this.doSignin(); + } + } + + doSignin() { this.setState((state) => { return { errors: ValidateInputForm({fields: this.getInputFields({state}), t: this.context.t}) }; }, async () => { - const {email, phonenumber, errors} = this.state; + const {email, phonenumber, errors, token} = this.state; const {redirect} = this.context.pageData ?? {}; const hasFormErrors = (errors && Object.values(errors).filter(d => !!d).length > 0); if (!hasFormErrors) { - this.context.onAction('signin', {email, phonenumber, redirect}); + this.context.onAction('signin', {email, phonenumber, redirect, token}); } }); } @@ -156,6 +172,16 @@ export default class SigninPage extends React.Component { onChange={(e, field) => this.handleInputChange(e, field)} onKeyDown={(e, field) => this.onKeyDown(e, field)} /> + {(hasCaptchaEnabled({site}) && + this.setState({captchaLoaded: true})} + onVerify={token => this.setState({token: token}, this.doSignin)} + ref={this.captchaRef} + id="hcaptcha-signin" + /> + )}
    {this.renderSubmitButton()} diff --git a/apps/portal/src/components/pages/SignupPage.js b/apps/portal/src/components/pages/SignupPage.js index f46e944b1ac..7e7b40eea20 100644 --- a/apps/portal/src/components/pages/SignupPage.js +++ b/apps/portal/src/components/pages/SignupPage.js @@ -7,9 +7,10 @@ import NewsletterSelectionPage from './NewsletterSelectionPage'; import ProductsSection from '../common/ProductsSection'; import InputForm from '../common/InputForm'; import {ValidateInputForm} from '../../utils/form'; -import {getSiteProducts, getSitePrices, hasAvailablePrices, hasOnlyFreePlan, isInviteOnly, isFreeSignupAllowed, isPaidMembersOnly, freeHasBenefitsOrDescription, hasMultipleNewsletters, hasFreeTrialTier, isSignupAllowed, isSigninAllowed} from '../../utils/helpers'; +import {getSiteProducts, getSitePrices, hasAvailablePrices, hasOnlyFreePlan, isInviteOnly, isFreeSignupAllowed, isPaidMembersOnly, freeHasBenefitsOrDescription, hasMultipleNewsletters, hasFreeTrialTier, isSignupAllowed, isSigninAllowed, hasCaptchaEnabled, getCaptchaSitekey} from '../../utils/helpers'; import {ReactComponent as InvitationIcon} from '../../images/icons/invitation.svg'; import {interceptAnchorClicks} from '../../utils/links'; +import HCaptcha from '@hcaptcha/react-hcaptcha'; export const SignupPageStyles = ` .gh-portal-back-sitetitle { @@ -356,6 +357,7 @@ class SignupPage extends React.Component { }; this.termsRef = React.createRef(); + this.captchaRef = React.createRef(); } componentDidMount() { @@ -400,6 +402,16 @@ class SignupPage extends React.Component { }; } + doSignupWithChecks() { + const {site} = this.context; + if (hasCaptchaEnabled({site})) { + // hCaptcha's callback will call doSignup + return this.captchaRef.current.execute(); + } else { + this.doSignup(); + } + } + doSignup() { this.setState((state) => { return { @@ -407,7 +419,7 @@ class SignupPage extends React.Component { }; }, () => { const {site, onAction} = this.context; - const {name, email, plan, phonenumber, errors} = this.state; + const {name, email, plan, phonenumber, token, errors} = this.state; const hasFormErrors = (errors && Object.values(errors).filter(d => !!d).length > 0); // Only scroll checkbox into view if it's the only error @@ -423,14 +435,14 @@ class SignupPage extends React.Component { if (hasMultipleNewsletters({site})) { this.setState({ showNewsletterSelection: true, - pageData: {name, email, plan, phonenumber}, + pageData: {name, email, plan, phonenumber, token}, errors: {} }); } else { this.setState({ errors: {} }); - onAction('signup', {name, email, phonenumber, plan}); + onAction('signup', {name, email, phonenumber, plan, token}); } } }); @@ -444,7 +456,7 @@ class SignupPage extends React.Component { handleChooseSignup(e, plan) { e.preventDefault(); this.setState({plan}, () => { - this.doSignup(); + this.doSignupWithChecks(); }); } @@ -732,6 +744,16 @@ class SignupPage extends React.Component { onChange={(e, field) => this.handleInputChange(e, field)} onKeyDown={e => this.onKeyDown(e)} /> + {(hasCaptchaEnabled({site}) && + this.setState({captchaLoaded: true})} + onVerify={token => this.setState({token: token}, this.doSignup)} + ref={this.captchaRef} + id="hcaptcha-signup" + /> + )}
    {(hasOnlyFree ? diff --git a/apps/portal/src/components/pages/SignupPage.test.js b/apps/portal/src/components/pages/SignupPage.test.js index 852934211ce..5e69b1564f9 100644 --- a/apps/portal/src/components/pages/SignupPage.test.js +++ b/apps/portal/src/components/pages/SignupPage.test.js @@ -1,6 +1,6 @@ import SignupPage from './SignupPage'; import {getFreeProduct, getProductData, getSiteData} from '../../utils/fixtures-generator'; -import {render, fireEvent, getByTestId, queryByTestId} from '../../utils/test-utils'; +import {render, fireEvent, getByTestId, queryByTestId, queryByAttribute} from '../../utils/test-utils'; const setup = (overrides) => { const {mockOnActionFn, ...utils} = render( @@ -212,4 +212,21 @@ describe('SignupPage', () => { expect(signinLink).toBeInTheDocument(); }); }); + + // Cannot test using hCaptcha component, as it cannot run in a test environment + describe('when captcha is enabled', () => { + test('renders', () => { + setup({ + site: getSiteData({ + captchaEnabled: true, + captchaSiteKey: '20000000-ffff-ffff-ffff-000000000002' + }) + }); + + const getById = queryByAttribute.bind(null, 'id'); + + const hcaptchaElement = getById(document.body, 'hcaptcha-signup'); + expect(hcaptchaElement).toBeInTheDocument(); + }); + }); }); diff --git a/apps/portal/src/utils/api.js b/apps/portal/src/utils/api.js index 55bd0a8536a..41abc9263e3 100644 --- a/apps/portal/src/utils/api.js +++ b/apps/portal/src/utils/api.js @@ -262,7 +262,7 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}) { } }, - async sendMagicLink({email, emailType, labels, name, oldEmail, newsletters, redirect, integrityToken, phonenumber, customUrlHistory, autoRedirect = true}) { + async sendMagicLink({email, emailType, labels, name, oldEmail, newsletters, redirect, integrityToken, phonenumber, customUrlHistory, token, autoRedirect = true}) { const url = endpointFor({type: 'members', resource: 'send-magic-link'}); const body = { name, @@ -274,7 +274,9 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}) { requestSrc: 'portal', redirect, integrityToken, - honeypot: phonenumber, // we don't actually use a phone #, this is from a hidden field to prevent bot activity + // we don't actually use a phone #, this is from a hidden field to prevent bot activity + honeypot: phonenumber, + token, autoRedirect }; const urlHistory = customUrlHistory ?? getUrlHistory(); diff --git a/apps/portal/src/utils/fixtures-generator.js b/apps/portal/src/utils/fixtures-generator.js index 72f8075e80a..55f1455018f 100644 --- a/apps/portal/src/utils/fixtures-generator.js +++ b/apps/portal/src/utils/fixtures-generator.js @@ -43,7 +43,9 @@ export function getSiteData({ posts = getPostsData(), commentsEnabled, recommendations = [], - recommendationsEnabled + recommendationsEnabled, + captchaEnabled = false, + captchaSiteKey } = {}) { return { title, @@ -71,7 +73,9 @@ export function getSiteData({ recommendations, recommendations_enabled: !!recommendationsEnabled, editor_default_email_recipients, - posts + posts, + captcha_enabled: !!captchaEnabled, + captcha_sitekey: captchaSiteKey }; } diff --git a/apps/portal/src/utils/helpers.js b/apps/portal/src/utils/helpers.js index ce70d3b9a3d..df0472b7aa3 100644 --- a/apps/portal/src/utils/helpers.js +++ b/apps/portal/src/utils/helpers.js @@ -511,6 +511,14 @@ export function getSiteNewsletters({site}) { return newsletters; } +export function hasCaptchaEnabled({site}) { + return site?.captcha_enabled === true; +} + +export function getCaptchaSitekey({site}) { + return site?.captcha_sitekey || ''; +} + export function hasMultipleNewsletters({site}) { const { newsletters diff --git a/yarn.lock b/yarn.lock index 5ba170a3f82..1fcff823eb6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1875,6 +1875,13 @@ dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.17.9": + version "7.26.0" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.26.0.tgz#8600c2f595f277c60815256418b85356a65173c1" + integrity sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/template@^7.20.7", "@babel/template@^7.22.15", "@babel/template@^7.24.7", "@babel/template@^7.3.3": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.24.7.tgz#02efcee317d0609d2c07117cb70ef8fb17ab7315" @@ -3337,6 +3344,19 @@ resolved "https://registry.yarnpkg.com/@handlebars/parser/-/parser-2.0.0.tgz#5e8b7298f31ff8f7b260e6b7363c7e9ceed7d9c5" integrity sha512-EP9uEDZv/L5Qh9IWuMUGJRfwhXJ4h1dqKTT4/3+tY0eu7sPis7xh23j61SYUnNF4vqCQvvUXpDo9Bh/+q1zASA== +"@hcaptcha/loader@^1.2.1": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@hcaptcha/loader/-/loader-1.2.4.tgz#541714395a82e27ec0f0e8bd80ef1a0bea141cc3" + integrity sha512-3MNrIy/nWBfyVVvMPBKdKrX7BeadgiimW0AL/a/8TohNtJqxoySKgTJEXOQvYwlHemQpUzFrIsK74ody7JiMYw== + +"@hcaptcha/react-hcaptcha@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@hcaptcha/react-hcaptcha/-/react-hcaptcha-1.11.1.tgz#cc0dd0b180b3458ca7b0f8cf1446f09afca2ec55" + integrity sha512-g6TwatNIzBtOR3RM4mxzvTUQGs5T9HMN+4fcNGHn7wUVThvmazThUs0vImI836bSkGpJS8n0rOYvv1UZ47q8Vw== + dependencies: + "@babel/runtime" "^7.17.9" + "@hcaptcha/loader" "^1.2.1" + "@headlessui/react@1.7.19": version "1.7.19" resolved "https://registry.yarnpkg.com/@headlessui/react/-/react-1.7.19.tgz#91c78cf5fcb254f4a0ebe96936d48421caf75f40" From 19d9c3e3e2cb847bc2b20f9adff631e8b7bcc986 Mon Sep 17 00:00:00 2001 From: Peter Zimon Date: Thu, 23 Jan 2025 16:48:29 +0100 Subject: [PATCH 77/90] Post analytics router update (#22050) ref https://linear.app/ghost/issue/DES-1082/router-prototype This task is about testing, figuring out pros and cons of React Router compared to our current (custom) router, and what effort and risks are involved in migrating to it. --- apps/admin-x-framework/package.json | 3 +- apps/admin-x-framework/src/index.ts | 13 ++- .../src/providers/RouterProvider.tsx | 81 +++++++++++++++++++ apps/posts/package.json | 4 +- apps/posts/src/App.tsx | 7 +- apps/posts/src/components/Header.tsx | 71 +++++++++------- apps/posts/src/routes.ts | 20 +++-- .../views/post-analytics/PostAnalytics.tsx | 7 +- .../post-analytics/modals/ShareModal.tsx | 16 ++++ apps/posts/src/views/posts/Posts.tsx | 21 +++++ .../src/components/layout/error-page.tsx | 23 ++++++ apps/shade/src/components/ui/card.stories.tsx | 2 +- .../src/components/ui/dialog.stories.tsx | 10 ++- apps/shade/src/index.ts | 1 + .../admin/app/components/gh-nav-menu/main.hbs | 2 +- ghost/admin/app/router.js | 3 - 16 files changed, 229 insertions(+), 55 deletions(-) create mode 100644 apps/admin-x-framework/src/providers/RouterProvider.tsx create mode 100644 apps/posts/src/views/post-analytics/modals/ShareModal.tsx create mode 100644 apps/posts/src/views/posts/Posts.tsx create mode 100644 apps/shade/src/components/layout/error-page.tsx diff --git a/apps/admin-x-framework/package.json b/apps/admin-x-framework/package.json index 2c22e5288dc..cf474f2eb50 100644 --- a/apps/admin-x-framework/package.json +++ b/apps/admin-x-framework/package.json @@ -91,6 +91,7 @@ "@vitejs/plugin-react": "4.2.1", "react": "18.3.1", "react-dom": "18.3.1", + "react-router": "^7.1.3", "vite": "4.5.3", "vite-plugin-css-injected-by-js": "^3.3.0", "vite-plugin-svgr": "3.3.0", @@ -116,4 +117,4 @@ } } } -} \ No newline at end of file +} diff --git a/apps/admin-x-framework/src/index.ts b/apps/admin-x-framework/src/index.ts index f8766594214..06d0cbf98d2 100644 --- a/apps/admin-x-framework/src/index.ts +++ b/apps/admin-x-framework/src/index.ts @@ -1,6 +1,13 @@ -export {FrameworkProvider, useFramework} from './providers/FrameworkProvider'; +// Framework export type {FrameworkContextType, FrameworkProviderProps, TopLevelFrameworkProps} from './providers/FrameworkProvider'; +export {FrameworkProvider, useFramework} from './providers/FrameworkProvider'; -export {useQueryClient} from '@tanstack/react-query'; -export type {InfiniteData} from '@tanstack/react-query'; +// Routing +export type {RouteObject} from 'react-router'; +export type {RouterProviderProps} from './providers/RouterProvider'; +export {RouterProvider, useNavigate} from './providers/RouterProvider'; +export {useLocation, useParams, useSearchParams, Outlet} from 'react-router'; +// Data fetching +export type {InfiniteData} from '@tanstack/react-query'; +export {useQueryClient} from '@tanstack/react-query'; diff --git a/apps/admin-x-framework/src/providers/RouterProvider.tsx b/apps/admin-x-framework/src/providers/RouterProvider.tsx new file mode 100644 index 00000000000..b039d3a2960 --- /dev/null +++ b/apps/admin-x-framework/src/providers/RouterProvider.tsx @@ -0,0 +1,81 @@ +import {ErrorPage} from '@tryghost/shade'; +import React, {useCallback, useMemo} from 'react'; +import {createHashRouter, RouteObject, RouterProvider as ReactRouterProvider, NavigateOptions as ReactRouterNavigateOptions, useNavigate as useReactRouterNavigate} from 'react-router'; +import {useFramework} from './FrameworkProvider'; + +/** + * READ THIS BEFORE USING THIS PROVIDER + * + * This is an experimental provider that tests using React Router to provide + * a router context to React apps in Ghost. + * + * It is not ready for production yet. For apps in production, use the custom + * RoutingProvider. + */ + +/** + * Wrap React Router in a custom provider to provide a standard, simplified + * interface for all Ghost apps for routing. It also sanitizes the routes and + * adds a default error element. + */ +export interface RouterProviderProps { + routes: RouteObject[]; + prefix?: string; + + // Custom routing props + errorElement?: React.ReactNode; +} + +export function RouterProvider({ + routes, + prefix, + errorElement +}: RouterProviderProps) { + // Memoize the router to avoid re-creating it on every render + const router = useMemo(() => { + // Ensure prefix has a leading slash and no double+ or trailing slashes + const normalizedPrefix = `/${prefix?.replace(/\/+/g, '/').replace(/^\/|\/$/g, '')}`; + + // Add default error element if not provided + const finalRoutes = routes.map(route => ({ + ...route, + errorElement: route.errorElement || errorElement || + })); + + return createHashRouter(finalRoutes, { + basename: normalizedPrefix + }); + }, [routes, prefix, errorElement]); + + return ( + + ); +} + +/** + * Override the default navigate function to add the crossApp option. This is + * used to determine if the navigate should be handled by the custom router, ie. + * if we need to navigate outside of the current app in Ghost. + */ +interface NavigateOptions extends ReactRouterNavigateOptions { + crossApp?: boolean; +} + +export function useNavigate() { + const navigate = useReactRouterNavigate(); + const {externalNavigate} = useFramework(); + + return useCallback(( + to: string, + options?: NavigateOptions + ) => { + if (options?.crossApp) { + externalNavigate({route: to, isExternal: true}); + return; + } + + navigate(to, options); + }, [navigate, externalNavigate]); +} diff --git a/apps/posts/package.json b/apps/posts/package.json index 0c51c321116..8997a01b964 100644 --- a/apps/posts/package.json +++ b/apps/posts/package.json @@ -52,7 +52,5 @@ } } }, - "dependencies": { - "react-router": "^7.1.3" - } + "dependencies": {} } diff --git a/apps/posts/src/App.tsx b/apps/posts/src/App.tsx index 94899476e4e..72c6a102888 100644 --- a/apps/posts/src/App.tsx +++ b/apps/posts/src/App.tsx @@ -1,7 +1,6 @@ -import {FrameworkProvider, TopLevelFrameworkProps} from '@tryghost/admin-x-framework'; -import {RouterProvider} from 'react-router'; +import {APP_ROUTE_PREFIX, routes} from './routes'; +import {FrameworkProvider, RouterProvider, TopLevelFrameworkProps} from '@tryghost/admin-x-framework'; import {ShadeApp, ShadeAppProps, SidebarProvider} from '@tryghost/shade'; -import {router} from './routes'; interface AppProps { framework: TopLevelFrameworkProps; @@ -13,7 +12,7 @@ const App: React.FC = ({framework, designSystem}) => { - + diff --git a/apps/posts/src/components/Header.tsx b/apps/posts/src/components/Header.tsx index f8b2d50c0b1..6e1bf1179ec 100644 --- a/apps/posts/src/components/Header.tsx +++ b/apps/posts/src/components/Header.tsx @@ -1,15 +1,30 @@ -import {Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuTrigger, H1, LucideIcon} from '@tryghost/shade'; +import ShareModal from '../views/post-analytics/modals/ShareModal'; +import {Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, Button, Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuTrigger, H1, LucideIcon} from '@tryghost/shade'; +import {useLocation, useNavigate, useParams} from '@tryghost/admin-x-framework'; interface headerProps {}; const Header: React.FC = () => { + const navigate = useNavigate(); + const location = useLocation(); + const {postId} = useParams(); + + // Handling the share dialog via navigation + const isShareDialogOpen = location.pathname === `/analytics/${postId}/share`; + const openShareDialog = () => { + navigate(`/analytics/${postId}/share`); + }; + const closeShareDialog = () => { + navigate(`/analytics/${postId}`); + }; + return (
    - + navigate('/')}> Posts @@ -22,36 +37,38 @@ const Header: React.FC = () => {
    + + + + - - - - + + + + + + + Edit post + ⇧⌘E + + + View in browser + ⇧⌘O + + + + + Delete + + + + + - Share - - This is a dialog, lets see how it works. - + Are you sure you want to delete this post? - - - - - - - Edit post - ⇧⌘E - - - View in browser - ⇧⌘O - - - Delete - -

    The Evolution of Basketball: From Pastime to Professional and One of the Most Popular Sports

    diff --git a/apps/posts/src/routes.ts b/apps/posts/src/routes.ts index 8e1f20afd82..11d75ea3532 100644 --- a/apps/posts/src/routes.ts +++ b/apps/posts/src/routes.ts @@ -1,14 +1,18 @@ import Newsletter from './views/post-analytics/components/Newsletter'; import Overview from './views/post-analytics/components/Overview'; import PostAnalytics from './views/post-analytics/PostAnalytics'; -import {createHashRouter} from 'react-router'; +import Posts from './views/posts/Posts'; +import {RouteObject} from '@tryghost/admin-x-framework'; -export const BASE_PATH = '/posts-x'; -export const ANALYTICS = `${BASE_PATH}/analytics`; +export const APP_ROUTE_PREFIX = '/posts-x'; -const postAnalyticsRoutes = [ +export const routes: RouteObject[] = [ { - path: `${BASE_PATH}/analytics/:postId`, + path: '', + Component: Posts + }, + { + path: 'analytics/:postId', Component: PostAnalytics, children: [ { @@ -22,9 +26,11 @@ const postAnalyticsRoutes = [ { path: 'newsletter', Component: Newsletter + }, + { + path: 'share', + Component: Overview } ] } ]; - -export const router = createHashRouter(postAnalyticsRoutes); diff --git a/apps/posts/src/views/post-analytics/PostAnalytics.tsx b/apps/posts/src/views/post-analytics/PostAnalytics.tsx index ebb78a58f63..39e6e0dc6c5 100644 --- a/apps/posts/src/views/post-analytics/PostAnalytics.tsx +++ b/apps/posts/src/views/post-analytics/PostAnalytics.tsx @@ -1,6 +1,5 @@ import Header from '../../components/Header'; -import {ANALYTICS} from '../../routes'; -import {Outlet, useLocation, useNavigate, useParams} from 'react-router'; +import {Outlet, useLocation, useNavigate, useParams} from '@tryghost/admin-x-framework'; import {Page, Tabs, TabsList, TabsTrigger} from '@tryghost/shade'; interface postAnalyticsProps {}; @@ -17,9 +16,9 @@ const PostAnalytics: React.FC = () => { const handleTabChange = (value: string) => { if (value === 'overview') { - navigate(`${ANALYTICS}/${postId}`); + navigate(`analytics/${postId}`); } else { - navigate(`${ANALYTICS}/${postId}/${value}`); + navigate(`analytics/${postId}/${value}`); } }; diff --git a/apps/posts/src/views/post-analytics/modals/ShareModal.tsx b/apps/posts/src/views/post-analytics/modals/ShareModal.tsx new file mode 100644 index 00000000000..ba0af18e66a --- /dev/null +++ b/apps/posts/src/views/post-analytics/modals/ShareModal.tsx @@ -0,0 +1,16 @@ +import {DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@tryghost/shade'; + +const ShareModal = () => { + return ( + + + Share + + This is a dialog opened with router and with a custom width. + + + + ); +}; + +export default ShareModal; \ No newline at end of file diff --git a/apps/posts/src/views/posts/Posts.tsx b/apps/posts/src/views/posts/Posts.tsx new file mode 100644 index 00000000000..86f0e8d52b4 --- /dev/null +++ b/apps/posts/src/views/posts/Posts.tsx @@ -0,0 +1,21 @@ +import {Button, H1, Page} from '@tryghost/shade'; +import {useNavigate} from '@tryghost/admin-x-framework'; + +interface postAnalyticsProps {}; + +const Posts: React.FC = () => { + const navigate = useNavigate(); + + return ( + +

    Posts

    +
    + + + +
    +
    + ); +}; + +export default Posts; diff --git a/apps/shade/src/components/layout/error-page.tsx b/apps/shade/src/components/layout/error-page.tsx new file mode 100644 index 00000000000..6625e589d87 --- /dev/null +++ b/apps/shade/src/components/layout/error-page.tsx @@ -0,0 +1,23 @@ +import {cn} from '@/lib/utils'; +import * as React from 'react'; + +export interface ErrorPageProps + extends React.HTMLAttributes {} + +const ErrorPage = React.forwardRef( + ({className, ...props}, ref) => { + return ( +
    +

    Error

    +
    + ); + } +); + +ErrorPage.displayName = 'ErrorPage'; + +export {ErrorPage}; diff --git a/apps/shade/src/components/ui/card.stories.tsx b/apps/shade/src/components/ui/card.stories.tsx index e81ca6418ca..469439e9eba 100644 --- a/apps/shade/src/components/ui/card.stories.tsx +++ b/apps/shade/src/components/ui/card.stories.tsx @@ -24,7 +24,7 @@ export const Default: Story = { Card contents , - + diff --git a/apps/shade/src/components/ui/dialog.stories.tsx b/apps/shade/src/components/ui/dialog.stories.tsx index 4a4727b8fce..eaaaacc6b3d 100644 --- a/apps/shade/src/components/ui/dialog.stories.tsx +++ b/apps/shade/src/components/ui/dialog.stories.tsx @@ -1,5 +1,5 @@ import type {Meta, StoryObj} from '@storybook/react'; -import {Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle} from './dialog'; +import {Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription} from './dialog'; import {Button} from './button'; const meta = { @@ -26,7 +26,15 @@ export const Default: Story = { Are you absolutely sure? + + This action cannot be undone. Are you sure you want to permanently + delete this file from our servers? + + + + + ) diff --git a/apps/shade/src/index.ts b/apps/shade/src/index.ts index f45cd4a989f..41f51c94803 100644 --- a/apps/shade/src/index.ts +++ b/apps/shade/src/index.ts @@ -19,6 +19,7 @@ export * from './components/ui/tooltip'; // Layout components export * from './components/layout/page'; +export * from './components/layout/error-page'; export * from './components/layout/heading'; // Third party components diff --git a/ghost/admin/app/components/gh-nav-menu/main.hbs b/ghost/admin/app/components/gh-nav-menu/main.hbs index 1a5392416fa..b1d75ec9836 100644 --- a/ghost/admin/app/components/gh-nav-menu/main.hbs +++ b/ghost/admin/app/components/gh-nav-menu/main.hbs @@ -164,7 +164,7 @@ {{/if}} {{#if (feature "postsX")}}
  • - {{svg-jar "chart"}}Post analytics + {{svg-jar "chart"}}Post analytics
  • {{/if}} diff --git a/ghost/admin/app/router.js b/ghost/admin/app/router.js index 5c0a1352a1a..5fa7d260a08 100644 --- a/ghost/admin/app/router.js +++ b/ghost/admin/app/router.js @@ -54,9 +54,6 @@ Router.map(function () { this.route('posts-x', function () { this.route('posts-x', {path: '/*sub'}); - this.route('analytics', function () { - this.route('123'); - }); }); this.route('settings-x', {path: '/settings'}, function () { From 8c2e62dc23043e03bdafa2ab0821dbccdfbabc18 Mon Sep 17 00:00:00 2001 From: Djordje Vlaisavljevic Date: Thu, 23 Jan 2025 17:35:05 +0000 Subject: [PATCH 78/90] Improved ActivityPub design (#22051) ref https://linear.app/ghost/issue/AP-677/standardize-border-radius-used-in-avatars, https://linear.app/ghost/issue/AP-680/standardize-font-sizes-colors-and-weights, https://linear.app/ghost/issue/AP-676/improve-the-sidebar-widget - Ensured consistent use of border-radius in avatars - Removed onClick for large avatars, since we only use them when you're already viewing someone's profile - Updated font colors, weights and sizes for consistency - Updated design of the sidebar widget in (simpler design, less lines, tighter spacing= - "Explore" button looks more like what we use in settings and dashboard --- apps/admin-x-activitypub/package.json | 2 +- .../src/components/Inbox.tsx | 10 +++---- .../src/components/Search.tsx | 4 +-- .../src/components/feed/ArticleModal.tsx | 2 +- .../src/components/feed/FeedItem.tsx | 9 +++---- .../src/components/global/APAvatar.tsx | 26 ++++++++++--------- apps/admin-x-activitypub/src/styles/index.css | 5 ++-- 7 files changed, 28 insertions(+), 30 deletions(-) diff --git a/apps/admin-x-activitypub/package.json b/apps/admin-x-activitypub/package.json index 1d51e5499d6..26a555f3578 100644 --- a/apps/admin-x-activitypub/package.json +++ b/apps/admin-x-activitypub/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/admin-x-activitypub", - "version": "0.3.53", + "version": "0.3.54", "license": "MIT", "repository": { "type": "git", diff --git a/apps/admin-x-activitypub/src/components/Inbox.tsx b/apps/admin-x-activitypub/src/components/Inbox.tsx index 4fb7fc75372..9f4c3e5b546 100644 --- a/apps/admin-x-activitypub/src/components/Inbox.tsx +++ b/apps/admin-x-activitypub/src/components/Inbox.tsx @@ -137,9 +137,9 @@ const Inbox: React.FC = ({layout}) => {
    -

    This is your {layout === 'inbox' ? 'inbox' : 'feed'}

    -

    You'll find {layout === 'inbox' ? 'long-form content' : 'short posts and updates'} from the accounts you follow here.

    -

    You might also like

    +

    This is your {layout === 'inbox' ? 'inbox' : 'feed'}

    +

    You'll find {layout === 'inbox' ? 'long-form content' : 'short posts and updates'} from the accounts you follow here.

    +

    You might also like

    {isLoadingSuggested ? ( ) : ( @@ -154,7 +154,7 @@ const Inbox: React.FC = ({layout}) => { >
    - {getName(actor)} + {getName(actor)} {getUsername(actor)}
    @@ -165,7 +165,7 @@ const Inbox: React.FC = ({layout}) => { })} )} -
    diff --git a/apps/admin-x-activitypub/src/components/Search.tsx b/apps/admin-x-activitypub/src/components/Search.tsx index 2c63000fab9..7062408724d 100644 --- a/apps/admin-x-activitypub/src/components/Search.tsx +++ b/apps/admin-x-activitypub/src/components/Search.tsx @@ -59,7 +59,7 @@ const AccountSearchResultItem: React.FC = ({accoun }}/>
    - {account.name} {account.handle} + {account.name} {account.handle}
    {new Intl.NumberFormat().format(account.followerCount)} followers
    @@ -124,7 +124,7 @@ const SuggestedProfile: React.FC = ({profile, update}) =>
    - {profile.actor.name} {profile.handle} + {profile.actor.name} {profile.handle}
    {new Intl.NumberFormat().format(profile.followerCount)} followers
    diff --git a/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx b/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx index d1a2ee92cc6..67461928c1a 100644 --- a/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx +++ b/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx @@ -718,7 +718,7 @@ const ArticleModal: React.FC = ({
    - {actor.name} + {actor.name}
    {getUsername(actor)} diff --git a/apps/admin-x-activitypub/src/components/feed/FeedItem.tsx b/apps/admin-x-activitypub/src/components/feed/FeedItem.tsx index 068c5b888e2..410d8960f67 100644 --- a/apps/admin-x-activitypub/src/components/feed/FeedItem.tsx +++ b/apps/admin-x-activitypub/src/components/feed/FeedItem.tsx @@ -7,7 +7,6 @@ import APAvatar from '../global/APAvatar'; import FeedItemStats from './FeedItemStats'; import clsx from 'clsx'; import getReadingTime from '../../utils/get-reading-time'; -import getRelativeTimestamp from '../../utils/get-relative-timestamp'; import getUsername from '../../utils/get-username'; import stripHtml from '../../utils/strip-html'; import {handleProfileClick} from '../../utils/handle-profile-click'; @@ -161,8 +160,6 @@ const FeedItem: React.FC = ({actor, object, layout, type, comment const timestamp = new Date(object?.published ?? new Date()).toLocaleDateString('default', {year: 'numeric', month: 'short', day: '2-digit'}) + ', ' + new Date(object?.published ?? new Date()).toLocaleTimeString('default', {hour: '2-digit', minute: '2-digit'}); - const date = new Date(object?.published ?? new Date()); - const [, setIsCopied] = useState(false); const contentRef = useRef(null); @@ -324,7 +321,7 @@ const FeedItem: React.FC = ({actor, object, layout, type, comment
    - {author.name} + {author.name}
    {renderTimestamp(object)}
    @@ -375,7 +372,7 @@ const FeedItem: React.FC = ({actor, object, layout, type, comment
    - {author.name} + {author.name}
    {renderTimestamp(object)}
    @@ -432,7 +429,7 @@ const FeedItem: React.FC = ({actor, object, layout, type, comment onClick={e => handleProfileClick(actor, e)} >{author.name} - {getRelativeTimestamp(date)} + {renderTimestamp(object)}
    diff --git a/apps/admin-x-activitypub/src/components/global/APAvatar.tsx b/apps/admin-x-activitypub/src/components/global/APAvatar.tsx index 62190fe86fa..d9ed572e311 100644 --- a/apps/admin-x-activitypub/src/components/global/APAvatar.tsx +++ b/apps/admin-x-activitypub/src/components/global/APAvatar.tsx @@ -21,8 +21,8 @@ interface APAvatarProps { const APAvatar: React.FC = ({author, size}) => { let iconSize = 18; - let containerClass = `shrink-0 items-center justify-center relative cursor-pointer z-10 flex ${size === 'lg' ? '' : 'hover:opacity-80'}`; - let imageClass = 'z-10 rounded-md w-10 h-10 object-cover'; + let containerClass = `shrink-0 items-center justify-center overflow-hidden relative z-10 flex ${size === 'lg' ? '' : 'hover:opacity-80 cursor-pointer'}`; + let imageClass = 'z-10 object-cover'; const [iconUrl, setIconUrl] = useState(author?.icon?.url); useEffect(() => { @@ -36,28 +36,30 @@ const APAvatar: React.FC = ({author, size}) => { switch (size) { case '2xs': iconSize = 10; - containerClass = clsx('h-4 w-4 rounded-md ', containerClass); - imageClass = 'z-10 rounded-md w-4 h-4 object-cover'; + containerClass = clsx('h-4 w-4 rounded-md', containerClass); + imageClass = clsx('h-4 w-4', imageClass); break; case 'xs': iconSize = 12; - containerClass = clsx('h-6 w-6 rounded-md ', containerClass); - imageClass = 'z-10 rounded-md w-6 h-6 object-cover'; + containerClass = clsx('h-6 w-6 rounded-lg', containerClass); + imageClass = clsx('h-6 w-6', imageClass); break; case 'notification': iconSize = 12; - containerClass = clsx('h-9 w-9 rounded-md', containerClass); - imageClass = 'z-10 rounded-xl w-9 h-9 object-cover'; + containerClass = clsx('h-9 w-9 rounded-lg', containerClass); + imageClass = clsx('h-9 w-9', imageClass); break; case 'sm': - containerClass = clsx('h-10 w-10 rounded-md', containerClass); + containerClass = clsx('h-10 w-10 rounded-xl', containerClass); + imageClass = clsx('h-10 w-10', imageClass); break; case 'lg': containerClass = clsx('h-22 w-22 rounded-xl', containerClass); - imageClass = 'z-10 rounded-xl w-22 h-22 object-cover'; + imageClass = clsx('h-22 w-22', imageClass); break; default: - containerClass = clsx('h-10 w-10 rounded-md', containerClass); + containerClass = clsx('h-10 w-10 rounded-lg', containerClass); + imageClass = clsx('h-10 w-10', imageClass); break; } @@ -79,7 +81,7 @@ const APAvatar: React.FC = ({author, size}) => {
    * + * { - margin-top: 1.6rem !important; + margin-top: 1.65rem !important; } .ap-note-content > h1 + *, From 9589a9168401cbd897b6b2f65cc611a96f25c909 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20van=20der=20Winden?= Date: Fri, 24 Jan 2025 14:11:07 +0100 Subject: [PATCH 79/90] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20tags=20and=20autho?= =?UTF-8?q?rs=20not=20fitting=20in=20the=20input=20field=20(#22052)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Input fields for tags and authors in the post sidebar were hard to use; they became scrollable if you added more than one line of either. This fix addresses that; the input field now grows in size to accommodate for the number of tags or authors you enter. fixes https://linear.app/ghost/issue/DES-1087/overflow-on-boxes-in-post-settings-too-small, https://linear.app/ghost/issue/DES-1084/tag-field-in-post-settings-menu-is-difficult-to-work-now-with-when --- .../app/styles/components/power-select.css | 3 +- ghost/admin/app/styles/patterns/forms.css | 32 +++++++++++++++++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/ghost/admin/app/styles/components/power-select.css b/ghost/admin/app/styles/components/power-select.css index cc1ed01aa7c..5cf220442ea 100644 --- a/ghost/admin/app/styles/components/power-select.css +++ b/ghost/admin/app/styles/components/power-select.css @@ -309,7 +309,8 @@ } .ember-power-select-status-icon { - top: -4px; + position: absolute; + top: 13px; right: 13px; border: solid var(--midlightgrey); border-width: 0 1px 1px 0; diff --git a/ghost/admin/app/styles/patterns/forms.css b/ghost/admin/app/styles/patterns/forms.css index 922d4d4077f..d1d3a81b99c 100644 --- a/ghost/admin/app/styles/patterns/forms.css +++ b/ghost/admin/app/styles/patterns/forms.css @@ -282,7 +282,8 @@ textarea { .gh-input-x { width: 100%; - height: 38px; + min-height: 38px; + height: auto; padding: 4px 12px; font-size: 1.4rem; color: var(--black); @@ -305,7 +306,34 @@ textarea.gh-input-x { } .ember-power-select-multiple-trigger { - padding-left: 4px; + padding: 4px; + min-height: 38px; + display: grid; + grid-template-columns: 1fr 24px; + position: relative; +} + +.ember-power-select-multiple-options { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + margin: 0; + padding: 0; +} + +.ember-power-select-trigger-multiple-input { + margin: 2px; + padding: 0; + border: 0; + background: none; + min-width: 60px; +} + +.ember-basic-dropdown-trigger[aria-expanded="true"] .ember-power-select-status-icon, +.ember-power-select-status-icon { + margin: 0 auto; + transform: none; } From d2c868db00cb62825302e059bfe5bcaf953d25a5 Mon Sep 17 00:00:00 2001 From: Ghost CI <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 24 Jan 2025 15:07:51 +0000 Subject: [PATCH 80/90] v5.108.0 --- ghost/admin/package.json | 2 +- ghost/core/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ghost/admin/package.json b/ghost/admin/package.json index 2cd6914487a..375e2185ce0 100644 --- a/ghost/admin/package.json +++ b/ghost/admin/package.json @@ -1,6 +1,6 @@ { "name": "ghost-admin", - "version": "5.107.2", + "version": "5.108.0", "description": "Ember.js admin client for Ghost", "author": "Ghost Foundation", "homepage": "http://ghost.org", diff --git a/ghost/core/package.json b/ghost/core/package.json index 8190ca2df04..15b9199f4ad 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -1,6 +1,6 @@ { "name": "ghost", - "version": "5.107.2", + "version": "5.108.0", "description": "The professional publishing platform", "author": "Ghost Foundation", "homepage": "https://ghost.org", From 3e2658baa0fe76ec0ee68b8cca439427adf4e09a Mon Sep 17 00:00:00 2001 From: Ghost CI <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 26 Jan 2025 17:53:15 +0000 Subject: [PATCH 81/90] v5.108.1 --- ghost/admin/package.json | 2 +- ghost/core/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ghost/admin/package.json b/ghost/admin/package.json index 375e2185ce0..1e21da93267 100644 --- a/ghost/admin/package.json +++ b/ghost/admin/package.json @@ -1,6 +1,6 @@ { "name": "ghost-admin", - "version": "5.108.0", + "version": "5.108.1", "description": "Ember.js admin client for Ghost", "author": "Ghost Foundation", "homepage": "http://ghost.org", diff --git a/ghost/core/package.json b/ghost/core/package.json index 15b9199f4ad..aac4af6a3b2 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -1,6 +1,6 @@ { "name": "ghost", - "version": "5.108.0", + "version": "5.108.1", "description": "The professional publishing platform", "author": "Ghost Foundation", "homepage": "https://ghost.org", From 439bbf8b79042fd6e227bb14a1ecc55891d77c8d Mon Sep 17 00:00:00 2001 From: Sam Lord Date: Mon, 27 Jan 2025 15:57:31 +0000 Subject: [PATCH 82/90] Use Captcha middleware in members API ref BAE-104 The members send-magic-link API should be protected by Captcha. This required initialising the Captcha service in the members API, and putting the middleware into the send-magic-link API. If it's enabled via lab flag and config, then the service will prevent API calls that don't have a valid Captcha response. --- .../server/api/endpoints/settings-public.js | 16 ++- .../core/core/server/services/members/api.js | 6 + ghost/core/package.json | 1 + .../__snapshots__/settings.test.js.snap | 107 ++++++++++++++++++ .../test/e2e-api/content/settings.test.js | 30 +++++ ghost/members-api/lib/members-api.js | 2 + 6 files changed, 161 insertions(+), 1 deletion(-) diff --git a/ghost/core/core/server/api/endpoints/settings-public.js b/ghost/core/core/server/api/endpoints/settings-public.js index 72de70973e3..730a2b582d8 100644 --- a/ghost/core/core/server/api/endpoints/settings-public.js +++ b/ghost/core/core/server/api/endpoints/settings-public.js @@ -1,6 +1,19 @@ const settingsCache = require('../../../shared/settings-cache'); const urlUtils = require('../../../shared/url-utils'); const ghostVersion = require('@tryghost/version'); +const config = require('../../../shared/config'); +const labs = require('../../../shared/labs'); + +const getCaptchaSettings = () => { + if (labs.isSet('captcha')) { + return { + captcha_enabled: config.get('captcha:enabled'), + captcha_sitekey: config.get('captcha:siteKey') + }; + } else { + return {}; + } +}; /** @type {import('@tryghost/api-framework').Controller} */ const controller = { @@ -18,7 +31,8 @@ const controller = { settingsCache.getPublic(), { url: urlUtils.urlFor('home', true), version: ghostVersion.safe - } + }, + getCaptchaSettings() ); } } diff --git a/ghost/core/core/server/services/members/api.js b/ghost/core/core/server/services/members/api.js index 26f5d46d7ca..cbdcd18a7cd 100644 --- a/ghost/core/core/server/services/members/api.js +++ b/ghost/core/core/server/services/members/api.js @@ -18,6 +18,7 @@ const tiersService = require('../tiers'); const newslettersService = require('../newsletters'); const memberAttributionService = require('../member-attribution'); const emailSuppressionList = require('../email-suppression-list'); +const CaptchaService = require('@tryghost/captcha-service'); const {t} = require('../i18n'); const sentry = require('../../../shared/sentry'); const sharedConfig = require('../../../shared/config'); @@ -240,6 +241,11 @@ function createApiInstance(config) { settingsCache, sentry, settingsHelpers, + captchaService: new CaptchaService({ + enabled: labsService.isSet('captcha') && sharedConfig.get('captcha:enabled'), + scoreThreshold: sharedConfig.get('captcha:scoreThreshold'), + secretKey: sharedConfig.get('captcha:secretKey') + }), config: sharedConfig }); diff --git a/ghost/core/package.json b/ghost/core/package.json index aac4af6a3b2..ce75556c25e 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -71,6 +71,7 @@ "@tryghost/audience-feedback": "0.0.0", "@tryghost/bookshelf-plugins": "0.6.25", "@tryghost/bootstrap-socket": "0.0.0", + "@tryghost/captcha-service": "0.0.0", "@tryghost/color-utils": "0.2.2", "@tryghost/config-url-helpers": "1.0.12", "@tryghost/constants": "0.0.0", diff --git a/ghost/core/test/e2e-api/content/__snapshots__/settings.test.js.snap b/ghost/core/test/e2e-api/content/__snapshots__/settings.test.js.snap index 20070fc80be..74c51a3ecf3 100644 --- a/ghost/core/test/e2e-api/content/__snapshots__/settings.test.js.snap +++ b/ghost/core/test/e2e-api/content/__snapshots__/settings.test.js.snap @@ -104,3 +104,110 @@ Object { "x-powered-by": "Express", } `; + +exports[`Settings Content API Captcha settings Can request captcha settings 1: [body] 1`] = ` +Object { + "meta": Object {}, + "settings": Object { + "accent_color": "#FF1A75", + "allow_self_signup": true, + "captcha_enabled": true, + "captcha_sitekey": "testkey", + "codeinjection_foot": null, + "codeinjection_head": null, + "comments_enabled": "off", + "cover_image": "https://static.ghost.org/v5.0.0/images/publication-cover.jpg", + "default_email_address": "noreply@127.0.0.1", + "description": "Thoughts, stories and ideas", + "editor_default_email_recipients": "visibility", + "facebook": "ghost", + "firstpromoter_account": null, + "icon": null, + "labs": Any, + "lang": "en", + "locale": "en", + "logo": null, + "members_enabled": true, + "members_invite_only": false, + "members_signup_access": "all", + "members_support_address": "noreply", + "meta_description": null, + "meta_title": null, + "navigation": Array [ + Object { + "label": "Home", + "url": "/", + }, + Object { + "label": "About", + "url": "/about/", + }, + Object { + "label": "Collection", + "url": "/tag/getting-started/", + }, + Object { + "label": "Author", + "url": "/author/ghost/", + }, + Object { + "label": "Portal", + "url": "/portal/", + }, + ], + "og_description": null, + "og_image": null, + "og_title": null, + "outbound_link_tagging": true, + "paid_members_enabled": true, + "portal_button": true, + "portal_button_icon": null, + "portal_button_signup_text": "Subscribe", + "portal_button_style": "icon-and-text", + "portal_default_plan": "yearly", + "portal_name": true, + "portal_plans": Array [ + "free", + ], + "portal_signup_checkbox_required": false, + "portal_signup_terms_html": null, + "recommendations_enabled": false, + "secondary_navigation": Array [ + Object { + "label": "Data & privacy", + "url": "/privacy/", + }, + Object { + "label": "Contact", + "url": "/contact/", + }, + Object { + "label": "Contribute →", + "url": "/contribute/", + }, + ], + "support_email_address": "noreply@127.0.0.1", + "timezone": "Etc/UTC", + "title": "Ghost", + "twitter": "@ghost", + "twitter_description": null, + "twitter_image": null, + "twitter_title": null, + "url": "http://127.0.0.1:2369/", + "version": Any, + }, +} +`; + +exports[`Settings Content API Captcha settings Can request captcha settings 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "*", + "cache-control": "public, max-age=0", + "content-length": StringMatching /\\\\d\\+/, + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Accept-Encoding", + "x-powered-by": "Express", +} +`; diff --git a/ghost/core/test/e2e-api/content/settings.test.js b/ghost/core/test/e2e-api/content/settings.test.js index bc3e558d1a1..19d8f65887e 100644 --- a/ghost/core/test/e2e-api/content/settings.test.js +++ b/ghost/core/test/e2e-api/content/settings.test.js @@ -1,5 +1,6 @@ const {agentProvider, fixtureManager, matchers} = require('../../utils/e2e-framework'); const {anyEtag, anyContentLength, anyContentVersion} = matchers; +const configUtils = require('../../utils/configUtils'); const settingsMatcher = { version: matchers.anyString, @@ -27,4 +28,33 @@ describe('Settings Content API', function () { settings: settingsMatcher }); }); + + describe('Captcha settings', function () { + beforeEach(function () { + configUtils.set('captcha', { + enabled: true, + siteKey: 'testkey' + }); + }); + + afterEach(function () { + configUtils.restore(); + }); + + it('Can request captcha settings', async function () { + await agent.get('settings/') + .expectStatus(200) + .matchHeaderSnapshot({ + etag: anyEtag, + 'content-version': anyContentVersion, + 'content-length': anyContentLength + }) + .matchBodySnapshot({ + settings: Object.assign({}, settingsMatcher, { + captcha_enabled: true, + captcha_sitekey: 'testkey' + }) + }); + }); + }); }); diff --git a/ghost/members-api/lib/members-api.js b/ghost/members-api/lib/members-api.js index 660bc6cdca7..a426c507254 100644 --- a/ghost/members-api/lib/members-api.js +++ b/ghost/members-api/lib/members-api.js @@ -74,6 +74,7 @@ module.exports = function MembersAPI({ settingsCache, sentry, settingsHelpers, + captchaService, config }) { const tokenService = new TokenService({ @@ -337,6 +338,7 @@ module.exports = function MembersAPI({ const middleware = { sendMagicLink: Router().use( body.json(), + captchaService.getMiddleware(), forwardError((req, res) => routerController.sendMagicLink(req, res)) ), createCheckoutSession: Router().use( From 2f63fa23020ce4fa3afddccec551ef3db3d5a6a4 Mon Sep 17 00:00:00 2001 From: Sam Lord Date: Mon, 27 Jan 2025 16:52:52 +0000 Subject: [PATCH 83/90] Added Captcha to data attribute forms ref BAE-370 Enables Captcha (when labs flag and config entry set) in data-attribute forms within Portal. --- apps/portal/src/data-attributes.js | 28 ++++- apps/portal/src/tests/data-attributes.test.js | 35 ++++++ .../core/core/frontend/helpers/ghost_head.js | 8 ++ .../__snapshots__/ghost_head.test.js.snap | 103 ++++++++++++++++++ .../unit/frontend/helpers/ghost_head.test.js | 38 +++++++ 5 files changed, 206 insertions(+), 6 deletions(-) diff --git a/apps/portal/src/data-attributes.js b/apps/portal/src/data-attributes.js index 4d3c3cb69c0..bb960ecbd94 100644 --- a/apps/portal/src/data-attributes.js +++ b/apps/portal/src/data-attributes.js @@ -1,12 +1,12 @@ /* eslint-disable no-console */ -import {getCheckoutSessionDataFromPlanAttribute, getUrlHistory} from './utils/helpers'; +import {getCheckoutSessionDataFromPlanAttribute, getUrlHistory, hasCaptchaEnabled, getCaptchaSitekey} from './utils/helpers'; import {HumanReadableError, chooseBestErrorMessage} from './utils/errors'; import i18nLib from '@tryghost/i18n'; -export async function formSubmitHandler({event, form, errorEl, siteUrl, submitHandler}, - t = (str) => { - return str; - }) { +export async function formSubmitHandler( + {event, form, errorEl, siteUrl, captchaId, submitHandler}, + t = str => str +) { form.removeEventListener('submit', submitHandler); event.preventDefault(); if (errorEl) { @@ -66,6 +66,11 @@ export async function formSubmitHandler({event, form, errorEl, siteUrl, submitHa }); const integrityToken = await integrityTokenRes.text(); + if (captchaId) { + const {response} = await window.hcaptcha.execute(captchaId, {async: true}); + reqBody.token = response; + } + const magicLinkRes = await fetch(`${siteUrl}/members/api/send-magic-link/`, { method: 'POST', headers: { @@ -187,9 +192,20 @@ export function handleDataAttributes({siteUrl, site, member}) { } siteUrl = siteUrl.replace(/\/$/, ''); Array.prototype.forEach.call(document.querySelectorAll('form[data-members-form]'), function (form) { + let captchaId; + if (hasCaptchaEnabled({site})) { + const captchaSitekey = getCaptchaSitekey({site}); + const captchaEl = document.createElement('div'); + form.appendChild(captchaEl); + captchaId = window.hcaptcha.render(captchaEl, { + size: 'invisible', + sitekey: captchaSitekey + }); + } + let errorEl = form.querySelector('[data-members-error]'); function submitHandler(event) { - formSubmitHandler({event, errorEl, form, siteUrl, submitHandler}, t); + formSubmitHandler({event, errorEl, form, siteUrl, captchaId, submitHandler}, t); } form.addEventListener('submit', submitHandler); }); diff --git a/apps/portal/src/tests/data-attributes.test.js b/apps/portal/src/tests/data-attributes.test.js index 0a0ec95f55e..4e137bfc0f1 100644 --- a/apps/portal/src/tests/data-attributes.test.js +++ b/apps/portal/src/tests/data-attributes.test.js @@ -136,6 +136,16 @@ describe('Member Data attributes:', () => { }]; }); + // Mock hCaptcha + window.hcaptcha = { + execute: () => { } + }; + jest.spyOn(window.hcaptcha, 'execute').mockImplementation(() => { + return Promise.resolve({ + response: 'testresponse' + }); + }); + // Mock window.location let locationMock = jest.fn(); delete window.location; @@ -169,6 +179,31 @@ describe('Member Data attributes:', () => { }); expect(window.fetch).toHaveBeenLastCalledWith('https://portal.localhost/members/api/send-magic-link/', {body: expectedBody, headers: {'Content-Type': 'application/json'}, method: 'POST'}); }); + + test('submits captcha response if captcha id specified', async () => { + const {event, form, errorEl, siteUrl, submitHandler} = getMockData(); + + await formSubmitHandler({event, form, errorEl, siteUrl, submitHandler, captchaId: '123123'}); + + expect(window.fetch).toHaveBeenCalledTimes(2); + const expectedBody = JSON.stringify({ + email: 'jamie@example.com', + emailType: 'signup', + labels: ['Gold'], + name: 'Jamie Larsen', + autoRedirect: true, + urlHistory: [{ + path: '/blog/', + refMedium: null, + refSource: 'ghost-explore', + refUrl: 'https://example.com/blog/', + time: 1611234567890 + }], + token: 'testresponse', + integrityToken: 'testtoken' + }); + expect(window.fetch).toHaveBeenLastCalledWith('https://portal.localhost/members/api/send-magic-link/', {body: expectedBody, headers: {'Content-Type': 'application/json'}, method: 'POST'}); + }); }); describe('data-members-plan', () => { diff --git a/ghost/core/core/frontend/helpers/ghost_head.js b/ghost/core/core/frontend/helpers/ghost_head.js index 8a5838f5449..8928f088b8f 100644 --- a/ghost/core/core/frontend/helpers/ghost_head.js +++ b/ghost/core/core/frontend/helpers/ghost_head.js @@ -165,6 +165,10 @@ function getTinybirdTrackerScript(dataRoot) { return ``; } +function getHCaptchaScript() { + return ``; +} + /** * **NOTE** * Express adds `_locals`, see https://github.com/expressjs/express/blob/4.15.4/lib/response.js#L962. @@ -353,6 +357,10 @@ module.exports = async function ghost_head(options) { // eslint-disable-line cam head.push(getTinybirdTrackerScript(dataRoot)); } + if (labs.isSet('captcha') && config.get('captcha:enabled')) { + head.push(getHCaptchaScript()); + } + // Check if if the request is for a site preview, in which case we **always** use the custom font values // from the passed in data, even when they're empty strings or settings cache has values. const isSitePreview = options.data?.site?._preview ?? false; diff --git a/ghost/core/test/unit/frontend/helpers/__snapshots__/ghost_head.test.js.snap b/ghost/core/test/unit/frontend/helpers/__snapshots__/ghost_head.test.js.snap index 4633dd26c5e..43ced680d90 100644 --- a/ghost/core/test/unit/frontend/helpers/__snapshots__/ghost_head.test.js.snap +++ b/ghost/core/test/unit/frontend/helpers/__snapshots__/ghost_head.test.js.snap @@ -1,5 +1,108 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`{{ghost_head}} helper CAPTCHA does not return CAPTCHA script when disabled 1 1`] = ` +Object { + "rendered": " + + + + + + + + + + + + + + + + + + + + + + + ", +} +`; + +exports[`{{ghost_head}} helper CAPTCHA returns CAPTCHA script when enabled 1 1`] = ` +Object { + "rendered": " + + + + + + + + + + + + + + + + + + + + + + + + ", +} +`; + exports[`{{ghost_head}} helper accent_color attaches style tag to existing script/style tag 1 1`] = ` Object { "rendered": " diff --git a/ghost/core/test/unit/frontend/helpers/ghost_head.test.js b/ghost/core/test/unit/frontend/helpers/ghost_head.test.js index e6c10fef25c..59029c87d7a 100644 --- a/ghost/core/test/unit/frontend/helpers/ghost_head.test.js +++ b/ghost/core/test/unit/frontend/helpers/ghost_head.test.js @@ -1382,6 +1382,44 @@ describe('{{ghost_head}} helper', function () { }); }); + describe('CAPTCHA', function () { + beforeEach(function () { + configUtils.set({ + captcha: { + enabled: true + } + }); + }); + + it('returns CAPTCHA script when enabled', async function () { + sinon.stub(labs, 'isSet').withArgs('captcha').returns(true); + + const rendered = await testGhostHead(testUtils.createHbsResponse({ + locals: { + relativeUrl: '/', + context: ['home', 'index'], + safeVersion: '4.3' + } + })); + + rendered.should.match(/hcaptcha/); + }); + + it('does not return CAPTCHA script when disabled', async function () { + sinon.stub(labs, 'isSet').withArgs('captcha').returns(false); + + const rendered = await testGhostHead(testUtils.createHbsResponse({ + locals: { + relativeUrl: '/', + context: ['home', 'index'], + safeVersion: '4.3' + } + })); + + rendered.should.not.match(/hcaptcha/); + }); + }); + describe('attribution scripts', function () { it('is included when tracking setting is enabled', async function () { settingsCache.get.withArgs('members_track_sources').returns(true); From 13449701287770c683b79a022609a056112d9539 Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Mon, 27 Jan 2025 21:37:40 -0800 Subject: [PATCH 84/90] Added docker:setup yarn script (#22058) ref https://linear.app/ghost/issue/ENG-1959/extend-setupjs-to-modify-config-as-appropriate-for-full-docker-dev - When switching from local development to docker, there are a few configuration parameters that need to be updated to e.g. point to the right database host within the docker network. - Setting these values with environment variables doesn't work well because the configuration passed via environment overrides the configuration set in tests, and thus points tests to the wrong database. - This commit adds a yarn docker:setup command to the root of the repo, to make it easier to get started with a full docker compose based workflow. It edits you config.local.json file to update the necessary settings for running Ghost in docker compose. - It also updates the clean.js script such that it will run successfully regardless of whether it is run locally or in docker. - Finally, this commit also adds convenience commands for developing and running tests in docker compose --- .docker/development.entrypoint.sh | 2 + .github/scripts/clean.js | 10 +-- .github/scripts/setup-docker.js | 100 ++++++++++++++++++++++++++++++ package.json | 5 ++ 4 files changed, 108 insertions(+), 9 deletions(-) create mode 100644 .github/scripts/setup-docker.js diff --git a/.docker/development.entrypoint.sh b/.docker/development.entrypoint.sh index 69df8c12b74..600dc59c98e 100755 --- a/.docker/development.entrypoint.sh +++ b/.docker/development.entrypoint.sh @@ -4,5 +4,7 @@ # so we need to install dependencies again yarn install --frozen-lockfile --prefer-offline +yarn nx run-many -t build:ts + # Execute the CMD exec "$@" \ No newline at end of file diff --git a/.github/scripts/clean.js b/.github/scripts/clean.js index e6909bd9f8c..2913ca12ed1 100644 --- a/.github/scripts/clean.js +++ b/.github/scripts/clean.js @@ -1,8 +1,6 @@ // NOTE: this file can't use any NPM dependencies because it needs to run even if dependencies aren't installed yet or are corrupted const {execSync} = require('child_process'); -const isDevContainer = process.env.DEVCONTAINER === 'true'; - cleanYarnCache(); resetNxCache(); deleteNodeModules(); @@ -49,13 +47,7 @@ function resetNxCache() { function cleanYarnCache() { console.log('Cleaning yarn cache...'); try { - if (isDevContainer) { - // In devcontainer, these directories are mounted from the host so we can't delete them — `yarn cache clean` will fail - // so we delete the contents of the directories instead - execSync('rm -rf .yarncache/* .yarncachecopy/*'); - } else { - execSync('yarn cache clean'); - } + execSync('rm -rf .yarncache/* .yarncachecopy/*'); } catch (error) { console.error('Failed to clean yarn cache:', error); process.exit(1); diff --git a/.github/scripts/setup-docker.js b/.github/scripts/setup-docker.js new file mode 100644 index 00000000000..efb2e4abbed --- /dev/null +++ b/.github/scripts/setup-docker.js @@ -0,0 +1,100 @@ +const path = require('path'); +const fs = require('fs').promises; +const {spawn} = require('child_process'); + +/** + * Run a command and stream output to the console + * + * @param {string} command + * @param {string[]} args + * @param {object} options + */ +async function runAndStream(command, args, options) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: 'inherit', + ...options + }); + + child.on('close', (code) => { + if (code === 0) { + resolve(code); + } else { + reject(new Error(`'${command} ${args.join(' ')}' exited with code ${code}`)); + } + }); + + }); +} + +/** + * Removes node dependencies and cleans up local caches + */ +function clean() { + require('./clean'); +} + +/** + * Adjust config.local.json for Docker Compose setup + */ +async function adjustConfig() { + console.log('Adjusting configuration...'); + const coreFolder = path.join(__dirname, '../../ghost/core'); + const currentConfigPath = path.join(coreFolder, 'config.local.json'); + let currentConfig; + try { + currentConfig = require(currentConfigPath); + } catch (err) { + currentConfig = {}; + } + + currentConfig.database = { + client: 'mysql', + docker: true, + connection: { + host: 'mysql', + user: 'root', + password: 'root', + database: 'ghost' + } + }; + + currentConfig.adapters = { + ...currentConfig.adapters, + Redis: { + host: 'redis', + port: 6379 + } + }; + + currentConfig.server = { + ...currentConfig.server, + host: '0.0.0.0', + port: 2368 + }; + + try { + await fs.writeFile(currentConfigPath, JSON.stringify(currentConfig, null, 4)); + } catch (err) { + console.error('Failed to write config.local.json', err); + console.log(`Please add the following to config.local.json:\n`, JSON.stringify(currentConfig, null, 4)); + process.exit(1); + } +} + +async function buildContainer() { + console.log('Building container...'); + await runAndStream('docker-compose', ['build'], {}); +} + +async function runMigrations() { + console.log('Running migrations...'); + await runAndStream('docker-compose', ['run', '--rm', '-w', '/home/ghost/ghost/core', 'ghost', 'yarn', 'knex-migrator', 'init'], {cwd: path.join(__dirname, '../../')}); +} + +(async () => { + clean(); + await adjustConfig(); + await buildContainer(); + await runMigrations(); +})(); diff --git a/package.json b/package.json index 78532267242..32292183d40 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,11 @@ "reset:data": "cd ghost/core && node index.js generate-data --clear-database --quantities members:100000,posts:500 --seed 123", "reset:data:empty": "cd ghost/core && node index.js generate-data --clear-database --quantities members:0,posts:0 --seed 123", "reset:data:xxl": "cd ghost/core && node index.js generate-data --clear-database --quantities members:2000000,posts:0,emails:0,members_stripe_customers:0,members_login_events:0,members_status_events:0 --seed 123", + "docker:setup": "git submodule update --init && node .github/scripts/setup-docker.js", + "docker:dev": "COMPOSE_PROFILES=full docker compose up --attach=ghost --no-log-prefix", + "docker:test:unit": "COMPOSE_PROFILES=full docker compose run --rm --no-deps ghost yarn test:unit", + "docker:test:browser": "COMPOSE_PROFILES=full docker compose run --rm ghost yarn test:browser", + "docker:test:all": "COMPOSE_PROFILES=full docker compose run --rm ghost yarn nx run ghost:test:all", "docker:reset": "docker compose down -v && docker compose up -d --wait", "docker:down": "docker compose down", "compose": "docker compose -f .devcontainer/compose.yml", From d4bf982392c16eae6fd29ad06674bd1bf2c193f8 Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Mon, 27 Jan 2025 22:06:53 -0800 Subject: [PATCH 85/90] Fixed hot reload for admin in docker compose (#22059) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no issue - Hot reload for admin depends on the browser being able to reach port 4201, which was not exposed in the docker compose setup — this fixes that so admin will hot reload when running Ghost in docker compose --- .docker/Dockerfile | 1 + compose.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.docker/Dockerfile b/.docker/Dockerfile index 2b83e7a4b3a..358f24e9d99 100644 --- a/.docker/Dockerfile +++ b/.docker/Dockerfile @@ -33,6 +33,7 @@ ENV YARN_CACHE_FOLDER=$WORKDIR/.yarncache EXPOSE 2368 EXPOSE 4200 +EXPOSE 4201 EXPOSE 4173 EXPOSE 41730 EXPOSE 4175 diff --git a/compose.yml b/compose.yml index cd68a04918a..bb2e9c394ff 100644 --- a/compose.yml +++ b/compose.yml @@ -9,6 +9,7 @@ services: ports: - "2368:2368" - "4200:4200" + - "4201:4201" profiles: [full] volumes: - .:/home/ghost From d489299d6bbb5e313bfab4dd26de07f05fb52bce Mon Sep 17 00:00:00 2001 From: Sodbileg Gansukh Date: Tue, 28 Jan 2025 14:40:30 +0800 Subject: [PATCH 86/90] Fixed double ESC press to close the preview (#22060) ref DES-549 - togglePreview() function was called twice when the key combination CMD+P is pressed - added a guard in the function to prevent it from being called twice --- ghost/admin/app/components/editor/publish-management.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ghost/admin/app/components/editor/publish-management.js b/ghost/admin/app/components/editor/publish-management.js index eb2906522b8..f62fd8b6623 100644 --- a/ghost/admin/app/components/editor/publish-management.js +++ b/ghost/admin/app/components/editor/publish-management.js @@ -124,6 +124,9 @@ export default class PublishManagement extends Component { // triggered by ctrl/cmd+p @action togglePreview(event) { + if (event?.defaultPrevented) { + return; + } event?.preventDefault(); if (!this.previewModal || this.previewModal.isClosing) { From c586b1c034f5f447c0613203066d69e935f39987 Mon Sep 17 00:00:00 2001 From: Sag Date: Tue, 28 Jan 2025 13:46:37 +0700 Subject: [PATCH 87/90] =?UTF-8?q?=E2=9C=A8=20Enabled=20publishers=20to=20b?= =?UTF-8?q?lock=20additional=20email=20domains=20in=20member=20signups=20(?= =?UTF-8?q?#22047)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref https://linear.app/ghost/issue/ENG-1973 ref https://app.incident.io/ghost/incidents/132 - following an increase in spam members signups, we have recently added a blocklist of email domains, based on config (see https://github.com/TryGhost/Ghost/pull/22027). With this change, we are extending that feature with a user-facing setting in Ghost Admin - publishers can now block additional email domains in member signups, directly from Ghost Admin. These emails domains will be added to the list of domains already blocked by config --- .../src/components/Sidebar.tsx | 1 + .../settings/advanced/AdvancedSettings.tsx | 5 +- .../settings/advanced/SpamFilters.tsx | 80 ++++++++++ .../acceptance/advanced/spamFilters.test.ts | 114 ++++++++++++++ apps/portal/src/utils/errors.js | 2 +- .../core/core/server/services/members/api.js | 3 +- .../core/server/services/members/service.js | 1 + .../newsletters/NewslettersService.js | 4 +- .../settings-helpers/SettingsHelpers.js | 24 +++ .../services/settings/SettingsBREADService.js | 4 +- .../services/settings/settings-service.js | 3 + .../admin/__snapshots__/settings.test.js.snap | 54 ++++++- .../core/test/e2e-api/admin/settings.test.js | 2 +- .../send-magic-link.test.js.snap | 54 +++++++ .../e2e-api/members/send-magic-link.test.js | 109 ++++++++++--- .../services/stats/mrr-stats-service.test.js | 10 +- .../settings-helpers/settings-helpers.test.js | 109 ++++++++++++- ghost/i18n/locales/af/portal.json | 2 +- ghost/i18n/locales/ar/portal.json | 2 +- ghost/i18n/locales/bg/portal.json | 2 +- ghost/i18n/locales/bn/portal.json | 2 +- ghost/i18n/locales/bs/portal.json | 2 +- ghost/i18n/locales/ca/portal.json | 2 +- ghost/i18n/locales/context.json | 2 +- ghost/i18n/locales/cs/portal.json | 2 +- ghost/i18n/locales/da/portal.json | 2 +- ghost/i18n/locales/de-CH/portal.json | 2 +- ghost/i18n/locales/de/portal.json | 2 +- ghost/i18n/locales/el/portal.json | 2 +- ghost/i18n/locales/en/portal.json | 2 +- ghost/i18n/locales/eo/portal.json | 2 +- ghost/i18n/locales/es/portal.json | 2 +- ghost/i18n/locales/et/portal.json | 2 +- ghost/i18n/locales/fa/portal.json | 2 +- ghost/i18n/locales/fi/portal.json | 2 +- ghost/i18n/locales/fr/portal.json | 2 +- ghost/i18n/locales/gd/portal.json | 2 +- ghost/i18n/locales/he/portal.json | 2 +- ghost/i18n/locales/hi/portal.json | 2 +- ghost/i18n/locales/hr/portal.json | 2 +- ghost/i18n/locales/hu/portal.json | 2 +- ghost/i18n/locales/id/portal.json | 2 +- ghost/i18n/locales/is/portal.json | 2 +- ghost/i18n/locales/it/portal.json | 2 +- ghost/i18n/locales/ja/portal.json | 2 +- ghost/i18n/locales/ko/portal.json | 2 +- ghost/i18n/locales/kz/portal.json | 2 +- ghost/i18n/locales/lt/portal.json | 2 +- ghost/i18n/locales/lv/portal.json | 2 +- ghost/i18n/locales/mk/portal.json | 2 +- ghost/i18n/locales/mn/portal.json | 2 +- ghost/i18n/locales/ms/portal.json | 2 +- ghost/i18n/locales/ne/portal.json | 2 +- ghost/i18n/locales/nl/portal.json | 2 +- ghost/i18n/locales/nn/portal.json | 2 +- ghost/i18n/locales/no/portal.json | 2 +- ghost/i18n/locales/pl/portal.json | 2 +- ghost/i18n/locales/pt-BR/portal.json | 2 +- ghost/i18n/locales/pt/portal.json | 2 +- ghost/i18n/locales/ro/portal.json | 2 +- ghost/i18n/locales/ru/portal.json | 2 +- ghost/i18n/locales/si/portal.json | 2 +- ghost/i18n/locales/sk/portal.json | 2 +- ghost/i18n/locales/sl/portal.json | 2 +- ghost/i18n/locales/sq/portal.json | 2 +- ghost/i18n/locales/sr-Cyrl/portal.json | 2 +- ghost/i18n/locales/sr/portal.json | 2 +- ghost/i18n/locales/sv/portal.json | 2 +- ghost/i18n/locales/sw/portal.json | 2 +- ghost/i18n/locales/ta/portal.json | 2 +- ghost/i18n/locales/th/portal.json | 2 +- ghost/i18n/locales/tr/portal.json | 2 +- ghost/i18n/locales/uk/portal.json | 2 +- ghost/i18n/locales/ur/portal.json | 2 +- ghost/i18n/locales/uz/portal.json | 2 +- ghost/i18n/locales/vi/portal.json | 2 +- ghost/i18n/locales/zh-Hant/portal.json | 2 +- ghost/i18n/locales/zh/portal.json | 2 +- ghost/magic-link/lib/MagicLink.js | 29 +--- ghost/magic-link/test/index.test.js | 42 ----- .../lib/controllers/RouterController.js | 19 ++- ghost/members-api/lib/members-api.js | 6 +- .../test/unit/lib/controllers/router.test.js | 143 ++++++++++-------- 83 files changed, 700 insertions(+), 240 deletions(-) create mode 100644 apps/admin-x-settings/src/components/settings/advanced/SpamFilters.tsx create mode 100644 apps/admin-x-settings/test/acceptance/advanced/spamFilters.test.ts diff --git a/apps/admin-x-settings/src/components/Sidebar.tsx b/apps/admin-x-settings/src/components/Sidebar.tsx index 3fdb337da53..007e8e95e3d 100644 --- a/apps/admin-x-settings/src/components/Sidebar.tsx +++ b/apps/admin-x-settings/src/components/Sidebar.tsx @@ -200,6 +200,7 @@ const Sidebar: React.FC = () => { + diff --git a/apps/admin-x-settings/src/components/settings/advanced/AdvancedSettings.tsx b/apps/admin-x-settings/src/components/settings/advanced/AdvancedSettings.tsx index 2633c011fa6..c73d9734297 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/AdvancedSettings.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/AdvancedSettings.tsx @@ -6,6 +6,7 @@ import Labs from './Labs'; import MigrationTools from './MigrationTools'; import React from 'react'; import SearchableSection from '../../SearchableSection'; +import SpamFilters from './SpamFilters'; export const searchKeywords = { integrations: ['advanced', 'integrations', 'zapier', 'slack', 'unsplash', 'first promoter', 'firstpromoter', 'pintura', 'disqus', 'analytics', 'ulysses', 'typeform', 'buffer', 'plausible', 'github'], @@ -13,7 +14,8 @@ export const searchKeywords = { codeInjection: ['advanced', 'code injection', 'head', 'footer'], labs: ['advanced', 'labs', 'alpha', 'beta', 'flag', 'routes', 'redirect', 'translation', 'editor', 'portal'], history: ['advanced', 'history', 'log', 'events', 'user events', 'staff'], - dangerzone: ['danger', 'danger zone', 'delete', 'content', 'delete all content', 'delete site'] + dangerzone: ['danger', 'danger zone', 'delete', 'content', 'delete all content', 'delete site'], + spamFilters: ['membership', 'signup', 'sign up', 'spam', 'filters', 'prevention', 'prevent', 'block', 'domains', 'email'] }; const AdvancedSettings: React.FC = () => { @@ -21,6 +23,7 @@ const AdvancedSettings: React.FC = () => { + diff --git a/apps/admin-x-settings/src/components/settings/advanced/SpamFilters.tsx b/apps/admin-x-settings/src/components/settings/advanced/SpamFilters.tsx new file mode 100644 index 00000000000..ac517ccf926 --- /dev/null +++ b/apps/admin-x-settings/src/components/settings/advanced/SpamFilters.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import TopLevelGroup from '../../TopLevelGroup'; +import useSettingGroup from '../../../hooks/useSettingGroup'; +import {SettingGroupContent, TextArea, withErrorBoundary} from '@tryghost/admin-x-design-system'; +import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; + +const SpamFilters: React.FC<{ keywords: string[] }> = ({keywords}) => { + const { + localSettings, + isEditing, + saveState, + handleSave, + handleCancel, + updateSetting, + errors, + clearError, + handleEditingChange + } = useSettingGroup({ + onValidate: () => { + return {}; + } + }); + + const [initialBlockedEmailDomainsJSON] = getSettingValues(localSettings, ['blocked_email_domains']) as string[]; + const initialBlockedEmailDomains = JSON.parse(initialBlockedEmailDomainsJSON || '[]') as string[]; + const [blockedEmailDomains, setBlockedEmailDomains] = React.useState(initialBlockedEmailDomains.join('\n')); + + const updateBlockedEmailDomainsSetting = (e: React.ChangeEvent) => { + const input = e.target.value; + setBlockedEmailDomains(input); + + const validEmailDomains = input + .split(/[\s,]+/) // Split by space, comma, or newline + .map(domain => domain.trim().toLowerCase().split('@').pop()) // Normalise and keep only the email domain, e.g. 'hello@spam.xyz' -> 'spam.xyz' + .filter(domain => domain && domain.includes('.')); // Filter out domains without a dot + + updateSetting('blocked_email_domains', JSON.stringify(validEmailDomains)); + + if (!isEditing) { + handleEditingChange(true); + } + }; + + const hint = ( + <> + Prevent unwanted signups by blocking email domains. Add one domain per line, e.g., spam.xyz to block signups from email addresses like hello@spam.xyz. + + ); + + return ( + + +