Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion ghost/admin/app/helpers/parse-member-event.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,11 @@ export default class ParseMemberEventHelper extends Helper {
}

if (event.type === 'gift_purchase_event') {
icon = 'subscriptions';
icon = 'gift';
}

if (event.type === 'gift_redemption_event') {
icon = 'gift';
}

if (event.type === 'email_change_event') {
Expand Down Expand Up @@ -268,6 +272,11 @@ export default class ParseMemberEventHelper extends Helper {

return `Purchased a gift subscription for ${formattedAmount} (${tierName}, ${duration} ${cadenceLabel})`;
}

if (event.type === 'gift_redemption_event') {
const tierName = event.data.tier_name;
return `Started paid subscription (${tierName}) via gift`;
}
}

/**
Expand Down
2 changes: 2 additions & 0 deletions ghost/admin/app/utils/member-event-types.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,12 @@ export function toggleEventType(eventType, eventTypes) {
excludedEvents.delete('payment_event');
excludedEvents.delete('donation_event');
excludedEvents.delete('gift_purchase_event');
excludedEvents.delete('gift_redemption_event');
} else {
excludedEvents.add('payment_event');
excludedEvents.add('donation_event');
excludedEvents.add('gift_purchase_event');
excludedEvents.add('gift_redemption_event');
}
} else {
if (excludedEvents.has(eventType)) {
Expand Down
3 changes: 3 additions & 0 deletions ghost/admin/public/assets/icons/event-gift.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion ghost/admin/tests/unit/utils/member-event-types-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ describe('Unit | Utility | event-type-utils', function () {

const newExcludedEvents = toggleEventType('payment_event', eventTypes);

expect(newExcludedEvents).to.equal('payment_event,donation_event,gift_purchase_event');
expect(newExcludedEvents).to.equal('payment_event,donation_event,gift_purchase_event,gift_redemption_event');
});

it('should toggle both payment_event and donation_event off when toggling payment_event off', function () {
Expand Down
2 changes: 2 additions & 0 deletions ghost/core/core/server/services/gifts/constants.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export const GIFT_EXPIRY_DAYS = 365;
export const GIFT_REMINDER_LEAD_DAYS = 7;
export const GIFT_REMINDER_FLOOR_DAYS = 3;
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Your gift subscription is ending soon</title>
<style>
@media only screen and (max-width: 620px) {
table.body h1 { font-size: 22px !important; padding-bottom: 16px !important; }
table.body p, table.body td, table.body a { font-size: 16px !important; }
table.body .wrapper { padding: 10px !important; }
table.body .content { padding: 0 !important; }
table.body .container { padding: 0 !important; width: 100% !important; }
table.body .main { border-radius: 0 !important; }
table.body p.large, table.body p.large a { font-size: 18px !important; }
table.body p.small, table.body a.small { font-size: 12px !important; }
}
</style>
</head>
<body style="background-color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; -webkit-font-smoothing: antialiased; font-size: 14px; line-height: 1.5em; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;">
<table border="0" cellpadding="0" cellspacing="0" class="body" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">&nbsp;</td>
<td class="container" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; display: block; Margin: 0 auto; max-width: 540px; padding: 10px; width: 540px;">
<div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 600px; padding: 30px 20px;">

<!-- START CENTERED CONTAINER -->

<table class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background: #ffffff; border-radius: 8px;">

<!-- START MAIN CONTENT AREA -->
<tr>
<td class="wrapper" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; box-sizing: border-box;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
{{#if siteIconUrl}}
<tr>
<td align="center" style="padding-bottom: 56px; text-align: center;"><a href="{{siteUrl}}"><img src="{{siteIconUrl}}" alt="{{siteTitle}}" border="0" width="48" height="48"></a></td>
</tr>
{{/if}}
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">
<h1 style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 26px; color: #15212A; font-weight: bold; line-height: 28px; margin: 0; padding-bottom: 12px;">Your gift subscription is ending&nbsp;soon</h1>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; padding-bottom: 24px;">{{#if memberName}}Hi {{memberName}}, your{{else}}Your{{/if}} gift subscription to {{siteTitle}} ends on {{gift.consumesAt}}. Continue with a paid subscription to keep your access.</p>
<table width="100%" border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; table-layout: fixed; width: 100%; min-width: 100%; box-sizing: border-box; background: #F4F5F6; border-radius: 8px;">
<tbody>
<tr>
<td align="left" style="padding: 24px;">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
<td style="padding-right: 8px; background-color: #F4F5F6; text-align: left; vertical-align: middle;" valign="middle">
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; margin: 0; padding-bottom: 4px; color: #15171A; font-weight: 700;">Gift subscription</p>
<p class="large" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; margin: 0; padding-bottom: 24px; color: #15171A; font-weight: 400;">{{gift.tierName}} &bull; {{gift.cadenceLabel}}</p>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; margin: 0; padding-bottom: 4px; color: #15171A; font-weight: 700;">Ends on</p>
<p class="large" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; margin: 0; padding-bottom: 0; color: #15171A; font-weight: 400;">{{gift.consumesAt}}</p>
</td>
</tr>
</table>
<table border="0" cellpadding="0" cellspacing="0" class="btn btn-primary" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; box-sizing: border-box; padding-top: 24px;">
<tbody>
<tr>
<td align="left" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;">
<tbody>
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; vertical-align: top; background-color: {{accentColor}}; border-radius: 8px; text-align: center;">
<a href="{{gift.manageSubscriptionUrl}}" target="_blank" style="display: inline-block; color: #ffffff; background-color: {{accentColor}}; border: solid 1px {{accentColor}}; border-radius: 8px; padding: 10px 20px; text-decoration: none;">Manage subscription</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>

<!-- START FOOTER -->
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 12px; vertical-align: top; padding-top: 56px;">
<p class="small" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; line-height: 18px; font-size: 12px; color: #7C8B9A; font-weight: normal; margin: 0;">This message was sent from <a class="small" href="{{siteUrl}}" style="text-decoration: underline; color: #7C8B9A; font-size: 12px;">{{siteDomain}}</a> to <a class="small" href="mailto:{{memberEmail}}" style="text-decoration: underline; color: #7C8B9A; font-size: 12px;">{{memberEmail}}</a></p>
</td>
</tr>
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 12px; vertical-align: top;">
<p class="small" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; line-height: 18px; font-size: 12px; color: #7C8B9A; font-weight: normal; margin: 0;">You received this email because your gift subscription to <a class="small" href="{{siteUrl}}" style="text-decoration: underline; color: #7C8B9A; font-size: 12px;">{{siteTitle}}</a> is ending soon.</p>
</td>
</tr>

<!-- END FOOTER -->
</table>
</td>
</tr>

<!-- END MAIN CONTENT AREA -->
</table>


<!-- END CENTERED CONTAINER -->
</div>
</td>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">&nbsp;</td>
</tr>
</table>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export interface GiftReminderData {
siteTitle: string;
siteUrl: string;
siteIconUrl: string | null;
siteDomain: string;
accentColor: string | undefined;
memberEmail: string;
memberName: string | null;
gift: {
tierName: string;
cadenceLabel: string;
consumesAt: string;
manageSubscriptionUrl: string;
};
}

export function renderText(data: GiftReminderData): string {
const greeting = data.memberName ? `Hi ${data.memberName},` : 'Hi,';

return `${greeting}

Your gift subscription to ${data.siteTitle} ends on ${data.gift.consumesAt}.

Gift subscription: ${data.gift.tierName} • ${data.gift.cadenceLabel}

To keep your access, continue with a paid subscription before your gift ends:
${data.gift.manageSubscriptionUrl}

---

Sent to ${data.memberEmail} from ${data.siteDomain}.
You received this email because your gift subscription to ${data.siteTitle} is ending soon.`;
}
21 changes: 18 additions & 3 deletions ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import errors from '@tryghost/errors';
import {Gift, type GiftCadence, type GiftStatus} from './gift';
import type {GiftRepository, RepositoryTransactionOptions} from './gift-repository';
import type {FindPendingReminderOptions, GiftRepository, RepositoryTransactionOptions} from './gift-repository';

type BookshelfDocument<T> = {
save(data: Partial<T>, options?: unknown): Promise<unknown>;
Expand Down Expand Up @@ -40,6 +40,7 @@ type GiftRow = {
consumed_at: Date | null;
expired_at: Date | null;
refunded_at: Date | null;
consumes_soon_reminder_sent_at: Date | null;
};

type GiftBookshelfModel = BookshelfModel<GiftRow>;
Expand Down Expand Up @@ -95,6 +96,18 @@ export class GiftBookshelfRepository implements GiftRepository {
return collection.models.map(model => this.toGift(model));
}

async findPendingReminder({now, reminderLeadMs, reminderFloorMs, transacting}: FindPendingReminderOptions): Promise<Gift[]> {
const upper = new Date(now.getTime() + reminderLeadMs).toISOString();
const lower = new Date(now.getTime() + reminderFloorMs).toISOString();

const collection = await this.model.findAll({
filter: `status:redeemed+consumes_at:<='${upper}'+consumes_at:>'${lower}'+consumes_soon_reminder_sent_at:null`,
transacting
});

return collection.models.map(model => this.toGift(model));
}

async create(gift: Gift, options: RepositoryTransactionOptions = {}) {
await this.model.add(this.toRow(gift), options);
}
Expand Down Expand Up @@ -140,7 +153,8 @@ export class GiftBookshelfRepository implements GiftRepository {
redeemed_at: gift.redeemedAt,
consumed_at: gift.consumedAt,
expired_at: gift.expiredAt,
refunded_at: gift.refundedAt
refunded_at: gift.refundedAt,
consumes_soon_reminder_sent_at: gift.consumesSoonReminderSentAt
};
}

Expand All @@ -166,7 +180,8 @@ export class GiftBookshelfRepository implements GiftRepository {
redeemedAt: json.redeemed_at,
consumedAt: json.consumed_at,
expiredAt: json.expired_at,
refundedAt: json.refunded_at
refundedAt: json.refunded_at,
consumesSoonReminderSentAt: json.consumes_soon_reminder_sent_at ?? null
});
}
}
16 changes: 16 additions & 0 deletions ghost/core/core/server/services/gifts/gift-email-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ import path from 'node:path';
import Handlebars from 'handlebars';
import type {GiftPurchaseConfirmationData} from './email-templates/gift-purchase-confirmation';
import {renderText as renderPurchaseConfirmationText} from './email-templates/gift-purchase-confirmation';
import type {GiftReminderData} from './email-templates/gift-reminder';
import {renderText as renderReminderText} from './email-templates/gift-reminder';

export class GiftEmailRenderer {
private readonly handlebars: typeof Handlebars;

private purchaseConfirmationTemplate: HandlebarsTemplateDelegate | null = null;
private reminderTemplate: HandlebarsTemplateDelegate | null = null;

constructor() {
this.handlebars = Handlebars.create();
Expand All @@ -25,4 +28,17 @@ export class GiftEmailRenderer {
text: renderPurchaseConfirmationText(data)
};
}

async renderReminder(data: GiftReminderData): Promise<{html: string; text: string}> {
if (!this.reminderTemplate) {
const source = await fs.readFile(path.join(__dirname, './email-templates/gift-reminder.hbs'), 'utf8');

this.reminderTemplate = this.handlebars.compile(source);
}

return {
html: this.reminderTemplate(data),
text: renderReminderText(data)
};
}
}
Loading
Loading