Skip to content

Commit a906c05

Browse files
committed
Added gift links reader toast
ref https://linear.app/ghost/issue/BER-3728 - a gift read now shows the reader a toast ("<publication> unlocked this post so you can read it for free.") so it's clear they're seeing paid content via a gift, not a paywall bypass - shipped as an overridable `gift-toast` partial (core default in helpers/tpl/gift-toast.hbs), rendered by ghost_foot when the internal `_gift` flag is set — themes can supply their own partials/gift-toast.hbs, the same override mechanism as navigation/pagination - driven by an underscore-prefixed internal `_gift` flag rather than a public `@gift` theme-context contract, so we don't commit to an API that's hard to walk back; flag-gated on labs.giftLinks - the toast string uses the {{t}} helper (translatable) and reads @site.title / @site.accent_color; handlebars auto-escapes both
1 parent 9ea01ea commit a906c05

4 files changed

Lines changed: 283 additions & 3 deletions

File tree

ghost/core/core/frontend/helpers/ghost_foot.js

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
// Usage: `{{ghost_foot}}`
33
//
44
// Outputs scripts and other assets at the bottom of a Ghost theme
5-
const {settingsCache} = require('../services/proxy');
6-
const {SafeString} = require('../services/handlebars');
5+
const {settingsCache, labs} = require('../services/proxy');
6+
const {SafeString, templates, hbs} = require('../services/handlebars');
77
const _ = require('lodash');
88

9+
const createFrame = hbs.handlebars.createFrame;
10+
911
// We use the name ghost_foot to match the helper for consistency:
1012
module.exports = function ghost_foot(options) { // eslint-disable-line camelcase
1113
const foot = [];
@@ -26,5 +28,15 @@ module.exports = function ghost_foot(options) { // eslint-disable-line camelcase
2628
foot.push(tagCodeinjection);
2729
}
2830

31+
// Reader-side gift announcement. The internal `_gift` flag is set by the
32+
// gift-links controller only on the verified render path (valid token,
33+
// matching slug), so the toast appears on gift reads and never on canonical
34+
// post URLs. Rendered as the overridable `gift-toast` partial — themes can
35+
// supply their own `partials/gift-toast.hbs`.
36+
if (labs.isSet('giftLinks') && options.data._gift) {
37+
const data = createFrame(options.data);
38+
foot.push(templates.execute('gift-toast', this, {data}));
39+
}
40+
2941
return new SafeString(foot.join(' ').trim());
3042
};
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
<style>
2+
.gh-gift-toast {
3+
--gh-gift-toast-initial-top: 80px;
4+
--gh-gift-toast-sticky-top: 32px;
5+
--gh-gift-toast-accent: {{@site.accent_color}};
6+
7+
position: fixed;
8+
top: var(--gh-gift-toast-initial-top);
9+
left: 50%;
10+
transform: translateX(-50%);
11+
z-index: 99998;
12+
display: flex;
13+
align-items: center;
14+
gap: 12px;
15+
max-width: min(560px, calc(100vw - 32px));
16+
padding: 16px;
17+
background: #ffffff;
18+
color: #15171a;
19+
border-radius: 16px;
20+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 8px 24px rgba(0, 0, 0, 0.08);
21+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
22+
font-size: 13px;
23+
line-height: 1.4;
24+
transition: top 200ms ease, opacity 200ms ease, transform 200ms ease;
25+
box-sizing: border-box;
26+
}
27+
28+
.gh-gift-toast-icon {
29+
flex-shrink: 0;
30+
display: flex;
31+
align-items: center;
32+
justify-content: center;
33+
width: 36px;
34+
height: 36px;
35+
border-radius: 50%;
36+
background: color-mix(in srgb, var(--gh-gift-toast-accent) 15%, transparent);
37+
}
38+
39+
.gh-gift-toast-icon svg {
40+
width: 18px;
41+
height: 18px;
42+
color: var(--gh-gift-toast-accent);
43+
fill: var(--gh-gift-toast-accent);
44+
}
45+
46+
.gh-gift-toast-text {
47+
flex: 1;
48+
min-width: 0;
49+
color: #15171a;
50+
font-weight: 400;
51+
}
52+
53+
.gh-gift-toast-close {
54+
flex-shrink: 0;
55+
display: inline-flex;
56+
align-items: center;
57+
justify-content: center;
58+
width: 24px;
59+
height: 24px;
60+
padding: 0;
61+
border: none;
62+
border-radius: 50%;
63+
background: #f4f5f6;
64+
color: #5b6573;
65+
cursor: pointer;
66+
transition: background 120ms ease, color 120ms ease;
67+
}
68+
69+
.gh-gift-toast-close:hover {
70+
background: #e8eaed;
71+
color: #15171a;
72+
}
73+
74+
.gh-gift-toast-close svg {
75+
width: 12px;
76+
height: 12px;
77+
}
78+
79+
.gh-gift-toast.is-dismissed {
80+
opacity: 0;
81+
transform: translate(-50%, -16px);
82+
pointer-events: none;
83+
}
84+
85+
@media (max-width: 600px) {
86+
.gh-gift-toast {
87+
width: calc(100vw - 32px);
88+
}
89+
}
90+
91+
@media (prefers-reduced-motion: reduce) {
92+
.gh-gift-toast {
93+
transition: none;
94+
}
95+
}
96+
</style>
97+
<aside id="gh-gift-toast" class="gh-gift-toast" role="status" aria-live="polite">
98+
<div class="gh-gift-toast-icon" aria-hidden="true">
99+
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
100+
<path d="M12 21.488H4v-10.75a.75.75 0 0 0-1.5 0v10.75c0 .914.586 1.5 1.5 1.5h8a.75.75 0 0 0 0-1.5z"/>
101+
<path d="M12.25 21.488h8v-10.75a.75.75 0 0 1 1.5 0v10.75c0 .914-.586 1.5-1.5 1.5h-8a.75.75 0 0 1 0-1.5z"/>
102+
<path d="M2.682 6.238h18.636c1.004 0 1.682.546 1.682 1.5v2.25c0 .954-.678 1.5-1.682 1.5H2.682c-1.004 0-1.682-.546-1.682-1.5v-2.25c0-.954.678-1.5 1.682-1.5zm0 1.5c-.239 0-.182-.046-.182 0v2.25c0 .046-.057 0 .182 0h18.636c.239 0 .182.046.182 0v-2.25c0-.046.057 0-.182 0H2.682z"/>
103+
<path d="M12.75 22.238V6.988a.75.75 0 0 0-1.5 0v15.25a.75.75 0 0 0 1.5 0z"/>
104+
<path d="M3.5 17.25h17a.75.75 0 0 0 0-1.5h-17a.75.75 0 0 0 0 1.5zM7.271 5.457a12.506 12.506 0 0 0 4.51 2.255.75.75 0 0 0 .92-.918 12.473 12.473 0 0 0-2.256-4.511C8.947.777 7.705.657 6.671 1.683c-1.033 1.027-.908 2.273.533 3.714l.067.06zm.458-2.709c.397-.395.702-.366 1.596.528.587.755 1.079 1.6 1.456 2.516a11.002 11.002 0 0 1-2.549-1.487c-.865-.872-.89-1.173-.503-1.557z"/>
105+
<path d="m16.796 5.397-.067.06a12.5 12.5 0 0 1-4.512 2.255.75.75 0 0 1-.918-.918 12.502 12.502 0 0 1 2.315-4.579c1.44-1.438 2.681-1.558 3.715-.531 1.033 1.026.908 2.272-.533 3.713zm-.525-2.649c-.397-.395-.702-.366-1.537.46a11.024 11.024 0 0 0-1.515 2.584 11.038 11.038 0 0 0 2.549-1.487c.865-.872.89-1.173.503-1.557z"/>
106+
</svg>
107+
</div>
108+
<span class="gh-gift-toast-text">{{t "{publication} unlocked this post so you can read it for free." publication=@site.title}}</span>
109+
<button type="button" class="gh-gift-toast-close" aria-label="{{t "Dismiss"}}">
110+
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
111+
<path d="M3 3 L13 13 M13 3 L3 13"/>
112+
</svg>
113+
</button>
114+
</aside>
115+
<script>
116+
(function(){
117+
var toast = document.getElementById('gh-gift-toast');
118+
if (!toast) return;
119+
120+
// Scroll the toast up with the page until it reaches a 32px sticky offset.
121+
// Reading inline styles back as numbers means themes that override the
122+
// CSS variables still get a consistent scroll behaviour.
123+
var styles = getComputedStyle(toast);
124+
var initialTop = parseFloat(styles.getPropertyValue('--gh-gift-toast-initial-top')) || 80;
125+
var stickyTop = parseFloat(styles.getPropertyValue('--gh-gift-toast-sticky-top')) || 32;
126+
var ticking = false;
127+
128+
function applyTop(){
129+
ticking = false;
130+
var y = window.pageYOffset || document.documentElement.scrollTop || 0;
131+
var next = Math.max(stickyTop, initialTop - y);
132+
toast.style.top = next + 'px';
133+
}
134+
135+
function onScroll(){
136+
if (!ticking) {
137+
window.requestAnimationFrame(applyTop);
138+
ticking = true;
139+
}
140+
}
141+
142+
applyTop();
143+
window.addEventListener('scroll', onScroll, {passive: true});
144+
145+
var closeBtn = toast.querySelector('.gh-gift-toast-close');
146+
if (closeBtn) {
147+
closeBtn.addEventListener('click', function(){
148+
toast.classList.add('is-dismissed');
149+
window.removeEventListener('scroll', onScroll);
150+
// Defer removal so the fade/slide-out transition runs to completion.
151+
setTimeout(function(){ if (toast.parentNode) toast.parentNode.removeChild(toast); }, 260);
152+
});
153+
}
154+
})();
155+
</script>
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import assert from 'node:assert/strict';
2+
import path from 'node:path';
3+
import sinon from 'sinon';
4+
import supertest from 'supertest';
5+
import moment from 'moment';
6+
import {afterAll, beforeAll} from 'vitest';
7+
8+
const fs = require('fs-extra');
9+
const testUtils = require('../utils');
10+
const configUtils = require('../utils/config-utils');
11+
const settingsCache = require('../../core/shared/settings-cache');
12+
const themeActivator = require('../../core/server/services/themes/activate');
13+
14+
const OVERRIDE_THEME = 'gift-toast-override-theme';
15+
16+
// A theme's `partials/` dir is only registered (and thus able to override the
17+
// core `gift-toast` partial) when the theme references at least one partial:
18+
// gscan only reports referenced partials, and `active.partialsPath` is null
19+
// otherwise. So the theme references a `{{> marker}}` partial from its layout.
20+
const THEME_FILES: Record<string, string> = {
21+
'package.json': JSON.stringify({
22+
name: OVERRIDE_THEME,
23+
description: 'Theme that overrides the default gift-toast partial',
24+
version: '1.0.0',
25+
license: 'MIT',
26+
config: {posts_per_page: 25}
27+
}),
28+
'index.hbs': '',
29+
'default.hbs': [
30+
'<!DOCTYPE html>',
31+
'<html lang="{{@site.locale}}">',
32+
'<head>{{ghost_head}}</head>',
33+
'<body class="{{body_class}}">',
34+
' {{> marker}}',
35+
' {{{body}}}',
36+
' {{ghost_foot}}',
37+
'</body>',
38+
'</html>'
39+
].join('\n'),
40+
'post.hbs': '{{!< default}}\n{{#post}}<div class="post-content">{{content}}</div>{{/post}}',
41+
'partials/marker.hbs': '<span id="theme-marker">override theme</span>',
42+
'partials/gift-toast.hbs': '<div id="custom-gift-toast">Custom theme gift toast</div>'
43+
};
44+
45+
// Generates the override theme on disk and activates it at runtime — kept out of
46+
// the shared test/utils/fixtures/themes dir so the admin themes-list fixtures
47+
// (and the tests that enumerate them) are unaffected.
48+
async function writeAndActivateOverrideTheme() {
49+
const themesPath = configUtils.config.getContentPath('themes');
50+
for (const [relPath, contents] of Object.entries(THEME_FILES)) {
51+
const filePath = path.join(themesPath, OVERRIDE_THEME, relPath);
52+
await fs.ensureDir(path.dirname(filePath));
53+
await fs.writeFile(filePath, contents);
54+
}
55+
await themeActivator.loadAndActivate(OVERRIDE_THEME);
56+
}
57+
58+
describe('Front-end gift links — theme toast override', function () {
59+
let request: any;
60+
let token: string;
61+
const slug = 'gift-override-paid-post';
62+
63+
beforeAll(async function () {
64+
const originalSettingsCacheGetFn = settingsCache.get;
65+
sinon.stub(settingsCache, 'get').callsFake(function (key: any, options: any) {
66+
if (key === 'labs') {
67+
return {members: true, giftLinks: true};
68+
}
69+
if (key === 'active_theme') {
70+
return OVERRIDE_THEME;
71+
}
72+
return originalSettingsCacheGetFn(key, options);
73+
});
74+
75+
await testUtils.startGhost({copyThemes: true});
76+
await writeAndActivateOverrideTheme();
77+
78+
const paidPost = testUtils.DataGenerator.forKnex.createPost({
79+
slug,
80+
visibility: 'paid',
81+
status: 'published',
82+
published_at: moment().toDate(),
83+
mobiledoc: testUtils.DataGenerator.markdownToMobiledoc('Before paywall\n\n<!--members-only-->\n\nAfter paywall')
84+
});
85+
await testUtils.fixtures.insertPosts([paidPost]);
86+
87+
const giftLinksService = require('../../core/server/services/gift-links');
88+
const post = await giftLinksService.service.issue(paidPost.id);
89+
token = post.giftLinks[0].token;
90+
91+
request = supertest.agent(configUtils.config.get('url'));
92+
});
93+
94+
afterAll(function () {
95+
sinon.restore();
96+
});
97+
98+
it('renders the theme partials/gift-toast.hbs instead of the core default', async function () {
99+
const res = await request
100+
.get(`/g/${slug}/?key=${token}`)
101+
.expect(200);
102+
103+
assert.match(res.text, /id="custom-gift-toast"/, 'theme partial overrides the default toast');
104+
assert.doesNotMatch(res.text, /id="gh-gift-toast"/, 'core default toast is not rendered when overridden');
105+
});
106+
});

ghost/core/test/e2e-frontend/gift-links.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,13 @@ describe('Front-end gift links', function () {
8484
});
8585

8686
it('paywalls the paid post for an anonymous visitor on the canonical URL', async function () {
87-
await request
87+
const res = await request
8888
.get(`/${slug}/`)
8989
.expect(200)
9090
.expect(assertLocked);
91+
92+
// No gift → no toast on the canonical URL.
93+
assert.doesNotMatch(res.text, /gh-gift-toast/, 'gift toast must not appear on the canonical URL');
9194
});
9295

9396
it('unlocks the full post for an anonymous visitor with a valid /g/ + key', async function () {
@@ -100,6 +103,10 @@ describe('Front-end gift links', function () {
100103
assert.match(res.headers['cache-control'], /no-store/);
101104
assert.equal(res.headers['x-robots-tag'], 'noindex');
102105
assert.equal(res.headers['referrer-policy'], 'no-referrer');
106+
107+
// The default gift toast renders on the verified gift view.
108+
assert.match(res.text, /id="gh-gift-toast"/, 'default gift toast renders on a gift view');
109+
assert.match(res.text, /unlocked this post/, 'toast announces the gift');
103110
});
104111

105112
it('canonicalises a stale slug to the current slug, keeping the key', async function () {

0 commit comments

Comments
 (0)