diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 2e1792a83e8..108fac8ec9d 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -514,44 +514,6 @@ "automergeType": "pr" }, - // The catalog rule above is local, so it is evaluated after package - // rules inherited from the shared preset. Repeat the shared CSS/style - // exclusion locally so adding catalog support does not turn intentionally - // human-reviewed build-pipeline updates into automerges, and label every - // such PR so it is easy to find. - { - "description": "Require human review for CSS preprocessor updates", - "matchPackageNames": [ - "/^postcss/", - "/^css/", - "/^sass/", - "/^less/", - "/^styl/", - "autoprefixer", - "cssnano", - "postcss", - "postcss-cli", - "postcss-import", - "postcss-custom-media", - "postcss-custom-properties", - "postcss-color-mod-function", - "postcss-easy-import", - "ember-cli-postcss", - "gulp-postcss", - "sass", - "node-sass", - "sass-embedded", - "less", - "stylus", - "stylelint", - "tailwindcss" - ], - "automerge": false, - "addLabels": [ - "needs:review" - ] - }, - // Overrides change transitive resolution across the entire workspace, // so keep them manual and make that policy explicit on their PRs. { @@ -708,8 +670,8 @@ }, // Security patches and minors automerge after CI passes regardless - // of any other automerge exclusion (e.g. the CSS preprocessor rule - // in the shared preset). Security majors are intentionally left + // of any other automerge exclusion (e.g. the third-party action rule + // above). Security majors are intentionally left // for human merge — but the `vulnerabilityAlerts` block above // still opens them without a dashboard-approval tick. { diff --git a/apps/admin-x-framework/src/api/member-custom-fields.ts b/apps/admin-x-framework/src/api/member-custom-fields.ts index e2794067269..47c7dbffa56 100644 --- a/apps/admin-x-framework/src/api/member-custom-fields.ts +++ b/apps/admin-x-framework/src/api/member-custom-fields.ts @@ -160,6 +160,61 @@ export const memberCustomFieldParts = (type: T): MemberCust return partKeys.map(key => ({key, label: labels[key]})); }; +/** + * A value as the record of parts a composite reads from, and nothing otherwise. + * + * A predicate rather than an assertion: the same checks either way, but this one hands + * the narrowing to the compiler rather than overriding it, so a formatter below cannot be + * reached by a value nobody looked at. + */ +const isPartRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** + * How each composite type reads as one line. Written per type rather than walked from + * `subFieldsOf`, because where a part sits in the sentence is a fact about how the value + * reads, not one the value schema can supply — an address fuses state and postal code the + * way people write them. A part added upstream stays out of the line until someone decides + * where it belongs. + * + * Total over the field types, the way the presentation catalog above is: a type added + * upstream fails to compile here until someone has decided how its value reads, rather + * than reaching every surface as a blank cell. A scalar declares `undefined`, which is + * how "its value is already a line" is said. + */ +const compositeValueFormatters: { + [T in FieldType]: [PartsOf] extends [never] ? undefined : (value: Record) => string +} = { + short_text: undefined, + long_text: undefined, + address: (value) => { + const {line1, line2, city, state, postal_code: postalCode, country} = value; + const statePostal = [state, postalCode].filter(Boolean).join(' '); + return [line1, line2, city, statePostal, country].filter(Boolean).join(', '); + } +}; + +/** + * A member's value for one field as a single readable line: the string itself for a + * scalar, and for a composite its parts joined the way that type reads — e.g. + * "1 Main St, 12 apt B, New York, NY 00001, US". Missing parts drop out, so a partial + * value still reads naturally. + * + * Empty string for a value that is not the shape its type declares: the type decides + * which shape is readable, so a composite reads only from its parts and a scalar only + * from text. Callers own their own placeholder, since "no value" reads differently in a + * table cell than in a detail row. + */ +export const formatMemberCustomFieldValue = (type: FieldType, value: unknown): string => { + const formatComposite = compositeValueFormatters[type]; + + if (formatComposite) { + return isPartRecord(value) ? formatComposite(value) : ''; + } + + return typeof value === 'string' ? value : ''; +}; + export interface MemberCustomFieldsResponseType { meta?: Meta; members_custom_fields: MemberCustomField[]; diff --git a/apps/admin-x-framework/test/unit/api/member-custom-fields.test.ts b/apps/admin-x-framework/test/unit/api/member-custom-fields.test.ts index 0501ff11124..fe80a51988b 100644 --- a/apps/admin-x-framework/test/unit/api/member-custom-fields.test.ts +++ b/apps/admin-x-framework/test/unit/api/member-custom-fields.test.ts @@ -1,4 +1,4 @@ -import {type FieldTypePresentation, type MemberCustomField, memberCustomFieldCsvColumns, memberCustomFieldParts} from '../../../src/api/member-custom-fields'; +import {type FieldTypePresentation, type MemberCustomField, formatMemberCustomFieldValue, memberCustomFieldCsvColumns, memberCustomFieldParts} from '../../../src/api/member-custom-fields'; // Compile-time cases: the build failing is the assertion. Each `@ts-expect-error` fails the // build if the case it names stops being an error. Declared on one line each, because the @@ -98,4 +98,40 @@ describe('member custom fields api helpers', () => { ]); }); }); + + describe('formatMemberCustomFieldValue', () => { + it('returns a scalar value as it stands', () => { + expect(formatMemberCustomFieldValue('short_text', 'Editor')).toBe('Editor'); + expect(formatMemberCustomFieldValue('long_text', 'A longer note')).toBe('A longer note'); + }); + + it('formats a full address as one readable line', () => { + expect(formatMemberCustomFieldValue('address', {line1: '1 Main St', line2: '12 apt B', city: 'New York', state: 'NY', postal_code: '00001', country: 'US'})) + .toBe('1 Main St, 12 apt B, New York, NY 00001, US'); + }); + + it('pairs state and postal code, and drops missing parts cleanly', () => { + expect(formatMemberCustomFieldValue('address', {line1: '1 Main St', city: 'Berlin', postal_code: '10115', country: 'DE'})) + .toBe('1 Main St, Berlin, 10115, DE'); + expect(formatMemberCustomFieldValue('address', {city: 'Berlin'})).toBe('Berlin'); + }); + + // A surface renders its own placeholder, so every unreadable value has to reduce to + // the same empty string rather than leaking a raw object or the word "undefined". + it('formats an empty or unreadable value as an empty string', () => { + expect(formatMemberCustomFieldValue('address', {})).toBe(''); + expect(formatMemberCustomFieldValue('address', null)).toBe(''); + expect(formatMemberCustomFieldValue('address', undefined)).toBe(''); + expect(formatMemberCustomFieldValue('address', ['a', 'b'])).toBe(''); + // A composite type from a later build: no formatter, so no line. + expect(formatMemberCustomFieldValue('unheard_of' as MemberCustomField['type'], {line1: '1 Main St'})).toBe(''); + }); + + // The type decides which shape is readable, so a value of the other shape is not + // read at all rather than being coerced into a line that misrepresents it. + it('reads only the shape its type declares', () => { + expect(formatMemberCustomFieldValue('address', '1 Main St, Berlin')).toBe(''); + expect(formatMemberCustomFieldValue('short_text', {line1: '1 Main St'})).toBe(''); + }); + }); }); diff --git a/apps/admin/eslint.config.js b/apps/admin/eslint.config.js index c9b27a46dbf..cbaa8054f1b 100644 --- a/apps/admin/eslint.config.js +++ b/apps/admin/eslint.config.js @@ -62,30 +62,6 @@ export default tseslint.config( } } }, - // Legacy quarantine: settings code moved from admin-x-settings, which linted without - // type-aware rules. Overrides die as files migrate out of src/settings/app; no new code there. - { - files: ['src/settings/app/**/*.{ts,tsx}'], - rules: { - '@typescript-eslint/await-thenable': 'off', - '@typescript-eslint/no-base-to-string': 'off', - '@typescript-eslint/no-duplicate-type-constituents': 'off', - '@typescript-eslint/no-floating-promises': 'off', - '@typescript-eslint/no-misused-promises': 'off', - '@typescript-eslint/no-redundant-type-constituents': 'off', - '@typescript-eslint/no-unnecessary-type-assertion': 'off', - '@typescript-eslint/no-unsafe-argument': 'off', - '@typescript-eslint/no-unsafe-assignment': 'off', - '@typescript-eslint/no-unsafe-call': 'off', - '@typescript-eslint/no-unsafe-member-access': 'off', - '@typescript-eslint/no-unsafe-return': 'off', - '@typescript-eslint/only-throw-error': 'off', - '@typescript-eslint/require-await': 'off', - '@typescript-eslint/restrict-plus-operands': 'off', - '@typescript-eslint/restrict-template-expressions': 'off', - 'react-refresh/only-export-components': 'off' - } - }, { files: ['src/**/*.{ts,tsx}'], plugins: {'no-relative-import-paths': noRelativeImportPaths}, diff --git a/apps/admin/src/automations/automations.test.tsx b/apps/admin/src/automations/automations.test.tsx index 0870285a397..eec29da4979 100644 --- a/apps/admin/src/automations/automations.test.tsx +++ b/apps/admin/src/automations/automations.test.tsx @@ -94,6 +94,11 @@ describe('Automations', () => { it('shows free and paid sequences when Stripe is connected', () => { renderPage(); + expect(mockUseBrowseAutomations).toHaveBeenCalledWith({ + defaultErrorHandler: false, + refetchOnMount: 'always', + staleTime: 0 + }); expect(screen.getByText('Free member welcome flow')).toBeInTheDocument(); expect(screen.getByText('Paid member welcome flow')).toBeInTheDocument(); }); diff --git a/apps/admin/src/automations/hooks/use-visible-automations.ts b/apps/admin/src/automations/hooks/use-visible-automations.ts index b24cc46cce5..1aec10b3d95 100644 --- a/apps/admin/src/automations/hooks/use-visible-automations.ts +++ b/apps/admin/src/automations/hooks/use-visible-automations.ts @@ -6,7 +6,9 @@ import type {Config} from '@tryghost/admin-x-framework/api/config'; export const useVisibleAutomations = () => { const {data, error, isError, isLoading} = useBrowseAutomations({ - defaultErrorHandler: false + defaultErrorHandler: false, + refetchOnMount: 'always', + staleTime: 0 }); const {data: settingsData, isLoading: isSettingsLoading} = useBrowseSettings(); const {data: configData, isLoading: isConfigLoading} = useBrowseConfig(); diff --git a/apps/admin/src/automations/utils/default-welcome-email-values.ts b/apps/admin/src/automations/utils/default-welcome-email-values.ts index 9b69b9cc8e5..faaced44b7c 100644 --- a/apps/admin/src/automations/utils/default-welcome-email-values.ts +++ b/apps/admin/src/automations/utils/default-welcome-email-values.ts @@ -1,4 +1,4 @@ -// NOTE: duplicated in apps/admin/src/settings/app/components/settings/membership/member-emails/default-welcome-email-values.ts — the email design modal needs it in both apps until Automations GAs; keep in sync +// NOTE: duplicated in apps/admin/src/settings/membership/member-emails/default-welcome-email-values.ts — the email design modal needs it in both apps until Automations GAs; keep in sync import type {AutomatedEmail} from '@tryghost/admin-x-framework/api/automated-emails'; export type WelcomeEmailType = 'free' | 'paid'; diff --git a/apps/admin/src/hooks/use-hash-link-navigation-guard.ts b/apps/admin/src/hooks/use-hash-link-navigation-guard.ts index da4217c6fd9..766cf3cb2f5 100644 --- a/apps/admin/src/hooks/use-hash-link-navigation-guard.ts +++ b/apps/admin/src/hooks/use-hash-link-navigation-guard.ts @@ -13,10 +13,13 @@ import React from 'react'; * back to the caller to confirm. Links rendered by react-router (marked with * `data-discover`) are left alone — the router's own blocker guards those. */ -export function useHashLinkNavigationGuard(when: boolean) { +export function useHashLinkNavigationGuard(when: boolean, onBlocked?: () => void) { const [blockedHref, setBlockedHref] = React.useState(null); + const blockedHrefRef = React.useRef(null); const whenRef = React.useRef(when); whenRef.current = when; + const onBlockedRef = React.useRef(onBlocked); + onBlockedRef.current = onBlocked; React.useEffect(() => { const onClick = (event: MouseEvent) => { @@ -34,7 +37,10 @@ export function useHashLinkNavigationGuard(when: boolean) { } event.preventDefault(); event.stopPropagation(); - setBlockedHref(anchor.getAttribute('href')); + const href = anchor.getAttribute('href'); + blockedHrefRef.current = href; + onBlockedRef.current?.(); + setBlockedHref(href); }; document.addEventListener('click', onClick, true); @@ -46,12 +52,17 @@ export function useHashLinkNavigationGuard(when: boolean) { isBlocked: blockedHref !== null, /** Confirm leaving: performs the intercepted navigation for real. */ proceed: () => { - if (blockedHref) { + const href = blockedHrefRef.current; + if (href) { + blockedHrefRef.current = null; setBlockedHref(null); - window.location.hash = blockedHref; + window.location.hash = href; } }, /** Cancel leaving: drops the intercepted navigation. */ - reset: () => setBlockedHref(null) + reset: () => { + blockedHrefRef.current = null; + setBlockedHref(null); + } }; } diff --git a/apps/admin/src/index.css b/apps/admin/src/index.css index 9ad4360f3bc..21e15fc7ed7 100644 --- a/apps/admin/src/index.css +++ b/apps/admin/src/index.css @@ -20,12 +20,13 @@ height: 100%; } -/* Keep admin-x heading line-height consistent with pre-migration settings render. */ -.admin-x-base h1, -.admin-x-base h2, -.admin-x-base h3, -.admin-x-base h4, -.admin-x-base h5 { +/* Settings' raw h1-h5 still expect the 1.25em admin-x heading line-height; Shade's base gives 1.5. + Remove once those headings carry an explicit leading. */ +.settings-app h1, +.settings-app h2, +.settings-app h3, +.settings-app h4, +.settings-app h5 { line-height: 1.25em; } diff --git a/apps/admin/src/layout/app-sidebar/nav-menu-item.tsx b/apps/admin/src/layout/app-sidebar/nav-menu-item.tsx index 333d31610f9..13fefe6e891 100644 --- a/apps/admin/src/layout/app-sidebar/nav-menu-item.tsx +++ b/apps/admin/src/layout/app-sidebar/nav-menu-item.tsx @@ -2,6 +2,8 @@ import React from 'react'; import {Button, SidebarMenuButton, SidebarMenuItem, useSidebar} from '@tryghost/shade/components'; import {cn, LucideIcon} from '@tryghost/shade/utils'; import { useIsActiveLink } from './use-is-active-link'; +import { Link } from '@tryghost/admin-x-framework'; +import { isEmberOwnedRoute } from '@/routes'; function NavMenuItem({ children, ...props }: React.ComponentProps) { return ( @@ -114,10 +116,12 @@ function NavMenuLink({ children, ...props }: NavMenuLinkProps) { - const href = `#/${to?.replace(/^\/?#\//, '')}`; + const path = `/${to?.replace(/^\/?#\//, '')}`; const computedIsActive = useIsActiveLink({ path: to, activeOnSubpath }); const isActive = controlledIsActive !== undefined ? controlledIsActive : computedIsActive; const { isMobile, setOpenMobile } = useSidebar(); + const isExternal = target === '_blank'; + const useRouterLink = !isExternal && !isEmberOwnedRoute(path.split('?')[0]); const handleClick = () => { if (isMobile) { @@ -130,15 +134,27 @@ function NavMenuLink({ isActive={isActive} asChild {...props}> - - {children} - + {useRouterLink ? ( + + {children} + + ) : ( + + {children} + + )} ) } diff --git a/apps/admin/src/layout/sidebar.acceptance.test.tsx b/apps/admin/src/layout/sidebar.acceptance.test.tsx index 875fa013d8b..3010c11c9cc 100644 --- a/apps/admin/src/layout/sidebar.acceptance.test.tsx +++ b/apps/admin/src/layout/sidebar.acceptance.test.tsx @@ -56,6 +56,22 @@ describe("Sidebar navigation", () => { await expect.element(sidebarScreen.navLink("Tags")).not.toHaveAttribute("aria-current"); }); + it("uses router navigation for React-owned routes and hash anchors for Ember-owned ones", async () => { + fakeTags([]); + await renderAdminApp("/site"); + + // Router links carry the router's history state (the unsaved-changes + // blockers rely on it); Ember's router only follows hashchange, so its + // links must stay native anchors. + await sidebarScreen.navLink("Tags").click(); + await expect.poll(currentRoute).toBe("/tags"); + expect(typeof (window.history.state as {key?: unknown} | null)?.key).toBe("string"); + + await sidebarScreen.navLink("Posts").click(); + await expect.poll(currentRoute).toBe("/posts"); + expect((window.history.state as {key?: unknown} | null)?.key).toBeUndefined(); + }); + it("clicking Posts and Pages navigates to the Ember-owned lists", async () => { // Posts/Pages active states come from the Ember routing bridge, absent in this tier. await renderAdminApp("/site"); diff --git a/apps/admin/src/members/components/members-filters.tsx b/apps/admin/src/members/components/members-filters.tsx index 5138f7b20c2..1715cc6352a 100644 --- a/apps/admin/src/members/components/members-filters.tsx +++ b/apps/admin/src/members/components/members-filters.tsx @@ -9,6 +9,7 @@ import { toOfferFilterDisplayValues, useMemberFilterFields } from '@/members/use-member-filter-fields'; +import {CUSTOM_FIELDS_PREFIX} from '@/members/member-fields'; import {getSettingValue, useBrowseSettings} from '@tryghost/admin-x-framework/api/settings'; import {getSiteTimezone} from '@tryghost/admin-x-framework/utils/get-site-timezone'; import {useBrowseNewsletters} from '@tryghost/admin-x-framework/api/newsletters'; @@ -104,8 +105,8 @@ const MembersFilters: React.FC = ({ const referencedCustomFieldKeys = useMemo(() => new Set( filters .map(filter => filter.field) - .filter(field => field.startsWith('custom_field.')) - .map(field => field.slice('custom_field.'.length)) + .filter(field => field.startsWith(CUSTOM_FIELDS_PREFIX)) + .map(field => field.slice(CUSTOM_FIELDS_PREFIX.length)) .filter(Boolean) ), [filters]); // Only when the current filter references a custom field do we also pull the archived diff --git a/apps/admin/src/members/components/members-list-item.tsx b/apps/admin/src/members/components/members-list-item.tsx index 68dc06cdec9..5bc4243dcb2 100644 --- a/apps/admin/src/members/components/members-list-item.tsx +++ b/apps/admin/src/members/components/members-list-item.tsx @@ -4,8 +4,7 @@ import {Avatar, TableCell, TableRow} from '@tryghost/shade/components'; import {type Member} from '@tryghost/admin-x-framework/api/members'; import {buildMemberDetailPath} from '@/members/member-detail-hash'; import {cn, formatPercentage} from '@tryghost/shade/utils'; -import {getActiveColumnValue} from '@/members/member-query-params'; -import type {ActiveColumn} from '@/members/member-query-params'; +import type {MemberActiveColumn} from '@/members/member-query-params'; import {forwardRef, type CSSProperties} from 'react'; import type {MemberTableColumnStyles} from './member-table-layout'; @@ -179,11 +178,11 @@ function MembersListItemDynamicColumn({ member, timezone }: { - column: ActiveColumn; + column: MemberActiveColumn; member: Member; timezone: string; }) { - const value = getActiveColumnValue(column, member, timezone); + const value = column.getValue(member, timezone); if (!value) { return ( @@ -193,9 +192,13 @@ function MembersListItemDynamicColumn({ return (
-
{value.text}
+ {/* A dynamic column holds whatever a publisher collected, so its value has no + length a column width can be sized to — an address routinely outruns the + cell. The native title is the fallback for reading one in full: no cost per + row, which a tooltip component would have across a virtualised list. */} +
{value.text}
{value.subtext && ( -
+
{value.subtext}
)} @@ -207,7 +210,7 @@ function MembersListItemDynamicColumn({ interface MembersListItemProps { item: Member; - activeColumns: ActiveColumn[]; + activeColumns: MemberActiveColumn[]; backPath?: string; columnStyles: MemberTableColumnStyles; showPinnedEdge: boolean; diff --git a/apps/admin/src/members/components/members-list.tsx b/apps/admin/src/members/components/members-list.tsx index 164f98e69ea..83a472ef645 100644 --- a/apps/admin/src/members/components/members-list.tsx +++ b/apps/admin/src/members/components/members-list.tsx @@ -6,7 +6,7 @@ import {Table, TableBody, TableCell, TableRow} from '@tryghost/shade/components' import {buildMemberDetailPath} from '@/members/member-detail-hash'; import {forwardRef, useEffect, useMemo, useRef, useState} from 'react'; import {getMemberTableLayout, getMemberTableLayoutStyles} from './member-table-layout'; -import type {ActiveColumn} from '@/members/member-query-params'; +import type {MemberActiveColumn} from '@/members/member-query-params'; import type {RefObject} from 'react'; const SpacerRow = ({height}: { height: number }) => ( @@ -43,7 +43,7 @@ interface MembersListProps { fetchNextPage: () => void; isLoading?: boolean; showEmailOpenRate?: boolean; - activeColumns: ActiveColumn[]; + activeColumns: MemberActiveColumn[]; timezone: string; pageHeaderRef?: RefObject; onRowClick?: (memberId: string) => void; diff --git a/apps/admin/src/members/custom-field-filter-renderer.tsx b/apps/admin/src/members/custom-field-filter-renderer.tsx index 695e8d0a2be..a76944ff62a 100644 --- a/apps/admin/src/members/custom-field-filter-renderer.tsx +++ b/apps/admin/src/members/custom-field-filter-renderer.tsx @@ -1,26 +1,24 @@ import React, {useEffect} from 'react'; -import {CUSTOM_FIELD_OPERATORS, CUSTOM_FIELD_SET_OPERATORS} from './member-fields'; +import {CUSTOM_FIELDS_PREFIX, CUSTOM_FIELD_OPERATORS, CUSTOM_FIELD_SET_OPERATORS} from './member-fields'; import {FilterSegmentInput, FilterSegmentSelect} from '@tryghost/shade/patterns'; import {createOperatorOptions} from '@/shared/filters'; import {memberCustomFieldParts, useBrowseMemberCustomFieldsIncludingArchived} from '@tryghost/admin-x-framework/api/member-custom-fields'; import type {CustomRendererProps} from '@tryghost/shade/patterns'; // The dropdown entry has already chosen the field (its key is in `field.key` as -// `custom_field.`), so this renders only what's left in the pill: for a +// `custom_fields.`), so this renders only what's left in the pill: for a // composite field a part selector (with "Any" for the whole field), then the // operator, then the value. The predicate carries [subfield, value]; subfield is '' // for a scalar field or the "Any" whole-field set/unset case. The operator lives here // because its valid set depends on the part chosen here. -const KEY_PREFIX = 'custom_field.'; - const CustomFieldFilterRenderer: React.FC> = ({field, values, onChange, operator, onOperatorChange, readOnly}) => { // Include-archived so an archived composite field's pill can still resolve its parts // and show which one the saved segment filters on. const {data} = useBrowseMemberCustomFieldsIncludingArchived(); const definitions = data?.members_custom_fields ?? []; - const fieldKey = (field.key ?? '').slice(KEY_PREFIX.length); + const fieldKey = (field.key ?? '').slice(CUSTOM_FIELDS_PREFIX.length); const definition = definitions.find(candidate => candidate.key === fieldKey); // The shared catalog decides which parts a type has and what they are called; a scalar // field has none. Its keys are the ones the predicate carries. diff --git a/apps/admin/src/members/detail/member-custom-fields-field.tsx b/apps/admin/src/members/detail/member-custom-fields-field.tsx index a81d66fc591..24afca75020 100644 --- a/apps/admin/src/members/detail/member-custom-fields-field.tsx +++ b/apps/admin/src/members/detail/member-custom-fields-field.tsx @@ -3,9 +3,8 @@ import {Button, Card, CardContent, Dialog, DialogContent, DialogFooter, DialogHe import {LucideIcon} from '@tryghost/shade/utils'; import {dequal} from 'dequal'; import {ADDRESS_PARTS, buildCustomFieldSavePayload, getCustomFieldValidationErrors, getEditableCustomFieldValues, parseCustomFieldServerErrors} from './member-detail-edit'; -import {formatAddressValue} from './member-detail-format'; import {toast} from 'sonner'; -import {useBrowseMemberCustomFields, userTypeForField} from '@tryghost/admin-x-framework/api/member-custom-fields'; +import {formatMemberCustomFieldValue, useBrowseMemberCustomFields, userTypeForField} from '@tryghost/admin-x-framework/api/member-custom-fields'; import {useEditMember} from '@tryghost/admin-x-framework/api/members'; import type {EditableAddressValue, EditableCustomFieldValue} from './member-detail-edit'; import type {MemberCustomField} from '@tryghost/admin-x-framework/api/member-custom-fields'; @@ -230,17 +229,6 @@ const MemberCustomFieldEditModal: React.FC<{ ); }; -/** One readable line per value; address collapses to a formatted string. */ -function formatValue(value: EditableCustomFieldValue | undefined): string | null { - if (value === undefined) { - return null; - } - if (typeof value === 'string') { - return value; - } - return formatAddressValue(value) || null; -} - /** * The Custom fields section of the member detail screen: a read-only list of * the member's values, one row per field defined in Settings. This screen is @@ -270,7 +258,8 @@ const MemberCustomFieldsField: React.FC = ({member
    {fields.map((field) => { - const display = formatValue(values[field.key]); + // Null rather than an empty line, so an unset value shows its placeholder. + const display = formatMemberCustomFieldValue(field.type, values[field.key]) || null; return ( // Dividers fade around the hovered row (its own border-b, and the // previous row's via :has), so the hover tint floats free of the diff --git a/apps/admin/src/members/detail/member-detail-format.test.ts b/apps/admin/src/members/detail/member-detail-format.test.ts index bac0ef92fca..8b399a93da3 100644 --- a/apps/admin/src/members/detail/member-detail-format.test.ts +++ b/apps/admin/src/members/detail/member-detail-format.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {formatAddressValue, formatMemberLocation, getMemberReferrerSource, parseMemberGeolocation} from './member-detail-format'; +import {formatMemberLocation, getMemberReferrerSource, parseMemberGeolocation} from './member-detail-format'; describe('parseMemberGeolocation', () => { it('returns null for missing input', () => { @@ -81,20 +81,3 @@ describe('getMemberReferrerSource', () => { expect(getMemberReferrerSource({referrer_source: 'Created manually'})).toBeNull(); }); }); - -describe('formatAddressValue', () => { - it('formats a full address as one readable line', () => { - expect(formatAddressValue({line1: '1 Main St', line2: '12 apt B', city: 'New York', state: 'NY', postal_code: '00001', country: 'US'})) - .toBe('1 Main St, 12 apt B, New York, NY 00001, US'); - }); - - it('pairs state and postal code, and drops missing sub-fields cleanly', () => { - expect(formatAddressValue({line1: '1 Main St', city: 'Berlin', postal_code: '10115', country: 'DE'})) - .toBe('1 Main St, Berlin, 10115, DE'); - expect(formatAddressValue({city: 'Berlin'})).toBe('Berlin'); - }); - - it('formats an empty address as an empty string', () => { - expect(formatAddressValue({})).toBe(''); - }); -}); diff --git a/apps/admin/src/members/detail/member-detail-format.ts b/apps/admin/src/members/detail/member-detail-format.ts index 383915186e4..cbb12fe6454 100644 --- a/apps/admin/src/members/detail/member-detail-format.ts +++ b/apps/admin/src/members/detail/member-detail-format.ts @@ -1,5 +1,3 @@ -import type {MemberCustomFieldAddress} from '@tryghost/admin-x-framework/api/member-custom-fields'; - export interface MemberGeolocation { country_code?: string; country?: string; @@ -51,23 +49,6 @@ export function formatMemberLocation(rawGeolocation: string | null | undefined): return geo.country || 'Unknown location'; } -/** - * An address value as one readable line for the custom fields card: - * "1 Main St, 12 apt B, New York, NY 00001, US". State and postal code pair - * up the way people write them; whatever sub-fields are missing simply drop - * out, so a partial address still reads naturally. - * - * The parts are named one by one rather than walked, because where each sits in the - * sentence is a fact about how an address reads, not one the value schema can supply. A - * part added upstream will not appear here until someone decides where it belongs. - */ -export function formatAddressValue(address: Partial): string { - const statePostal = [address.state, address.postal_code].filter(Boolean).join(' '); - return [address.line1, address.line2, address.city, statePostal, address.country] - .filter(Boolean) - .join(', '); -} - /** * The signup referrer source for the "Signup info" block. Members created from the * admin carry the placeholder source "Created manually", which the Ember screen diff --git a/apps/admin/src/members/import-members-custom-fields.acceptance.test.tsx b/apps/admin/src/members/import-members-custom-fields.acceptance.test.tsx index f049c9fea63..5c5bc06e655 100644 --- a/apps/admin/src/members/import-members-custom-fields.acceptance.test.tsx +++ b/apps/admin/src/members/import-members-custom-fields.acceptance.test.tsx @@ -17,6 +17,11 @@ const RAGGED_CSV = 'email,name,note\nada@example.com\ngrace@example.com,Grace Ho // A Ghost export re-imported somewhere its field no longer exists: archived since, or a // different site. The header says what the column is even though nothing matches it. +// Every browse of the field list, whether or not it carries a status filter: the members +// screen behind this modal asks for archived fields too, and an exact-path fake would +// leave that request unhandled. +const customFieldsBrowsePath = new RegExp('^/members/custom_fields/(\\?|$)'); + const EXPORTED_CSV = 'email,custom_fields.nickname\nada@example.com,Countess\n'; /** @@ -27,7 +32,7 @@ const EXPORTED_CSV = 'email,custom_fields.nickname\nada@example.com,Countess\n'; function fakeCustomFieldsWorld() { const fields: Array> = []; fakeMembers([member({name: 'Ada Lovelace'})]); - fakeAdminEndpoint('GET', '/members/custom_fields/', () => ({members_custom_fields: fields})); + fakeAdminEndpoint('GET', customFieldsBrowsePath, () => ({members_custom_fields: fields})); const uploadApi = fakeAdminEndpoint('POST', '/members/upload/', { meta: {stats: {imported: 1, invalid: []}, import_label: {name: 'Import', slug: 'import'}} }); @@ -465,7 +470,7 @@ describe('Import members custom fields', () => { // A query whose only job is to add targets to a list must not be able to stop the import. it('imports with membership fields when custom fields cannot be loaded', async () => { fakeMembers([member({name: 'Ada Lovelace'})]); - fakeAdminEndpoint('GET', '/members/custom_fields/', {errors: [{message: 'nope'}]}, {status: 500}); + fakeAdminEndpoint('GET', customFieldsBrowsePath, {errors: [{message: 'nope'}]}, {status: 500}); const uploadApi = fakeAdminEndpoint('POST', '/members/upload/', { meta: {stats: {imported: 1, invalid: []}, import_label: {name: 'Import', slug: 'import'}} }); diff --git a/apps/admin/src/members/import-members-gate.acceptance.test.tsx b/apps/admin/src/members/import-members-gate.acceptance.test.tsx index 172096c6de2..98ea2aaae15 100644 --- a/apps/admin/src/members/import-members-gate.acceptance.test.tsx +++ b/apps/admin/src/members/import-members-gate.acceptance.test.tsx @@ -17,7 +17,7 @@ const CSV = 'email,name\nada@example.com,Ada Lovelace\n'; */ async function openMappingStep(labs: Record) { fakeMembers([member({name: 'Ada Lovelace'})]); - fakeAdminEndpoint('GET', '/members/custom_fields/', {members_custom_fields: []}); + fakeAdminEndpoint('GET', new RegExp('^/members/custom_fields/(\\?|$)'), {members_custom_fields: []}); await renderAdminApp('/members', {labs}); await membersScreen.openActionsMenu(); diff --git a/apps/admin/src/members/member-fields.test.ts b/apps/admin/src/members/member-fields.test.ts index c49858123c9..880c775c57a 100644 --- a/apps/admin/src/members/member-fields.test.ts +++ b/apps/admin/src/members/member-fields.test.ts @@ -43,7 +43,7 @@ describe('memberFields', () => { 'newsletter_feedback', 'offer_redemptions', 'count.active_stripe_customers', - 'custom_field.:key' + 'custom_fields.:key' ]); }); @@ -91,25 +91,25 @@ describe('memberFields', () => { expect(memberFields.label.metadata).toEqual({ activeColumn: { key: 'labels', - label: 'Labels', - include: 'labels' - } + label: 'Labels' + }, + columnInclude: 'labels' }); expect(memberFields.tier_id.metadata).toEqual({ activeColumn: { key: 'tiers', - label: 'Tiers', - include: 'tiers' - } + label: 'Tiers' + }, + columnInclude: 'tiers' }); expect(memberFields['subscriptions.current_period_end'].metadata).toEqual({ activeColumn: { key: 'subscriptions.current_period_end', - label: 'Next billing date', - include: 'subscriptions' - } + label: 'Next billing date' + }, + columnInclude: 'subscriptions' }); }); diff --git a/apps/admin/src/members/member-fields.ts b/apps/admin/src/members/member-fields.ts index eb78c7559f4..22359a00135 100644 --- a/apps/admin/src/members/member-fields.ts +++ b/apps/admin/src/members/member-fields.ts @@ -148,6 +148,16 @@ export const CUSTOM_FIELD_OPERATORS: readonly string[] = [ ...CUSTOM_FIELD_SET_OPERATORS ]; +/** + * The namespace custom fields are addressed under, in filter field keys and list column + * keys alike, so one field reads the same wherever it is named. + * + * Named rather than spelled out at each use because the namespace describes a bag of + * fields, and there is one bag today. An extension bringing its own bag would bring its + * own namespace, which is a change to what this resolves to rather than to its callers. + */ +export const CUSTOM_FIELDS_PREFIX = 'custom_fields.'; + // NQL operator symbol for each value operator. The field is named in the value // position (`custom_fields.key:'…'`) so its key can carry hyphens; the value is // matched on `custom_fields.value` (scalar) or `custom_fields.value.` @@ -168,7 +178,7 @@ const customFieldsCodec: FilterCodec = { parse() { return null; }, - // The field's stable key comes from the dropdown entry (`custom_field.`, + // The field's stable key comes from the dropdown entry (`custom_fields.`, // resolved into `ctx.params.key`); the predicate carries only [subfield, value], // with subfield '' for a scalar field or the "Any" (whole-field set/unset) case. serialize(predicate, ctx) { @@ -242,9 +252,9 @@ const baseMemberFields = defineFields({ metadata: { activeColumn: { key: 'labels', - label: 'Labels', - include: 'labels' - } + label: 'Labels' + }, + columnInclude: 'labels' }, codec: setCodec() }, @@ -317,9 +327,9 @@ const baseMemberFields = defineFields({ metadata: { activeColumn: { key: 'tiers', - label: 'Tiers', - include: 'tiers' - } + label: 'Tiers' + }, + columnInclude: 'tiers' }, codec: setCodec() }, @@ -351,9 +361,9 @@ const baseMemberFields = defineFields({ metadata: { activeColumn: { key: 'subscriptions.plan_interval', - label: 'Billing period', - include: 'subscriptions' - } + label: 'Billing period' + }, + columnInclude: 'subscriptions' }, codec: scalarCodec() }, @@ -368,9 +378,9 @@ const baseMemberFields = defineFields({ metadata: { activeColumn: { key: 'subscriptions.status', - label: 'Subscription status', - include: 'subscriptions' - } + label: 'Subscription status' + }, + columnInclude: 'subscriptions' }, codec: scalarCodec() }, @@ -384,9 +394,9 @@ const baseMemberFields = defineFields({ metadata: { activeColumn: { key: 'subscriptions.start_date', - label: 'Paid start date', - include: 'subscriptions' - } + label: 'Paid start date' + }, + columnInclude: 'subscriptions' }, codec: dateCodec() }, @@ -400,9 +410,9 @@ const baseMemberFields = defineFields({ metadata: { activeColumn: { key: 'subscriptions.current_period_end', - label: 'Next billing date', - include: 'subscriptions' - } + label: 'Next billing date' + }, + columnInclude: 'subscriptions' }, codec: dateCodec() }, @@ -529,15 +539,29 @@ const baseMemberFields = defineFields({ codec: multipleActiveSubscriptionsCodec }, // Each defined custom field is its own filter, named directly in the dropdown - // (`custom_field.`), so this template supplies the shared operators and codec; + // (`custom_fields.`), so this template supplies the shared operators and codec; // use-member-filter-fields builds one entry per field from the definitions. - 'custom_field.:key': { + 'custom_fields.:key': { operators: CUSTOM_FIELD_OPERATORS, ui: { label: 'Custom field', type: 'custom', component: 'custom-field' }, + metadata: { + // One column per field filtered on, named after the field itself. A value a + // publisher collected varies member to member, which is what earns a column; + // it is shown whatever the operator, the way Label is. No name resolved means + // no such field for this site (or the flag is off), so no column either. + activeColumn: ({params, label}) => (label + ? {key: `${CUSTOM_FIELDS_PREFIX}${params.key}`, label} + : null), + // Asked for as soon as a custom field is filtered on, without waiting for the + // names: the values are what the column will hold, and they travel on the same + // request the filter already sends. The API takes this include whether or not + // the flag is on, and returns values only when it is. + columnInclude: 'custom_fields' + }, codec: customFieldsCodec } }); diff --git a/apps/admin/src/members/member-filter-query.test.ts b/apps/admin/src/members/member-filter-query.test.ts index ccb8a08071b..50491f917fe 100644 --- a/apps/admin/src/members/member-filter-query.test.ts +++ b/apps/admin/src/members/member-filter-query.test.ts @@ -337,24 +337,24 @@ describe('isPredicateEnabled', () => { describe('member-filter-query - custom fields', () => { // Serialize each operator to NQL, then parse it back, and confirm the predicate // survives the round trip a saved segment relies on. Each field is its own - // predicate keyed `custom_field.`; `values` is [subfield, value]. + // predicate keyed `custom_fields.`; `values` is [subfield, value]. const cases: Array<{field: string; operator: string; values: [string, string]; nql: string}> = [ - {field: 'custom_field.company', operator: 'is', values: ['', 'Ghost'], nql: "(custom_fields.key:'company'+custom_fields.value:'Ghost')"}, - {field: 'custom_field.company', operator: 'is-not', values: ['', 'Ghost'], nql: "(custom_fields.key:'company'+custom_fields.value:-'Ghost')"}, - {field: 'custom_field.company', operator: 'contains', values: ['', 'host'], nql: "(custom_fields.key:'company'+custom_fields.value:~'host')"}, - {field: 'custom_field.company', operator: 'does-not-contain', values: ['', 'host'], nql: "(custom_fields.key:'company'+custom_fields.value:-~'host')"}, - {field: 'custom_field.company', operator: 'starts-with', values: ['', 'Gh'], nql: "(custom_fields.key:'company'+custom_fields.value:~^'Gh')"}, - {field: 'custom_field.company', operator: 'ends-with', values: ['', 'st'], nql: "(custom_fields.key:'company'+custom_fields.value:~$'st')"}, + {field: 'custom_fields.company', operator: 'is', values: ['', 'Ghost'], nql: "(custom_fields.key:'company'+custom_fields.value:'Ghost')"}, + {field: 'custom_fields.company', operator: 'is-not', values: ['', 'Ghost'], nql: "(custom_fields.key:'company'+custom_fields.value:-'Ghost')"}, + {field: 'custom_fields.company', operator: 'contains', values: ['', 'host'], nql: "(custom_fields.key:'company'+custom_fields.value:~'host')"}, + {field: 'custom_fields.company', operator: 'does-not-contain', values: ['', 'host'], nql: "(custom_fields.key:'company'+custom_fields.value:-~'host')"}, + {field: 'custom_fields.company', operator: 'starts-with', values: ['', 'Gh'], nql: "(custom_fields.key:'company'+custom_fields.value:~^'Gh')"}, + {field: 'custom_fields.company', operator: 'ends-with', values: ['', 'st'], nql: "(custom_fields.key:'company'+custom_fields.value:~$'st')"}, // A value that ends with a literal `$` must survive as contains, not be // misread as ends-with when the regex source (`5\$`) is parsed back. - {field: 'custom_field.company', operator: 'contains', values: ['', '5$'], nql: "(custom_fields.key:'company'+custom_fields.value:~'5$')"}, - {field: 'custom_field.shipping-address', operator: 'is', values: ['country', 'GB'], nql: "(custom_fields.key:'shipping-address'+custom_fields.value.country:'GB')"}, - {field: 'custom_field.shipping-address', operator: 'is-not', values: ['country', 'GB'], nql: "(custom_fields.key:'shipping-address'+custom_fields.value.country:-'GB')"}, - {field: 'custom_field.phone', operator: 'is-set', values: ['', ''], nql: "custom_fields.key:'phone'"}, - {field: 'custom_field.phone', operator: 'is-not-set', values: ['', ''], nql: "custom_fields.key:-'phone'"}, + {field: 'custom_fields.company', operator: 'contains', values: ['', '5$'], nql: "(custom_fields.key:'company'+custom_fields.value:~'5$')"}, + {field: 'custom_fields.shipping-address', operator: 'is', values: ['country', 'GB'], nql: "(custom_fields.key:'shipping-address'+custom_fields.value.country:'GB')"}, + {field: 'custom_fields.shipping-address', operator: 'is-not', values: ['country', 'GB'], nql: "(custom_fields.key:'shipping-address'+custom_fields.value.country:-'GB')"}, + {field: 'custom_fields.phone', operator: 'is-set', values: ['', ''], nql: "custom_fields.key:'phone'"}, + {field: 'custom_fields.phone', operator: 'is-not-set', values: ['', ''], nql: "custom_fields.key:-'phone'"}, // A part's set / not-set targets its presence via `path`, not the whole field. - {field: 'custom_field.shipping-address', operator: 'is-set', values: ['country', ''], nql: "(custom_fields.key:'shipping-address'+custom_fields.path:'country')"}, - {field: 'custom_field.shipping-address', operator: 'is-not-set', values: ['country', ''], nql: "(custom_fields.key:'shipping-address'+custom_fields.path:-'country')"} + {field: 'custom_fields.shipping-address', operator: 'is-set', values: ['country', ''], nql: "(custom_fields.key:'shipping-address'+custom_fields.path:'country')"}, + {field: 'custom_fields.shipping-address', operator: 'is-not-set', values: ['country', ''], nql: "(custom_fields.key:'shipping-address'+custom_fields.path:-'country')"} ]; it.each(cases)('serializes $field $operator to the expected NQL', ({field, operator, values, nql}) => { diff --git a/apps/admin/src/members/member-filter-query.ts b/apps/admin/src/members/member-filter-query.ts index 9e7826b9a80..f8a1621f06b 100644 --- a/apps/admin/src/members/member-filter-query.ts +++ b/apps/admin/src/members/member-filter-query.ts @@ -247,7 +247,7 @@ function interpretCustomFieldValue(raw: unknown): {operator: string; value: stri // A custom-field filter is `(custom_fields.key:''+custom_fields.value[.sub]:)`, // or the flat `custom_fields.key:''` / `:-''` for set / not set. Each field -// is its own predicate keyed `custom_field.`, so the field's stable key becomes +// is its own predicate keyed `custom_fields.`, so the field's stable key becomes // part of the predicate field and the remaining `values` are [subfield, value] // (subfield '' for a scalar field or the whole-field set/unset case). This can't ride // the generic parser because the key lives in the value of the key clause, not the key. @@ -258,11 +258,11 @@ function matchCustomFieldNode(node: AstNode): ParsedPredicate | null { const keyValue = node['custom_fields.key']; if (typeof keyValue === 'string') { - return {field: `custom_field.${keyValue}`, operator: 'is-set', values: ['', '']}; + return {field: `custom_fields.${keyValue}`, operator: 'is-set', values: ['', '']}; } if (keyValue && typeof keyValue === 'object' && !Array.isArray(keyValue) && typeof (keyValue as Record).$ne === 'string') { - return {field: `custom_field.${(keyValue as Record).$ne}`, operator: 'is-not-set', values: ['', '']}; + return {field: `custom_fields.${(keyValue as Record).$ne}`, operator: 'is-not-set', values: ['', '']}; } return null; @@ -305,7 +305,7 @@ function matchCustomFieldNode(node: AstNode): ParsedPredicate | null { // A `path` clause is a part's set / not-set: its presence, carrying no value. if (pathEntry) { return { - field: `custom_field.${fieldKey}`, + field: `custom_fields.${fieldKey}`, operator: pathEntry.negated ? 'is-not-set' : 'is-set', values: [pathEntry.subfield, ''] }; @@ -322,7 +322,7 @@ function matchCustomFieldNode(node: AstNode): ParsedPredicate | null { } return { - field: `custom_field.${fieldKey}`, + field: `custom_fields.${fieldKey}`, operator: interpreted.operator, values: [valueEntry.subfield, interpreted.value] }; diff --git a/apps/admin/src/members/member-query-params.test.ts b/apps/admin/src/members/member-query-params.test.ts index 7e7c9c9ad82..98372be8399 100644 --- a/apps/admin/src/members/member-query-params.test.ts +++ b/apps/admin/src/members/member-query-params.test.ts @@ -1,11 +1,11 @@ import { buildMemberListSearchParams, buildMemberOperationParams, - getActiveColumnValue, getMemberActiveColumns } from './member-query-params'; import {describe, expect, it} from 'vitest'; import type {FilterPredicate} from '@/shared/filters'; +import type {MemberActiveColumnOptions} from './member-query-params'; import type {Member, MemberSubscription} from '@tryghost/admin-x-framework/api/members'; const sub = (overrides: Partial = {}): MemberSubscription => ({ @@ -30,6 +30,16 @@ const member = (overrides: Partial = {}): Member => ({ ...overrides } as Member); +/** + * The column a filter on this field earns, read the way the list reads it: a column is only + * ever reached through the filter that raised it, so a test that hand-wrote one could pass + * against a column the app can no longer produce. + */ +const columnFor = (field: string, options?: MemberActiveColumnOptions) => { + const filters: FilterPredicate[] = [{id: '1', field, operator: 'is', values: ['x']}]; + return getMemberActiveColumns(filters, options)[0]; +}; + describe('member-query-params', () => { it('keeps search separate while deriving includes from active field metadata', () => { const filters: FilterPredicate[] = [ @@ -76,16 +86,14 @@ describe('member-query-params', () => { } ]; - expect(getMemberActiveColumns(filters)).toEqual([ + expect(getMemberActiveColumns(filters).map(({key, label}) => ({key, label}))).toEqual([ { key: 'labels', - label: 'Labels', - include: 'labels' + label: 'Labels' }, { key: 'subscriptions.current_period_end', - label: 'Next billing date', - include: 'subscriptions' + label: 'Next billing date' } ]); }); @@ -115,23 +123,82 @@ describe('member-query-params', () => { }); }); -describe('getActiveColumnValue reads the resolved current_subscription', () => { +describe('a subscription column reads the resolved current_subscription', () => { const activeMonthly = sub({id: 'a', status: 'active', plan: {id: 'p', nickname: 'M', interval: 'month', currency: 'usd', amount: 1000}}); const cancelledYearly = sub({id: 'c', status: 'canceled', plan: {id: 'p', nickname: 'Y', interval: 'year', currency: 'usd', amount: 10000}}); it('reads status and plan from current_subscription', () => { const m = member({current_subscription: activeMonthly, subscriptions: [activeMonthly, cancelledYearly]}); - expect(getActiveColumnValue({key: 'subscriptions.status', label: 'Status'}, m, 'UTC')?.text).toBe('Active'); - expect(getActiveColumnValue({key: 'subscriptions.plan_interval', label: 'Plan'}, m, 'UTC')?.text).toBe('Monthly'); + expect(columnFor('subscriptions.status').getValue(m, 'UTC')?.text).toBe('Active'); + expect(columnFor('subscriptions.plan_interval').getValue(m, 'UTC')?.text).toBe('Monthly'); }); it('returns null when the member has no current subscription', () => { const m = member({current_subscription: null, subscriptions: [cancelledYearly]}); - expect(getActiveColumnValue({key: 'subscriptions.status', label: 'Status'}, m, 'UTC')).toBeNull(); + expect(columnFor('subscriptions.status').getValue(m, 'UTC')).toBeNull(); }); it('returns null when the field is absent', () => { const m = member({subscriptions: [cancelledYearly]}); - expect(getActiveColumnValue({key: 'subscriptions.status', label: 'Status'}, m, 'UTC')).toBeNull(); + expect(columnFor('subscriptions.status').getValue(m, 'UTC')).toBeNull(); + }); +}); + +describe('custom field columns', () => { + const customFields = [ + {key: 'job_title', name: 'Job title', type: 'short_text' as const}, + {key: 'shipping_address', name: 'Shipping address', type: 'address' as const} + ]; + + const filterOn = (key: string): FilterPredicate[] => [ + {id: '1', field: `custom_fields.${key}`, operator: 'contains', values: ['x']} + ]; + + it('names one column per field filtered on, from the field itself', () => { + expect(getMemberActiveColumns(filterOn('job_title'), {customFields}).map(({key, label}) => ({key, label}))) + .toEqual([{key: 'custom_fields.job_title', label: 'Job title'}]); + }); + + // The template resolves to one shared definition for every custom field, so two + // filters must still produce two distinct columns rather than collapsing into one. + it('gives two filtered fields a column each', () => { + const filters = [...filterOn('job_title'), ...filterOn('shipping_address')]; + + expect(getMemberActiveColumns(filters, {customFields}).map(column => column.key)) + .toEqual(['custom_fields.job_title', 'custom_fields.shipping_address']); + }); + + // No names supplied is what the flag being off looks like from here, and what the + // moment before the fetch lands looks like too. + it('adds no column when the field cannot be named', () => { + expect(getMemberActiveColumns(filterOn('job_title'))).toEqual([]); + expect(getMemberActiveColumns(filterOn('nonexistent'), {customFields})).toEqual([]); + }); + + // Naming the column waits on the fetch; asking for the values must not, or the list + // fetches once without them and again once the names land. + it('asks for values as soon as a custom field is filtered on, named or not', () => { + expect(buildMemberListSearchParams({filters: filterOn('job_title'), nql: 'x', search: ''})?.include) + .toBe('labels,tiers,custom_fields'); + }); + + it('reads a scalar value by key', () => { + const m = member({custom_fields: {job_title: 'Editor'}}); + + expect(columnFor('custom_fields.job_title', {customFields}).getValue(m, 'UTC')) + .toEqual({text: 'Editor'}); + }); + + it('reads a composite value as one line', () => { + const m = member({custom_fields: {shipping_address: {line1: '1 Main St', city: 'Berlin', postal_code: '10115', country: 'DE'}}}); + + expect(columnFor('custom_fields.shipping_address', {customFields}).getValue(m, 'UTC')) + .toEqual({text: '1 Main St, Berlin, 10115, DE'}); + }); + + it('returns null when the member has no value', () => { + const m = member({custom_fields: {}}); + + expect(columnFor('custom_fields.job_title', {customFields}).getValue(m, 'UTC')).toBeNull(); }); }); diff --git a/apps/admin/src/members/member-query-params.ts b/apps/admin/src/members/member-query-params.ts index dc4fc3d88d6..dbf08f3ba2e 100644 --- a/apps/admin/src/members/member-query-params.ts +++ b/apps/admin/src/members/member-query-params.ts @@ -1,21 +1,44 @@ import moment from 'moment-timezone'; -import {type FilterPredicate, resolveField} from '@/shared/filters'; +import {type ActiveColumn, type FilterPredicate, resolveField} from '@/shared/filters'; import {memberFields} from './member-fields'; +import {formatMemberCustomFieldValue} from '@tryghost/admin-x-framework/api/member-custom-fields'; import type {Member} from '@tryghost/admin-x-framework/api/members'; +import type {MemberCustomField} from '@tryghost/admin-x-framework/api/member-custom-fields'; const MAX_ACTIVE_COLUMNS = 2; -export type ActiveColumn = { - key: string; - label: string; - include?: string; -}; +export type {ActiveColumn}; + +/** All a column needs of a custom field: the name to head it with, and the type to read its value as. */ +export type MemberColumnCustomField = Pick; + +/** + * Runtime data the static field schema cannot hold. Custom fields are defined by the + * publisher, so their names arrive from the API; passing none (the default) simply leaves + * them out of the columns, which is also what the flag being off looks like. + */ +export interface MemberActiveColumnOptions { + customFields?: MemberColumnCustomField[]; +} export type ColumnValue = { text: string; subtext?: string; }; +type ColumnValueReader = (member: Member, timezone: string) => ColumnValue | null; + +/** + * A column the member list appends, carrying how to read one member's value for it. + * + * The reader is attached where the column is built, which is where everything it needs is + * already in hand. A cell asks the column rather than working back from its key to the + * field behind it. + */ +export interface MemberActiveColumn extends ActiveColumn { + getValue: ColumnValueReader; +} + interface BuildMemberListSearchParamsOptions { filters: FilterPredicate[]; nql?: string; @@ -27,26 +50,66 @@ interface BuildMemberOperationParamsOptions { search: string; } -export function getMemberActiveColumns(filters: FilterPredicate[]): ActiveColumn[] { - const columns = new Map(); +export function getMemberActiveColumns( + filters: FilterPredicate[], + options: MemberActiveColumnOptions = {} +): MemberActiveColumn[] { + const columns = new Map(); for (const filter of filters) { - const activeColumn = resolveField(memberFields, filter.field, 'UTC')?.definition.metadata?.activeColumn; + const resolved = resolveField(memberFields, filter.field, 'UTC'); + const activeColumn = resolved?.definition.metadata?.activeColumn; - if (activeColumn) { - columns.set(activeColumn.key, activeColumn); + if (!activeColumn) { + continue; + } + + const {context} = resolved; + + // A field that declares a fixed column is read by the reader filed under it. + if (typeof activeColumn !== 'function') { + const getValue = fixedColumnValues[activeColumn.key]; + + if (getValue) { + columns.set(activeColumn.key, {...activeColumn, getValue}); + } + continue; + } + + // A pattern field stands for many columns, so it is hydrated from whatever runtime + // record names the key it matched, then resolves its own column from that name. + const hydrated = patternColumnHydrators[context.pattern]?.(context.params, options); + + if (!hydrated) { + continue; + } + + const column = activeColumn({...context, label: hydrated.label}); + + if (column) { + columns.set(column.key, {...column, getValue: hydrated.getValue}); } } return Array.from(columns.values()).slice(0, MAX_ACTIVE_COLUMNS); } +/** + * What the list asks the API for, given the filters applied. + * + * Read off the filtered fields themselves rather than off the columns they resolve to. + * Whether a value is needed follows from the filter, so it is known on the first render; + * whether a column can be named can wait on data still in flight, and a column can be + * dropped by the display budget while its values are still wanted. + */ function getMemberIncludes(filters: FilterPredicate[]): string { const includes = new Set(['labels', 'tiers']); - for (const column of getMemberActiveColumns(filters)) { - if (column.include) { - includes.add(column.include); + for (const filter of filters) { + const columnInclude = resolveField(memberFields, filter.field, 'UTC')?.definition.metadata?.columnInclude; + + if (columnInclude) { + includes.add(columnInclude); } } @@ -96,31 +159,29 @@ function formatDateColumn(date: string | undefined, timezone: string): ColumnVal }; } -export function getActiveColumnValue( - column: ActiveColumn, - member: Member, - timezone: string -): ColumnValue | null { - switch (column.key) { - case 'labels': - return member.labels?.length - ? {text: member.labels.map(l => l.name).join(', ')} - : null; - - case 'tiers': - return member.tiers?.length - ? {text: member.tiers.map(t => t.name).join(', ')} - : null; - - case 'subscriptions.plan_interval': { +/** + * How each fixed column reads a member, filed under the column its field declares. A field + * names a column and this names the same column's value, so the two are found the same way + * instead of one being declared and the other matched by hand. + */ +const fixedColumnValues: Record = { + labels: member => (member.labels?.length + ? {text: member.labels.map(l => l.name).join(', ')} + : null), + + tiers: member => (member.tiers?.length + ? {text: member.tiers.map(t => t.name).join(', ')} + : null), + + 'subscriptions.plan_interval': (member) => { const interval = member.current_subscription?.plan?.interval; if (!interval) { return null; } return {text: interval === 'month' ? 'Monthly' : 'Yearly'}; - } + }, - case 'subscriptions.status': { + 'subscriptions.status': (member) => { const status = member.current_subscription?.status; if (!status) { return null; @@ -131,21 +192,19 @@ export function getActiveColumnValue( .map(part => part.charAt(0).toUpperCase() + part.slice(1)) .join(' ') }; - } + }, - case 'subscriptions.start_date': - return formatDateColumn( - member.current_subscription?.start_date, - timezone - ); + 'subscriptions.start_date': (member, timezone) => formatDateColumn( + member.current_subscription?.start_date, + timezone + ), - case 'subscriptions.current_period_end': - return formatDateColumn( - member.current_subscription?.current_period_end, - timezone - ); + 'subscriptions.current_period_end': (member, timezone) => formatDateColumn( + member.current_subscription?.current_period_end, + timezone + ), - case 'offer_redemptions': { + offer_redemptions: (member) => { const offers = member.subscriptions ?.map(s => s.offer?.name) .filter(Boolean); @@ -153,8 +212,36 @@ export function getActiveColumnValue( ? {text: offers.join(', ')} : null; } +}; - default: - return null; +/** + * How a pattern field's column is filled in, filed under the pattern it matches. + * + * A pattern field's columns are named by data that only exists at runtime, so each one + * says which record names it and how a member's value is read once a record is found. + * Returning null is how "nothing here answers to that key" is said, and it costs the + * column: unnamed is unshown. A second pattern field earning a column adds an entry here + * rather than a branch anywhere. + */ +const patternColumnHydrators: Record< + string, + ((params: Record, options: MemberActiveColumnOptions) => {label: string; getValue: ColumnValueReader} | null) | undefined +> = { + 'custom_fields.:key': ({key}, {customFields}) => { + const field = customFields?.find(candidate => candidate.key === key); + + if (!field) { + return null; + } + + return { + label: field.name, + // The catalog turns whatever type the field is into one line, so this reads a + // composite the same way the member detail screen does. + getValue: (member) => { + const text = formatMemberCustomFieldValue(field.type, member.custom_fields?.[field.key]); + return text ? {text} : null; + } + }; } -} +}; diff --git a/apps/admin/src/members/members.tsx b/apps/admin/src/members/members.tsx index 023aa380327..fa026eaab03 100644 --- a/apps/admin/src/members/members.tsx +++ b/apps/admin/src/members/members.tsx @@ -12,6 +12,7 @@ import {Box, Container} from '@tryghost/shade/primitives'; import {ListPage} from '@tryghost/shade/page-templates'; import {keepPreviousData} from '@tanstack/react-query'; import {LucideIcon, cn, formatNumber} from '@tryghost/shade/utils'; +import {CUSTOM_FIELDS_PREFIX} from './member-fields'; import {buildMemberListSearchParams, getMemberActiveColumns} from './member-query-params'; import {canBulkDeleteMembers, shouldShowMembersLoading} from './members-view-state'; import {checkStripeEnabled, getSettingValue, useBrowseSettings} from '@tryghost/admin-x-framework/api/settings'; @@ -19,8 +20,10 @@ import {getSiteTimezone} from '@tryghost/admin-x-framework/utils/get-site-timezo import {shouldDelayMembersDateFilterHydration, useMembersFilterState} from './hooks/use-members-filter-state'; import {useActiveMemberView, useMemberViews} from './hooks/use-member-views'; import {useBrowseConfig} from '@tryghost/admin-x-framework/api/config'; +import {useBrowseMemberCustomFieldsIncludingArchived} from '@tryghost/admin-x-framework/api/member-custom-fields'; import {useBrowseMembersInfinite} from '@tryghost/admin-x-framework/api/members'; import {useDebouncedCallback} from 'use-debounce'; +import {useFeatureFlag} from '@tryghost/admin-x-framework/hooks'; import {useLocation, useSearchParams} from 'react-router'; import {useMultipleActiveSubscriptionsCount} from './hooks/use-multiple-active-subscriptions-count'; @@ -62,9 +65,26 @@ const MembersPage: React.FC = ({ hasResolvedCount: hasResolvedMultipleActiveSubscriptionsCount } = useMultipleActiveSubscriptionsCount({enabled: hasStripeEnabled}); + // Names the custom field columns. Archived fields are included so a filter on one + // still shows its values, matching the read-only pill the filter bar renders for it. + // + // Only a filter on a custom field earns a column, and naming one is all these are for, + // so the fetch waits for a filter rather than riding every visit to the members list. + const customFieldsEnabled = useFeatureFlag('membersCustomFields'); + const hasCustomFieldFilter = useMemo( + () => filters.some(filter => filter.field.startsWith(CUSTOM_FIELDS_PREFIX)), + [filters] + ); + const {data: customFieldsData} = useBrowseMemberCustomFieldsIncludingArchived({ + enabled: customFieldsEnabled && hasCustomFieldFilter + }); + // Left undefined until the fetch lands (and while the flag is off) rather than defaulted + // to an empty array, so the identity the memos below depend on stays stable. + const customFields = customFieldsData?.members_custom_fields; + const activeColumns = useMemo(() => { - return getMemberActiveColumns(filters); - }, [filters]); + return getMemberActiveColumns(filters, {customFields}); + }, [filters, customFields]); const canBulkDelete = useMemo(() => { return canBulkDeleteMembers(filters, nql); diff --git a/apps/admin/src/members/use-member-filter-fields.test.ts b/apps/admin/src/members/use-member-filter-fields.test.ts index fbaf7ca9b21..826249896c6 100644 --- a/apps/admin/src/members/use-member-filter-fields.test.ts +++ b/apps/admin/src/members/use-member-filter-fields.test.ts @@ -238,8 +238,8 @@ describe('useMemberFilterFields', () => { const customFields = result.current.find(group => group.group === 'Custom fields')?.fields ?? []; expect(customFields.map(field => ({key: field.key, label: field.label}))).toEqual([ - {key: 'custom_field.shipping_address', label: 'Shipping address'}, - {key: 'custom_field.job_title', label: 'Job title'} + {key: 'custom_fields.shipping_address', label: 'Shipping address'}, + {key: 'custom_fields.job_title', label: 'Job title'} ]); }); diff --git a/apps/admin/src/members/use-member-filter-fields.ts b/apps/admin/src/members/use-member-filter-fields.ts index 2a4b7f0ca80..5c8580d63b5 100644 --- a/apps/admin/src/members/use-member-filter-fields.ts +++ b/apps/admin/src/members/use-member-filter-fields.ts @@ -6,7 +6,7 @@ import CustomFieldIcon from '@/shared/member-custom-fields/custom-field-icon'; import {LabelFilterRenderer} from '@/members/label-picker'; import {LucideIcon} from '@tryghost/shade/utils'; import {MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FIELD} from './multiple-active-subscriptions'; -import {getMemberFields} from './member-fields'; +import {CUSTOM_FIELDS_PREFIX, getMemberFields} from './member-fields'; import type {MemberCustomField} from '@tryghost/admin-x-framework/api/member-custom-fields'; import type {Offer} from '@tryghost/admin-x-framework/api/offers'; @@ -285,8 +285,8 @@ export function useMemberFilterFields({ let field; if (key.startsWith('newsletters.')) { field = fields['newsletters.:slug']; - } else if (key.startsWith('custom_field.')) { - field = fields['custom_field.:key']; + } else if (key.startsWith(CUSTOM_FIELDS_PREFIX)) { + field = fields['custom_fields.:key']; } else { field = fields[key as MemberFieldKey]; } @@ -371,7 +371,7 @@ export function useMemberFilterFields({ // "Custom field" door. A simple field filters on its value; a composite field's // renderer opens its parts (plus "Any") in the pill. if (customFieldsEnabled) { - const customFieldFields = customFields.map(field => createFieldConfig(`custom_field.${field.key}`, { + const customFieldFields = customFields.map(field => createFieldConfig(`custom_fields.${field.key}`, { label: field.name, // The dropdown entry and the added filter show the field type's own icon // rather than a generic custom-field mark. @@ -389,7 +389,7 @@ export function useMemberFilterFields({ // removable-only pill with an archive icon. Its key is already in the filter, // so the picker's own de-dup keeps it out of the add-list — it only ever // renders as an existing pill. - const archivedFieldFields = archivedCustomFields.map(field => createFieldConfig(`custom_field.${field.key}`, { + const archivedFieldFields = archivedCustomFields.map(field => createFieldConfig(`custom_fields.${field.key}`, { label: field.name, icon: React.createElement(LucideIcon.Archive, {className: 'size-4'}), // Read-only: the operator and value stay visible so the segment reads diff --git a/apps/admin/src/routes.tsx b/apps/admin/src/routes.tsx index a4f9874189f..aa3a8fea14d 100644 --- a/apps/admin/src/routes.tsx +++ b/apps/admin/src/routes.tsx @@ -1,4 +1,4 @@ -import {type AdminRouteHandle, type RouteObject, Outlet, lazyComponent, redirect} from "@tryghost/admin-x-framework"; +import {type AdminRouteHandle, type RouteObject, Outlet, lazyComponent, matchRoutes, redirect} from "@tryghost/admin-x-framework"; // ActivityPub import { FeatureFlagsProvider, routes as activityPubRoutes } from "@tryghost/activitypub/api"; @@ -213,3 +213,14 @@ export const routes: RouteObject[] = [ ], }, ]; + +// Ember's router only learns about a URL change from `hashchange`, which the +// React router's pushState navigation does not fire, so links into Ember-owned +// routes must stay native hash anchors. Everything else can be a router link +// (and so gets router history state, which the unsaved-changes blockers need). +const EMBER_ROUTE_COMPONENTS = new Set([EmberFallback, EmberListWithGiftLinks, TagDetailGate]); + +export function isEmberOwnedRoute(pathname: string): boolean { + const leaf = matchRoutes(routes, pathname)?.at(-1)?.route; + return !leaf || EMBER_ROUTE_COMPONENTS.has(leaf.Component); +} diff --git a/apps/admin/src/settings/advanced/advanced-settings.tsx b/apps/admin/src/settings/advanced/advanced-settings.tsx new file mode 100644 index 00000000000..f4e9f5f9033 --- /dev/null +++ b/apps/admin/src/settings/advanced/advanced-settings.tsx @@ -0,0 +1,24 @@ +import CodeInjection from './code-injection'; +import DangerZone from './danger-zone'; +import History from './history'; +import Integrations from './integrations'; +import Labs from './labs'; +import MigrationTools from './migration-tools'; +import React from 'react'; +import SearchableSection from '@/settings/components/searchable-section'; +import {searchKeywords} from './search-keywords'; + +const AdvancedSettings: React.FC = () => { + return ( + + + + + + + + + ); +}; + +export default AdvancedSettings; diff --git a/apps/admin/src/settings/app/components/settings/advanced/code-injection.tsx b/apps/admin/src/settings/advanced/code-injection.tsx similarity index 79% rename from apps/admin/src/settings/app/components/settings/advanced/code-injection.tsx rename to apps/admin/src/settings/advanced/code-injection.tsx index 2c48683ec8f..5fb0e83e3cd 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/code-injection.tsx +++ b/apps/admin/src/settings/advanced/code-injection.tsx @@ -1,9 +1,9 @@ import CodeModal from './code/code-modal'; import React, {useState} from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; import {Button} from '@tryghost/shade/components'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; -import {DialogPortal} from '@/settings/app/components/providers/dialog-portal'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; +import {DialogPortal} from '@/settings/providers/dialog-portal'; const CodeInjection: React.FC<{ keywords: string[] }> = ({keywords}) => { const [isCodeModalOpen, setIsCodeModalOpen] = useState(false); diff --git a/apps/admin/src/settings/app/components/settings/advanced/code/code-modal.tsx b/apps/admin/src/settings/advanced/code/code-modal.tsx similarity index 92% rename from apps/admin/src/settings/app/components/settings/advanced/code/code-modal.tsx rename to apps/admin/src/settings/advanced/code/code-modal.tsx index c2e49fadc62..645fb0a25b6 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/code/code-modal.tsx +++ b/apps/admin/src/settings/advanced/code/code-modal.tsx @@ -1,12 +1,12 @@ -import CodeEditor from '@/settings/app/components/code-editor'; +import CodeEditor from '@/settings/components/code-editor'; import React, {useEffect, useMemo, useRef, useState} from 'react'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; import {Button, Tabs, TabsContent, TabsList, TabsTrigger} from '@tryghost/shade/components'; import {Inline, Text} from '@tryghost/shade/primitives'; import {type ReactCodeMirrorRef} from '@uiw/react-codemirror'; import {SettingsModal} from '@tryghost/shade/patterns'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; -import {useSaveButton} from '@/settings/app/hooks/use-save-button'; +import {useSaveButton} from '@/settings/hooks/use-save-button'; interface CodeModalProps { onClose: () => void; @@ -48,7 +48,7 @@ const CodeModal: React.FC = ({onClose}) => { const handleCMDS = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault(); - onSaveClick(); + void onSaveClick(); } }; window.addEventListener('keydown', handleCMDS); @@ -71,7 +71,7 @@ const CodeModal: React.FC = ({onClose}) => { Code injection - +
setSelectedTab(value as typeof selectedTab)}> diff --git a/apps/admin/src/settings/app/components/settings/advanced/danger-zone.tsx b/apps/admin/src/settings/advanced/danger-zone.tsx similarity index 95% rename from apps/admin/src/settings/app/components/settings/advanced/danger-zone.tsx rename to apps/admin/src/settings/advanced/danger-zone.tsx index 28ab18ff0c0..65d6648d819 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/danger-zone.tsx +++ b/apps/admin/src/settings/advanced/danger-zone.tsx @@ -1,18 +1,18 @@ import React from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; -import trackEvent from '@/settings/app/utils/analytics'; -import useStaffUsers from '@/settings/app/hooks/use-staff-users'; +import TopLevelGroup from '@/settings/components/top-level-group'; +import trackEvent from '@/settings/utils/analytics'; +import useStaffUsers from '@/settings/hooks/use-staff-users'; import {ActionList, ActionListItem, ActionListItemActions, ActionListItemContent, Button} from '@tryghost/shade/components'; import {formatNumber} from '@tryghost/shade/utils'; import {getGhostPaths} from '@tryghost/admin-x-framework/helpers'; import {toast} from 'sonner'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; import {useDeleteAllContent} from '@tryghost/admin-x-framework/api/db'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useQueryClient} from '@tryghost/admin-x-framework'; import {useRemoveAllGiftLinks} from '@tryghost/admin-x-framework/api/gift-links'; import {useResetAuth} from '@tryghost/admin-x-framework/api/security'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const DangerZone: React.FC<{ keywords: string[] }> = ({keywords}) => { const {mutateAsync: deleteAllContent} = useDeleteAllContent(); diff --git a/apps/admin/src/settings/app/components/settings/advanced/history-modal.tsx b/apps/admin/src/settings/advanced/history-modal.tsx similarity index 98% rename from apps/admin/src/settings/app/components/settings/advanced/history-modal.tsx rename to apps/admin/src/settings/advanced/history-modal.tsx index 5f2239cfde9..46ba0b5084a 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/history-modal.tsx +++ b/apps/admin/src/settings/advanced/history-modal.tsx @@ -1,10 +1,10 @@ -import InfiniteScrollListener from '@/settings/app/components/infinite-scroll-listener'; +import InfiniteScrollListener from '@/settings/components/infinite-scroll-listener'; import {type Action, getActionTitle, getContextResource, getLinkTarget, isBulkAction, useBrowseActions} from '@tryghost/admin-x-framework/api/actions'; import {ActionList, ActionListItem, ActionListItemContent, Avatar, Button, Field, FieldLabel, LoadingIndicator, MultiSelectCombobox, NoValueLabel, NoValueLabelIcon, Popover, PopoverContent, PopoverTrigger, Switch, inputSurface} from '@tryghost/shade/components'; import {ChevronDown, History, Pen, Plus, Trash2, X} from 'lucide-react'; import {Inline, Stack} from '@tryghost/shade/primitives'; import {useParams} from '@tryghost/admin-x-framework'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; import {SettingsModal} from '@tryghost/shade/patterns'; import {type User} from '@tryghost/admin-x-framework/api/users'; import {formatNumber} from '@tryghost/shade/utils'; @@ -300,7 +300,7 @@ function HistoryModal() { const fetchNext = useCallback(() => { if (!data?.isEnd) { - fetchNextPage(); + void fetchNextPage(); } }, [data?.isEnd, fetchNextPage]); diff --git a/apps/admin/src/settings/app/components/settings/advanced/history.tsx b/apps/admin/src/settings/advanced/history.tsx similarity index 75% rename from apps/admin/src/settings/app/components/settings/advanced/history.tsx rename to apps/admin/src/settings/advanced/history.tsx index 9d1d1873e87..e7885a2e91c 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/history.tsx +++ b/apps/admin/src/settings/advanced/history.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; import {Button} from '@tryghost/shade/components'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const History: React.FC<{ keywords: string[] }> = ({keywords}) => { const {updateRoute} = useSettingsNavigation(); diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations.tsx b/apps/admin/src/settings/advanced/integrations.tsx similarity index 94% rename from apps/admin/src/settings/app/components/settings/advanced/integrations.tsx rename to apps/admin/src/settings/advanced/integrations.tsx index ebc50fb01ff..17d254d2792 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations.tsx +++ b/apps/admin/src/settings/advanced/integrations.tsx @@ -1,21 +1,21 @@ -import BrandIcon from '@/settings/app/components/icons/brand-icon'; -import IntegrationsSettingsImg from '@/settings/app/assets/images/integrations-settings.png'; +import BrandIcon from '@/settings/components/icons/brand-icon'; +import IntegrationsSettingsImg from '@/settings/assets/images/integrations-settings.png'; import React, {useState} from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; -import usePinturaEditor from '@/settings/app/hooks/use-pintura-editor'; +import TopLevelGroup from '@/settings/components/top-level-group'; +import usePinturaEditor from '@/settings/hooks/use-pintura-editor'; import {ActionList, ActionListItem, ActionListItemActions, ActionListItemContent, Button, NoValueLabel, NoValueLabelIcon, Tabs, TabsContent, TabsList, TabsTrigger} from '@tryghost/shade/components'; import {type Integration, useBrowseIntegrations, useDeleteIntegration} from '@tryghost/admin-x-framework/api/integrations'; import {LucideIcon} from '@tryghost/shade/utils'; import {Plug} from 'lucide-react'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {toast} from 'sonner'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; import {DEFAULT_UPGRADE_ROUTE} from '@tryghost/admin-x-framework/api/config'; -import {useUpgradeRoute} from '@/settings/app/hooks/use-upgrade-route'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {useUpgradeRoute} from '@/settings/hooks/use-upgrade-route'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; interface IntegrationItemProps { icon?: React.ReactNode, diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/add-integration-modal.tsx b/apps/admin/src/settings/advanced/integrations/add-integration-modal.tsx similarity index 89% rename from apps/admin/src/settings/app/components/settings/advanced/integrations/add-integration-modal.tsx rename to apps/admin/src/settings/advanced/integrations/add-integration-modal.tsx index d5245bfda35..a6fb10872cf 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/add-integration-modal.tsx +++ b/apps/admin/src/settings/advanced/integrations/add-integration-modal.tsx @@ -1,9 +1,9 @@ import {useEffect, useState} from 'react'; import {Field, FieldError, FieldGroup, FieldLabel, Input} from '@tryghost/shade/components'; -import {HostLimitError, useLimiter} from '@/settings/app/hooks/use-limiter'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {useUpgradeRoute} from '@/settings/app/hooks/use-upgrade-route'; +import {HostLimitError, useLimiter} from '@/settings/hooks/use-limiter'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {useUpgradeRoute} from '@/settings/hooks/use-upgrade-route'; import {SettingsModal} from '@tryghost/shade/patterns'; import {useCreateIntegration} from '@tryghost/admin-x-framework/api/integrations'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/api-keys.tsx b/apps/admin/src/settings/advanced/integrations/api-keys.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/advanced/integrations/api-keys.tsx rename to apps/admin/src/settings/advanced/integrations/api-keys.tsx diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/content-api-modal.tsx b/apps/admin/src/settings/advanced/integrations/content-api-modal.tsx similarity index 96% rename from apps/admin/src/settings/app/components/settings/advanced/integrations/content-api-modal.tsx rename to apps/admin/src/settings/advanced/integrations/content-api-modal.tsx index 67900de4173..7183bb3571b 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/content-api-modal.tsx +++ b/apps/admin/src/settings/advanced/integrations/content-api-modal.tsx @@ -5,7 +5,7 @@ import {LucideIcon} from '@tryghost/shade/utils'; import {SettingsModal} from '@tryghost/shade/patterns'; import {getGhostPaths} from '@tryghost/admin-x-framework/helpers'; import {useBrowseIntegrations} from '@tryghost/admin-x-framework/api/integrations'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; function ContentApiModal() { const {updateRoute} = useSettingsNavigation(); diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/custom-integration-modal.tsx b/apps/admin/src/settings/advanced/integrations/custom-integration-modal.tsx similarity index 89% rename from apps/admin/src/settings/app/components/settings/advanced/integrations/custom-integration-modal.tsx rename to apps/admin/src/settings/advanced/integrations/custom-integration-modal.tsx index 932f48e4ecd..12702840221 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/custom-integration-modal.tsx +++ b/apps/admin/src/settings/advanced/integrations/custom-integration-modal.tsx @@ -7,9 +7,9 @@ import {Box, Stack} from '@tryghost/shade/primitives'; import {Field, FieldError, FieldLabel, Input} from '@tryghost/shade/components'; import {ImageUpload, ImageUploadAction, ImageUploadActions, ImageUploadDropzone, ImageUploadImage, ImageUploadPreview} from '@tryghost/shade/patterns'; import {type Integration, useBrowseIntegrations, useEditIntegration} from '@tryghost/admin-x-framework/api/integrations'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; import {useParams} from '@tryghost/admin-x-framework'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; import {SettingsModal} from '@tryghost/shade/patterns'; import {Trash2} from 'lucide-react'; import {getGhostPaths} from '@tryghost/admin-x-framework/helpers'; @@ -23,6 +23,7 @@ const CustomIntegrationModalContent: React.FC<{integration: Integration}> = ({in const {mutateAsync: refreshAPIKey} = useRefreshAPIKey(); const {mutateAsync: uploadImage} = useUploadImage(); const handleError = useHandleError(); + const {confirm} = useConfirmation(); const {formState, updateForm, handleSave, saveState, errors, clearError, okProps} = useForm({ @@ -47,6 +48,19 @@ const CustomIntegrationModalContent: React.FC<{integration: Integration}> = ({in } }); + const handleIconUpload = async (file: File) => { + try { + const imageUrl = getImageUrl(await uploadImage({file})); + updateForm(state => ({...state, icon_image: imageUrl})); + } catch (e) { + const error = e as APIError; + if (error.response!.status === 415) { + error.message = 'Unsupported file type'; + } + handleError(error); + } + }; + const adminApiKey = integration.api_keys?.find(key => key.type === 'admin'); const contentApiKey = integration.api_keys?.find(key => key.type === 'content'); @@ -110,18 +124,7 @@ const CustomIntegrationModalContent: React.FC<{integration: Integration}> = ({in ) : ( - { - try { - const imageUrl = getImageUrl(await uploadImage({file})); - updateForm(state => ({...state, icon_image: imageUrl})); - } catch (e) { - const error = e as APIError; - if (error.response!.status === 415) { - error.message = 'Unsupported file type'; - } - handleError(error); - } - }}> + void handleIconUpload(file)}> Upload icon )} diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/first-promoter-modal.tsx b/apps/admin/src/settings/advanced/integrations/first-promoter-modal.tsx similarity index 94% rename from apps/admin/src/settings/app/components/settings/advanced/integrations/first-promoter-modal.tsx rename to apps/admin/src/settings/advanced/integrations/first-promoter-modal.tsx index f59a29c9f35..f70da7b5763 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/first-promoter-modal.tsx +++ b/apps/admin/src/settings/advanced/integrations/first-promoter-modal.tsx @@ -1,12 +1,12 @@ -import BrandIcon from '@/settings/app/components/icons/brand-icon'; +import BrandIcon from '@/settings/components/icons/brand-icon'; import IntegrationHeader from './integration-header'; import {Field, FieldContent, FieldDescription, FieldGroup, FieldLabel, FieldSet, Input, Switch} from '@tryghost/shade/components'; import {type Setting, getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {SettingsModal} from '@tryghost/shade/patterns'; import {useEffect, useState} from 'react'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; function FirstPromoterModal() { const {updateRoute} = useSettingsNavigation(); diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/integration-header.tsx b/apps/admin/src/settings/advanced/integrations/integration-header.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/advanced/integrations/integration-header.tsx rename to apps/admin/src/settings/advanced/integrations/integration-header.tsx diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/pintura-modal.tsx b/apps/admin/src/settings/advanced/integrations/pintura-modal.tsx similarity index 93% rename from apps/admin/src/settings/app/components/settings/advanced/integrations/pintura-modal.tsx rename to apps/admin/src/settings/advanced/integrations/pintura-modal.tsx index 70b3051048f..b99b718da8d 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/pintura-modal.tsx +++ b/apps/admin/src/settings/advanced/integrations/pintura-modal.tsx @@ -1,14 +1,14 @@ -import BrandIcon from '@/settings/app/components/icons/brand-icon'; +import BrandIcon from '@/settings/components/icons/brand-icon'; import IntegrationHeader from './integration-header'; -import pinturaScreenshot from '@/settings/app/assets/images/pintura-screenshot.png'; +import pinturaScreenshot from '@/settings/assets/images/pintura-screenshot.png'; import {Dropzone, Field, FieldContent, FieldDescription, FieldGroup, FieldLabel, FieldLegend, FieldSet, Switch} from '@tryghost/shade/components'; import {type Setting, getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {SettingsModal} from '@tryghost/shade/patterns'; import {toast} from 'sonner'; import {useEffect, useState} from 'react'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; import {useUploadFile} from '@tryghost/admin-x-framework/api/files'; function PinturaModal() { @@ -121,7 +121,7 @@ function PinturaModal() {
Upload Pintura script
Upload the pintura-umd.js file from the Pintura package
- handleUpload(files[0], 'js')}> + void handleUpload(files[0], 'js')}> {uploadingState.js ? 'Uploading...' : 'Upload'} @@ -130,7 +130,7 @@ function PinturaModal() {
Upload Pintura styles
Upload the pintura.css file from the Pintura package
- handleUpload(files[0], 'css')}> + void handleUpload(files[0], 'css')}> {uploadingState.css ? 'Uploading...' : 'Upload'} diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/slack-modal.tsx b/apps/admin/src/settings/advanced/integrations/slack-modal.tsx similarity index 93% rename from apps/admin/src/settings/app/components/settings/advanced/integrations/slack-modal.tsx rename to apps/admin/src/settings/advanced/integrations/slack-modal.tsx index 28f6a1c5a4f..aba94b7392d 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/slack-modal.tsx +++ b/apps/admin/src/settings/advanced/integrations/slack-modal.tsx @@ -1,12 +1,12 @@ -import BrandIcon from '@/settings/app/components/icons/brand-icon'; +import BrandIcon from '@/settings/components/icons/brand-icon'; import IntegrationHeader from './integration-header'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; import validator from 'validator'; import {Button, Field, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldLegend, FieldSet, Input} from '@tryghost/shade/components'; import {SettingsModal} from '@tryghost/shade/patterns'; import {getSettingValues, useTestSlack} from '@tryghost/admin-x-framework/api/settings'; import {toast} from 'sonner'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; function SlackModal() { const {updateRoute} = useSettingsNavigation(); @@ -75,7 +75,7 @@ function SlackModal() { updateSetting('slack_username', e.target.value)} /> The username to display messages from - + diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/transistor-modal.tsx b/apps/admin/src/settings/advanced/integrations/transistor-modal.tsx similarity index 94% rename from apps/admin/src/settings/app/components/settings/advanced/integrations/transistor-modal.tsx rename to apps/admin/src/settings/advanced/integrations/transistor-modal.tsx index aa77cacf6bd..10d994d36d9 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/transistor-modal.tsx +++ b/apps/admin/src/settings/advanced/integrations/transistor-modal.tsx @@ -1,18 +1,18 @@ import APIKeys from './api-keys'; -import BookmarkThumb from '@/settings/app/assets/images/integrations/ghost-transistor.png'; -import BrandIcon from '@/settings/app/components/icons/brand-icon'; +import BookmarkThumb from '@/settings/assets/images/integrations/ghost-transistor.png'; +import BrandIcon from '@/settings/components/icons/brand-icon'; import IntegrationHeader from './integration-header'; import {Field, FieldContent, FieldDescription, FieldGroup, FieldLabel, FieldSet, Switch} from '@tryghost/shade/components'; import {type Setting, getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {SettingsModal} from '@tryghost/shade/patterns'; import {getGhostPaths} from '@tryghost/admin-x-framework/helpers'; import {useBrowseIntegrations} from '@tryghost/admin-x-framework/api/integrations'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; import {useEffect, useState} from 'react'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useRefreshAPIKey} from '@tryghost/admin-x-framework/api/api-keys'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; function TransistorModal() { const {updateRoute} = useSettingsNavigation(); diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/unsplash-modal.tsx b/apps/admin/src/settings/advanced/integrations/unsplash-modal.tsx similarity index 92% rename from apps/admin/src/settings/app/components/settings/advanced/integrations/unsplash-modal.tsx rename to apps/admin/src/settings/advanced/integrations/unsplash-modal.tsx index b027b4b3577..b275839b1fc 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/unsplash-modal.tsx +++ b/apps/admin/src/settings/advanced/integrations/unsplash-modal.tsx @@ -1,12 +1,12 @@ -import BrandIcon from '@/settings/app/components/icons/brand-icon'; +import BrandIcon from '@/settings/components/icons/brand-icon'; import IntegrationHeader from './integration-header'; import {Field, FieldContent, FieldDescription, FieldGroup, FieldLabel, Switch} from '@tryghost/shade/components'; import {type Setting, getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {SettingsModal} from '@tryghost/shade/patterns'; import {useEffect, useState} from 'react'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; function UnsplashModal() { const {updateRoute} = useSettingsNavigation(); diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/webhook-event-options.tsx b/apps/admin/src/settings/advanced/integrations/webhook-event-options.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/advanced/integrations/webhook-event-options.tsx rename to apps/admin/src/settings/advanced/integrations/webhook-event-options.tsx diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/webhook-modal.tsx b/apps/admin/src/settings/advanced/integrations/webhook-modal.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/advanced/integrations/webhook-modal.tsx rename to apps/admin/src/settings/advanced/integrations/webhook-modal.tsx diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.test.tsx b/apps/admin/src/settings/advanced/integrations/webhooks-table.test.tsx similarity index 92% rename from apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.test.tsx rename to apps/admin/src/settings/advanced/integrations/webhooks-table.test.tsx index 977bb1a35e8..0e07c74812f 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.test.tsx +++ b/apps/admin/src/settings/advanced/integrations/webhooks-table.test.tsx @@ -1,5 +1,5 @@ -import WebhooksTable from '@/settings/app/components/settings/advanced/integrations/webhooks-table'; -import {ConfirmationProvider} from '@/settings/app/components/providers/confirmation-provider'; +import WebhooksTable from '@/settings/advanced/integrations/webhooks-table'; +import {ConfirmationProvider} from '@/settings/providers/confirmation-provider'; import {type Integration} from '@tryghost/admin-x-framework/api/integrations'; import {render, screen} from '@testing-library/react'; diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.tsx b/apps/admin/src/settings/advanced/integrations/webhooks-table.tsx similarity index 98% rename from apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.tsx rename to apps/admin/src/settings/advanced/integrations/webhooks-table.tsx index e1554823ec4..d9a84a3e21c 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/webhooks-table.tsx +++ b/apps/admin/src/settings/advanced/integrations/webhooks-table.tsx @@ -6,7 +6,7 @@ import {type Integration} from '@tryghost/admin-x-framework/api/integrations'; import {LucideIcon, formatNumber} from '@tryghost/shade/utils'; import {getWebhookEventLabel} from './webhook-event-options'; import {toast} from 'sonner'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; import {type Webhook, useDeleteWebhook} from '@tryghost/admin-x-framework/api/webhooks'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; diff --git a/apps/admin/src/settings/app/components/settings/advanced/integrations/zapier-modal.tsx b/apps/admin/src/settings/advanced/integrations/zapier-modal.tsx similarity index 92% rename from apps/admin/src/settings/app/components/settings/advanced/integrations/zapier-modal.tsx rename to apps/admin/src/settings/advanced/integrations/zapier-modal.tsx index 6e32adfaf4a..8513e5bbd7f 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/integrations/zapier-modal.tsx +++ b/apps/admin/src/settings/advanced/integrations/zapier-modal.tsx @@ -1,19 +1,19 @@ import APIKeys from './api-keys'; -import BrandIcon from '@/settings/app/components/icons/brand-icon'; +import BrandIcon from '@/settings/components/icons/brand-icon'; import IntegrationHeader from './integration-header'; -import ZapierLogo from '@/settings/app/assets/images/zapier-logo.svg'; +import ZapierLogo from '@/settings/assets/images/zapier-logo.svg'; import {ActionList, ActionListItem, ActionListItemActions, ActionListItemContent, Button} from '@tryghost/shade/components'; import {LucideIcon} from '@tryghost/shade/utils'; import {SettingsModal} from '@tryghost/shade/patterns'; import {getGhostPaths} from '@tryghost/admin-x-framework/helpers'; import {useBrowseIntegrations} from '@tryghost/admin-x-framework/api/integrations'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; import {useEffect, useState} from 'react'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useRefreshAPIKey} from '@tryghost/admin-x-framework/api/api-keys'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {useSettingsApp} from '@/settings/app/components/providers/settings-app-provider'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {useSettingsApp} from '@/settings/providers/settings-app-context'; export interface ZapierTemplate { ghostImage: string; diff --git a/apps/admin/src/settings/app/components/settings/advanced/labs.tsx b/apps/admin/src/settings/advanced/labs.tsx similarity index 85% rename from apps/admin/src/settings/app/components/settings/advanced/labs.tsx rename to apps/admin/src/settings/advanced/labs.tsx index f146307ba51..0bf93f12697 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/labs.tsx +++ b/apps/admin/src/settings/advanced/labs.tsx @@ -1,12 +1,12 @@ import BetaFeatures from './labs/beta-features'; -import LabsBubbles from '@/settings/app/assets/images/labs-bg.svg'; +import LabsBubbles from '@/settings/assets/images/labs-bg.svg'; import PrivateFeatures from './labs/private-features'; import React, {useState} from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; import {Button, Tabs, TabsContent, TabsList, TabsTrigger} from '@tryghost/shade/components'; -import {useAutoExpandable} from '@/settings/app/hooks/use-auto-expandable'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {useAutoExpandable} from '@/settings/hooks/use-auto-expandable'; +import {useGlobalData} from '@/settings/providers/global-data-context'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; type LabsTab = 'labs-private-features' | 'labs-beta-features'; diff --git a/apps/admin/src/settings/app/components/settings/advanced/labs/beta-features.tsx b/apps/admin/src/settings/advanced/labs/beta-features.tsx similarity index 83% rename from apps/admin/src/settings/app/components/settings/advanced/labs/beta-features.tsx rename to apps/admin/src/settings/advanced/labs/beta-features.tsx index 30f79243fe9..f31f591a648 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/labs/beta-features.tsx +++ b/apps/admin/src/settings/advanced/labs/beta-features.tsx @@ -8,9 +8,9 @@ import {downloadRedirects, useUploadRedirects} from '@tryghost/admin-x-framework import {downloadRoutes, useUploadRoutes} from '@tryghost/admin-x-framework/api/routes'; import {getSettingValue} from '@tryghost/admin-x-framework/api/settings'; import {toast} from 'sonner'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {DialogPortal} from '@/settings/app/components/providers/dialog-portal'; +import {DialogPortal} from '@/settings/providers/dialog-portal'; const IS_AUTOMATIONS_BETA_ACTIVE = true; @@ -21,10 +21,34 @@ const BetaFeatures: React.FC = () => { const handleError = useHandleError(); const [redirectsUploading, setRedirectsUploading] = useState(false); const [routesUploading, setRoutesUploading] = useState(false); - const labs = JSON.parse(getSettingValue(settings, 'labs') || '{}'); + const labs = JSON.parse(getSettingValue(settings, 'labs') || '{}') as Record; const isAutomationsEnabled = !!labs.automations; const [openEditor, setOpenEditor] = useState<'redirects' | 'routes' | null>(null); + const uploadRedirectsFile = async (file: File) => { + try { + setRedirectsUploading(true); + await uploadRedirects(file); + toast.success('Redirects uploaded'); + } catch (e) { + handleError(e); + } finally { + setRedirectsUploading(false); + } + }; + + const uploadRoutesFile = async (file: File) => { + try { + setRoutesUploading(true); + await uploadRoutes(file); + toast.success('Routes uploaded'); + } catch (e) { + handleError(e); + } finally { + setRoutesUploading(false); + } + }; + const openRedirectsEditor = () => setOpenEditor('redirects'); const openRoutesEditor = () => setOpenEditor('routes'); const closeEditor = () => setOpenEditor(null); @@ -65,17 +89,7 @@ const BetaFeatures: React.FC = () => { { - try { - setRedirectsUploading(true); - await uploadRedirects(file); - toast.success('Redirects uploaded'); - } catch (e) { - handleError(e); - } finally { - setRedirectsUploading(false); - } - }} + onDropAccepted={([file]) => void uploadRedirectsFile(file)} > {redirectsUploading ? 'Uploading ...' : 'Upload redirects file'} @@ -92,17 +106,7 @@ const BetaFeatures: React.FC = () => { { - try { - setRoutesUploading(true); - await uploadRoutes(file); - toast.success('Routes uploaded'); - } catch (e) { - handleError(e); - } finally { - setRoutesUploading(false); - } - }} + onDropAccepted={([file]) => void uploadRoutesFile(file)} > {routesUploading ? 'Uploading ...' : 'Upload routes file'} diff --git a/apps/admin/src/settings/app/components/settings/advanced/labs/feature-toggle.tsx b/apps/admin/src/settings/advanced/labs/feature-toggle.tsx similarity index 90% rename from apps/admin/src/settings/app/components/settings/advanced/labs/feature-toggle.tsx rename to apps/admin/src/settings/advanced/labs/feature-toggle.tsx index 9586f79c080..724c0936e2d 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/labs/feature-toggle.tsx +++ b/apps/admin/src/settings/advanced/labs/feature-toggle.tsx @@ -1,13 +1,13 @@ import React, {useState} from 'react'; -import trackEvent from '@/settings/app/utils/analytics'; +import trackEvent from '@/settings/utils/analytics'; import {type ConfigResponseType, configDataType} from '@tryghost/admin-x-framework/api/config'; import {SettingsModal} from '@tryghost/shade/patterns'; import {Switch} from '@tryghost/shade/components'; import {getSettingValue, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useQueryClient} from '@tanstack/react-query'; -import {DialogPortal} from '@/settings/app/components/providers/dialog-portal'; +import {DialogPortal} from '@/settings/providers/dialog-portal'; type ConfirmationProps = { title: string; @@ -71,7 +71,7 @@ const FeatureToggleConfirmationModal: React.FC = ({label, flag, disabled, confirmation}) => { const {settings} = useGlobalData(); - const labs = JSON.parse(getSettingValue(settings, 'labs') || '{}'); + const labs = JSON.parse(getSettingValue(settings, 'labs') || '{}') as Record; const {mutateAsync: editSettings} = useEditSettings(); const client = useQueryClient(); const handleError = useHandleError(); @@ -102,13 +102,13 @@ const FeatureToggle: React.FC = ({label, flag, disabled, con }; return <> - { + { if (confirmation && newValue) { setIsConfirming(true); return; } - await saveFeatureValue(newValue); + void saveFeatureValue(newValue); }} /> {confirmation && isConfirming && ( diff --git a/apps/admin/src/settings/app/components/settings/advanced/labs/lab-item.tsx b/apps/admin/src/settings/advanced/labs/lab-item.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/advanced/labs/lab-item.tsx rename to apps/admin/src/settings/advanced/labs/lab-item.tsx diff --git a/apps/admin/src/settings/app/components/settings/advanced/labs/private-features.tsx b/apps/admin/src/settings/advanced/labs/private-features.tsx similarity index 93% rename from apps/admin/src/settings/app/components/settings/advanced/labs/private-features.tsx rename to apps/admin/src/settings/advanced/labs/private-features.tsx index d1fc22aa5b1..95f2b308e39 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/labs/private-features.tsx +++ b/apps/admin/src/settings/advanced/labs/private-features.tsx @@ -2,7 +2,7 @@ import FeatureToggle from './feature-toggle'; import LabItem from './lab-item'; import React, {useEffect, useState} from 'react'; import {ActionList} from '@tryghost/shade/components'; -import {HostLimitError, useLimiter} from '@/settings/app/hooks/use-limiter'; +import {HostLimitError, useLimiter} from '@/settings/hooks/use-limiter'; type Feature = { title: string; @@ -55,6 +55,10 @@ const features: Feature[] = [{ title: 'Get helper deduplication', description: 'Deduplicate identical {{#get}} helper queries within a single request to avoid redundant database calls', flag: 'getHelperDeduplication' +}, { + title: 'Navigation icons & visibility', + description: 'Add icons and member-visibility controls to navigation menu items. Requires theme support to render icons.', + flag: 'navigationIcons' }, { title: 'React tag details', description: 'Renders the tag detail screen (/tags/:slug) from the React app instead of the Ember screen. Gates the migration behind a runtime toggle so we can compare both implementations.', @@ -105,7 +109,7 @@ const AlphaFeatures: React.FC = () => { } setAllowedFeatures(filtered); }; - filterFeatures(); + void filterFeatures(); }, [limiter]); return ( diff --git a/apps/admin/src/settings/app/components/settings/advanced/labs/yaml-file-editor-modal.tsx b/apps/admin/src/settings/advanced/labs/yaml-file-editor-modal.tsx similarity index 98% rename from apps/admin/src/settings/app/components/settings/advanced/labs/yaml-file-editor-modal.tsx rename to apps/admin/src/settings/advanced/labs/yaml-file-editor-modal.tsx index 58bb3a92832..10b3add21a5 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/labs/yaml-file-editor-modal.tsx +++ b/apps/admin/src/settings/advanced/labs/yaml-file-editor-modal.tsx @@ -1,4 +1,4 @@ -import CodeEditor from '@/settings/app/components/code-editor'; +import CodeEditor from '@/settings/components/code-editor'; import React, {useEffect, useMemo, useState} from 'react'; import {APIError, JSONError} from '@tryghost/admin-x-framework/errors'; import {Button} from '@tryghost/shade/components'; @@ -87,7 +87,7 @@ const YamlFileEditorModal: React.FC = ({ } }; - loadContent(); + void loadContent(); return () => { isMounted = false; diff --git a/apps/admin/src/settings/app/components/settings/advanced/migration-tools.tsx b/apps/admin/src/settings/advanced/migration-tools.tsx similarity index 90% rename from apps/admin/src/settings/app/components/settings/advanced/migration-tools.tsx rename to apps/admin/src/settings/advanced/migration-tools.tsx index c9660236535..773ab5370bb 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/migration-tools.tsx +++ b/apps/admin/src/settings/advanced/migration-tools.tsx @@ -1,9 +1,9 @@ import MigrationToolsExport from './migration-tools/migration-tools-export'; import MigrationToolsImport from './migration-tools/migration-tools-import'; import React, {useState} from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; import {Tabs, TabsContent, TabsList, TabsTrigger} from '@tryghost/shade/components'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; type MigrationTab = 'import' | 'export'; diff --git a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/export-all-modal.tsx b/apps/admin/src/settings/advanced/migration-tools/export-all-modal.tsx similarity index 99% rename from apps/admin/src/settings/app/components/settings/advanced/migration-tools/export-all-modal.tsx rename to apps/admin/src/settings/advanced/migration-tools/export-all-modal.tsx index 17286d65129..e211638d0df 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/export-all-modal.tsx +++ b/apps/admin/src/settings/advanced/migration-tools/export-all-modal.tsx @@ -80,7 +80,7 @@ const ExportAllModal: React.FC<{open: boolean; onOpenChange: (open: boolean) => }; const triggerMockDownload = () => { - const emptyZip = new Uint8Array([0x50, 0x4b, 0x05, 0x06, ...new Array(18).fill(0)]); + const emptyZip = new Uint8Array([0x50, 0x4b, 0x05, 0x06, ...new Array(18).fill(0)]); const url = URL.createObjectURL(new Blob([emptyZip], {type: 'application/zip'})); const anchor = document.createElement('a'); anchor.href = url; diff --git a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/migration-tools-export.tsx b/apps/admin/src/settings/advanced/migration-tools/migration-tools-export.tsx similarity index 89% rename from apps/admin/src/settings/app/components/settings/advanced/migration-tools/migration-tools-export.tsx rename to apps/admin/src/settings/advanced/migration-tools/migration-tools-export.tsx index f487eb9dd42..6e796e70273 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/migration-tools-export.tsx +++ b/apps/admin/src/settings/advanced/migration-tools/migration-tools-export.tsx @@ -1,6 +1,6 @@ import ExportAllModal, {type ExportMode} from './export-all-modal'; import React from 'react'; -import useFeatureFlag from '@/settings/app/hooks/use-feature-flag'; +import useFeatureFlag from '@/settings/hooks/use-feature-flag'; import {Button, LoadingIndicator} from '@tryghost/shade/components'; import {LucideIcon} from '@tryghost/shade/utils'; import {blobDownloadFromEndpoint} from '@tryghost/admin-x-framework/helpers'; @@ -50,7 +50,7 @@ const MigrationToolsExport: React.FC = () => { return (
- +
); }; diff --git a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/migration-tools-import.tsx b/apps/admin/src/settings/advanced/migration-tools/migration-tools-import.tsx similarity index 90% rename from apps/admin/src/settings/app/components/settings/advanced/migration-tools/migration-tools-import.tsx rename to apps/admin/src/settings/advanced/migration-tools/migration-tools-import.tsx index 83566954d5d..fd3dd235f87 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/migration-tools-import.tsx +++ b/apps/admin/src/settings/advanced/migration-tools/migration-tools-import.tsx @@ -1,10 +1,10 @@ -import BrandIcon from '@/settings/app/components/icons/brand-icon'; +import BrandIcon from '@/settings/components/icons/brand-icon'; import React, {useState} from 'react'; import UniversalImportModal from './universal-import-modal'; import {Button} from '@tryghost/shade/components'; import {LucideIcon} from '@tryghost/shade/utils'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {DialogPortal} from '@/settings/app/components/providers/dialog-portal'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {DialogPortal} from '@/settings/providers/dialog-portal'; const MigrationToolsImport: React.FC = () => { const {updateRoute} = useSettingsNavigation(); diff --git a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.test.tsx b/apps/admin/src/settings/advanced/migration-tools/universal-import-modal.test.tsx similarity index 94% rename from apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.test.tsx rename to apps/admin/src/settings/advanced/migration-tools/universal-import-modal.test.tsx index 78825bf3f8c..ae382dea9af 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.test.tsx +++ b/apps/admin/src/settings/advanced/migration-tools/universal-import-modal.test.tsx @@ -1,6 +1,6 @@ import {useState} from 'react'; -import UniversalImportModal from '@/settings/app/components/settings/advanced/migration-tools/universal-import-modal'; -import {ConfirmationProvider} from '@/settings/app/components/providers/confirmation-provider'; +import UniversalImportModal from '@/settings/advanced/migration-tools/universal-import-modal'; +import {ConfirmationProvider} from '@/settings/providers/confirmation-provider'; import {act, fireEvent, render, screen, waitFor} from '@testing-library/react'; const {mockImportContent, mockImportContentCSV, mockUseFeatureFlag, mockHandleError} = vi.hoisted(() => ({ @@ -25,8 +25,8 @@ vi.mock('@tryghost/admin-x-framework/hooks', async () => { return {...actual, useHandleError: () => mockHandleError}; }); -vi.mock('@/settings/app/hooks/use-feature-flag', () => ({ - default: (flag: string) => mockUseFeatureFlag(flag) +vi.mock('@/settings/hooks/use-feature-flag', () => ({ + default: (flag: string) => mockUseFeatureFlag(flag) as boolean })); describe('UniversalImportModal', () => { @@ -57,6 +57,7 @@ describe('UniversalImportModal', () => { // "not called" assertion afterwards can't pass vacuously await act(async () => { fireEvent.change(input, {target: {files: [file]}}); + await Promise.resolve(); }); }; diff --git a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.tsx b/apps/admin/src/settings/advanced/migration-tools/universal-import-modal.tsx similarity index 64% rename from apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.tsx rename to apps/admin/src/settings/advanced/migration-tools/universal-import-modal.tsx index acb85bbbca0..db98e64a1f9 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/migration-tools/universal-import-modal.tsx +++ b/apps/admin/src/settings/advanced/migration-tools/universal-import-modal.tsx @@ -1,10 +1,10 @@ import React, {useState} from 'react'; -import useFeatureFlag from '@/settings/app/hooks/use-feature-flag'; +import useFeatureFlag from '@/settings/hooks/use-feature-flag'; import {Button, Dropzone} from '@tryghost/shade/components'; import {ExternalLink} from 'lucide-react'; import {Inline} from '@tryghost/shade/primitives'; import {SettingsModal} from '@tryghost/shade/patterns'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useImportContent} from '@tryghost/admin-x-framework/api/db'; import {useImportContentCSV} from '@tryghost/admin-x-framework/api/posts'; @@ -21,6 +21,34 @@ const UniversalImportModal: React.FC<{onClose: () => void}> = ({onClose}) => { ? {'application/json': ['.json'], 'application/zip': ['.zip'], 'text/csv': ['.csv']} : {'application/json': ['.json'], 'application/zip': ['.zip']}; + const importFile = async (file: File) => { + setUploading(true); + try { + const isCSV = csvContentImporter && file.name.toLowerCase().endsWith('.csv'); + if (isCSV) { + await importContentCSV(file); + } else { + await importContent(file); + } + onClose(); + confirm({ + title: 'Import in progress', + // CSV imports don't send a completion email yet, so don't promise one + prompt: isCSV + ? `Your import is being processed, and imported posts will appear on your site as soon as it’s complete. Usually this only takes a few minutes, but larger imports may take longer.` + : `Your import is being processed, and you'll receive a confirmation email as soon as it’s complete. Usually this only takes a few minutes, but larger imports may take longer.`, + cancelLabel: '', + okLabel: 'Got it', + onOk: confirmModal => confirmModal?.remove(), + formSheet: false + }); + } catch (e) { + handleError(e); + } finally { + setUploading(false); + } + }; + return ( void}> = ({onClose}) => { accept={acceptedTypes} inputId="import-file" inputTestId="import-file" - onDropAccepted={async ([file]) => { - setUploading(true); - try { - if (csvContentImporter && file.name.toLowerCase().endsWith('.csv')) { - await importContentCSV(file); - } else { - await importContent(file); - } - onClose(); - confirm({ - title: 'Import in progress', - prompt: `Your import is being processed, and you'll receive a confirmation email as soon as it’s complete. Usually this only takes a few minutes, but larger imports may take longer.`, - cancelLabel: '', - okLabel: 'Got it', - onOk: confirmModal => confirmModal?.remove(), - formSheet: false - }); - } catch (e) { - handleError(e); - } finally { - setUploading(false); - } - }} + onDropAccepted={([file]) => void importFile(file)} >
{uploading ? 'Uploading...' : <> diff --git a/apps/admin/src/settings/advanced/search-keywords.ts b/apps/admin/src/settings/advanced/search-keywords.ts new file mode 100644 index 00000000000..22b132143fd --- /dev/null +++ b/apps/admin/src/settings/advanced/search-keywords.ts @@ -0,0 +1,8 @@ +export const searchKeywords = { + integrations: ['advanced', 'integrations', 'zapier', 'slack', 'unsplash', 'first promoter', 'firstpromoter', 'pintura', 'disqus', 'analytics', 'ulysses', 'typeform', 'buffer', 'plausible', 'github', 'webhooks'], + migrationtools: ['import', 'export', 'migrate', 'substack', 'substack', 'migration', 'medium', 'wordpress', 'wp', 'squarespace', 'beehiiv'], + codeInjection: ['advanced', 'code injection', 'head', 'footer'], + labs: ['advanced', 'labs', 'alpha', 'private', 'beta', 'flag', 'routes', 'redirect', 'translation', 'editor', 'portal'], + history: ['advanced', 'history', 'log', 'events', 'user events', 'staff', 'audit', 'action'], + dangerzone: ['danger zone', 'delete all content', 'delete site', 'reset all authentication', 'reset api keys', 'reset password', 'compromised credentials', 'lock staff users', 'sign out all staff'] +}; diff --git a/apps/admin/src/settings/app/components/settings/advanced/spam-filters.tsx b/apps/admin/src/settings/advanced/spam-filters.tsx similarity index 94% rename from apps/admin/src/settings/app/components/settings/advanced/spam-filters.tsx rename to apps/admin/src/settings/advanced/spam-filters.tsx index 0764c44cc59..b54364ff85f 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/spam-filters.tsx +++ b/apps/admin/src/settings/advanced/spam-filters.tsx @@ -1,10 +1,10 @@ import React from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; import {Field, FieldDescription, FieldError, FieldLabel, Textarea} from '@tryghost/shade/components'; import {SettingGroupContent} from '@tryghost/shade/patterns'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const SpamFilters: React.FC<{ keywords: string[] }> = ({keywords}) => { const { diff --git a/apps/admin/src/settings/app/components/settings/advanced/advanced-settings.tsx b/apps/admin/src/settings/app/components/settings/advanced/advanced-settings.tsx deleted file mode 100644 index 7a35994cef7..00000000000 --- a/apps/admin/src/settings/app/components/settings/advanced/advanced-settings.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import CodeInjection from './code-injection'; -import DangerZone from './danger-zone'; -import History from './history'; -import Integrations from './integrations'; -import Labs from './labs'; -import MigrationTools from './migration-tools'; -import React from 'react'; -import SearchableSection from '@/settings/app/components/searchable-section'; - -export const searchKeywords = { - integrations: ['advanced', 'integrations', 'zapier', 'slack', 'unsplash', 'first promoter', 'firstpromoter', 'pintura', 'disqus', 'analytics', 'ulysses', 'typeform', 'buffer', 'plausible', 'github', 'webhooks'], - migrationtools: ['import', 'export', 'migrate', 'substack', 'substack', 'migration', 'medium', 'wordpress', 'wp', 'squarespace', 'beehiiv'], - codeInjection: ['advanced', 'code injection', 'head', 'footer'], - labs: ['advanced', 'labs', 'alpha', 'private', 'beta', 'flag', 'routes', 'redirect', 'translation', 'editor', 'portal'], - history: ['advanced', 'history', 'log', 'events', 'user events', 'staff', 'audit', 'action'], - dangerzone: ['danger zone', 'delete all content', 'delete site', 'reset all authentication', 'reset api keys', 'reset password', 'compromised credentials', 'lock staff users', 'sign out all staff'] -}; - -const AdvancedSettings: React.FC = () => { - return ( - - - - - - - - - ); -}; - -export default AdvancedSettings; diff --git a/apps/admin/src/settings/app/components/settings/email-design/email-design-context.tsx b/apps/admin/src/settings/app/components/settings/email-design/email-design-context.tsx deleted file mode 100644 index 416565488a0..00000000000 --- a/apps/admin/src/settings/app/components/settings/email-design/email-design-context.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import {type ReactNode, createContext, useContext} from 'react'; -import type {EmailDesignSettings} from './types'; - -interface EmailDesignContextValue { - settings: EmailDesignSettings; - onSettingsChange: (updates: Partial) => void; - accentColor: string; -} - -const EmailDesignContext = createContext(undefined); - -interface EmailDesignProviderProps extends EmailDesignContextValue { - children: ReactNode; -} - -export const EmailDesignProvider = ({settings, onSettingsChange, accentColor, children}: EmailDesignProviderProps) => ( - - {children} - -); - -export const useEmailDesign = (): EmailDesignContextValue => { - const ctx = useContext(EmailDesignContext); - if (!ctx) { - throw new Error('useEmailDesign must be used within an EmailDesignProvider'); - } - return ctx; -}; diff --git a/apps/admin/src/settings/app/components/settings/site/navigation/navigation-item-editor.tsx b/apps/admin/src/settings/app/components/settings/site/navigation/navigation-item-editor.tsx deleted file mode 100644 index 0a9b223f7e2..00000000000 --- a/apps/admin/src/settings/app/components/settings/site/navigation/navigation-item-editor.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import React, {type ReactNode} from 'react'; -import clsx from 'clsx'; -import useUrlInput from '@/settings/app/hooks/use-url-input'; -import {type EditableItem, type NavigationItem, type NavigationItemErrors} from '@/settings/app/hooks/site/use-navigation-editor'; -import {Field, FieldError, FieldLabel, Input} from '@tryghost/shade/components'; -import {Inline} from '@tryghost/shade/primitives'; -import {formatUrl} from '@/settings/app/utils/format-url'; - -export type NavigationItemEditorProps = React.HTMLAttributes & { - baseUrl: string; - item: EditableItem; - clearError?: (key: keyof NavigationItemErrors) => void; - updateItem?: (item: Partial) => void; - labelPlaceholder?: string - unstyled?: boolean - textFieldClasses?: string - action?: ReactNode - addItem?: () => void -} - -const NavigationItemEditor: React.FC = ({baseUrl, item, updateItem, addItem, clearError, labelPlaceholder, unstyled, textFieldClasses, action, className, ...props}) => { - const urlInput = useUrlInput({ - baseUrl, - nullable: true, - value: item.url, - onChange: value => updateItem?.({url: value || ''}) - }); - - return ( -
-
- - Label - updateItem?.({label: e.target.value})} - onKeyDown={(e) => { - updateItem?.({label: (e.target as HTMLInputElement).value}); - if (e.key === 'Enter') { - e.preventDefault(); - addItem?.(); - } - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - !!item.errors.label && clearError?.('label'); - }} /> - {item.errors.label && {item.errors.label}} - -
- - URL - urlInput.setDisplayValue(event.target.value)} - onFocus={urlInput.handleFocus} - onKeyDown={(e) => { - urlInput.handleKeyDown(e); - const urls = formatUrl((e.target as HTMLInputElement).value, baseUrl, true); - updateItem?.({url: urls.save || ''}); - }} - onKeyUp={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - const urls = formatUrl((e.target as HTMLInputElement).value, baseUrl, true); - updateItem?.({url: urls.save || ''}); - addItem?.(); - } - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - !!item.errors.url && clearError?.('url'); - }} - /> - {item.errors.url && {item.errors.url}} - - {action && ( - - {action} - - )} -
- ); -}; - -export default NavigationItemEditor; diff --git a/apps/admin/src/settings/app/assets/images/ali-abdaal.png b/apps/admin/src/settings/assets/images/ali-abdaal.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/ali-abdaal.png rename to apps/admin/src/settings/assets/images/ali-abdaal.png diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/beehiiv.svg b/apps/admin/src/settings/assets/images/brand-icons/beehiiv.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/beehiiv.svg rename to apps/admin/src/settings/assets/images/brand-icons/beehiiv.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/facebook.svg b/apps/admin/src/settings/assets/images/brand-icons/facebook.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/facebook.svg rename to apps/admin/src/settings/assets/images/brand-icons/facebook.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/firstpromoter.svg b/apps/admin/src/settings/assets/images/brand-icons/firstpromoter.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/firstpromoter.svg rename to apps/admin/src/settings/assets/images/brand-icons/firstpromoter.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/linkedin.svg b/apps/admin/src/settings/assets/images/brand-icons/linkedin.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/linkedin.svg rename to apps/admin/src/settings/assets/images/brand-icons/linkedin.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/mailchimp.svg b/apps/admin/src/settings/assets/images/brand-icons/mailchimp.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/mailchimp.svg rename to apps/admin/src/settings/assets/images/brand-icons/mailchimp.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/medium.svg b/apps/admin/src/settings/assets/images/brand-icons/medium.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/medium.svg rename to apps/admin/src/settings/assets/images/brand-icons/medium.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/pintura.svg b/apps/admin/src/settings/assets/images/brand-icons/pintura.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/pintura.svg rename to apps/admin/src/settings/assets/images/brand-icons/pintura.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/portal-icon-1.svg b/apps/admin/src/settings/assets/images/brand-icons/portal-icon-1.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/portal-icon-1.svg rename to apps/admin/src/settings/assets/images/brand-icons/portal-icon-1.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/portal-icon-2.svg b/apps/admin/src/settings/assets/images/brand-icons/portal-icon-2.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/portal-icon-2.svg rename to apps/admin/src/settings/assets/images/brand-icons/portal-icon-2.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/portal-icon-3.svg b/apps/admin/src/settings/assets/images/brand-icons/portal-icon-3.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/portal-icon-3.svg rename to apps/admin/src/settings/assets/images/brand-icons/portal-icon-3.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/portal-icon-4.svg b/apps/admin/src/settings/assets/images/brand-icons/portal-icon-4.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/portal-icon-4.svg rename to apps/admin/src/settings/assets/images/brand-icons/portal-icon-4.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/portal-icon-5.svg b/apps/admin/src/settings/assets/images/brand-icons/portal-icon-5.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/portal-icon-5.svg rename to apps/admin/src/settings/assets/images/brand-icons/portal-icon-5.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/slack.svg b/apps/admin/src/settings/assets/images/brand-icons/slack.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/slack.svg rename to apps/admin/src/settings/assets/images/brand-icons/slack.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/squarespace.svg b/apps/admin/src/settings/assets/images/brand-icons/squarespace.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/squarespace.svg rename to apps/admin/src/settings/assets/images/brand-icons/squarespace.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/substack.svg b/apps/admin/src/settings/assets/images/brand-icons/substack.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/substack.svg rename to apps/admin/src/settings/assets/images/brand-icons/substack.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/transistor.svg b/apps/admin/src/settings/assets/images/brand-icons/transistor.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/transistor.svg rename to apps/admin/src/settings/assets/images/brand-icons/transistor.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/twitter-x.svg b/apps/admin/src/settings/assets/images/brand-icons/twitter-x.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/twitter-x.svg rename to apps/admin/src/settings/assets/images/brand-icons/twitter-x.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/unsplash.svg b/apps/admin/src/settings/assets/images/brand-icons/unsplash.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/unsplash.svg rename to apps/admin/src/settings/assets/images/brand-icons/unsplash.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/wordpress.svg b/apps/admin/src/settings/assets/images/brand-icons/wordpress.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/wordpress.svg rename to apps/admin/src/settings/assets/images/brand-icons/wordpress.svg diff --git a/apps/admin/src/settings/app/assets/images/brand-icons/zapier.svg b/apps/admin/src/settings/assets/images/brand-icons/zapier.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/brand-icons/zapier.svg rename to apps/admin/src/settings/assets/images/brand-icons/zapier.svg diff --git a/apps/admin/src/settings/app/assets/images/cover-test.jpg b/apps/admin/src/settings/assets/images/cover-test.jpg similarity index 100% rename from apps/admin/src/settings/app/assets/images/cover-test.jpg rename to apps/admin/src/settings/assets/images/cover-test.jpg diff --git a/apps/admin/src/settings/app/assets/images/design-settings.png b/apps/admin/src/settings/assets/images/design-settings.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/design-settings.png rename to apps/admin/src/settings/assets/images/design-settings.png diff --git a/apps/admin/src/settings/app/assets/images/email-design-user-image.jpg b/apps/admin/src/settings/assets/images/email-design-user-image.jpg similarity index 100% rename from apps/admin/src/settings/app/assets/images/email-design-user-image.jpg rename to apps/admin/src/settings/assets/images/email-design-user-image.jpg diff --git a/apps/admin/src/settings/app/assets/images/explore-default-logo.png b/apps/admin/src/settings/assets/images/explore-default-logo.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/explore-default-logo.png rename to apps/admin/src/settings/assets/images/explore-default-logo.png diff --git a/apps/admin/src/settings/app/assets/images/footer-marketplace-bg.png b/apps/admin/src/settings/assets/images/footer-marketplace-bg.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/footer-marketplace-bg.png rename to apps/admin/src/settings/assets/images/footer-marketplace-bg.png diff --git a/apps/admin/src/settings/app/assets/images/ghost-explore.png b/apps/admin/src/settings/assets/images/ghost-explore.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/ghost-explore.png rename to apps/admin/src/settings/assets/images/ghost-explore.png diff --git a/apps/admin/src/settings/app/assets/images/ghost-favicon.png b/apps/admin/src/settings/assets/images/ghost-favicon.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/ghost-favicon.png rename to apps/admin/src/settings/assets/images/ghost-favicon.png diff --git a/apps/admin/src/settings/app/assets/images/integrations-settings.png b/apps/admin/src/settings/assets/images/integrations-settings.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/integrations-settings.png rename to apps/admin/src/settings/assets/images/integrations-settings.png diff --git a/apps/admin/src/settings/app/assets/images/integrations/ghost-transistor.png b/apps/admin/src/settings/assets/images/integrations/ghost-transistor.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/integrations/ghost-transistor.png rename to apps/admin/src/settings/assets/images/integrations/ghost-transistor.png diff --git a/apps/admin/src/settings/app/assets/images/integrations/google-docs.svg b/apps/admin/src/settings/assets/images/integrations/google-docs.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/integrations/google-docs.svg rename to apps/admin/src/settings/assets/images/integrations/google-docs.svg diff --git a/apps/admin/src/settings/app/assets/images/integrations/mailchimp.svg b/apps/admin/src/settings/assets/images/integrations/mailchimp.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/integrations/mailchimp.svg rename to apps/admin/src/settings/assets/images/integrations/mailchimp.svg diff --git a/apps/admin/src/settings/app/assets/images/integrations/patreon.svg b/apps/admin/src/settings/assets/images/integrations/patreon.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/integrations/patreon.svg rename to apps/admin/src/settings/assets/images/integrations/patreon.svg diff --git a/apps/admin/src/settings/app/assets/images/integrations/paypal.svg b/apps/admin/src/settings/assets/images/integrations/paypal.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/integrations/paypal.svg rename to apps/admin/src/settings/assets/images/integrations/paypal.svg diff --git a/apps/admin/src/settings/app/assets/images/integrations/slackicon.png b/apps/admin/src/settings/assets/images/integrations/slackicon.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/integrations/slackicon.png rename to apps/admin/src/settings/assets/images/integrations/slackicon.png diff --git a/apps/admin/src/settings/app/assets/images/integrations/typeform.svg b/apps/admin/src/settings/assets/images/integrations/typeform.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/integrations/typeform.svg rename to apps/admin/src/settings/assets/images/integrations/typeform.svg diff --git a/apps/admin/src/settings/app/assets/images/integrations/zero-bounce.png b/apps/admin/src/settings/assets/images/integrations/zero-bounce.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/integrations/zero-bounce.png rename to apps/admin/src/settings/assets/images/integrations/zero-bounce.png diff --git a/apps/admin/src/settings/app/assets/images/isaac-saul.png b/apps/admin/src/settings/assets/images/isaac-saul.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/isaac-saul.png rename to apps/admin/src/settings/assets/images/isaac-saul.png diff --git a/apps/admin/src/settings/app/assets/images/joel-warner.png b/apps/admin/src/settings/assets/images/joel-warner.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/joel-warner.png rename to apps/admin/src/settings/assets/images/joel-warner.png diff --git a/apps/admin/src/settings/app/assets/images/labs-bg.svg b/apps/admin/src/settings/assets/images/labs-bg.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/labs-bg.svg rename to apps/admin/src/settings/assets/images/labs-bg.svg diff --git a/apps/admin/src/settings/app/assets/images/latest-posts-1.jpg b/apps/admin/src/settings/assets/images/latest-posts-1.jpg similarity index 100% rename from apps/admin/src/settings/app/assets/images/latest-posts-1.jpg rename to apps/admin/src/settings/assets/images/latest-posts-1.jpg diff --git a/apps/admin/src/settings/app/assets/images/latest-posts-2.jpg b/apps/admin/src/settings/assets/images/latest-posts-2.jpg similarity index 100% rename from apps/admin/src/settings/app/assets/images/latest-posts-2.jpg rename to apps/admin/src/settings/assets/images/latest-posts-2.jpg diff --git a/apps/admin/src/settings/app/assets/images/latest-posts-3.jpg b/apps/admin/src/settings/assets/images/latest-posts-3.jpg similarity index 100% rename from apps/admin/src/settings/app/assets/images/latest-posts-3.jpg rename to apps/admin/src/settings/assets/images/latest-posts-3.jpg diff --git a/apps/admin/src/settings/app/assets/images/logos/orb-black-1.png b/apps/admin/src/settings/assets/images/logos/orb-black-1.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/logos/orb-black-1.png rename to apps/admin/src/settings/assets/images/logos/orb-black-1.png diff --git a/apps/admin/src/settings/app/assets/images/logos/orb-black-2.png b/apps/admin/src/settings/assets/images/logos/orb-black-2.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/logos/orb-black-2.png rename to apps/admin/src/settings/assets/images/logos/orb-black-2.png diff --git a/apps/admin/src/settings/app/assets/images/logos/orb-black-3.png b/apps/admin/src/settings/assets/images/logos/orb-black-3.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/logos/orb-black-3.png rename to apps/admin/src/settings/assets/images/logos/orb-black-3.png diff --git a/apps/admin/src/settings/app/assets/images/logos/orb-black-4.png b/apps/admin/src/settings/assets/images/logos/orb-black-4.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/logos/orb-black-4.png rename to apps/admin/src/settings/assets/images/logos/orb-black-4.png diff --git a/apps/admin/src/settings/app/assets/images/logos/orb-black-5.png b/apps/admin/src/settings/assets/images/logos/orb-black-5.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/logos/orb-black-5.png rename to apps/admin/src/settings/assets/images/logos/orb-black-5.png diff --git a/apps/admin/src/settings/app/assets/images/network.png b/apps/admin/src/settings/assets/images/network.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/network.png rename to apps/admin/src/settings/assets/images/network.png diff --git a/apps/admin/src/settings/app/assets/images/orb-pink.png b/apps/admin/src/settings/assets/images/orb-pink.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/orb-pink.png rename to apps/admin/src/settings/assets/images/orb-pink.png diff --git a/apps/admin/src/settings/app/assets/images/orb-squircle.png b/apps/admin/src/settings/assets/images/orb-squircle.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/orb-squircle.png rename to apps/admin/src/settings/assets/images/orb-squircle.png diff --git a/apps/admin/src/settings/app/assets/images/pintura-screenshot.png b/apps/admin/src/settings/assets/images/pintura-screenshot.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/pintura-screenshot.png rename to apps/admin/src/settings/assets/images/pintura-screenshot.png diff --git a/apps/admin/src/settings/app/assets/images/portal-splash-default-logo.png b/apps/admin/src/settings/assets/images/portal-splash-default-logo.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/portal-splash-default-logo.png rename to apps/admin/src/settings/assets/images/portal-splash-default-logo.png diff --git a/apps/admin/src/settings/app/assets/images/portal-splash-user-add.png b/apps/admin/src/settings/assets/images/portal-splash-user-add.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/portal-splash-user-add.png rename to apps/admin/src/settings/assets/images/portal-splash-user-add.png diff --git a/apps/admin/src/settings/app/assets/images/stripe-emblem.svg b/apps/admin/src/settings/assets/images/stripe-emblem.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/stripe-emblem.svg rename to apps/admin/src/settings/assets/images/stripe-emblem.svg diff --git a/apps/admin/src/settings/app/assets/images/stripe-thumb.jpg b/apps/admin/src/settings/assets/images/stripe-thumb.jpg similarity index 100% rename from apps/admin/src/settings/app/assets/images/stripe-thumb.jpg rename to apps/admin/src/settings/assets/images/stripe-thumb.jpg diff --git a/apps/admin/src/settings/app/assets/images/stripe-verified.svg b/apps/admin/src/settings/assets/images/stripe-verified.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/stripe-verified.svg rename to apps/admin/src/settings/assets/images/stripe-verified.svg diff --git a/apps/admin/src/settings/app/assets/images/themes/Alto.png b/apps/admin/src/settings/assets/images/themes/Alto.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Alto.png rename to apps/admin/src/settings/assets/images/themes/Alto.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Bulletin.png b/apps/admin/src/settings/assets/images/themes/Bulletin.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Bulletin.png rename to apps/admin/src/settings/assets/images/themes/Bulletin.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Casper.png b/apps/admin/src/settings/assets/images/themes/Casper.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Casper.png rename to apps/admin/src/settings/assets/images/themes/Casper.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Dawn.png b/apps/admin/src/settings/assets/images/themes/Dawn.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Dawn.png rename to apps/admin/src/settings/assets/images/themes/Dawn.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Digest.png b/apps/admin/src/settings/assets/images/themes/Digest.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Digest.png rename to apps/admin/src/settings/assets/images/themes/Digest.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Dope.png b/apps/admin/src/settings/assets/images/themes/Dope.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Dope.png rename to apps/admin/src/settings/assets/images/themes/Dope.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Ease.png b/apps/admin/src/settings/assets/images/themes/Ease.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Ease.png rename to apps/admin/src/settings/assets/images/themes/Ease.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Edge.png b/apps/admin/src/settings/assets/images/themes/Edge.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Edge.png rename to apps/admin/src/settings/assets/images/themes/Edge.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Edition.png b/apps/admin/src/settings/assets/images/themes/Edition.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Edition.png rename to apps/admin/src/settings/assets/images/themes/Edition.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Episode.png b/apps/admin/src/settings/assets/images/themes/Episode.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Episode.png rename to apps/admin/src/settings/assets/images/themes/Episode.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Headline.png b/apps/admin/src/settings/assets/images/themes/Headline.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Headline.png rename to apps/admin/src/settings/assets/images/themes/Headline.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Journal.png b/apps/admin/src/settings/assets/images/themes/Journal.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Journal.png rename to apps/admin/src/settings/assets/images/themes/Journal.png diff --git a/apps/admin/src/settings/app/assets/images/themes/London.png b/apps/admin/src/settings/assets/images/themes/London.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/London.png rename to apps/admin/src/settings/assets/images/themes/London.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Ruby.png b/apps/admin/src/settings/assets/images/themes/Ruby.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Ruby.png rename to apps/admin/src/settings/assets/images/themes/Ruby.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Solo.png b/apps/admin/src/settings/assets/images/themes/Solo.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Solo.png rename to apps/admin/src/settings/assets/images/themes/Solo.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Source-Magazine.png b/apps/admin/src/settings/assets/images/themes/Source-Magazine.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Source-Magazine.png rename to apps/admin/src/settings/assets/images/themes/Source-Magazine.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Source-Newsletter.png b/apps/admin/src/settings/assets/images/themes/Source-Newsletter.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Source-Newsletter.png rename to apps/admin/src/settings/assets/images/themes/Source-Newsletter.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Source.png b/apps/admin/src/settings/assets/images/themes/Source.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Source.png rename to apps/admin/src/settings/assets/images/themes/Source.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Taste.png b/apps/admin/src/settings/assets/images/themes/Taste.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Taste.png rename to apps/admin/src/settings/assets/images/themes/Taste.png diff --git a/apps/admin/src/settings/app/assets/images/themes/Wave.png b/apps/admin/src/settings/assets/images/themes/Wave.png similarity index 100% rename from apps/admin/src/settings/app/assets/images/themes/Wave.png rename to apps/admin/src/settings/assets/images/themes/Wave.png diff --git a/apps/admin/src/settings/app/assets/images/user-cover.jpg b/apps/admin/src/settings/assets/images/user-cover.jpg similarity index 100% rename from apps/admin/src/settings/app/assets/images/user-cover.jpg rename to apps/admin/src/settings/assets/images/user-cover.jpg diff --git a/apps/admin/src/settings/app/assets/images/zapier-logo.svg b/apps/admin/src/settings/assets/images/zapier-logo.svg similarity index 100% rename from apps/admin/src/settings/app/assets/images/zapier-logo.svg rename to apps/admin/src/settings/assets/images/zapier-logo.svg diff --git a/apps/admin/src/settings/app/assets/videos/logo-loader-dark.mp4 b/apps/admin/src/settings/assets/videos/logo-loader-dark.mp4 similarity index 100% rename from apps/admin/src/settings/app/assets/videos/logo-loader-dark.mp4 rename to apps/admin/src/settings/assets/videos/logo-loader-dark.mp4 diff --git a/apps/admin/src/settings/app/assets/videos/logo-loader.mp4 b/apps/admin/src/settings/assets/videos/logo-loader.mp4 similarity index 100% rename from apps/admin/src/settings/app/assets/videos/logo-loader.mp4 rename to apps/admin/src/settings/assets/videos/logo-loader.mp4 diff --git a/apps/admin/src/settings/app/components/code-editor-view.tsx b/apps/admin/src/settings/components/code-editor-view.tsx similarity index 97% rename from apps/admin/src/settings/app/components/code-editor-view.tsx rename to apps/admin/src/settings/components/code-editor-view.tsx index cf4e810a11b..5406912a36b 100644 --- a/apps/admin/src/settings/app/components/code-editor-view.tsx +++ b/apps/admin/src/settings/components/code-editor-view.tsx @@ -67,7 +67,7 @@ const CodeEditorView = forwardRef(function useEffect(() => { let cancelled = false; - Promise.all(extensions).then((nextExtensions) => { + Promise.all(extensions.map(extension => Promise.resolve(extension))).then((nextExtensions) => { if (!cancelled) { setResolvedExtensions(nextExtensions); } diff --git a/apps/admin/src/settings/app/components/code-editor.tsx b/apps/admin/src/settings/components/code-editor.tsx similarity index 100% rename from apps/admin/src/settings/app/components/code-editor.tsx rename to apps/admin/src/settings/components/code-editor.tsx diff --git a/apps/admin/src/settings/app/components/color-picker-field.tsx b/apps/admin/src/settings/components/color-picker-field.tsx similarity index 99% rename from apps/admin/src/settings/app/components/color-picker-field.tsx rename to apps/admin/src/settings/components/color-picker-field.tsx index 2e47bda854f..80d767936ae 100644 --- a/apps/admin/src/settings/app/components/color-picker-field.tsx +++ b/apps/admin/src/settings/components/color-picker-field.tsx @@ -2,7 +2,7 @@ import {ColorPicker, ColorPickerTrigger, type ColorSwatchOption, ColorSwatchRow} import {FieldDescription, Popover, PopoverContent, PopoverTrigger} from '@tryghost/shade/components'; import {Inline} from '@tryghost/shade/primitives'; import {type ReactNode, useCallback, useEffect, useId, useMemo, useRef, useState} from 'react'; -import {debounce} from '@/settings/app/utils/debounce'; +import {debounce} from '@/settings/utils/debounce'; export type ColorPickerFieldSwatch = ColorSwatchOption; diff --git a/apps/admin/src/settings/app/components/color-swatch-field.tsx b/apps/admin/src/settings/components/color-swatch-field.tsx similarity index 100% rename from apps/admin/src/settings/app/components/color-swatch-field.tsx rename to apps/admin/src/settings/components/color-swatch-field.tsx diff --git a/apps/admin/src/settings/app/components/confirmation-modal.test.tsx b/apps/admin/src/settings/components/confirmation-modal.test.tsx similarity index 92% rename from apps/admin/src/settings/app/components/confirmation-modal.test.tsx rename to apps/admin/src/settings/components/confirmation-modal.test.tsx index 39472b55f48..4286a38afdb 100644 --- a/apps/admin/src/settings/app/components/confirmation-modal.test.tsx +++ b/apps/admin/src/settings/components/confirmation-modal.test.tsx @@ -1,6 +1,7 @@ -import {type ConfirmationModalProps} from '@/settings/app/components/confirmation-modal'; +import {type ConfirmationModalProps} from '@/settings/components/confirmation-modal'; import {useEffect} from 'react'; -import {ConfirmationProvider, useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {ConfirmationProvider} from '@/settings/providers/confirmation-provider'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; import {fireEvent, render, screen, waitFor} from '@testing-library/react'; describe('ConfirmationModal', () => { diff --git a/apps/admin/src/settings/app/components/confirmation-modal.tsx b/apps/admin/src/settings/components/confirmation-modal.tsx similarity index 98% rename from apps/admin/src/settings/app/components/confirmation-modal.tsx rename to apps/admin/src/settings/components/confirmation-modal.tsx index 892ea1b62dc..2fe4046e9c8 100644 --- a/apps/admin/src/settings/app/components/confirmation-modal.tsx +++ b/apps/admin/src/settings/components/confirmation-modal.tsx @@ -24,7 +24,7 @@ export interface ConfirmationModalProps { onOk?: (modal?: { remove: () => void; }) => void | Promise; - customFooter?: boolean | React.ReactNode; + customFooter?: React.ReactNode; formSheet?: boolean; stickyFooter?: boolean; testId?: string; @@ -88,7 +88,7 @@ export const ConfirmationModalContent: React.FC )} {okLabel && ( - diff --git a/apps/admin/src/settings/app/components/error-boundary.tsx b/apps/admin/src/settings/components/error-boundary.tsx similarity index 78% rename from apps/admin/src/settings/app/components/error-boundary.tsx rename to apps/admin/src/settings/components/error-boundary.tsx index 82b4a2211aa..10fa7b45265 100644 --- a/apps/admin/src/settings/app/components/error-boundary.tsx +++ b/apps/admin/src/settings/components/error-boundary.tsx @@ -1,5 +1,5 @@ import * as Sentry from '@sentry/react'; -import React, {type ComponentType, type ErrorInfo, type ReactNode} from 'react'; +import React, {type ErrorInfo, type ReactNode} from 'react'; import {Banner} from '@tryghost/shade/components'; export interface ErrorBoundaryProps { @@ -47,13 +47,3 @@ class ErrorBoundary extends React.Component { } export default ErrorBoundary; - -export const withErrorBoundary = >(Component: ComponentType, name: string) => { - return function WithErrorBoundary(props: Props) { - return ( - - - - ); - }; -}; diff --git a/apps/admin/src/settings/app/components/exit-settings-button.tsx b/apps/admin/src/settings/components/exit-settings-button.tsx similarity index 100% rename from apps/admin/src/settings/app/components/exit-settings-button.tsx rename to apps/admin/src/settings/components/exit-settings-button.tsx diff --git a/apps/admin/src/settings/app/components/html-editor.tsx b/apps/admin/src/settings/components/html-editor.tsx similarity index 88% rename from apps/admin/src/settings/app/components/html-editor.tsx rename to apps/admin/src/settings/components/html-editor.tsx index c2b389779ab..a4475a892d7 100644 --- a/apps/admin/src/settings/app/components/html-editor.tsx +++ b/apps/admin/src/settings/components/html-editor.tsx @@ -1,4 +1,4 @@ -import KoenigEditorBase, {type KoenigInstance, type NodeType} from './koenig-editor-base'; +import KoenigEditorBase, {type KoenigLexicalModule, type NodeType} from './koenig-editor-base'; import React, {useCallback} from 'react'; // Re-export for backwards compatibility @@ -27,7 +27,7 @@ const HtmlEditor: React.FC = ({ // Likely related: https://github.com/facebook/lexical/issues/4255 const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); - const elements = doc.querySelectorAll('*') as NodeListOf; + const elements = doc.querySelectorAll('*'); elements.forEach((element) => { element.style.removeProperty('white-space'); @@ -44,7 +44,7 @@ const HtmlEditor: React.FC = ({ return ( - {(koenig: KoenigInstance) => ( + {(koenig: KoenigLexicalModule) => ( )} diff --git a/apps/admin/src/settings/app/components/html-field.tsx b/apps/admin/src/settings/components/html-field.tsx similarity index 100% rename from apps/admin/src/settings/app/components/html-field.tsx rename to apps/admin/src/settings/components/html-field.tsx diff --git a/apps/admin/src/settings/app/components/icons/brand-icon.tsx b/apps/admin/src/settings/components/icons/brand-icon.tsx similarity index 54% rename from apps/admin/src/settings/app/components/icons/brand-icon.tsx rename to apps/admin/src/settings/components/icons/brand-icon.tsx index 33589e65ddd..9920cf8b74c 100644 --- a/apps/admin/src/settings/app/components/icons/brand-icon.tsx +++ b/apps/admin/src/settings/components/icons/brand-icon.tsx @@ -1,24 +1,24 @@ -import Beehiiv from '@/settings/app/assets/images/brand-icons/beehiiv.svg'; -import Facebook from '@/settings/app/assets/images/brand-icons/facebook.svg'; -import FirstPromoter from '@/settings/app/assets/images/brand-icons/firstpromoter.svg'; -import Linkedin from '@/settings/app/assets/images/brand-icons/linkedin.svg'; -import Mailchimp from '@/settings/app/assets/images/brand-icons/mailchimp.svg'; -import Medium from '@/settings/app/assets/images/brand-icons/medium.svg'; -import Pintura from '@/settings/app/assets/images/brand-icons/pintura.svg'; -import PortalIcon1 from '@/settings/app/assets/images/brand-icons/portal-icon-1.svg'; -import PortalIcon2 from '@/settings/app/assets/images/brand-icons/portal-icon-2.svg'; -import PortalIcon3 from '@/settings/app/assets/images/brand-icons/portal-icon-3.svg'; -import PortalIcon4 from '@/settings/app/assets/images/brand-icons/portal-icon-4.svg'; -import PortalIcon5 from '@/settings/app/assets/images/brand-icons/portal-icon-5.svg'; +import Beehiiv from '@/settings/assets/images/brand-icons/beehiiv.svg'; +import Facebook from '@/settings/assets/images/brand-icons/facebook.svg'; +import FirstPromoter from '@/settings/assets/images/brand-icons/firstpromoter.svg'; +import Linkedin from '@/settings/assets/images/brand-icons/linkedin.svg'; +import Mailchimp from '@/settings/assets/images/brand-icons/mailchimp.svg'; +import Medium from '@/settings/assets/images/brand-icons/medium.svg'; +import Pintura from '@/settings/assets/images/brand-icons/pintura.svg'; +import PortalIcon1 from '@/settings/assets/images/brand-icons/portal-icon-1.svg'; +import PortalIcon2 from '@/settings/assets/images/brand-icons/portal-icon-2.svg'; +import PortalIcon3 from '@/settings/assets/images/brand-icons/portal-icon-3.svg'; +import PortalIcon4 from '@/settings/assets/images/brand-icons/portal-icon-4.svg'; +import PortalIcon5 from '@/settings/assets/images/brand-icons/portal-icon-5.svg'; import React from 'react'; -import Slack from '@/settings/app/assets/images/brand-icons/slack.svg'; -import Squarespace from '@/settings/app/assets/images/brand-icons/squarespace.svg'; -import Substack from '@/settings/app/assets/images/brand-icons/substack.svg'; -import Transistor from '@/settings/app/assets/images/brand-icons/transistor.svg'; -import TwitterX from '@/settings/app/assets/images/brand-icons/twitter-x.svg'; -import Unsplash from '@/settings/app/assets/images/brand-icons/unsplash.svg'; -import Wordpress from '@/settings/app/assets/images/brand-icons/wordpress.svg'; -import Zapier from '@/settings/app/assets/images/brand-icons/zapier.svg'; +import Slack from '@/settings/assets/images/brand-icons/slack.svg'; +import Squarespace from '@/settings/assets/images/brand-icons/squarespace.svg'; +import Substack from '@/settings/assets/images/brand-icons/substack.svg'; +import Transistor from '@/settings/assets/images/brand-icons/transistor.svg'; +import TwitterX from '@/settings/assets/images/brand-icons/twitter-x.svg'; +import Unsplash from '@/settings/assets/images/brand-icons/unsplash.svg'; +import Wordpress from '@/settings/assets/images/brand-icons/wordpress.svg'; +import Zapier from '@/settings/assets/images/brand-icons/zapier.svg'; import clsx from 'clsx'; const icons = { diff --git a/apps/admin/src/settings/app/components/infinite-scroll-listener.tsx b/apps/admin/src/settings/components/infinite-scroll-listener.tsx similarity index 100% rename from apps/admin/src/settings/app/components/infinite-scroll-listener.tsx rename to apps/admin/src/settings/components/infinite-scroll-listener.tsx diff --git a/apps/admin/src/settings/app/components/koenig-editor-base.tsx b/apps/admin/src/settings/components/koenig-editor-base.tsx similarity index 77% rename from apps/admin/src/settings/app/components/koenig-editor-base.tsx rename to apps/admin/src/settings/components/koenig-editor-base.tsx index 3c70c76db95..f6ef6731c7c 100644 --- a/apps/admin/src/settings/app/components/koenig-editor-base.tsx +++ b/apps/admin/src/settings/components/koenig-editor-base.tsx @@ -1,9 +1,10 @@ import * as Sentry from '@sentry/react'; import ErrorBoundary from './error-boundary'; import React, {Suspense, useCallback, useMemo} from 'react'; -import {type FetchKoenigLexical, useFocusContext} from '@tryghost/shade/app'; +import {type EditorResource, type KoenigInstance, type KoenigLexicalModule, loadKoenig} from './koenig-loader'; +import {useFocusContext} from '@tryghost/shade/app'; -export type {FetchKoenigLexical}; +export type {FetchKoenigLexical, KoenigInstance, KoenigLexicalModule} from './koenig-loader'; export type NodeType = 'DEFAULT_NODES' | 'BASIC_NODES' | 'MINIMAL_NODES' | 'EMAIL_NODES' | 'EMAIL_EDITOR_NODES'; @@ -25,61 +26,11 @@ export interface KoenigEditorBaseProps { registerAPI?: (API: KoenigInstance | null) => void } -declare global { - interface Window { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - '@tryghost/koenig-lexical': any; - } -} - -export type KoenigInstance = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [key: string]: any - editorInstance: { - getRootElement: () => HTMLElement | null - } - focusEditor: (options?: {position?: 'top' | 'bottom'}) => void - insertParagraphAtBottom: () => void - lastNodeIsDecorator: () => boolean -}; - -export const loadKoenig = function (fetchKoenigLexical: FetchKoenigLexical) { - let status = 'pending'; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let response: any; - - const suspender = fetchKoenigLexical().then( - (res) => { - status = 'success'; - response = res; - }, - (err) => { - status = 'error'; - response = err; - } - ); - - const read = () => { - switch (status) { - case 'pending': - throw suspender; - case 'error': - throw response; - default: - return response; - } - }; - - return {read}; -}; - -type EditorResource = ReturnType; - interface KoenigWrapperProps extends KoenigEditorBaseProps { editor: EditorResource // For HtmlEditor: render prop that returns HtmlOutputPlugin // For MinimalEditor: render prop that returns null (uses native onChange) - children: (koenig: KoenigInstance) => React.ReactNode + children: (koenig: KoenigLexicalModule) => React.ReactNode // For MinimalEditor: initial lexical state initialEditorState?: string // For MinimalEditor: native onChange handler @@ -131,9 +82,9 @@ export const KoenigWrapper: React.FC = ({ setFocusState(true); }; - const koenig = useMemo(() => new Proxy({} as KoenigInstance, { + const koenig = useMemo(() => new Proxy({} as KoenigLexicalModule, { get: (_target, prop) => { - return editor.read()[prop]; + return editor.read()[prop as keyof KoenigLexicalModule]; } }), [editor]); @@ -176,7 +127,7 @@ export const KoenigWrapper: React.FC = ({ }; interface KoenigEditorBaseInternalProps extends KoenigEditorBaseProps { - children: (koenig: KoenigInstance) => React.ReactNode + children: (koenig: KoenigLexicalModule) => React.ReactNode initialEditorState?: string onChange?: (editorState: unknown) => void } diff --git a/apps/admin/src/settings/components/koenig-loader.ts b/apps/admin/src/settings/components/koenig-loader.ts new file mode 100644 index 00000000000..39d0626e7f2 --- /dev/null +++ b/apps/admin/src/settings/components/koenig-loader.ts @@ -0,0 +1,75 @@ +import type React from 'react'; +import {type FetchKoenigLexical} from '@tryghost/shade/app'; + +export type {FetchKoenigLexical}; + +declare global { + interface Window { + '@tryghost/koenig-lexical'?: {version?: string}; + } +} + +type KoenigComponent = React.ComponentType>; + +// Minimal surface of the untyped @tryghost/koenig-lexical bundle used by the settings editors +export type KoenigLexicalModule = { + KoenigComposer: KoenigComponent; + KoenigComposableEditor: KoenigComponent; + EmojiPickerPlugin: KoenigComponent; + HtmlOutputPlugin: KoenigComponent; + EmailEditor: KoenigComponent; + DEFAULT_NODES: unknown; + BASIC_NODES: unknown; + MINIMAL_NODES: unknown; + EMAIL_NODES: unknown; + EMAIL_EDITOR_NODES: unknown; + DEFAULT_TRANSFORMERS: unknown; + BASIC_TRANSFORMERS: unknown; + MINIMAL_TRANSFORMERS: unknown; + EMAIL_TRANSFORMERS: unknown; +}; + +export type KoenigInstance = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any + editorInstance: { + getRootElement: () => HTMLElement | null + } + focusEditor: (options?: {position?: 'top' | 'bottom'}) => void + insertParagraphAtBottom: () => void + lastNodeIsDecorator: () => boolean +}; + +export const loadKoenig = function (fetchKoenigLexical: FetchKoenigLexical) { + let status: 'pending' | 'success' | 'error' = 'pending'; + let response: KoenigLexicalModule | undefined; + let error: unknown; + + const suspender = fetchKoenigLexical().then( + (res) => { + status = 'success'; + response = res as KoenigLexicalModule; + }, + (err: unknown) => { + status = 'error'; + error = err; + } + ); + + const read = (): KoenigLexicalModule => { + switch (status) { + case 'pending': + // React Suspense protocol: throwing the pending promise suspends the tree + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw suspender; + case 'error': + throw error instanceof Error ? error : new Error(String(error)); + default: + return response!; + } + }; + + return {read}; +}; + +export type EditorResource = ReturnType; diff --git a/apps/admin/src/settings/app/components/limit-modal.test.tsx b/apps/admin/src/settings/components/limit-modal.test.tsx similarity index 85% rename from apps/admin/src/settings/app/components/limit-modal.test.tsx rename to apps/admin/src/settings/components/limit-modal.test.tsx index fc3e6058be1..cd904785a80 100644 --- a/apps/admin/src/settings/app/components/limit-modal.test.tsx +++ b/apps/admin/src/settings/components/limit-modal.test.tsx @@ -1,6 +1,7 @@ -import {type LimitModalProps} from '@/settings/app/components/limit-modal'; +import {type LimitModalProps} from '@/settings/components/limit-modal'; import {useEffect} from 'react'; -import {ConfirmationProvider, useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {ConfirmationProvider} from '@/settings/providers/confirmation-provider'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; import {fireEvent, render, screen, waitFor} from '@testing-library/react'; describe('LimitModal', () => { diff --git a/apps/admin/src/settings/app/components/limit-modal.tsx b/apps/admin/src/settings/components/limit-modal.tsx similarity index 100% rename from apps/admin/src/settings/app/components/limit-modal.tsx rename to apps/admin/src/settings/components/limit-modal.tsx diff --git a/apps/admin/src/settings/app/components/settings/preview-modal.tsx b/apps/admin/src/settings/components/preview-modal.tsx similarity index 99% rename from apps/admin/src/settings/app/components/settings/preview-modal.tsx rename to apps/admin/src/settings/components/preview-modal.tsx index 894853bb720..453f5f06622 100644 --- a/apps/admin/src/settings/app/components/settings/preview-modal.tsx +++ b/apps/admin/src/settings/components/preview-modal.tsx @@ -46,7 +46,7 @@ export interface PreviewModalProps { size?: SettingsModalSize; width?: 'full' | number; height?: 'full' | number; - sidebar?: boolean | React.ReactNode; + sidebar?: React.ReactNode; preview?: React.ReactNode; dirty?: boolean; cancelLabel?: string; diff --git a/apps/admin/src/settings/app/components/searchable-section.tsx b/apps/admin/src/settings/components/searchable-section.tsx similarity index 92% rename from apps/admin/src/settings/app/components/searchable-section.tsx rename to apps/admin/src/settings/components/searchable-section.tsx index 64fc756402a..3a48eb4ef43 100644 --- a/apps/admin/src/settings/app/components/searchable-section.tsx +++ b/apps/admin/src/settings/components/searchable-section.tsx @@ -1,5 +1,5 @@ import {Box, Stack, Text} from '@tryghost/shade/primitives'; -import {useSearch} from './providers/settings-app-provider'; +import {useSearch} from '@/settings/providers/settings-app-context'; interface SearchableSectionProps { children?: React.ReactNode; diff --git a/apps/admin/src/settings/app/components/selectors/unsplash-selector.tsx b/apps/admin/src/settings/components/selectors/unsplash-selector.tsx similarity index 87% rename from apps/admin/src/settings/app/components/selectors/unsplash-selector.tsx rename to apps/admin/src/settings/components/selectors/unsplash-selector.tsx index ccd22103189..57d292e34c2 100644 --- a/apps/admin/src/settings/app/components/selectors/unsplash-selector.tsx +++ b/apps/admin/src/settings/components/selectors/unsplash-selector.tsx @@ -1,4 +1,4 @@ -import Portal from '@/settings/app/utils/portal'; +import Portal from '@/settings/utils/portal'; import React from 'react'; import {type DefaultHeaderTypes, type InsertImagePayload, UnsplashSearchModal} from '@tryghost/kg-unsplash-selector'; @@ -10,7 +10,7 @@ type UnsplashSelectorModalProps = { const UnsplashSelector : React.FC = ({unsplashProviderConfig, onClose, onImageInsert}) => { return ( - + >(Component: ComponentType, name: string) => { + return function WithErrorBoundary(props: Props) { + return ( + + + + ); + }; +}; diff --git a/apps/admin/src/settings/app/data/official-themes.ts b/apps/admin/src/settings/data/official-themes.ts similarity index 70% rename from apps/admin/src/settings/app/data/official-themes.ts rename to apps/admin/src/settings/data/official-themes.ts index 65d4e3caa20..ec3b89e7a68 100644 --- a/apps/admin/src/settings/app/data/official-themes.ts +++ b/apps/admin/src/settings/data/official-themes.ts @@ -1,26 +1,26 @@ -import {type OfficialTheme} from '@/settings/app/components/providers/settings-app-provider'; +import {type OfficialTheme} from '@/settings/providers/settings-app-context'; // Theme images -import AltoImage from '@/settings/app/assets/images/themes/Alto.png'; -import BulletinImage from '@/settings/app/assets/images/themes/Bulletin.png'; -import CasperImage from '@/settings/app/assets/images/themes/Casper.png'; -import DawnImage from '@/settings/app/assets/images/themes/Dawn.png'; -import DigestImage from '@/settings/app/assets/images/themes/Digest.png'; -import DopeImage from '@/settings/app/assets/images/themes/Dope.png'; -import EaseImage from '@/settings/app/assets/images/themes/Ease.png'; -import EdgeImage from '@/settings/app/assets/images/themes/Edge.png'; -import EditionImage from '@/settings/app/assets/images/themes/Edition.png'; -import EpisodeImage from '@/settings/app/assets/images/themes/Episode.png'; -import HeadlineImage from '@/settings/app/assets/images/themes/Headline.png'; -import JournalImage from '@/settings/app/assets/images/themes/Journal.png'; -import LondonImage from '@/settings/app/assets/images/themes/London.png'; -import RubyImage from '@/settings/app/assets/images/themes/Ruby.png'; -import SoloImage from '@/settings/app/assets/images/themes/Solo.png'; -import SourceImage from '@/settings/app/assets/images/themes/Source.png'; -import SourceMagazineImage from '@/settings/app/assets/images/themes/Source-Magazine.png'; -import SourceNewsletterImage from '@/settings/app/assets/images/themes/Source-Newsletter.png'; -import TasteImage from '@/settings/app/assets/images/themes/Taste.png'; -import WaveImage from '@/settings/app/assets/images/themes/Wave.png'; +import AltoImage from '@/settings/assets/images/themes/Alto.png'; +import BulletinImage from '@/settings/assets/images/themes/Bulletin.png'; +import CasperImage from '@/settings/assets/images/themes/Casper.png'; +import DawnImage from '@/settings/assets/images/themes/Dawn.png'; +import DigestImage from '@/settings/assets/images/themes/Digest.png'; +import DopeImage from '@/settings/assets/images/themes/Dope.png'; +import EaseImage from '@/settings/assets/images/themes/Ease.png'; +import EdgeImage from '@/settings/assets/images/themes/Edge.png'; +import EditionImage from '@/settings/assets/images/themes/Edition.png'; +import EpisodeImage from '@/settings/assets/images/themes/Episode.png'; +import HeadlineImage from '@/settings/assets/images/themes/Headline.png'; +import JournalImage from '@/settings/assets/images/themes/Journal.png'; +import LondonImage from '@/settings/assets/images/themes/London.png'; +import RubyImage from '@/settings/assets/images/themes/Ruby.png'; +import SoloImage from '@/settings/assets/images/themes/Solo.png'; +import SourceImage from '@/settings/assets/images/themes/Source.png'; +import SourceMagazineImage from '@/settings/assets/images/themes/Source-Magazine.png'; +import SourceNewsletterImage from '@/settings/assets/images/themes/Source-Newsletter.png'; +import TasteImage from '@/settings/assets/images/themes/Taste.png'; +import WaveImage from '@/settings/assets/images/themes/Wave.png'; export const officialThemes: OfficialTheme[] = [{ name: 'Source', diff --git a/apps/admin/src/settings/app/data/zapier-templates.ts b/apps/admin/src/settings/data/zapier-templates.ts similarity index 59% rename from apps/admin/src/settings/app/data/zapier-templates.ts rename to apps/admin/src/settings/data/zapier-templates.ts index d028eca40ed..69d01b90e99 100644 --- a/apps/admin/src/settings/app/data/zapier-templates.ts +++ b/apps/admin/src/settings/data/zapier-templates.ts @@ -1,20 +1,20 @@ -import {type ZapierTemplate} from '@/settings/app/components/settings/advanced/integrations/zapier-modal'; +import {type ZapierTemplate} from '@/settings/advanced/integrations/zapier-modal'; // Ghost logo images -import OrbBlack1 from '@/settings/app/assets/images/logos/orb-black-1.png'; -import OrbBlack2 from '@/settings/app/assets/images/logos/orb-black-2.png'; -import OrbBlack3 from '@/settings/app/assets/images/logos/orb-black-3.png'; -import OrbBlack4 from '@/settings/app/assets/images/logos/orb-black-4.png'; -import OrbBlack5 from '@/settings/app/assets/images/logos/orb-black-5.png'; +import OrbBlack1 from '@/settings/assets/images/logos/orb-black-1.png'; +import OrbBlack2 from '@/settings/assets/images/logos/orb-black-2.png'; +import OrbBlack3 from '@/settings/assets/images/logos/orb-black-3.png'; +import OrbBlack4 from '@/settings/assets/images/logos/orb-black-4.png'; +import OrbBlack5 from '@/settings/assets/images/logos/orb-black-5.png'; // Integration app images -import GoogleDocsIcon from '@/settings/app/assets/images/integrations/google-docs.svg'; -import MailchimpIcon from '@/settings/app/assets/images/integrations/mailchimp.svg'; -import PatreonIcon from '@/settings/app/assets/images/integrations/patreon.svg'; -import PaypalIcon from '@/settings/app/assets/images/integrations/paypal.svg'; -import SlackIcon from '@/settings/app/assets/images/integrations/slackicon.png'; -import TypeformIcon from '@/settings/app/assets/images/integrations/typeform.svg'; -import ZeroBounceIcon from '@/settings/app/assets/images/integrations/zero-bounce.png'; +import GoogleDocsIcon from '@/settings/assets/images/integrations/google-docs.svg'; +import MailchimpIcon from '@/settings/assets/images/integrations/mailchimp.svg'; +import PatreonIcon from '@/settings/assets/images/integrations/patreon.svg'; +import PaypalIcon from '@/settings/assets/images/integrations/paypal.svg'; +import SlackIcon from '@/settings/assets/images/integrations/slackicon.png'; +import TypeformIcon from '@/settings/assets/images/integrations/typeform.svg'; +import ZeroBounceIcon from '@/settings/assets/images/integrations/zero-bounce.png'; export const zapierTemplates: ZapierTemplate[] = [{ ghostImage: OrbBlack2, diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-fields/background-color-field.tsx b/apps/admin/src/settings/email-design/design-fields/background-color-field.tsx similarity index 74% rename from apps/admin/src/settings/app/components/settings/email-design/design-fields/background-color-field.tsx rename to apps/admin/src/settings/email-design/design-fields/background-color-field.tsx index 56eab541b02..7b6d08649b1 100644 --- a/apps/admin/src/settings/app/components/settings/email-design/design-fields/background-color-field.tsx +++ b/apps/admin/src/settings/email-design/design-fields/background-color-field.tsx @@ -1,5 +1,5 @@ -import ColorPickerField from '@/settings/app/components/color-picker-field'; -import {useEmailDesign} from '@/settings/app/components/settings/email-design/email-design-context'; +import ColorPickerField from '@/settings/components/color-picker-field'; +import {useEmailDesign} from '@/settings/email-design/email-design-context'; export const BackgroundColorField = () => { const {settings, onSettingsChange} = useEmailDesign(); diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-fields/body-font-field.tsx b/apps/admin/src/settings/email-design/design-fields/body-font-field.tsx similarity index 91% rename from apps/admin/src/settings/app/components/settings/email-design/design-fields/body-font-field.tsx rename to apps/admin/src/settings/email-design/design-fields/body-font-field.tsx index f48b0102231..624910c53fe 100644 --- a/apps/admin/src/settings/app/components/settings/email-design/design-fields/body-font-field.tsx +++ b/apps/admin/src/settings/email-design/design-fields/body-font-field.tsx @@ -1,6 +1,6 @@ import {FONT_OPTIONS} from './font-constants'; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@tryghost/shade/components'; -import {useEmailDesign} from '@/settings/app/components/settings/email-design/email-design-context'; +import {useEmailDesign} from '@/settings/email-design/email-design-context'; export const BodyFontField = () => { const {settings, onSettingsChange} = useEmailDesign(); diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-fields/button-color-field.tsx b/apps/admin/src/settings/email-design/design-fields/button-color-field.tsx similarity index 82% rename from apps/admin/src/settings/app/components/settings/email-design/design-fields/button-color-field.tsx rename to apps/admin/src/settings/email-design/design-fields/button-color-field.tsx index 8811ed0c286..2999399b5d3 100644 --- a/apps/admin/src/settings/app/components/settings/email-design/design-fields/button-color-field.tsx +++ b/apps/admin/src/settings/email-design/design-fields/button-color-field.tsx @@ -1,6 +1,6 @@ -import ColorPickerField from '@/settings/app/components/color-picker-field'; +import ColorPickerField from '@/settings/components/color-picker-field'; import {getAutoSwatchHex} from './color-swatch-helpers'; -import {useEmailDesign} from '@/settings/app/components/settings/email-design/email-design-context'; +import {useEmailDesign} from '@/settings/email-design/email-design-context'; export const ButtonColorField = () => { const {settings, onSettingsChange, accentColor} = useEmailDesign(); diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-fields/button-corners-field.tsx b/apps/admin/src/settings/email-design/design-fields/button-corners-field.tsx similarity index 90% rename from apps/admin/src/settings/app/components/settings/email-design/design-fields/button-corners-field.tsx rename to apps/admin/src/settings/email-design/design-fields/button-corners-field.tsx index ff6a0dbcfd1..1398b946c55 100644 --- a/apps/admin/src/settings/app/components/settings/email-design/design-fields/button-corners-field.tsx +++ b/apps/admin/src/settings/email-design/design-fields/button-corners-field.tsx @@ -1,5 +1,5 @@ import {ToggleGroup, ToggleGroupItem} from '@tryghost/shade/components'; -import {useEmailDesign} from '@/settings/app/components/settings/email-design/email-design-context'; +import {useEmailDesign} from '@/settings/email-design/email-design-context'; export const ButtonCornersField = () => { const {settings, onSettingsChange} = useEmailDesign(); diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-fields/button-style-field.tsx b/apps/admin/src/settings/email-design/design-fields/button-style-field.tsx similarity index 88% rename from apps/admin/src/settings/app/components/settings/email-design/design-fields/button-style-field.tsx rename to apps/admin/src/settings/email-design/design-fields/button-style-field.tsx index 0eb524afef0..93fc99392df 100644 --- a/apps/admin/src/settings/app/components/settings/email-design/design-fields/button-style-field.tsx +++ b/apps/admin/src/settings/email-design/design-fields/button-style-field.tsx @@ -1,5 +1,5 @@ import {ToggleGroup, ToggleGroupItem} from '@tryghost/shade/components'; -import {useEmailDesign} from '@/settings/app/components/settings/email-design/email-design-context'; +import {useEmailDesign} from '@/settings/email-design/email-design-context'; export const ButtonStyleField = () => { const {settings, onSettingsChange} = useEmailDesign(); diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-fields/color-fields.test.tsx b/apps/admin/src/settings/email-design/design-fields/color-fields.test.tsx similarity index 89% rename from apps/admin/src/settings/app/components/settings/email-design/design-fields/color-fields.test.tsx rename to apps/admin/src/settings/email-design/design-fields/color-fields.test.tsx index f5248ca61ef..771e760de6f 100644 --- a/apps/admin/src/settings/app/components/settings/email-design/design-fields/color-fields.test.tsx +++ b/apps/admin/src/settings/email-design/design-fields/color-fields.test.tsx @@ -1,13 +1,13 @@ import assert from 'node:assert/strict'; -import {BackgroundColorField} from '@/settings/app/components/settings/email-design/design-fields/background-color-field'; -import {ButtonColorField} from '@/settings/app/components/settings/email-design/design-fields/button-color-field'; -import {DEFAULT_EMAIL_DESIGN} from '@/settings/app/components/settings/email-design/types'; -import {DividerColorField} from '@/settings/app/components/settings/email-design/design-fields/divider-color-field'; -import {EmailDesignProvider} from '@/settings/app/components/settings/email-design/email-design-context'; -import {HeaderBackgroundField} from '@/settings/app/components/settings/email-design/design-fields/header-background-field'; -import {LinkColorField} from '@/settings/app/components/settings/email-design/design-fields/link-color-field'; +import {BackgroundColorField} from '@/settings/email-design/design-fields/background-color-field'; +import {ButtonColorField} from '@/settings/email-design/design-fields/button-color-field'; +import {DEFAULT_EMAIL_DESIGN} from '@/settings/email-design/types'; +import {DividerColorField} from '@/settings/email-design/design-fields/divider-color-field'; +import {EmailDesignProvider} from '@/settings/email-design/email-design-provider'; +import {HeaderBackgroundField} from '@/settings/email-design/design-fields/header-background-field'; +import {LinkColorField} from '@/settings/email-design/design-fields/link-color-field'; import {type ReactNode} from 'react'; -import {SectionTitleColorField} from '@/settings/app/components/settings/email-design/design-fields/section-title-color-field'; +import {SectionTitleColorField} from '@/settings/email-design/design-fields/section-title-color-field'; import {act, fireEvent, render, screen} from '@testing-library/react'; type EmailDesignUpdate = Partial; diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-fields/color-swatch-helpers.ts b/apps/admin/src/settings/email-design/design-fields/color-swatch-helpers.ts similarity index 100% rename from apps/admin/src/settings/app/components/settings/email-design/design-fields/color-swatch-helpers.ts rename to apps/admin/src/settings/email-design/design-fields/color-swatch-helpers.ts diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-fields/divider-color-field.tsx b/apps/admin/src/settings/email-design/design-fields/divider-color-field.tsx similarity index 79% rename from apps/admin/src/settings/app/components/settings/email-design/design-fields/divider-color-field.tsx rename to apps/admin/src/settings/email-design/design-fields/divider-color-field.tsx index d63cbc84aa4..78895f0b148 100644 --- a/apps/admin/src/settings/app/components/settings/email-design/design-fields/divider-color-field.tsx +++ b/apps/admin/src/settings/email-design/design-fields/divider-color-field.tsx @@ -1,5 +1,5 @@ -import ColorPickerField from '@/settings/app/components/color-picker-field'; -import {useEmailDesign} from '@/settings/app/components/settings/email-design/email-design-context'; +import ColorPickerField from '@/settings/components/color-picker-field'; +import {useEmailDesign} from '@/settings/email-design/email-design-context'; export const DividerColorField = () => { const {settings, onSettingsChange, accentColor} = useEmailDesign(); diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-fields/font-constants.ts b/apps/admin/src/settings/email-design/design-fields/font-constants.ts similarity index 100% rename from apps/admin/src/settings/app/components/settings/email-design/design-fields/font-constants.ts rename to apps/admin/src/settings/email-design/design-fields/font-constants.ts diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-fields/header-background-field.tsx b/apps/admin/src/settings/email-design/design-fields/header-background-field.tsx similarity index 75% rename from apps/admin/src/settings/app/components/settings/email-design/design-fields/header-background-field.tsx rename to apps/admin/src/settings/email-design/design-fields/header-background-field.tsx index f6ea3c767a1..88702112329 100644 --- a/apps/admin/src/settings/app/components/settings/email-design/design-fields/header-background-field.tsx +++ b/apps/admin/src/settings/email-design/design-fields/header-background-field.tsx @@ -1,5 +1,5 @@ -import ColorPickerField from '@/settings/app/components/color-picker-field'; -import {useEmailDesign} from '@/settings/app/components/settings/email-design/email-design-context'; +import ColorPickerField from '@/settings/components/color-picker-field'; +import {useEmailDesign} from '@/settings/email-design/email-design-context'; export const HeaderBackgroundField = () => { const {settings, onSettingsChange} = useEmailDesign(); diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-fields/heading-font-field.tsx b/apps/admin/src/settings/email-design/design-fields/heading-font-field.tsx similarity index 92% rename from apps/admin/src/settings/app/components/settings/email-design/design-fields/heading-font-field.tsx rename to apps/admin/src/settings/email-design/design-fields/heading-font-field.tsx index 2fd6abdb609..18834f787e3 100644 --- a/apps/admin/src/settings/app/components/settings/email-design/design-fields/heading-font-field.tsx +++ b/apps/admin/src/settings/email-design/design-fields/heading-font-field.tsx @@ -1,6 +1,6 @@ import {FONT_OPTIONS, getValidWeight} from './font-constants'; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@tryghost/shade/components'; -import {useEmailDesign} from '@/settings/app/components/settings/email-design/email-design-context'; +import {useEmailDesign} from '@/settings/email-design/email-design-context'; export const HeadingFontField = () => { const {settings, onSettingsChange} = useEmailDesign(); diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-fields/heading-weight-field.tsx b/apps/admin/src/settings/email-design/design-fields/heading-weight-field.tsx similarity index 92% rename from apps/admin/src/settings/app/components/settings/email-design/design-fields/heading-weight-field.tsx rename to apps/admin/src/settings/email-design/design-fields/heading-weight-field.tsx index 72e8b340b43..1d19b293ac8 100644 --- a/apps/admin/src/settings/app/components/settings/email-design/design-fields/heading-weight-field.tsx +++ b/apps/admin/src/settings/email-design/design-fields/heading-weight-field.tsx @@ -1,6 +1,6 @@ import {FONT_WEIGHT_OPTIONS, getValidWeight} from './font-constants'; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@tryghost/shade/components'; -import {useEmailDesign} from '@/settings/app/components/settings/email-design/email-design-context'; +import {useEmailDesign} from '@/settings/email-design/email-design-context'; export const HeadingWeightField = () => { const {settings, onSettingsChange} = useEmailDesign(); diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-fields/image-corners-field.tsx b/apps/admin/src/settings/email-design/design-fields/image-corners-field.tsx similarity index 89% rename from apps/admin/src/settings/app/components/settings/email-design/design-fields/image-corners-field.tsx rename to apps/admin/src/settings/email-design/design-fields/image-corners-field.tsx index 6c0be91c2a9..a16de048961 100644 --- a/apps/admin/src/settings/app/components/settings/email-design/design-fields/image-corners-field.tsx +++ b/apps/admin/src/settings/email-design/design-fields/image-corners-field.tsx @@ -1,5 +1,5 @@ import {ToggleGroup, ToggleGroupItem} from '@tryghost/shade/components'; -import {useEmailDesign} from '@/settings/app/components/settings/email-design/email-design-context'; +import {useEmailDesign} from '@/settings/email-design/email-design-context'; export const ImageCornersField = () => { const {settings, onSettingsChange} = useEmailDesign(); diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-fields/index.ts b/apps/admin/src/settings/email-design/design-fields/index.ts similarity index 100% rename from apps/admin/src/settings/app/components/settings/email-design/design-fields/index.ts rename to apps/admin/src/settings/email-design/design-fields/index.ts diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-fields/link-color-field.tsx b/apps/admin/src/settings/email-design/design-fields/link-color-field.tsx similarity index 81% rename from apps/admin/src/settings/app/components/settings/email-design/design-fields/link-color-field.tsx rename to apps/admin/src/settings/email-design/design-fields/link-color-field.tsx index 16b29ad75d6..8e6962dd116 100644 --- a/apps/admin/src/settings/app/components/settings/email-design/design-fields/link-color-field.tsx +++ b/apps/admin/src/settings/email-design/design-fields/link-color-field.tsx @@ -1,6 +1,6 @@ -import ColorPickerField from '@/settings/app/components/color-picker-field'; +import ColorPickerField from '@/settings/components/color-picker-field'; import {getAutoSwatchHex} from './color-swatch-helpers'; -import {useEmailDesign} from '@/settings/app/components/settings/email-design/email-design-context'; +import {useEmailDesign} from '@/settings/email-design/email-design-context'; export const LinkColorField = () => { const {settings, onSettingsChange, accentColor} = useEmailDesign(); diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-fields/link-style-field.tsx b/apps/admin/src/settings/email-design/design-fields/link-style-field.tsx similarity index 89% rename from apps/admin/src/settings/app/components/settings/email-design/design-fields/link-style-field.tsx rename to apps/admin/src/settings/email-design/design-fields/link-style-field.tsx index d03afc17f53..d1be2554678 100644 --- a/apps/admin/src/settings/app/components/settings/email-design/design-fields/link-style-field.tsx +++ b/apps/admin/src/settings/email-design/design-fields/link-style-field.tsx @@ -1,6 +1,6 @@ import {Bold, Type, Underline} from 'lucide-react'; import {ToggleGroup, ToggleGroupItem} from '@tryghost/shade/components'; -import {useEmailDesign} from '@/settings/app/components/settings/email-design/email-design-context'; +import {useEmailDesign} from '@/settings/email-design/email-design-context'; export const LinkStyleField = () => { const {settings, onSettingsChange} = useEmailDesign(); diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-fields/section-title-color-field.tsx b/apps/admin/src/settings/email-design/design-fields/section-title-color-field.tsx similarity index 82% rename from apps/admin/src/settings/app/components/settings/email-design/design-fields/section-title-color-field.tsx rename to apps/admin/src/settings/email-design/design-fields/section-title-color-field.tsx index 5d873932909..410b61d53ef 100644 --- a/apps/admin/src/settings/app/components/settings/email-design/design-fields/section-title-color-field.tsx +++ b/apps/admin/src/settings/email-design/design-fields/section-title-color-field.tsx @@ -1,6 +1,6 @@ -import ColorPickerField from '@/settings/app/components/color-picker-field'; +import ColorPickerField from '@/settings/components/color-picker-field'; import {getAutoSwatchHex} from './color-swatch-helpers'; -import {useEmailDesign} from '@/settings/app/components/settings/email-design/email-design-context'; +import {useEmailDesign} from '@/settings/email-design/email-design-context'; export const SectionTitleColorField = () => { const {settings, onSettingsChange, accentColor} = useEmailDesign(); diff --git a/apps/admin/src/settings/app/components/settings/email-design/design-utils.ts b/apps/admin/src/settings/email-design/design-utils.ts similarity index 100% rename from apps/admin/src/settings/app/components/settings/email-design/design-utils.ts rename to apps/admin/src/settings/email-design/design-utils.ts diff --git a/apps/admin/src/settings/email-design/email-design-context.ts b/apps/admin/src/settings/email-design/email-design-context.ts new file mode 100644 index 00000000000..3b1f3bb806e --- /dev/null +++ b/apps/admin/src/settings/email-design/email-design-context.ts @@ -0,0 +1,18 @@ +import {createContext, useContext} from 'react'; +import type {EmailDesignSettings} from './types'; + +export interface EmailDesignContextValue { + settings: EmailDesignSettings; + onSettingsChange: (updates: Partial) => void; + accentColor: string; +} + +export const EmailDesignContext = createContext(undefined); + +export const useEmailDesign = (): EmailDesignContextValue => { + const ctx = useContext(EmailDesignContext); + if (!ctx) { + throw new Error('useEmailDesign must be used within an EmailDesignProvider'); + } + return ctx; +}; diff --git a/apps/admin/src/settings/app/components/settings/email-design/email-design-modal.tsx b/apps/admin/src/settings/email-design/email-design-modal.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/email-design/email-design-modal.tsx rename to apps/admin/src/settings/email-design/email-design-modal.tsx diff --git a/apps/admin/src/settings/email-design/email-design-provider.tsx b/apps/admin/src/settings/email-design/email-design-provider.tsx new file mode 100644 index 00000000000..271152bb7eb --- /dev/null +++ b/apps/admin/src/settings/email-design/email-design-provider.tsx @@ -0,0 +1,12 @@ +import {type ReactNode} from 'react'; +import {EmailDesignContext, type EmailDesignContextValue} from './email-design-context'; + +interface EmailDesignProviderProps extends EmailDesignContextValue { + children: ReactNode; +} + +export const EmailDesignProvider = ({settings, onSettingsChange, accentColor, children}: EmailDesignProviderProps) => ( + + {children} + +); diff --git a/apps/admin/src/settings/app/components/settings/email-design/email-preview.tsx b/apps/admin/src/settings/email-design/email-preview.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/email-design/email-preview.tsx rename to apps/admin/src/settings/email-design/email-preview.tsx diff --git a/apps/admin/src/settings/app/components/settings/email-design/header-image-field.tsx b/apps/admin/src/settings/email-design/header-image-field.tsx similarity index 96% rename from apps/admin/src/settings/app/components/settings/email-design/header-image-field.tsx rename to apps/admin/src/settings/email-design/header-image-field.tsx index bb9ba72508a..d4ca9f6f3f6 100644 --- a/apps/admin/src/settings/app/components/settings/email-design/header-image-field.tsx +++ b/apps/admin/src/settings/email-design/header-image-field.tsx @@ -46,7 +46,7 @@ const HeaderImageField: React.FC = ({inputId = 'welcome-e files[0] && handleUpload(files[0])} + onDropAccepted={files => files[0] && void handleUpload(files[0])} > Upload header image diff --git a/apps/admin/src/settings/app/components/settings/email-design/link-style.test.tsx b/apps/admin/src/settings/email-design/link-style.test.tsx similarity index 72% rename from apps/admin/src/settings/app/components/settings/email-design/link-style.test.tsx rename to apps/admin/src/settings/email-design/link-style.test.tsx index 6205eb996fc..56e8b15463e 100644 --- a/apps/admin/src/settings/app/components/settings/email-design/link-style.test.tsx +++ b/apps/admin/src/settings/email-design/link-style.test.tsx @@ -1,8 +1,8 @@ -import WelcomeEmailPreviewContent from '@/settings/app/components/settings/email-design/welcome-email-preview-content'; +import WelcomeEmailPreviewContent from '@/settings/email-design/welcome-email-preview-content'; import assert from 'node:assert/strict'; -import {DEFAULT_EMAIL_DESIGN} from '@/settings/app/components/settings/email-design/types'; -import {EmailDesignProvider} from '@/settings/app/components/settings/email-design/email-design-context'; -import {LinkStyleField} from '@/settings/app/components/settings/email-design/design-fields/link-style-field'; +import {DEFAULT_EMAIL_DESIGN} from '@/settings/email-design/types'; +import {EmailDesignProvider} from '@/settings/email-design/email-design-provider'; +import {LinkStyleField} from '@/settings/email-design/design-fields/link-style-field'; import {render, screen} from '@testing-library/react'; describe('Welcome email link styles', function () { diff --git a/apps/admin/src/settings/app/components/settings/section-title-color.test.tsx b/apps/admin/src/settings/email-design/section-title-color.test.tsx similarity index 79% rename from apps/admin/src/settings/app/components/settings/section-title-color.test.tsx rename to apps/admin/src/settings/email-design/section-title-color.test.tsx index 62d74c6b58b..a3708adc208 100644 --- a/apps/admin/src/settings/app/components/settings/section-title-color.test.tsx +++ b/apps/admin/src/settings/email-design/section-title-color.test.tsx @@ -1,8 +1,8 @@ -import WelcomeEmailPreviewContent from '@/settings/app/components/settings/email-design/welcome-email-preview-content'; +import WelcomeEmailPreviewContent from '@/settings/email-design/welcome-email-preview-content'; import assert from 'node:assert/strict'; -import {DEFAULT_EMAIL_DESIGN} from '@/settings/app/components/settings/email-design/types'; +import {DEFAULT_EMAIL_DESIGN} from '@/settings/email-design/types'; import {DesignTab} from '@/settings/membership/member-emails/welcome-email-customize-modal'; -import {EmailDesignProvider} from '@/settings/app/components/settings/email-design/email-design-context'; +import {EmailDesignProvider} from '@/settings/email-design/email-design-provider'; import {render, screen} from '@testing-library/react'; describe('Section title color', function () { diff --git a/apps/admin/src/settings/app/components/settings/email-design/show-badge-field.tsx b/apps/admin/src/settings/email-design/show-badge-field.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/email-design/show-badge-field.tsx rename to apps/admin/src/settings/email-design/show-badge-field.tsx diff --git a/apps/admin/src/settings/app/components/settings/email-design/types.ts b/apps/admin/src/settings/email-design/types.ts similarity index 100% rename from apps/admin/src/settings/app/components/settings/email-design/types.ts rename to apps/admin/src/settings/email-design/types.ts diff --git a/apps/admin/src/settings/app/components/settings/email-design/welcome-email-customize-modal.test.tsx b/apps/admin/src/settings/email-design/welcome-email-customize-modal.test.tsx similarity index 84% rename from apps/admin/src/settings/app/components/settings/email-design/welcome-email-customize-modal.test.tsx rename to apps/admin/src/settings/email-design/welcome-email-customize-modal.test.tsx index ac651e61d05..c0ebb348d1f 100644 --- a/apps/admin/src/settings/app/components/settings/email-design/welcome-email-customize-modal.test.tsx +++ b/apps/admin/src/settings/email-design/welcome-email-customize-modal.test.tsx @@ -1,9 +1,9 @@ -import EmailDesignModal from '@/settings/app/components/settings/email-design/email-design-modal'; +import EmailDesignModal from '@/settings/email-design/email-design-modal'; import React, {useState} from 'react'; import assert from 'node:assert/strict'; -import {ButtonColorField} from '@/settings/app/components/settings/email-design/design-fields/button-color-field'; -import {DEFAULT_EMAIL_DESIGN} from '@/settings/app/components/settings/email-design/types'; -import {EmailDesignProvider} from '@/settings/app/components/settings/email-design/email-design-context'; +import {ButtonColorField} from '@/settings/email-design/design-fields/button-color-field'; +import {DEFAULT_EMAIL_DESIGN} from '@/settings/email-design/types'; +import {EmailDesignProvider} from '@/settings/email-design/email-design-provider'; import {act, fireEvent, render, screen} from '@testing-library/react'; vi.mock('@tryghost/shade/components', async () => { @@ -74,7 +74,7 @@ describe('Welcome email customize modal', function () { assert.ok(screen.getByRole('textbox')); - await act(async () => { + act(() => { fireEvent.keyDown(document, {key: 'Escape'}); }); @@ -82,13 +82,13 @@ describe('Welcome email customize modal', function () { assert.ok(screen.getByTestId('welcome-email-customize-modal')); }); - it('keeps the customize modal open when Escape is pressed immediately after opening the color picker', async function () { + it('keeps the customize modal open when Escape is pressed immediately after opening the color picker', function () { vi.useFakeTimers(); const onClose = vi.fn(); render(); - await act(async () => { + act(() => { fireEvent.click(screen.getByText('Button color')); assert.equal(screen.queryByRole('textbox'), null); fireEvent.keyDown(document, {key: 'Escape'}); diff --git a/apps/admin/src/settings/app/components/settings/email-design/welcome-email-preview-content.tsx b/apps/admin/src/settings/email-design/welcome-email-preview-content.tsx similarity index 98% rename from apps/admin/src/settings/app/components/settings/email-design/welcome-email-preview-content.tsx rename to apps/admin/src/settings/email-design/welcome-email-preview-content.tsx index 4ad80c6f9d9..bd4f056eddd 100644 --- a/apps/admin/src/settings/app/components/settings/email-design/welcome-email-preview-content.tsx +++ b/apps/admin/src/settings/email-design/welcome-email-preview-content.tsx @@ -1,4 +1,4 @@ -import CoverImage from '@/settings/app/assets/images/email-design-user-image.jpg'; +import CoverImage from '@/settings/assets/images/email-design-user-image.jpg'; import React from 'react'; import {cn} from '@tryghost/shade/utils'; import {resolveAllColors, resolveButtonCorners, resolveFontFamily, resolveImageCorners} from './design-utils'; diff --git a/apps/admin/src/settings/app/components/settings/email/default-recipients.tsx b/apps/admin/src/settings/email/default-recipients.tsx similarity index 98% rename from apps/admin/src/settings/app/components/settings/email/default-recipients.tsx rename to apps/admin/src/settings/email/default-recipients.tsx index 7c516da91be..9f7764fa357 100644 --- a/apps/admin/src/settings/app/components/settings/email/default-recipients.tsx +++ b/apps/admin/src/settings/email/default-recipients.tsx @@ -1,11 +1,11 @@ import React, {useEffect, useRef, useState} from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; import useDefaultRecipientsOptions, {type SegmentOption, type SegmentOptions} from './use-default-recipients-options'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; import {Combobox, ComboboxContent, ComboboxTrigger, ComboboxValue, Field, FieldDescription, FieldError, FieldLabel, MultiSelectCombobox, Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@tryghost/shade/components'; import {SettingGroupContent} from '@tryghost/shade/patterns'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; type RefipientValueArgs = { defaultEmailRecipients: string; diff --git a/apps/admin/src/settings/app/components/settings/email/email-settings.tsx b/apps/admin/src/settings/email/email-settings.tsx similarity index 74% rename from apps/admin/src/settings/app/components/settings/email/email-settings.tsx rename to apps/admin/src/settings/email/email-settings.tsx index d947b37b390..b8eb98ebb56 100644 --- a/apps/admin/src/settings/app/components/settings/email/email-settings.tsx +++ b/apps/admin/src/settings/email/email-settings.tsx @@ -3,16 +3,10 @@ import EnableNewsletters from './enable-newsletters'; import MailGun from './mailgun'; import Newsletters from './newsletters'; import React from 'react'; -import SearchableSection from '@/settings/app/components/searchable-section'; +import SearchableSection from '@/settings/components/searchable-section'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; - -export const searchKeywords = { - enableNewsletters: ['emails', 'newsletters', 'newsletter sending', 'enable', 'disable', 'turn on', 'turn off'], - newsletters: ['newsletters', 'emails', 'design', 'customization'], - defaultRecipients: ['newsletters', 'default recipients', 'emails'], - mailgun: ['mailgun', 'emails', 'newsletters'] -}; +import {useGlobalData} from '@/settings/providers/global-data-context'; +import {searchKeywords} from './search-keywords'; const EmailSettings: React.FC = () => { const {settings, config} = useGlobalData(); diff --git a/apps/admin/src/settings/email/emails-search-keywords.ts b/apps/admin/src/settings/email/emails-search-keywords.ts new file mode 100644 index 00000000000..a30d4f1229f --- /dev/null +++ b/apps/admin/src/settings/email/emails-search-keywords.ts @@ -0,0 +1,6 @@ +export const searchKeywords = { + enableNewsletters: ['emails', 'newsletters', 'newsletter sending', 'enable', 'disable', 'turn on', 'turn off'], + emails: ['emails', 'newsletters', 'automation emails', 'transactional', 'design', 'customization', 'automations', 'welcome'], + defaultRecipients: ['newsletters', 'default recipients', 'emails'], + mailgun: ['mailgun', 'emails', 'newsletters'] +}; diff --git a/apps/admin/src/settings/app/components/settings/email/emails.tsx b/apps/admin/src/settings/email/emails.tsx similarity index 91% rename from apps/admin/src/settings/app/components/settings/email/emails.tsx rename to apps/admin/src/settings/email/emails.tsx index 6f16a945594..80445ce916c 100644 --- a/apps/admin/src/settings/app/components/settings/email/emails.tsx +++ b/apps/admin/src/settings/email/emails.tsx @@ -3,26 +3,20 @@ import EnableNewsletters from './enable-newsletters'; import MailGun from './mailgun'; import NewslettersTabContent, {type NewslettersFilter} from './newsletters/newsletters-tab-content'; import React, {useEffect, useRef, useState} from 'react'; -import SearchableSection from '@/settings/app/components/searchable-section'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; +import SearchableSection from '@/settings/components/searchable-section'; +import TopLevelGroup from '@/settings/components/top-level-group'; import WelcomeEmailCustomizeModal from '@/settings/membership/member-emails/welcome-email-customize-modal'; -import useQueryParams from '@/settings/app/hooks/use-query-params'; +import useQueryParams from '@/settings/hooks/use-query-params'; import {APIError} from '@tryghost/admin-x-framework/errors'; import {ActionList, ActionListItem, ActionListItemActions, ActionListItemContent, Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Tabs, TabsContent, TabsList, TabsTrigger} from '@tryghost/shade/components'; import {LucideIcon} from '@tryghost/shade/utils'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; import {useVerifyAutomatedEmailSender} from '@tryghost/admin-x-framework/api/automated-emails'; - -export const searchKeywords = { - enableNewsletters: ['emails', 'newsletters', 'newsletter sending', 'enable', 'disable', 'turn on', 'turn off'], - emails: ['emails', 'newsletters', 'automation emails', 'transactional', 'design', 'customization', 'automations', 'welcome'], - defaultRecipients: ['newsletters', 'default recipients', 'emails'], - mailgun: ['mailgun', 'emails', 'newsletters'] -}; +import {searchKeywords} from './emails-search-keywords'; const TransactionalTabContent: React.FC = () => { const [isCustomizeOpen, setIsCustomizeOpen] = useState(false); @@ -130,7 +124,7 @@ const EmailsGroup: React.FC<{ keywords: string[]; newslettersEnabled: boolean }> } }; - verify(); + void verify(); }, [confirm, handleError, updateRoute, verifyEmailToken, verifySenderUpdate]); const openNewNewsletter = () => { diff --git a/apps/admin/src/settings/app/components/settings/email/enable-newsletters.tsx b/apps/admin/src/settings/email/enable-newsletters.tsx similarity index 89% rename from apps/admin/src/settings/app/components/settings/email/enable-newsletters.tsx rename to apps/admin/src/settings/email/enable-newsletters.tsx index 95860458acd..78528e566e1 100644 --- a/apps/admin/src/settings/app/components/settings/email/enable-newsletters.tsx +++ b/apps/admin/src/settings/email/enable-newsletters.tsx @@ -1,14 +1,14 @@ import React from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; import {Banner, Switch} from '@tryghost/shade/components'; import {Inline} from '@tryghost/shade/primitives'; import {LucideIcon} from '@tryghost/shade/utils'; import {type Setting, getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {SettingGroupContent} from '@tryghost/shade/patterns'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const EnableNewsletters: React.FC<{ keywords: string[] }> = ({keywords}) => { const {settings} = useGlobalData(); @@ -42,7 +42,7 @@ const EnableNewsletters: React.FC<{ keywords: string[] }> = ({keywords}) => { aria-label='Newsletters' checked={newslettersEnabled !== 'disabled' && !isDisabled} disabled={isDisabled} - onCheckedChange={handleToggleChange} + onCheckedChange={checked => void handleToggleChange(checked)} /> ); diff --git a/apps/admin/src/settings/app/components/settings/email/mailgun.tsx b/apps/admin/src/settings/email/mailgun.tsx similarity index 95% rename from apps/admin/src/settings/app/components/settings/email/mailgun.tsx rename to apps/admin/src/settings/email/mailgun.tsx index 70badb69e5c..436c02f5ef5 100644 --- a/apps/admin/src/settings/app/components/settings/email/mailgun.tsx +++ b/apps/admin/src/settings/email/mailgun.tsx @@ -1,13 +1,13 @@ import React from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; import {Field, FieldDescription, FieldLabel, Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@tryghost/shade/components'; import {Inline} from '@tryghost/shade/primitives'; import {LucideIcon} from '@tryghost/shade/utils'; import {SettingGroupContent, SettingGroupValue, SettingGroupValueContent, SettingGroupValueTitle} from '@tryghost/shade/patterns'; import {getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const MAILGUN_REGIONS = [ {label: '🇺🇸 US', value: 'https://api.mailgun.net/v3'}, @@ -120,7 +120,7 @@ const MailGun: React.FC<{ keywords: string[] }> = ({keywords}) => { return; } } - handleSave(); + void handleSave(); }} > {isEditing ? inputs : values} diff --git a/apps/admin/src/settings/app/components/settings/email/newsletters.tsx b/apps/admin/src/settings/email/newsletters.tsx similarity index 94% rename from apps/admin/src/settings/app/components/settings/email/newsletters.tsx rename to apps/admin/src/settings/email/newsletters.tsx index 400e157bf9f..9797816109d 100644 --- a/apps/admin/src/settings/app/components/settings/email/newsletters.tsx +++ b/apps/admin/src/settings/email/newsletters.tsx @@ -1,17 +1,17 @@ import NewslettersList from './newsletters/newsletters-list'; import React, {type ReactNode, useEffect, useState} from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; -import useQueryParams from '@/settings/app/hooks/use-query-params'; +import TopLevelGroup from '@/settings/components/top-level-group'; +import useQueryParams from '@/settings/hooks/use-query-params'; import {APIError} from '@tryghost/admin-x-framework/errors'; import {Button, Tabs, TabsContent, TabsList, TabsTrigger} from '@tryghost/shade/components'; import {type InfiniteData, useQueryClient} from '@tryghost/admin-x-framework'; import {type Newsletter, type NewslettersResponseType, newslettersDataType, useBrowseNewsletters, useEditNewsletter, useVerifyNewsletterEmail} from '@tryghost/admin-x-framework/api/newsletters'; import {arrayMove} from '@dnd-kit/sortable'; import {formatNumber} from '@tryghost/shade/utils'; -import {type ConfirmationHandle, useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {type ConfirmationHandle, useConfirmation} from '@/settings/providers/confirmation-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const NavigateToNewsletter = ({id, onNavigate, children}: {id: string; onNavigate: () => void; children: ReactNode}) => { const {updateRoute} = useSettingsNavigation(); @@ -90,7 +90,7 @@ const Newsletters: React.FC<{ keywords: string[] }> = ({keywords}) => { handleError(e, {withToast: false}); } }; - verify(); + void verify(); }, [verifyEmailToken, handleError, verifyEmail, confirm]); const buttons = ( @@ -152,10 +152,10 @@ const Newsletters: React.FC<{ keywords: string[] }> = ({keywords}) => { Active Archived - + void onSort(id, overId)} /> - {isEnd === false && } diff --git a/apps/admin/src/settings/app/components/settings/email/newsletters/add-newsletter-modal.tsx b/apps/admin/src/settings/email/newsletters/add-newsletter-modal.tsx similarity index 93% rename from apps/admin/src/settings/app/components/settings/email/newsletters/add-newsletter-modal.tsx rename to apps/admin/src/settings/email/newsletters/add-newsletter-modal.tsx index 9c80fde17d6..2d4cd86f936 100644 --- a/apps/admin/src/settings/app/components/settings/email/newsletters/add-newsletter-modal.tsx +++ b/apps/admin/src/settings/email/newsletters/add-newsletter-modal.tsx @@ -1,10 +1,10 @@ import React, {useEffect, useState} from 'react'; -import useFeatureFlag from '@/settings/app/hooks/use-feature-flag'; +import useFeatureFlag from '@/settings/hooks/use-feature-flag'; import {Field, FieldContent, FieldDescription, FieldError, FieldGroup, FieldLabel, Input, Switch, Textarea} from '@tryghost/shade/components'; -import {HostLimitError, useLimiter} from '@/settings/app/hooks/use-limiter'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {useUpgradeRoute} from '@/settings/app/hooks/use-upgrade-route'; +import {HostLimitError, useLimiter} from '@/settings/hooks/use-limiter'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {useUpgradeRoute} from '@/settings/hooks/use-upgrade-route'; import {SettingsModal} from '@tryghost/shade/patterns'; import {formatNumber} from '@tryghost/shade/utils'; import {useAddNewsletter} from '@tryghost/admin-x-framework/api/newsletters'; diff --git a/apps/admin/src/settings/app/components/settings/email/newsletters/newsletter-detail-modal.tsx b/apps/admin/src/settings/email/newsletters/newsletter-detail-modal.tsx similarity index 97% rename from apps/admin/src/settings/app/components/settings/email/newsletters/newsletter-detail-modal.tsx rename to apps/admin/src/settings/email/newsletters/newsletter-detail-modal.tsx index a7e3bdc9d36..940fe658607 100644 --- a/apps/admin/src/settings/app/components/settings/email/newsletters/newsletter-detail-modal.tsx +++ b/apps/admin/src/settings/email/newsletters/newsletter-detail-modal.tsx @@ -1,28 +1,28 @@ -import ColorPickerField from '@/settings/app/components/color-picker-field'; -import HeaderImageField from '@/settings/app/components/settings/email-design/header-image-field'; -import HtmlField from '@/settings/app/components/html-field'; +import ColorPickerField from '@/settings/components/color-picker-field'; +import HeaderImageField from '@/settings/email-design/header-image-field'; +import HtmlField from '@/settings/components/html-field'; import NewsletterPreview from './newsletter-preview'; import React, {useCallback, useEffect, useState} from 'react'; -import useFeatureFlag from '@/settings/app/hooks/use-feature-flag'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; +import useFeatureFlag from '@/settings/hooks/use-feature-flag'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; import validator from 'validator'; import {Button, Field, FieldContent, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldLegend, FieldSet, Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Separator, Switch, Tabs, TabsContent, TabsList, TabsTrigger, Textarea, ToggleGroup, ToggleGroupItem, Tooltip, TooltipContent, TooltipTrigger} from '@tryghost/shade/components'; import {type ErrorMessages, useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {HostLimitError, useLimiter} from '@/settings/app/hooks/use-limiter'; +import {HostLimitError, useLimiter} from '@/settings/hooks/use-limiter'; import {Inline, Stack} from '@tryghost/shade/primitives'; import {LucideIcon} from '@tryghost/shade/utils'; import {type Newsletter, useBrowseNewsletters, useEditNewsletter} from '@tryghost/admin-x-framework/api/newsletters'; -import {PreviewModalContent} from '@/settings/app/components/settings/preview-modal'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {PreviewModalContent} from '@/settings/components/preview-modal'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; import {useParams} from '@tryghost/admin-x-framework'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {useUpgradeRoute} from '@/settings/app/hooks/use-upgrade-route'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {useUpgradeRoute} from '@/settings/hooks/use-upgrade-route'; import {getSettingValue, getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {hasSendingDomain, isManagedEmail, sendingDomain} from '@tryghost/admin-x-framework/api/config'; -import {renderReplyToEmail, renderSenderEmail} from '@/settings/app/utils/newsletter-emails'; +import {renderReplyToEmail, renderSenderEmail} from '@/settings/utils/newsletter-emails'; import {textColorForBackgroundColor} from '@tryghost/color-utils'; import {toast} from 'sonner'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; interface IconToggleOption { value: string; @@ -299,7 +299,7 @@ const Sidebar: React.FC<{
- {newsletter.status === 'active' ? (!onlyOne && ) : } + {newsletter.status === 'active' ? (!onlyOne && ) : }
}, @@ -768,7 +768,7 @@ const NewsletterDetailModal: React.FC = () => { useEffect(() => { if (!newsletter && !isEnd) { - fetchNextPage(); + void fetchNextPage(); } }, [fetchNextPage, isEnd, newsletter]); diff --git a/apps/admin/src/settings/app/components/settings/email/newsletters/newsletter-preview-content.tsx b/apps/admin/src/settings/email/newsletters/newsletter-preview-content.tsx similarity index 98% rename from apps/admin/src/settings/app/components/settings/email/newsletters/newsletter-preview-content.tsx rename to apps/admin/src/settings/email/newsletters/newsletter-preview-content.tsx index bc0d7fabe16..127653f4d05 100644 --- a/apps/admin/src/settings/app/components/settings/email/newsletters/newsletter-preview-content.tsx +++ b/apps/admin/src/settings/email/newsletters/newsletter-preview-content.tsx @@ -1,12 +1,12 @@ -import CoverImage from '@/settings/app/assets/images/user-cover.jpg'; -import LatestPosts1 from '@/settings/app/assets/images/latest-posts-1.jpg'; -import LatestPosts2 from '@/settings/app/assets/images/latest-posts-2.jpg'; -import LatestPosts3 from '@/settings/app/assets/images/latest-posts-3.jpg'; +import CoverImage from '@/settings/assets/images/user-cover.jpg'; +import LatestPosts1 from '@/settings/assets/images/latest-posts-1.jpg'; +import LatestPosts2 from '@/settings/assets/images/latest-posts-2.jpg'; +import LatestPosts3 from '@/settings/assets/images/latest-posts-3.jpg'; import clsx from 'clsx'; import {GhostOrb} from '@tryghost/shade/components'; import {LucideIcon} from '@tryghost/shade/utils'; import {isManagedEmail} from '@tryghost/admin-x-framework/api/config'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; const NewsletterPreviewContent: React.FC<{ senderName?: string; diff --git a/apps/admin/src/settings/app/components/settings/email/newsletters/newsletter-preview.tsx b/apps/admin/src/settings/email/newsletters/newsletter-preview.tsx similarity index 98% rename from apps/admin/src/settings/app/components/settings/email/newsletters/newsletter-preview.tsx rename to apps/admin/src/settings/email/newsletters/newsletter-preview.tsx index 4eeaf6c292f..c22e4448c6b 100644 --- a/apps/admin/src/settings/app/components/settings/email/newsletters/newsletter-preview.tsx +++ b/apps/admin/src/settings/email/newsletters/newsletter-preview.tsx @@ -2,9 +2,9 @@ import NewsletterPreviewContent from './newsletter-preview-content'; import React from 'react'; import {type Newsletter} from '@tryghost/admin-x-framework/api/newsletters'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; -import {renderReplyToEmail, renderSenderEmail} from '@/settings/app/utils/newsletter-emails'; +import {renderReplyToEmail, renderSenderEmail} from '@/settings/utils/newsletter-emails'; import {textColorForBackgroundColor} from '@tryghost/color-utils'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; const NewsletterPreview: React.FC<{newsletter: Newsletter}> = ({newsletter}) => { const {currentUser, settings, siteData, config} = useGlobalData(); diff --git a/apps/admin/src/settings/app/components/settings/email/newsletters/newsletters-list.tsx b/apps/admin/src/settings/email/newsletters/newsletters-list.tsx similarity index 98% rename from apps/admin/src/settings/app/components/settings/email/newsletters/newsletters-list.tsx rename to apps/admin/src/settings/email/newsletters/newsletters-list.tsx index f56972e6cf6..0d742fb2404 100644 --- a/apps/admin/src/settings/app/components/settings/email/newsletters/newsletters-list.tsx +++ b/apps/admin/src/settings/email/newsletters/newsletters-list.tsx @@ -3,7 +3,7 @@ import {ActionList, ActionListItem, ActionListItemActions, ActionListItemContent import {Inline} from '@tryghost/shade/primitives'; import {LucideIcon, formatNumber} from '@tryghost/shade/utils'; import {type Newsletter} from '@tryghost/admin-x-framework/api/newsletters'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; interface NewslettersListProps { newsletters: Newsletter[]; diff --git a/apps/admin/src/settings/app/components/settings/email/newsletters/newsletters-tab-content.tsx b/apps/admin/src/settings/email/newsletters/newsletters-tab-content.tsx similarity index 94% rename from apps/admin/src/settings/app/components/settings/email/newsletters/newsletters-tab-content.tsx rename to apps/admin/src/settings/email/newsletters/newsletters-tab-content.tsx index 27434c66435..7a6124c17f8 100644 --- a/apps/admin/src/settings/app/components/settings/email/newsletters/newsletters-tab-content.tsx +++ b/apps/admin/src/settings/email/newsletters/newsletters-tab-content.tsx @@ -1,16 +1,16 @@ import NewslettersList from './newsletters-list'; import React, {type ReactNode, useEffect, useState} from 'react'; -import useQueryParams from '@/settings/app/hooks/use-query-params'; +import useQueryParams from '@/settings/hooks/use-query-params'; import {APIError} from '@tryghost/admin-x-framework/errors'; import {Button} from '@tryghost/shade/components'; import {type InfiniteData, useQueryClient} from '@tryghost/admin-x-framework'; import {type Newsletter, type NewslettersResponseType, newslettersDataType, useBrowseNewsletters, useEditNewsletter, useVerifyNewsletterEmail} from '@tryghost/admin-x-framework/api/newsletters'; import {arrayMove} from '@dnd-kit/sortable'; import {formatNumber} from '@tryghost/shade/utils'; -import {type ConfirmationHandle, useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {type ConfirmationHandle, useConfirmation} from '@/settings/providers/confirmation-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const NavigateToNewsletter = ({id, onNavigate, children}: {id: string; onNavigate: () => void; children: ReactNode}) => { const {updateRoute} = useSettingsNavigation(); @@ -97,7 +97,7 @@ const NewslettersTabContent: React.FC = ({filter}) = handleError(e, {withToast: false}); } }; - verify(); + void verify(); }, [verifyEmailToken, handleError, verifyEmail, confirm]); const sortedActiveNewsletters = newsletters.filter(n => n.status === 'active').sort((a, b) => a.sort_order - b.sort_order) || []; @@ -143,11 +143,11 @@ const NewslettersTabContent: React.FC = ({filter}) = return ( <> {showingActive ? ( - + void onSort(id, overId)} /> ) : ( )} - {isEnd === false && } diff --git a/apps/admin/src/settings/email/search-keywords.ts b/apps/admin/src/settings/email/search-keywords.ts new file mode 100644 index 00000000000..0896d5ab039 --- /dev/null +++ b/apps/admin/src/settings/email/search-keywords.ts @@ -0,0 +1,6 @@ +export const searchKeywords = { + enableNewsletters: ['emails', 'newsletters', 'newsletter sending', 'enable', 'disable', 'turn on', 'turn off'], + newsletters: ['newsletters', 'emails', 'design', 'customization'], + defaultRecipients: ['newsletters', 'default recipients', 'emails'], + mailgun: ['mailgun', 'emails', 'newsletters'] +}; diff --git a/apps/admin/src/settings/app/components/settings/email/use-default-recipients-options.tsx b/apps/admin/src/settings/email/use-default-recipients-options.tsx similarity index 99% rename from apps/admin/src/settings/app/components/settings/email/use-default-recipients-options.tsx rename to apps/admin/src/settings/email/use-default-recipients-options.tsx index 4f7a8c69b10..ee278f434f2 100644 --- a/apps/admin/src/settings/app/components/settings/email/use-default-recipients-options.tsx +++ b/apps/admin/src/settings/email/use-default-recipients-options.tsx @@ -1,7 +1,7 @@ import {type Label} from '@tryghost/admin-x-framework/api/labels'; import {type Offer} from '@tryghost/admin-x-framework/api/offers'; import {type Tier} from '@tryghost/admin-x-framework/api/tiers'; -import {isObjectId} from '@/settings/app/utils/helpers'; +import {isObjectId} from '@/settings/utils/helpers'; import {useEffect, useRef, useState} from 'react'; import {useFilterableApi} from '@tryghost/admin-x-framework/hooks'; diff --git a/apps/admin/src/settings/general/about.tsx b/apps/admin/src/settings/general/about.tsx index 7c008baf0df..02efb333940 100644 --- a/apps/admin/src/settings/general/about.tsx +++ b/apps/admin/src/settings/general/about.tsx @@ -1,11 +1,11 @@ import {GhostLogo, Separator} from '@tryghost/shade/components'; import {LucideIcon} from '@tryghost/shade/utils'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; import {SettingsModal} from '@tryghost/shade/patterns'; -import {linkToGitHubReleases} from '@/settings/app/utils/link-to-github-releases'; -import {showDatabaseWarning} from '@/settings/app/utils/show-database-warning'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; -import {useUpgradeStatus} from '@/settings/app/components/providers/settings-app-provider'; +import {linkToGitHubReleases} from '@/settings/utils/link-to-github-releases'; +import {showDatabaseWarning} from '@/settings/utils/show-database-warning'; +import {useGlobalData} from '@/settings/providers/global-data-context'; +import {useUpgradeStatus} from '@/settings/providers/settings-app-context'; const adminBuildVersion = import.meta.env.GHOST_BUILD_VERSION; diff --git a/apps/admin/src/settings/general/general-settings.tsx b/apps/admin/src/settings/general/general-settings.tsx index 99ee0eb765b..a6fe7a7a1b9 100644 --- a/apps/admin/src/settings/general/general-settings.tsx +++ b/apps/admin/src/settings/general/general-settings.tsx @@ -3,7 +3,7 @@ import React from 'react'; import Analytics from '@/settings/membership/analytics'; import PublicationLanguage from './publication-language'; import SEOMeta from './seo-meta'; -import SearchableSection from '@/settings/app/components/searchable-section'; +import SearchableSection from '@/settings/components/searchable-section'; import SocialAccounts from './social-accounts'; import TimeZone from './time-zone'; import TitleAndDescription from './title-and-description'; diff --git a/apps/admin/src/settings/general/invite-user-modal.tsx b/apps/admin/src/settings/general/invite-user-modal.tsx index f8959d7e0cf..d6d88eea967 100644 --- a/apps/admin/src/settings/general/invite-user-modal.tsx +++ b/apps/admin/src/settings/general/invite-user-modal.tsx @@ -1,7 +1,7 @@ import validator from 'validator'; import {APIError, ValidationError} from '@tryghost/admin-x-framework/errors'; import {Field, FieldContent, FieldDescription, FieldError, FieldLabel, FieldLegend, FieldSet, Input, RadioGroup, RadioGroupItem} from '@tryghost/shade/components'; -import {HostLimitError, useLimiter} from '@/settings/app/hooks/use-limiter'; +import {HostLimitError, useLimiter} from '@/settings/hooks/use-limiter'; import {SettingsModal} from '@tryghost/shade/patterns'; import {Stack} from '@tryghost/shade/primitives'; import {toast} from 'sonner'; @@ -9,9 +9,9 @@ import {useAddInvite, useBrowseInvites} from '@tryghost/admin-x-framework/api/in import {useBrowseRoles} from '@tryghost/admin-x-framework/api/roles'; import {useBrowseUsers} from '@tryghost/admin-x-framework/api/users'; import {useEffect, useState} from 'react'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; type RoleType = 'administrator' | 'editor' | 'author' | 'contributor' | 'super editor'; diff --git a/apps/admin/src/settings/general/publication-language.tsx b/apps/admin/src/settings/general/publication-language.tsx index 3b2a090b12a..fd1a21471e1 100644 --- a/apps/admin/src/settings/general/publication-language.tsx +++ b/apps/admin/src/settings/general/publication-language.tsx @@ -1,12 +1,12 @@ import LOCALE_DATA from '@tryghost/i18n/lib/locale-data.json'; import React from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; import {Button, Combobox, ComboboxContent, ComboboxTrigger, ComboboxValue, Field, FieldDescription, FieldError, FieldLabel, Input, MultiSelectCombobox} from '@tryghost/shade/components'; import {SettingGroupContent} from '@tryghost/shade/patterns'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; -import {validateLocale} from '@/settings/app/utils/locale-validation'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {validateLocale} from '@/settings/utils/locale-validation'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const PublicationLanguage: React.FC<{ keywords: string[] }> = ({keywords}) => { const languageErrorId = React.useId(); diff --git a/apps/admin/src/settings/general/seo-meta.tsx b/apps/admin/src/settings/general/seo-meta.tsx index 874b092c456..93e803fb712 100644 --- a/apps/admin/src/settings/general/seo-meta.tsx +++ b/apps/admin/src/settings/general/seo-meta.tsx @@ -1,7 +1,7 @@ import React, {useState} from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; -import usePinturaEditor from '@/settings/app/hooks/use-pintura-editor'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; +import usePinturaEditor from '@/settings/hooks/use-pintura-editor'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; import {APIError} from '@tryghost/admin-x-framework/errors'; import {FacebookLogo, Field, FieldDescription, FieldLabel, GoogleLogo, Input, Switch, Tabs, TabsContent, TabsList, TabsTrigger, XLogo} from '@tryghost/shade/components'; import {ImageUpload, ImageUploadAction, ImageUploadActions, ImageUploadDropzone, ImageUploadImage, ImageUploadPreview} from '@tryghost/shade/patterns'; @@ -11,7 +11,7 @@ import {SettingGroupContent} from '@tryghost/shade/patterns'; import {getImageUrl, useUploadImage} from '@tryghost/admin-x-framework/api/images'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; interface SearchEnginePreviewProps { title: string; diff --git a/apps/admin/src/settings/general/social-accounts.tsx b/apps/admin/src/settings/general/social-accounts.tsx index 81f08830912..f68cc7fc5d2 100644 --- a/apps/admin/src/settings/general/social-accounts.tsx +++ b/apps/admin/src/settings/general/social-accounts.tsx @@ -1,13 +1,13 @@ import React, {useEffect, useMemo, useState} from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; import {Field, FieldError, FieldLabel, Input} from '@tryghost/shade/components'; -import {SOCIAL_PLATFORM_CONFIGS, SOCIAL_PLATFORM_KEYS, getSocialValidationError, normalizeSocialInput} from '@/settings/app/utils/social-urls'; +import {SOCIAL_PLATFORM_CONFIGS, SOCIAL_PLATFORM_KEYS, getSocialValidationError, normalizeSocialInput} from '@/settings/utils/social-urls'; import {SettingGroupContent} from '@tryghost/shade/patterns'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; import type {Setting} from '@tryghost/admin-x-framework/api/settings'; -import type {SocialPlatformKey} from '@/settings/app/utils/social-urls'; +import type {SocialPlatformKey} from '@/settings/utils/social-urls'; const LEGACY_PLATFORM_KEYS: SocialPlatformKey[] = ['facebook', 'twitter']; const NEW_PLATFORM_KEYS: SocialPlatformKey[] = SOCIAL_PLATFORM_KEYS.filter( diff --git a/apps/admin/src/settings/general/time-zone.tsx b/apps/admin/src/settings/general/time-zone.tsx index 5f0b87c49a2..7128a174966 100644 --- a/apps/admin/src/settings/general/time-zone.tsx +++ b/apps/admin/src/settings/general/time-zone.tsx @@ -1,12 +1,12 @@ import React, {useEffect, useState} from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; import {Combobox, ComboboxContent, ComboboxTrigger, ComboboxValue, Field, FieldDescription, FieldLabel, MultiSelectCombobox} from '@tryghost/shade/components'; import {SettingGroupContent} from '@tryghost/shade/patterns'; -import {getLocalTime} from '@/settings/app/utils/helpers'; +import {getLocalTime} from '@/settings/utils/helpers'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {timezoneDataWithGMTOffset} from '@tryghost/timezone-data'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; interface TimezoneDataDropdownOption { name: string; diff --git a/apps/admin/src/settings/general/title-and-description.tsx b/apps/admin/src/settings/general/title-and-description.tsx index 67d924f4bd5..c92da9543df 100644 --- a/apps/admin/src/settings/general/title-and-description.tsx +++ b/apps/admin/src/settings/general/title-and-description.tsx @@ -1,10 +1,10 @@ import React from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; import {Field, FieldDescription, FieldError, FieldLabel, Input} from '@tryghost/shade/components'; import {SettingGroupContent, SettingGroupValue, SettingGroupValueContent, SettingGroupValueTitle} from '@tryghost/shade/patterns'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const TitleAndDescription: React.FC<{ keywords: string[] }> = ({keywords}) => { const { diff --git a/apps/admin/src/settings/general/user-detail-modal.tsx b/apps/admin/src/settings/general/user-detail-modal.tsx index ae099d66a7b..29099bca424 100644 --- a/apps/admin/src/settings/general/user-detail-modal.tsx +++ b/apps/admin/src/settings/general/user-detail-modal.tsx @@ -3,27 +3,27 @@ import ProfileTab from './users/profile-tab'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import SocialLinksTab from './users/social-links-tab'; import clsx from 'clsx'; -import usePinturaEditor from '@/settings/app/hooks/use-pintura-editor'; -import useStaffUsers from '@/settings/app/hooks/use-staff-users'; +import usePinturaEditor from '@/settings/hooks/use-pintura-editor'; +import useStaffUsers from '@/settings/hooks/use-staff-users'; import validator from 'validator'; import {APIError} from '@tryghost/admin-x-framework/errors'; import {Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Dropzone, Tabs, TabsContent, TabsList, TabsTrigger} from '@tryghost/shade/components'; import {type ErrorMessages, useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {HostLimitError, useLimiter} from '@/settings/app/hooks/use-limiter'; +import {HostLimitError, useLimiter} from '@/settings/hooks/use-limiter'; import {ImageUpload, ImageUploadAction, ImageUploadActions, ImageUploadDropzone, ImageUploadImage, ImageUploadPreview} from '@tryghost/shade/patterns'; import {LucideIcon} from '@tryghost/shade/utils'; import {Pencil, Trash2} from 'lucide-react'; import {useLocation, useParams} from '@tryghost/admin-x-framework'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {useUpgradeRoute} from '@/settings/app/hooks/use-upgrade-route'; -import {SOCIAL_PLATFORM_CONFIGS, SOCIAL_PLATFORM_KEYS, getSocialValidationError} from '@/settings/app/utils/social-urls/index'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {useUpgradeRoute} from '@/settings/hooks/use-upgrade-route'; +import {SOCIAL_PLATFORM_CONFIGS, SOCIAL_PLATFORM_KEYS, getSocialValidationError} from '@/settings/utils/social-urls/index'; import {SettingsModal} from '@tryghost/shade/patterns'; import {Text} from '@tryghost/shade/primitives'; import {type User, canAccessSettings, hasAdminAccess, isAdminUser, isAuthorOrContributor, isEditorUser, isOwnerUser, useDeleteUser, useEditUser, useGetUserBySlug, useMakeOwner} from '@tryghost/admin-x-framework/api/users'; import {getImageUrl, useUploadImage} from '@tryghost/admin-x-framework/api/images'; import {toast} from 'sonner'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; +import {useGlobalData} from '@/settings/providers/global-data-context'; const validators: Record) => string> = { name: ({name}) => { diff --git a/apps/admin/src/settings/general/users.tsx b/apps/admin/src/settings/general/users.tsx index 3e367dae8f3..dc4b3731a53 100644 --- a/apps/admin/src/settings/general/users.tsx +++ b/apps/admin/src/settings/general/users.tsx @@ -1,8 +1,8 @@ import React, {useEffect, useState} from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; import clsx from 'clsx'; -import useQueryParams from '@/settings/app/hooks/use-query-params'; -import useStaffUsers from '@/settings/app/hooks/use-staff-users'; +import useQueryParams from '@/settings/hooks/use-query-params'; +import useStaffUsers from '@/settings/hooks/use-staff-users'; import {ActionList, ActionListItem, ActionListItemActions, ActionListItemContent, Avatar, NoValueLabel, NoValueLabelIcon, Separator, Switch, Tabs, TabsContent, TabsList, TabsTrigger, TabsTriggerCount} from '@tryghost/shade/components'; import {Button} from '@tryghost/shade/components'; import {type User, hasAdminAccess, isContributorUser, isEditorUser} from '@tryghost/admin-x-framework/api/users'; @@ -11,10 +11,10 @@ import {UserRoundX} from 'lucide-react'; import {formatNumber} from '@tryghost/shade/utils'; import {getSettingValue, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {toast} from 'sonner'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; interface OwnerProps { user: User; @@ -304,12 +304,12 @@ const Users: React.FC<{ keywords: string[], highlight?: boolean }> = ({keywords, {hasNextPage && selectedTab !== 'invited' && } {invitesHasNextPage && selectedTab === 'invited' && } {config?.security?.staffDeviceVerification && hasAdminAccess(currentUser) && ( diff --git a/apps/admin/src/settings/general/users/change-password-form.tsx b/apps/admin/src/settings/general/users/change-password-form.tsx index ee3d6441485..02921b7c927 100644 --- a/apps/admin/src/settings/general/users/change-password-form.tsx +++ b/apps/admin/src/settings/general/users/change-password-form.tsx @@ -3,7 +3,7 @@ import {type User, useUpdatePassword} from '@tryghost/admin-x-framework/api/user import {ValidationError} from '@tryghost/admin-x-framework/errors'; import {toast} from 'sonner'; import {useEffect, useRef, useState} from 'react'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; const BAD_PASSWORDS = [ diff --git a/apps/admin/src/settings/general/users/email-notifications-tab.tsx b/apps/admin/src/settings/general/users/email-notifications-tab.tsx index d30b44585f1..ba56370a9b7 100644 --- a/apps/admin/src/settings/general/users/email-notifications-tab.tsx +++ b/apps/admin/src/settings/general/users/email-notifications-tab.tsx @@ -2,7 +2,7 @@ import {Field, FieldContent, FieldDescription, FieldLabel, Switch} from '@trygho import {SettingGroup, SettingGroupContent} from '@tryghost/shade/patterns'; import {type User, hasAdminAccess} from '@tryghost/admin-x-framework/api/users'; import {checkStripeEnabled} from '@tryghost/admin-x-framework/api/settings'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; const EmailNotificationsInputs: React.FC<{ user: User; setUserData: (user: User) => void; }> = ({user, setUserData}) => { const {config, settings} = useGlobalData(); diff --git a/apps/admin/src/settings/general/users/profile-tab.tsx b/apps/admin/src/settings/general/users/profile-tab.tsx index 2368ba9b947..ee5e458047b 100644 --- a/apps/admin/src/settings/general/users/profile-tab.tsx +++ b/apps/admin/src/settings/general/users/profile-tab.tsx @@ -7,7 +7,7 @@ import {type UserDetailProps} from '@/settings/general/user-detail-modal'; import {formatNumber} from '@tryghost/shade/utils'; import {getHomepageUrl} from '@tryghost/admin-x-framework/api/site'; import {hasAdminAccess} from '@tryghost/admin-x-framework/api/users'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; const BasicInputs: React.FC = ({errors, clearError, user, setUserData}) => { const {currentUser, siteData} = useGlobalData(); diff --git a/apps/admin/src/settings/general/users/role-selector.tsx b/apps/admin/src/settings/general/users/role-selector.tsx index 0023eebc2ca..f850100cc92 100644 --- a/apps/admin/src/settings/general/users/role-selector.tsx +++ b/apps/admin/src/settings/general/users/role-selector.tsx @@ -1,7 +1,7 @@ import {Field, FieldDescription, FieldLabel, Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@tryghost/shade/components'; import {type User, isOwnerUser} from '@tryghost/admin-x-framework/api/users'; import {useBrowseRoles} from '@tryghost/admin-x-framework/api/roles'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; const RoleSelector: React.FC<{ user: User; setUserData: (user: User) => void; }> = ({user, setUserData}) => { const {data: {roles} = {}} = useBrowseRoles(); diff --git a/apps/admin/src/settings/general/users/social-links-tab.tsx b/apps/admin/src/settings/general/users/social-links-tab.tsx index 92cc66dc009..77818fae657 100644 --- a/apps/admin/src/settings/general/users/social-links-tab.tsx +++ b/apps/admin/src/settings/general/users/social-links-tab.tsx @@ -1,9 +1,9 @@ import {Field, FieldError, FieldLabel, Input} from '@tryghost/shade/components'; -import {SOCIAL_PLATFORM_CONFIGS, normalizeSocialInput} from '@/settings/app/utils/social-urls/index'; +import {SOCIAL_PLATFORM_CONFIGS, normalizeSocialInput} from '@/settings/utils/social-urls/index'; import {SettingGroup, SettingGroupContent} from '@tryghost/shade/patterns'; import {type UserDetailProps} from '@/settings/general/user-detail-modal'; import {useState} from 'react'; -import type {SocialPlatformKey} from '@/settings/app/utils/social-urls/index'; +import type {SocialPlatformKey} from '@/settings/utils/social-urls/index'; export const DetailsInputs: React.FC = ({errors, clearError, validateField, user, setUserData}) => { const [urls, setUrls] = useState>(() => { diff --git a/apps/admin/src/settings/general/users/staff-token.tsx b/apps/admin/src/settings/general/users/staff-token.tsx index 05d53d52eb9..a777ac24685 100644 --- a/apps/admin/src/settings/general/users/staff-token.tsx +++ b/apps/admin/src/settings/general/users/staff-token.tsx @@ -1,7 +1,7 @@ -import APIKeys from '@/settings/app/components/settings/advanced/integrations/api-keys'; +import APIKeys from '@/settings/advanced/integrations/api-keys'; import {Text} from '@tryghost/shade/primitives'; import {genStaffToken, getStaffToken} from '@tryghost/admin-x-framework/api/staff-token'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; import {useEffect, useState} from 'react'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; diff --git a/apps/admin/src/settings/growth/embed-signup/embed-signup-form-modal.tsx b/apps/admin/src/settings/growth/embed-signup/embed-signup-form-modal.tsx index 8d7ae6805bc..58f180c10ce 100644 --- a/apps/admin/src/settings/growth/embed-signup/embed-signup-form-modal.tsx +++ b/apps/admin/src/settings/growth/embed-signup/embed-signup-form-modal.tsx @@ -1,12 +1,12 @@ import EmbedSignupPreview from './embed-signup-preview'; import EmbedSignupSidebar, {type SelectedLabelTypes} from './embed-signup-sidebar'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; -import {type EmbedSignupLayout, generateCode} from '@/settings/app/utils/generate-embed-code'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; +import {type EmbedSignupLayout, generateCode} from '@/settings/utils/generate-embed-code'; import {SettingsModal} from '@tryghost/shade/patterns'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {useEffect, useState} from 'react'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useGlobalData} from '@/settings/providers/global-data-context'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; const EmbedSignupFormModal = () => { const [selectedColor, setSelectedColor] = useState('#08090c'); diff --git a/apps/admin/src/settings/growth/embed-signup/embed-signup-form.tsx b/apps/admin/src/settings/growth/embed-signup/embed-signup-form.tsx index 17881387ac7..0a06b92d53e 100644 --- a/apps/admin/src/settings/growth/embed-signup/embed-signup-form.tsx +++ b/apps/admin/src/settings/growth/embed-signup/embed-signup-form.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; import {Button} from '@tryghost/shade/components'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; const EmbedSignupForm: React.FC<{ keywords: string[] }> = ({keywords}) => { const {updateRoute} = useSettingsNavigation(); diff --git a/apps/admin/src/settings/growth/embed-signup/embed-signup-preview.tsx b/apps/admin/src/settings/growth/embed-signup/embed-signup-preview.tsx index b006c0a89a8..86dfe2e37a1 100644 --- a/apps/admin/src/settings/growth/embed-signup/embed-signup-preview.tsx +++ b/apps/admin/src/settings/growth/embed-signup/embed-signup-preview.tsx @@ -1,6 +1,6 @@ -import IframeBuffering from '@/settings/app/utils/iframe-buffering'; +import IframeBuffering from '@/settings/utils/iframe-buffering'; import React, {useCallback, useRef} from 'react'; -import {type EmbedSignupLayout, getEmbedPreviewLayoutMarker} from '@/settings/app/utils/generate-embed-code'; +import {type EmbedSignupLayout, getEmbedPreviewLayoutMarker} from '@/settings/utils/generate-embed-code'; type EmbedSignupPreviewProps = { backgroundColor: string; diff --git a/apps/admin/src/settings/growth/embed-signup/embed-signup-sidebar.tsx b/apps/admin/src/settings/growth/embed-signup/embed-signup-sidebar.tsx index 2e90a96f754..89b6ccf3bfa 100644 --- a/apps/admin/src/settings/growth/embed-signup/embed-signup-sidebar.tsx +++ b/apps/admin/src/settings/growth/embed-signup/embed-signup-sidebar.tsx @@ -1,7 +1,7 @@ -import ColorPickerField from '@/settings/app/components/color-picker-field'; +import ColorPickerField from '@/settings/components/color-picker-field'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import {Button, Combobox, ComboboxContent, ComboboxTrigger, ComboboxValue, Field, FieldDescription, FieldGroup, FieldLabel, MultiSelectCombobox, Textarea, ToggleGroup, ToggleGroupItem} from '@tryghost/shade/components'; -import {type EmbedSignupLayout} from '@/settings/app/utils/generate-embed-code'; +import {type EmbedSignupLayout} from '@/settings/utils/generate-embed-code'; import {Inline, Stack, Text} from '@tryghost/shade/primitives'; import {type Label} from '@tryghost/admin-x-framework/api/labels'; import {Plus} from 'lucide-react'; diff --git a/apps/admin/src/settings/growth/explore.tsx b/apps/admin/src/settings/growth/explore.tsx index 687448e8028..6c237d384c2 100644 --- a/apps/admin/src/settings/growth/explore.tsx +++ b/apps/admin/src/settings/growth/explore.tsx @@ -1,18 +1,18 @@ -import FakeLogo from '@/settings/app/assets/images/explore-default-logo.png'; +import FakeLogo from '@/settings/assets/images/explore-default-logo.png'; import React, {useEffect, useState} from 'react'; -import SettingImg from '@/settings/app/assets/images/ghost-explore.png'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; +import SettingImg from '@/settings/assets/images/ghost-explore.png'; +import TopLevelGroup from '@/settings/components/top-level-group'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; import {Button} from '@tryghost/shade/components'; import {Field, FieldContent, FieldDescription, FieldLabel, Separator, Switch} from '@tryghost/shade/components'; import {LucideIcon, abbreviateNumber} from '@tryghost/shade/utils'; import {type Setting, getSettingValue, getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {SettingGroupContent} from '@tryghost/shade/patterns'; import {useBrowseMembers} from '@tryghost/admin-x-framework/api/members'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const Explore: React.FC<{ keywords: string[] }> = ({keywords}) => { const {settings} = useGlobalData(); diff --git a/apps/admin/src/settings/growth/explore/testimonials-modal.tsx b/apps/admin/src/settings/growth/explore/testimonials-modal.tsx index 59cee4d02ae..fb989786cc4 100644 --- a/apps/admin/src/settings/growth/explore/testimonials-modal.tsx +++ b/apps/admin/src/settings/growth/explore/testimonials-modal.tsx @@ -1,6 +1,6 @@ -import AliAbdaal from '@/settings/app/assets/images/ali-abdaal.png'; -import IsaacSaul from '@/settings/app/assets/images/isaac-saul.png'; -import JoelWarner from '@/settings/app/assets/images/joel-warner.png'; +import AliAbdaal from '@/settings/assets/images/ali-abdaal.png'; +import IsaacSaul from '@/settings/assets/images/isaac-saul.png'; +import JoelWarner from '@/settings/assets/images/joel-warner.png'; import React from 'react'; import {Avatar, Field, FieldError, FieldGroup, FieldLabel, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Textarea} from '@tryghost/shade/components'; import {Button, LoadingIndicator} from '@tryghost/shade/components'; @@ -8,8 +8,8 @@ import {SettingsModal} from '@tryghost/shade/patterns'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {toast} from 'sonner'; import {useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useGlobalData} from '@/settings/providers/global-data-context'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; interface FormState { content: string; diff --git a/apps/admin/src/settings/growth/growth-settings.tsx b/apps/admin/src/settings/growth/growth-settings.tsx index 2b68f8ecfbf..72fb523a346 100644 --- a/apps/admin/src/settings/growth/growth-settings.tsx +++ b/apps/admin/src/settings/growth/growth-settings.tsx @@ -4,10 +4,10 @@ import Network from './network'; import Offers from './offers'; import React from 'react'; import Recommendations from './recommendations'; -import SearchableSection from '@/settings/app/components/searchable-section'; +import SearchableSection from '@/settings/components/searchable-section'; import {checkStripeEnabled} from '@tryghost/admin-x-framework/api/settings'; import {searchKeywords} from './search-keywords'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; const GrowthSettings: React.FC = () => { const {config, settings} = useGlobalData(); diff --git a/apps/admin/src/settings/growth/network.tsx b/apps/admin/src/settings/growth/network.tsx index bfe77585731..86ade83c2f3 100644 --- a/apps/admin/src/settings/growth/network.tsx +++ b/apps/admin/src/settings/growth/network.tsx @@ -1,17 +1,17 @@ import React from 'react'; -import SettingImg from '@/settings/app/assets/images/network.png'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; +import SettingImg from '@/settings/assets/images/network.png'; +import TopLevelGroup from '@/settings/components/top-level-group'; import validator from 'validator'; import {LucideIcon} from '@tryghost/shade/utils'; import {type Setting, getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {SettingGroupContent} from '@tryghost/shade/patterns'; import {Switch} from '@tryghost/shade/components'; import {getGhostPaths} from '@tryghost/admin-x-framework/helpers'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useLimiter} from '@/settings/app/hooks/use-limiter'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {useLimiter} from '@/settings/hooks/use-limiter'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const Network: React.FC<{ keywords: string[] }> = ({keywords}) => { const {settings} = useGlobalData(); diff --git a/apps/admin/src/settings/growth/offers.tsx b/apps/admin/src/settings/growth/offers.tsx index 82d3b5ed8b1..d7dd75e3081 100644 --- a/apps/admin/src/settings/growth/offers.tsx +++ b/apps/admin/src/settings/growth/offers.tsx @@ -1,12 +1,12 @@ import React from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; import {Button} from '@tryghost/shade/components'; import {checkStripeEnabled} from '@tryghost/admin-x-framework/api/settings'; import {getPaidActiveTiers, useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers'; import {useBrowseOffers} from '@tryghost/admin-x-framework/api/offers'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {useGlobalData} from '@/settings/providers/global-data-context'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const Offers: React.FC<{ keywords: string[] }> = ({keywords}) => { const {updateRoute} = useSettingsNavigation(); diff --git a/apps/admin/src/settings/growth/offers/add-offer-modal.tsx b/apps/admin/src/settings/growth/offers/add-offer-modal.tsx index 73dd0f299d7..36254a5016a 100644 --- a/apps/admin/src/settings/growth/offers/add-offer-modal.tsx +++ b/apps/admin/src/settings/growth/offers/add-offer-modal.tsx @@ -2,18 +2,18 @@ import PortalFrame from '@/settings/membership/portal/portal-frame'; import {Button, Field, FieldContent, FieldDescription, FieldError, FieldGroup, FieldLabel, Input, InputGroup, InputGroupAddon, InputGroupInput, InputGroupText, RadioGroup, RadioGroupItem, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Textarea} from '@tryghost/shade/components'; import {type ErrorMessages, useForm} from '@tryghost/admin-x-framework/hooks'; import {JSONError} from '@tryghost/admin-x-framework/errors'; -import {PreviewModalContent} from '@/settings/app/components/settings/preview-modal'; +import {PreviewModalContent} from '@/settings/components/preview-modal'; import {formatNumber} from '@tryghost/shade/utils'; import {getHomepageUrl} from '@tryghost/admin-x-framework/api/site'; -import {getOfferPortalPreviewUrl, type offerPortalPreviewUrlTypes} from '@/settings/app/utils/get-offers-portal-preview-url'; +import {getOfferPortalPreviewUrl, type offerPortalPreviewUrlTypes} from '@/settings/utils/get-offers-portal-preview-url'; import {getPaidActiveTiers, useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers'; -import {getTiersCadences} from '@/settings/app/utils/get-tiers-cadences'; +import {getTiersCadences} from '@/settings/utils/get-tiers-cadences'; import {toast} from 'sonner'; import {useAddOffer} from '@tryghost/admin-x-framework/api/offers'; import {useBrowseOffers} from '@tryghost/admin-x-framework/api/offers'; import {useEffect, useMemo, useState} from 'react'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useGlobalData} from '@/settings/providers/global-data-context'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; // we should replace this with a library function slugify(text: string): string { diff --git a/apps/admin/src/settings/growth/offers/edit-offer-modal.tsx b/apps/admin/src/settings/growth/offers/edit-offer-modal.tsx index 0ba5e7c992a..c29307ea3a6 100644 --- a/apps/admin/src/settings/growth/offers/edit-offer-modal.tsx +++ b/apps/admin/src/settings/growth/offers/edit-offer-modal.tsx @@ -1,19 +1,19 @@ import PortalFrame from '@/settings/membership/portal/portal-frame'; -import SettingsBreadcrumbs from '@/settings/app/components/settings/settings-breadcrumbs'; +import SettingsBreadcrumbs from '@/settings/components/settings-breadcrumbs'; import {Button, Field, FieldDescription, FieldError, FieldGroup, FieldLabel, Input, InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, Textarea} from '@tryghost/shade/components'; import {type ErrorMessages, useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; import {JSONError} from '@tryghost/admin-x-framework/errors'; import {type Offer, useBrowseOffersById, useEditOffer} from '@tryghost/admin-x-framework/api/offers'; -import {PreviewModalContent} from '@/settings/app/components/settings/preview-modal'; +import {PreviewModalContent} from '@/settings/components/preview-modal'; import {createOfferRedemptionFilterUrl} from './offer-helpers'; import {formatNumber} from '@tryghost/shade/utils'; import {getHomepageUrl} from '@tryghost/admin-x-framework/api/site'; -import {getOfferPortalPreviewUrl, type offerPortalPreviewUrlTypes} from '@/settings/app/utils/get-offers-portal-preview-url'; +import {getOfferPortalPreviewUrl, type offerPortalPreviewUrlTypes} from '@/settings/utils/get-offers-portal-preview-url'; import {toast} from 'sonner'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; import {useEffect, useState} from 'react'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useGlobalData} from '@/settings/providers/global-data-context'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; function formatTimestamp(timestamp: string): string { const date = new Date(timestamp); diff --git a/apps/admin/src/settings/growth/offers/edit-retention-offer-modal.tsx b/apps/admin/src/settings/growth/offers/edit-retention-offer-modal.tsx index c593d544af0..570f65b384e 100644 --- a/apps/admin/src/settings/growth/offers/edit-retention-offer-modal.tsx +++ b/apps/admin/src/settings/growth/offers/edit-retention-offer-modal.tsx @@ -1,18 +1,18 @@ import PortalFrame from '@/settings/membership/portal/portal-frame'; -import SettingsBreadcrumbs from '@/settings/app/components/settings/settings-breadcrumbs'; +import SettingsBreadcrumbs from '@/settings/components/settings-breadcrumbs'; import {type ErrorMessages, useForm} from '@tryghost/admin-x-framework/hooks'; import {Field, FieldContent, FieldDescription, FieldError, FieldGroup, FieldLabel, Input, InputGroup, InputGroupAddon, InputGroupInput, InputGroupText, RadioGroup, RadioGroupItem, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch, Textarea} from '@tryghost/shade/components'; import {JSONError} from '@tryghost/admin-x-framework/errors'; import {type Offer, useAddOffer, useBrowseOffers, useEditOffer, useInvalidateOffers} from '@tryghost/admin-x-framework/api/offers'; -import {PreviewModalContent} from '@/settings/app/components/settings/preview-modal'; +import {PreviewModalContent} from '@/settings/components/preview-modal'; import {createOfferRedemptionsFilterUrl, formatOfferTimestamp, generateRetentionOfferName} from './offer-helpers'; import {formatNumber} from '@tryghost/shade/utils'; -import {getOfferPortalPreviewUrl, type offerPortalPreviewUrlTypes} from '@/settings/app/utils/get-offers-portal-preview-url'; +import {getOfferPortalPreviewUrl, type offerPortalPreviewUrlTypes} from '@/settings/utils/get-offers-portal-preview-url'; import {getPaidActiveTiers, useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers'; import {toast} from 'sonner'; import {useEffect, useMemo, useState} from 'react'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useGlobalData} from '@/settings/providers/global-data-context'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; type RetentionOfferFormState = { enabled: boolean; diff --git a/apps/admin/src/settings/growth/offers/offer-helpers.ts b/apps/admin/src/settings/growth/offers/offer-helpers.ts index f626814a868..ea66664c8e9 100644 --- a/apps/admin/src/settings/growth/offers/offer-helpers.ts +++ b/apps/admin/src/settings/growth/offers/offer-helpers.ts @@ -1,5 +1,5 @@ import {type Tier} from '@tryghost/admin-x-framework/api/tiers'; -import {currencyToDecimal, getSymbol} from '@/settings/app/utils/currency'; +import {currencyToDecimal, getSymbol} from '@/settings/utils/currency'; import {formatNumber} from '@tryghost/shade/utils'; import {getGhostPaths} from '@tryghost/admin-x-framework/helpers'; diff --git a/apps/admin/src/settings/growth/offers/offer-success.tsx b/apps/admin/src/settings/growth/offers/offer-success.tsx index 886675f9fe7..f0f016c1287 100644 --- a/apps/admin/src/settings/growth/offers/offer-success.tsx +++ b/apps/admin/src/settings/growth/offers/offer-success.tsx @@ -1,15 +1,15 @@ -import BrandIcon from '@/settings/app/components/icons/brand-icon'; -import SettingsBreadcrumbs from '@/settings/app/components/settings/settings-breadcrumbs'; +import BrandIcon from '@/settings/components/icons/brand-icon'; +import SettingsBreadcrumbs from '@/settings/components/settings-breadcrumbs'; import {Button, Input} from '@tryghost/shade/components'; import {LucideIcon} from '@tryghost/shade/utils'; import {type Offer, useBrowseOffersById} from '@tryghost/admin-x-framework/api/offers'; import {SettingsModal} from '@tryghost/shade/patterns'; -import {currencyToDecimal} from '@/settings/app/utils/currency'; +import {currencyToDecimal} from '@/settings/utils/currency'; import {formatNumber} from '@tryghost/shade/utils'; import {getHomepageUrl} from '@tryghost/admin-x-framework/api/site'; import {useEffect, useState} from 'react'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useGlobalData} from '@/settings/providers/global-data-context'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; const OfferSuccess: React.FC<{id: string}> = ({id}) => { const {updateRoute} = useSettingsNavigation(); diff --git a/apps/admin/src/settings/growth/offers/offers-container-modal.tsx b/apps/admin/src/settings/growth/offers/offers-container-modal.tsx index 32d88808901..9e6181bc108 100644 --- a/apps/admin/src/settings/growth/offers/offers-container-modal.tsx +++ b/apps/admin/src/settings/growth/offers/offers-container-modal.tsx @@ -3,7 +3,7 @@ import EditOfferModal from './edit-offer-modal'; import EditRetentionOfferModal from './edit-retention-offer-modal'; import OfferSuccess from './offer-success'; import {OffersIndexModal} from './offers-index'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; type OffersRouteHandlerProps = { route: string; diff --git a/apps/admin/src/settings/growth/offers/offers-index.tsx b/apps/admin/src/settings/growth/offers/offers-index.tsx index c05440910b2..a3a6da9dca7 100644 --- a/apps/admin/src/settings/growth/offers/offers-index.tsx +++ b/apps/admin/src/settings/growth/offers/offers-index.tsx @@ -7,8 +7,8 @@ import {SettingsModal} from '@tryghost/shade/patterns'; import {type Tier, getPaidActiveTiers, useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers'; import {createOfferRedemptionFilterUrl, createOfferRedemptionsFilterUrl, getOfferCadence, getOfferDiscount, getOfferDuration} from './offer-helpers'; import {toast} from 'sonner'; -import {useOffersShowArchived, useSortingState} from '@/settings/app/components/providers/settings-app-provider'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useOffersShowArchived, useSortingState} from '@/settings/providers/settings-app-context'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; const OffersFilterMenu: React.FC<{ showArchived: boolean; diff --git a/apps/admin/src/settings/growth/recommendations.tsx b/apps/admin/src/settings/growth/recommendations.tsx index f426cfcf9a9..deef48b2985 100644 --- a/apps/admin/src/settings/growth/recommendations.tsx +++ b/apps/admin/src/settings/growth/recommendations.tsx @@ -1,16 +1,16 @@ import IncomingRecommendationList from './recommendations/incoming-recommendation-list'; import React, {useState} from 'react'; import RecommendationList from './recommendations/recommendation-list'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; import {Button} from '@tryghost/shade/components'; import {Tabs, TabsContent, TabsList, TabsTrigger, TabsTriggerCount} from '@tryghost/shade/components'; import {formatNumber} from '@tryghost/shade/utils'; import {keepPreviousData} from '@tanstack/react-query'; import {useBrowseIncomingRecommendations, useBrowseRecommendations} from '@tryghost/admin-x-framework/api/recommendations'; import {useReferrerHistory} from '@tryghost/admin-x-framework/api/referrers'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const Recommendations: React.FC<{ keywords: string[] }> = ({keywords}) => { const { diff --git a/apps/admin/src/settings/growth/recommendations/add-recommendation-modal-confirm.tsx b/apps/admin/src/settings/growth/recommendations/add-recommendation-modal-confirm.tsx index edb92496993..43fedc8c6f4 100644 --- a/apps/admin/src/settings/growth/recommendations/add-recommendation-modal-confirm.tsx +++ b/apps/admin/src/settings/growth/recommendations/add-recommendation-modal-confirm.tsx @@ -1,7 +1,7 @@ import React from 'react'; import RecommendationDescriptionForm from './recommendation-description-form'; import {validateDescriptionForm} from './recommendation-validation'; -import trackEvent from '@/settings/app/utils/analytics'; +import trackEvent from '@/settings/utils/analytics'; import {Button} from '@tryghost/shade/components'; import {type EditOrAddRecommendation, useAddRecommendation} from '@tryghost/admin-x-framework/api/recommendations'; import {LucideIcon} from '@tryghost/shade/utils'; diff --git a/apps/admin/src/settings/growth/recommendations/add-recommendation-modal.tsx b/apps/admin/src/settings/growth/recommendations/add-recommendation-modal.tsx index b0af9b4644c..46f417b9212 100644 --- a/apps/admin/src/settings/growth/recommendations/add-recommendation-modal.tsx +++ b/apps/admin/src/settings/growth/recommendations/add-recommendation-modal.tsx @@ -5,9 +5,9 @@ import {type EditOrAddRecommendation, useCheckRecommendation} from '@tryghost/ad import {type ErrorMessages, useForm} from '@tryghost/admin-x-framework/hooks'; import {Field, FieldDescription, FieldError, FieldGroup, FieldLabel, Input, LoadingIndicator} from '@tryghost/shade/components'; import {useSearchParams} from '@tryghost/admin-x-framework'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; import {SettingsModal} from '@tryghost/shade/patterns'; -import {formatUrl} from '@/settings/app/utils/format-url'; +import {formatUrl} from '@/settings/utils/format-url'; import {toast} from 'sonner'; const doFormatUrl = (url: string) => { diff --git a/apps/admin/src/settings/growth/recommendations/edit-recommendation-modal.tsx b/apps/admin/src/settings/growth/recommendations/edit-recommendation-modal.tsx index 61c4a3d9c3e..a9d574af94b 100644 --- a/apps/admin/src/settings/growth/recommendations/edit-recommendation-modal.tsx +++ b/apps/admin/src/settings/growth/recommendations/edit-recommendation-modal.tsx @@ -5,7 +5,7 @@ import {Button} from '@tryghost/shade/components'; import {type Recommendation, useDeleteRecommendation, useEditRecommendation} from '@tryghost/admin-x-framework/api/recommendations'; import {SettingsModal} from '@tryghost/shade/patterns'; import {toast} from 'sonner'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; import {useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; interface EditRecommendationModalProps { diff --git a/apps/admin/src/settings/growth/recommendations/incoming-recommendation-list.tsx b/apps/admin/src/settings/growth/recommendations/incoming-recommendation-list.tsx index 9d8cfe23df6..11556555820 100644 --- a/apps/admin/src/settings/growth/recommendations/incoming-recommendation-list.tsx +++ b/apps/admin/src/settings/growth/recommendations/incoming-recommendation-list.tsx @@ -6,7 +6,7 @@ import {type IncomingRecommendation} from '@tryghost/admin-x-framework/api/recom import {Inline} from '@tryghost/shade/primitives'; import {type ReferrerHistoryItem} from '@tryghost/admin-x-framework/api/referrers'; import {cn, formatNumber} from '@tryghost/shade/utils'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; interface IncomingRecommendationListProps { incomingRecommendations: IncomingRecommendation[], diff --git a/apps/admin/src/settings/growth/recommendations/recommendation-icon.tsx b/apps/admin/src/settings/growth/recommendations/recommendation-icon.tsx index c3ecea1cd4c..2ceccf2437b 100644 --- a/apps/admin/src/settings/growth/recommendations/recommendation-icon.tsx +++ b/apps/admin/src/settings/growth/recommendations/recommendation-icon.tsx @@ -1,5 +1,5 @@ /* eslint-disable camelcase */ -import GhostLogo from '@/settings/app/assets/images/ghost-favicon.png'; +import GhostLogo from '@/settings/assets/images/ghost-favicon.png'; import React, {useState} from 'react'; interface Props { diff --git a/apps/admin/src/settings/growth/recommendations/recommendation-list.tsx b/apps/admin/src/settings/growth/recommendations/recommendation-list.tsx index a595b982442..da24480faf8 100644 --- a/apps/admin/src/settings/growth/recommendations/recommendation-list.tsx +++ b/apps/admin/src/settings/growth/recommendations/recommendation-list.tsx @@ -1,12 +1,12 @@ import EditRecommendationModal from './edit-recommendation-modal'; import React, {useState} from 'react'; import RecommendationIcon from './recommendation-icon'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; import {ActionList, ActionListItem, ActionListItemContent, Button, LoadingIndicator, NoValueLabel, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from '@tryghost/shade/components'; import {Inline} from '@tryghost/shade/primitives'; import {LucideIcon, formatNumber} from '@tryghost/shade/utils'; import {type Recommendation} from '@tryghost/admin-x-framework/api/recommendations'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; interface RecommendationListProps { recommendations: Recommendation[], diff --git a/apps/admin/src/settings/growth/tips-and-donations-settings.ts b/apps/admin/src/settings/growth/tips-and-donations-settings.ts index bf3a98b8378..9b1b95ea2ff 100644 --- a/apps/admin/src/settings/growth/tips-and-donations-settings.ts +++ b/apps/admin/src/settings/growth/tips-and-donations-settings.ts @@ -1,4 +1,4 @@ -import {currencySelectGroups} from '@/settings/app/utils/currency'; +import {currencySelectGroups} from '@/settings/utils/currency'; import {z} from 'zod'; const DEFAULT_DONATIONS_CURRENCY = 'USD'; diff --git a/apps/admin/src/settings/growth/tips-and-donations.tsx b/apps/admin/src/settings/growth/tips-and-donations.tsx index c780ef7df6c..26d292bf8e2 100644 --- a/apps/admin/src/settings/growth/tips-and-donations.tsx +++ b/apps/admin/src/settings/growth/tips-and-donations.tsx @@ -1,15 +1,15 @@ import React, {useEffect, useState} from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; -import useCurrencyInput from '@/settings/app/hooks/use-currency-input'; -import useSettingGroup from '@/settings/app/hooks/use-setting-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; +import useCurrencyInput from '@/settings/hooks/use-currency-input'; +import useSettingGroup from '@/settings/hooks/use-setting-group'; import {Button, CopyField, CopyFieldActions, CopyFieldContent, CopyFieldCopyButton, CopyFieldLabel, CopyFieldValue, Field, FieldError, FieldLabel, InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, MultiSelectCombobox, Popover, PopoverContent, PopoverTrigger} from '@tryghost/shade/components'; import {ChevronDown} from 'lucide-react'; import {DirtyConfirmDialog, useDirtyConfirmation} from '@tryghost/shade/patterns'; import {SettingGroupContent} from '@tryghost/shade/patterns'; -import {currencySelectGroups, validateCurrencyAmount} from '@/settings/app/utils/currency'; +import {currencySelectGroups, validateCurrencyAmount} from '@/settings/utils/currency'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {parseTipsAndDonationsSettings} from './tips-and-donations-settings'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; // Stripe doesn't allow amounts over 10,000 as a preset amount const MAX_AMOUNT = 10_000; diff --git a/apps/admin/src/settings/app/hooks/use-scroll-section.tsx b/apps/admin/src/settings/hooks/scroll-section-provider.tsx similarity index 78% rename from apps/admin/src/settings/app/hooks/use-scroll-section.tsx rename to apps/admin/src/settings/hooks/scroll-section-provider.tsx index c63ad962e0b..f415dc9b4db 100644 --- a/apps/admin/src/settings/app/hooks/use-scroll-section.tsx +++ b/apps/admin/src/settings/hooks/scroll-section-provider.tsx @@ -1,27 +1,10 @@ -import {type ReactNode, createContext, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; - -interface ScrollSectionContextData { - updateSection: (id: string, element: HTMLDivElement) => void; - updateNav: (id: string, element: HTMLLIElement) => void; - currentSection: string | null; - updateNavigatedSection: (id: string) => void; - scrollToSection: (id: string) => void; -} - -const ScrollSectionContext = createContext({ - updateSection: () => {}, - updateNav: () => {}, - currentSection: null, - updateNavigatedSection: () => {}, - scrollToSection: () => {} -}); - -export const useScrollSectionContext = () => useContext(ScrollSectionContext); +import {type ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {ScrollSectionContext} from './use-scroll-section'; const scrollMargin = 193; const scrollToSection = (element: HTMLDivElement, doneInitialScroll: boolean) => { - const root = document.getElementById('admin-x-settings-scroller')!; + const root = document.getElementById('settings-scroller')!; const top = element.getBoundingClientRect().top + root.scrollTop; root.scrollTo({ @@ -31,8 +14,7 @@ const scrollToSection = (element: HTMLDivElement, doneInitialScroll: boolean) => }; const scrollSidebarNav = (navElement: HTMLLIElement, doneInitialScroll: boolean) => { - // const sidebar = document.getElementById('admin-x-settings-sidebar')!; - const sidebar = document.getElementById('admin-x-settings-sidebar-scroller')!; + const sidebar = document.getElementById('settings-sidebar-scroller')!; const bounds = navElement.getBoundingClientRect(); @@ -213,40 +195,3 @@ export const ScrollSectionProvider: React.FC<{ ); }; - -export const useScrollSection = (id?: string) => { - const {updateSection} = useScrollSectionContext(); - const ref = useRef(null); - - useEffect(() => { - if (id && ref.current) { - updateSection(id, ref.current); - } - }, [id, updateSection]); - - return { - ref - }; -}; - -export const useScrollSectionNav = (id?: string | string[]) => { - const {updateNav} = useScrollSectionContext(); - const ref = useRef(null); - - useEffect(() => { - if (id && ref.current) { - // Convert to array if it's a string - const ids = Array.isArray(id) ? id : [id]; - - // Register the nav element for all IDs - ids.forEach((navId) => { - updateNav(navId, ref.current!); - }); - } - }, [id, updateNav]); - - return { - ref, - props: {'data-setting-nav-item': true} - }; -}; diff --git a/apps/admin/src/settings/hooks/site/use-navigation-editor.test.tsx b/apps/admin/src/settings/hooks/site/use-navigation-editor.test.tsx new file mode 100644 index 00000000000..9bed4af881e --- /dev/null +++ b/apps/admin/src/settings/hooks/site/use-navigation-editor.test.tsx @@ -0,0 +1,192 @@ +import * as assert from 'node:assert/strict'; +import useNavigationEditor, {type NavigationItem} from '@/settings/hooks/site/use-navigation-editor'; +import {act, renderHook, waitFor} from '@testing-library/react'; + +describe('useNavigationEditor', function () { + it('defaults optional icon and visibility fields for editing', function () { + const {result} = renderHook(() => useNavigationEditor({ + items: [{label: 'Home', url: '/'}], + setItems: vi.fn() + })); + + assert.equal(result.current.items[0].icon, ''); + assert.equal(result.current.items[0].visibility, 'public'); + }); + + it('serializes icon and non-public visibility when an item changes', async function () { + const setItems = vi.fn(); + const {result} = renderHook(() => useNavigationEditor({ + items: [{label: 'Home', url: '/'}], + setItems + })); + + act(() => { + result.current.updateItem('0', { + icon: ' https://example.com/nav.svg ', + visibility: 'members' + }); + }); + + await waitFor(() => { + assert.deepEqual(setItems.mock.calls.at(-1)?.[0], [{ + url: '/', + label: 'Home', + icon: 'https://example.com/nav.svg', + visibility: 'members' + }]); + }); + }); + + it('serializes granular visibility combinations', async function () { + const setItems = vi.fn(); + const {result} = renderHook(() => useNavigationEditor({ + items: [{label: 'Home', url: '/'}], + setItems + })); + + act(() => { + result.current.updateItem('0', { + visibility: 'public_free' + }); + }); + + await waitFor(() => { + assert.deepEqual(setItems.mock.calls.at(-1)?.[0], [{ + url: '/', + label: 'Home', + visibility: 'public_free' + }]); + }); + }); + + it('serializes hidden visibility', async function () { + const setItems = vi.fn(); + const {result} = renderHook(() => useNavigationEditor({ + items: [{label: 'Home', url: '/'}], + setItems + })); + + act(() => { + result.current.updateItem('0', { + visibility: 'none' + }); + }); + + await waitFor(() => { + assert.deepEqual(setItems.mock.calls.at(-1)?.[0], [{ + url: '/', + label: 'Home', + visibility: 'none' + }]); + }); + }); + + it('omits empty icon and public visibility when serializing new items', async function () { + const setItems = vi.fn(); + const {result} = renderHook(() => useNavigationEditor({ + items: [], + setItems + })); + + act(() => { + result.current.setNewItem({ + label: ' Blog ', + url: '/blog/', + icon: '', + visibility: 'public' + }); + }); + + act(() => { + result.current.addItem(); + }); + + await waitFor(() => { + assert.deepEqual(setItems.mock.calls.at(-1)?.[0], [{ + url: '/blog/', + label: 'Blog' + }]); + }); + }); + + it('allows adding an icon-only item', async function () { + const setItems = vi.fn(); + const {result} = renderHook(() => useNavigationEditor({ + items: [], + setItems + })); + + act(() => { + result.current.setNewItem({ + label: '', + url: '/icon-only/', + icon: 'https://example.com/nav.svg' + }); + }); + + act(() => { + result.current.addItem(); + }); + + await waitFor(() => { + assert.deepEqual(setItems.mock.calls.at(-1)?.[0], [{ + url: '/icon-only/', + label: '', + icon: 'https://example.com/nav.svg' + }]); + }); + }); + + it('requires either a label or an icon', async function () { + const {result} = renderHook(() => useNavigationEditor({ + items: [{ + label: '', + url: '/missing-label-and-icon/' + }], + setItems: vi.fn() + })); + + act(() => { + assert.equal(result.current.validate(), false); + }); + + await waitFor(() => { + assert.equal(result.current.items[0].errors.label, 'You must specify a label or icon'); + }); + }); + + it('validates visibility values', async function () { + const {result} = renderHook(() => useNavigationEditor({ + items: [{ + label: 'Members', + url: '/members/', + icon: 'mailto:test@example.com', + visibility: 'invalid' + } as unknown as NavigationItem], + setItems: vi.fn() + })); + + act(() => { + assert.equal(result.current.validate(), false); + }); + + await waitFor(() => { + assert.equal(result.current.items[0].errors.visibility, 'You must specify a valid visibility'); + }); + }); + + it('does not show user-facing validation errors for stored icon URLs', function () { + const {result} = renderHook(() => useNavigationEditor({ + items: [{ + label: 'FTP icon', + url: '/ftp-icon/', + icon: 'ftp://example.com/icon.svg' + }], + setItems: vi.fn() + })); + + act(() => { + assert.equal(result.current.validate(), true); + }); + }); +}); diff --git a/apps/admin/src/settings/app/hooks/site/use-navigation-editor.tsx b/apps/admin/src/settings/hooks/site/use-navigation-editor.tsx similarity index 63% rename from apps/admin/src/settings/app/hooks/site/use-navigation-editor.tsx rename to apps/admin/src/settings/hooks/site/use-navigation-editor.tsx index 8a30535f478..110ef5b8657 100644 --- a/apps/admin/src/settings/app/hooks/site/use-navigation-editor.tsx +++ b/apps/admin/src/settings/hooks/site/use-navigation-editor.tsx @@ -1,16 +1,22 @@ -import useSortableIndexedList from '@/settings/app/hooks/use-sortable-indexed-list'; +import useSortableIndexedList from '@/settings/hooks/use-sortable-indexed-list'; import validator from 'validator'; import {useCallback, useMemo} from 'react'; +export const NAVIGATION_ITEM_VISIBILITY = ['public', 'members', 'paid', 'public_free', 'public_paid', 'public_only', 'free_members', 'none'] as const; +export type NavigationItemVisibility = typeof NAVIGATION_ITEM_VISIBILITY[number]; + export type NavigationItem = { label: string; url: string; + icon?: string; + visibility?: NavigationItemVisibility; } export type NavigationItemErrors = { [key in keyof NavigationItem]?: string } export type EditableItem = NavigationItem & { id: string; errors: NavigationItemErrors } -const hasNewItem = (newItem: NavigationItem) => Boolean((newItem.label && !newItem.label.match(/^\s*$/)) || newItem.url !== '/'); +const hasTextValue = (value?: string) => Boolean(value && !value.match(/^\s*$/)); +const hasNewItem = (newItem: NavigationItem) => Boolean(hasTextValue(newItem.label) || newItem.url !== '/' || hasTextValue(newItem.icon)); export type NavigationEditor = { items: EditableItem[] @@ -24,41 +30,71 @@ export type NavigationEditor = { validate: () => boolean } +const normalizeItem = (item: NavigationItem) => ({ + ...item, + icon: item.icon || '', + visibility: item.visibility || 'public', + errors: {} +}); + +const serializeItem = ({url, label, icon, visibility}: NavigationItem): NavigationItem => ({ + url: url.trim(), + label: label.trim(), + ...(icon?.trim() ? {icon: icon.trim()} : {}), + ...(visibility && visibility !== 'public' ? {visibility} : {}) +}); + const useNavigationEditor = ({items, setItems}: { items: NavigationItem[]; setItems: (newItems: NavigationItem[]) => void; }): NavigationEditor => { - const editableItems = useMemo(() => items.map(item => ({...item, errors: {}})), [items]); + const editableItems = useMemo(() => items.map(normalizeItem), [items]); const setNavigationItems = useCallback((newItems: Omit[]) => { - setItems(newItems.map(({url, label}) => ({url, label}))); + setItems(newItems.map(serializeItem)); }, [setItems]); const list = useSortableIndexedList>({ items: editableItems, setItems: setNavigationItems, - blank: {url: '/',label: '', errors: {}}, + blank: {url: '/', label: '', icon: '', visibility: 'public', errors: {}}, canAddNewItem: hasNewItem }); const urlRegex = new RegExp(/^(\/|#|[a-zA-Z0-9-]+:)/); - const validateItem = (item: NavigationItem) => { const errors: NavigationItemErrors = {}; + const hasLabel = hasTextValue(item.label); + const hasIcon = hasTextValue(item.icon); - if (!item.label || item.label.match(/^\s*$/)) { - errors.label = 'You must specify a label'; + if (!hasLabel && !hasIcon) { + errors.label = 'You must specify a label or icon'; } if (!item.url || item.url.match(/\s/) || (!validator.isURL(item.url, {require_protocol: true}) && !item.url.match(urlRegex))) { errors.url = 'You must specify a valid URL or relative path'; } + if (item.visibility && !NAVIGATION_ITEM_VISIBILITY.includes(item.visibility)) { + errors.visibility = 'You must specify a valid visibility'; + } + return errors; }; + const mergeItemUpdates = (currentItem: Omit, item: Partial): Omit => { + const errors = {...currentItem.errors}; + + if (hasTextValue(item.icon)) { + errors.icon = undefined; + errors.label = undefined; + } + + return {...currentItem, ...item, errors}; + }; + const updateItem = (id: string, item: Partial) => { const currentItem = list.items.find(current => current.id === id)!; - list.updateItem(id, {...currentItem.item, ...item}); + list.updateItem(id, mergeItemUpdates(currentItem.item, item)); }; const addItem = () => { @@ -99,7 +135,7 @@ const useNavigationEditor = ({items, setItems}: { moveItem, newItem: {...list.newItem, id: newItemId}, - setNewItem: item => list.setNewItem({...list.newItem, ...item}), + setNewItem: item => list.setNewItem(mergeItemUpdates(list.newItem, item)), clearError, validate: () => { diff --git a/apps/admin/src/settings/app/hooks/use-auto-expandable.tsx b/apps/admin/src/settings/hooks/use-auto-expandable.tsx similarity index 93% rename from apps/admin/src/settings/app/hooks/use-auto-expandable.tsx rename to apps/admin/src/settings/hooks/use-auto-expandable.tsx index 8bb589f9681..3e205a0efe0 100644 --- a/apps/admin/src/settings/app/hooks/use-auto-expandable.tsx +++ b/apps/admin/src/settings/hooks/use-auto-expandable.tsx @@ -1,5 +1,5 @@ import {useEffect, useRef, useState} from 'react'; -import {useSearch} from '@/settings/app/components/providers/settings-app-provider'; +import {useSearch} from '@/settings/providers/settings-app-context'; /** * Simple hook to auto-expand a component when it's the only search result. diff --git a/apps/admin/src/settings/app/hooks/use-check-theme-limit-error.tsx b/apps/admin/src/settings/hooks/use-check-theme-limit-error.tsx similarity index 95% rename from apps/admin/src/settings/app/hooks/use-check-theme-limit-error.tsx rename to apps/admin/src/settings/hooks/use-check-theme-limit-error.tsx index 4ac969c183e..fd8ad193169 100644 --- a/apps/admin/src/settings/app/hooks/use-check-theme-limit-error.tsx +++ b/apps/admin/src/settings/hooks/use-check-theme-limit-error.tsx @@ -1,6 +1,6 @@ import {HostLimitError, useLimiter} from './use-limiter'; import {useCallback} from 'react'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; interface UseCheckThemeLimitErrorReturn { checkThemeLimitError: (themeName?: string) => Promise; diff --git a/apps/admin/src/settings/app/hooks/use-currency-input.ts b/apps/admin/src/settings/hooks/use-currency-input.ts similarity index 100% rename from apps/admin/src/settings/app/hooks/use-currency-input.ts rename to apps/admin/src/settings/hooks/use-currency-input.ts diff --git a/apps/admin/src/settings/app/hooks/use-custom-fonts.tsx b/apps/admin/src/settings/hooks/use-custom-fonts.tsx similarity index 93% rename from apps/admin/src/settings/app/hooks/use-custom-fonts.tsx rename to apps/admin/src/settings/hooks/use-custom-fonts.tsx index 1c417c630fd..a41d458ff70 100644 --- a/apps/admin/src/settings/app/hooks/use-custom-fonts.tsx +++ b/apps/admin/src/settings/hooks/use-custom-fonts.tsx @@ -7,7 +7,7 @@ const useCustomFonts = () => { const supportsCustomFonts = !activeTheme?.warnings?.some(warning => warning.code === 'GS051-CUSTOM-FONTS'); const refreshActiveThemeData = useCallback(() => { - activeThemes.refetch(); + void activeThemes.refetch(); }, [activeThemes]); return {supportsCustomFonts, refreshActiveThemeData}; diff --git a/apps/admin/src/settings/app/hooks/use-feature-flag.tsx b/apps/admin/src/settings/hooks/use-feature-flag.tsx similarity index 64% rename from apps/admin/src/settings/app/hooks/use-feature-flag.tsx rename to apps/admin/src/settings/hooks/use-feature-flag.tsx index 362e54e0342..19437001cd7 100644 --- a/apps/admin/src/settings/app/hooks/use-feature-flag.tsx +++ b/apps/admin/src/settings/hooks/use-feature-flag.tsx @@ -1,4 +1,4 @@ -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; const useFeatureFlag = (flag: string) => { const {config} = useGlobalData(); diff --git a/apps/admin/src/settings/app/hooks/use-limiter.tsx b/apps/admin/src/settings/hooks/use-limiter.tsx similarity index 93% rename from apps/admin/src/settings/app/hooks/use-limiter.tsx rename to apps/admin/src/settings/hooks/use-limiter.tsx index 23b9371c37c..bde5fec73ce 100644 --- a/apps/admin/src/settings/app/hooks/use-limiter.tsx +++ b/apps/admin/src/settings/hooks/use-limiter.tsx @@ -2,7 +2,7 @@ import useStaffUsers from './use-staff-users'; import {useBrowseMembers} from '@tryghost/admin-x-framework/api/members'; import {useBrowseNewsletters} from '@tryghost/admin-x-framework/api/newsletters'; import {useEffect, useMemo, useState} from 'react'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; const limitServiceImport = import('@tryghost/limit-service'); @@ -49,10 +49,10 @@ interface LimiterLimits { export const useLimiter = () => { const {config} = useGlobalData(); - const [LimitService, setLimitService] = useState(null); + const [LimitService, setLimitService] = useState(null); useEffect(() => { - limitServiceImport.then(exports => setLimitService(() => exports.default)); + void limitServiceImport.then(exports => setLimitService(() => exports.default)); }, []); const {users, contributorUsers, invites, isLoading} = useStaffUsers(); @@ -92,13 +92,13 @@ export const useLimiter = () => { const limiter = new LimitService(); if (limits.staff) { - limits.staff.currentCountQuery = async () => { + limits.staff.currentCountQuery = () => { // useStaffUsers will only return the first 100 users by default, but we can assume // that either there's no limit or the limit is <100 const staffUsers = users.filter(u => u.status !== 'inactive' && !contributorUsers.includes(u)); const staffInvites = invites.filter(i => i.role !== 'Contributor'); - return staffUsers.length + staffInvites.length; + return Promise.resolve(staffUsers.length + staffInvites.length); }; } diff --git a/apps/admin/src/settings/app/hooks/use-pintura-editor.ts b/apps/admin/src/settings/hooks/use-pintura-editor.ts similarity index 99% rename from apps/admin/src/settings/app/hooks/use-pintura-editor.ts rename to apps/admin/src/settings/hooks/use-pintura-editor.ts index 81e49a9d402..c1b9485e75c 100644 --- a/apps/admin/src/settings/app/hooks/use-pintura-editor.ts +++ b/apps/admin/src/settings/hooks/use-pintura-editor.ts @@ -3,7 +3,7 @@ import {type Config} from '@tryghost/admin-x-framework/api/config'; import {type Setting, getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {getGhostPaths} from '@tryghost/admin-x-framework/helpers'; import {useCallback, useEffect, useRef, useState} from 'react'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; interface OpenEditorParams { diff --git a/apps/admin/src/settings/app/hooks/use-query-params.ts b/apps/admin/src/settings/hooks/use-query-params.ts similarity index 100% rename from apps/admin/src/settings/app/hooks/use-query-params.ts rename to apps/admin/src/settings/hooks/use-query-params.ts diff --git a/apps/admin/src/settings/app/hooks/use-save-button.ts b/apps/admin/src/settings/hooks/use-save-button.ts similarity index 100% rename from apps/admin/src/settings/app/hooks/use-save-button.ts rename to apps/admin/src/settings/hooks/use-save-button.ts diff --git a/apps/admin/src/settings/hooks/use-scroll-section.ts b/apps/admin/src/settings/hooks/use-scroll-section.ts new file mode 100644 index 00000000000..567d749213b --- /dev/null +++ b/apps/admin/src/settings/hooks/use-scroll-section.ts @@ -0,0 +1,56 @@ +import {createContext, useContext, useEffect, useRef} from 'react'; + +export interface ScrollSectionContextData { + updateSection: (id: string, element: HTMLDivElement) => void; + updateNav: (id: string, element: HTMLLIElement) => void; + currentSection: string | null; + updateNavigatedSection: (id: string) => void; + scrollToSection: (id: string) => void; +} + +export const ScrollSectionContext = createContext({ + updateSection: () => {}, + updateNav: () => {}, + currentSection: null, + updateNavigatedSection: () => {}, + scrollToSection: () => {} +}); + +export const useScrollSectionContext = () => useContext(ScrollSectionContext); + +export const useScrollSection = (id?: string) => { + const {updateSection} = useScrollSectionContext(); + const ref = useRef(null); + + useEffect(() => { + if (id && ref.current) { + updateSection(id, ref.current); + } + }, [id, updateSection]); + + return { + ref + }; +}; + +export const useScrollSectionNav = (id?: string | string[]) => { + const {updateNav} = useScrollSectionContext(); + const ref = useRef(null); + + useEffect(() => { + if (id && ref.current) { + // Convert to array if it's a string + const ids = Array.isArray(id) ? id : [id]; + + // Register the nav element for all IDs + ids.forEach((navId) => { + updateNav(navId, ref.current!); + }); + } + }, [id, updateNav]); + + return { + ref, + props: {'data-setting-nav-item': true} + }; +}; diff --git a/apps/admin/src/settings/app/hooks/use-setting-group.tsx b/apps/admin/src/settings/hooks/use-setting-group.tsx similarity index 97% rename from apps/admin/src/settings/app/hooks/use-setting-group.tsx rename to apps/admin/src/settings/hooks/use-setting-group.tsx index 9cda77f229c..a279c4db0dd 100644 --- a/apps/admin/src/settings/app/hooks/use-setting-group.tsx +++ b/apps/admin/src/settings/hooks/use-setting-group.tsx @@ -2,7 +2,7 @@ import React, {useCallback, useEffect, useRef, useState} from 'react'; import {type ErrorMessages, type OkProps, type SaveHandler, type SaveState, useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; import {type Setting, type SettingValue, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {type SiteData} from '@tryghost/admin-x-framework/api/site'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useGlobalDirtyState} from '@tryghost/shade/utils'; interface LocalSetting extends Setting { diff --git a/apps/admin/src/settings/app/hooks/use-settings-navigation.ts b/apps/admin/src/settings/hooks/use-settings-navigation.ts similarity index 100% rename from apps/admin/src/settings/app/hooks/use-settings-navigation.ts rename to apps/admin/src/settings/hooks/use-settings-navigation.ts diff --git a/apps/admin/src/settings/app/hooks/use-sortable-indexed-list.test.ts b/apps/admin/src/settings/hooks/use-sortable-indexed-list.test.ts similarity index 98% rename from apps/admin/src/settings/app/hooks/use-sortable-indexed-list.test.ts rename to apps/admin/src/settings/hooks/use-sortable-indexed-list.test.ts index 0f5879cd9b3..ad40d9de99d 100644 --- a/apps/admin/src/settings/app/hooks/use-sortable-indexed-list.test.ts +++ b/apps/admin/src/settings/hooks/use-sortable-indexed-list.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import useSortableIndexedList from '@/settings/app/hooks/use-sortable-indexed-list'; +import useSortableIndexedList from '@/settings/hooks/use-sortable-indexed-list'; import {act, renderHook} from '@testing-library/react'; import {describe, it, vi} from 'vitest'; diff --git a/apps/admin/src/settings/app/hooks/use-sortable-indexed-list.ts b/apps/admin/src/settings/hooks/use-sortable-indexed-list.ts similarity index 98% rename from apps/admin/src/settings/app/hooks/use-sortable-indexed-list.ts rename to apps/admin/src/settings/hooks/use-sortable-indexed-list.ts index e29e603ddc5..95afc78acf7 100644 --- a/apps/admin/src/settings/app/hooks/use-sortable-indexed-list.ts +++ b/apps/admin/src/settings/hooks/use-sortable-indexed-list.ts @@ -1,7 +1,7 @@ import {arrayMove} from '@dnd-kit/sortable'; import {useEffect, useState} from 'react'; -const stableStringify = (value: unknown) => JSON.stringify(value, (_key, nestedValue) => { +const stableStringify = (value: unknown) => JSON.stringify(value, (_key, nestedValue: unknown) => { if (nestedValue && typeof nestedValue === 'object' && !Array.isArray(nestedValue)) { return Object.keys(nestedValue).sort().reduce>((sorted, key) => { sorted[key] = (nestedValue as Record)[key]; diff --git a/apps/admin/src/settings/app/hooks/use-staff-users.tsx b/apps/admin/src/settings/hooks/use-staff-users.tsx similarity index 94% rename from apps/admin/src/settings/app/hooks/use-staff-users.tsx rename to apps/admin/src/settings/hooks/use-staff-users.tsx index 38af6856c6f..a5ab7168518 100644 --- a/apps/admin/src/settings/app/hooks/use-staff-users.tsx +++ b/apps/admin/src/settings/hooks/use-staff-users.tsx @@ -1,7 +1,7 @@ import {type User, useBrowseUsers} from '@tryghost/admin-x-framework/api/users'; import {type UserInvite, useBrowseInvites} from '@tryghost/admin-x-framework/api/invites'; import {useBrowseRoles} from '@tryghost/admin-x-framework/api/roles'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useMemo} from 'react'; export type UsersHook = { @@ -18,8 +18,8 @@ export type UsersHook = { isLoading: boolean; hasNextPage?: boolean; invitesHasNextPage?: boolean; - fetchNextPage: () => void; - fetchNextInvitePage: () => void; + fetchNextPage: () => Promise; + fetchNextInvitePage: () => Promise; }; function getUsersByRole(users: User[], role: string): User[] { diff --git a/apps/admin/src/settings/app/hooks/use-upgrade-route.test.tsx b/apps/admin/src/settings/hooks/use-upgrade-route.test.tsx similarity index 85% rename from apps/admin/src/settings/app/hooks/use-upgrade-route.test.tsx rename to apps/admin/src/settings/hooks/use-upgrade-route.test.tsx index 204b1af725c..d25738c4408 100644 --- a/apps/admin/src/settings/app/hooks/use-upgrade-route.test.tsx +++ b/apps/admin/src/settings/hooks/use-upgrade-route.test.tsx @@ -1,9 +1,9 @@ import {renderHook} from '@testing-library/react'; -import {useUpgradeRoute} from '@/settings/app/hooks/use-upgrade-route'; +import {useUpgradeRoute} from '@/settings/hooks/use-upgrade-route'; -const mockConfig = vi.fn(); +const mockConfig = vi.fn<() => unknown>(); -vi.mock('@/settings/app/components/providers/global-data-provider', () => ({ +vi.mock('@/settings/providers/global-data-context', () => ({ useGlobalData: () => ({config: mockConfig()}) })); diff --git a/apps/admin/src/settings/app/hooks/use-upgrade-route.ts b/apps/admin/src/settings/hooks/use-upgrade-route.ts similarity index 67% rename from apps/admin/src/settings/app/hooks/use-upgrade-route.ts rename to apps/admin/src/settings/hooks/use-upgrade-route.ts index 25e76816f2d..efd473e2c59 100644 --- a/apps/admin/src/settings/app/hooks/use-upgrade-route.ts +++ b/apps/admin/src/settings/hooks/use-upgrade-route.ts @@ -1,5 +1,5 @@ import {upgradeRoute} from '@tryghost/admin-x-framework/api/config'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; export function useUpgradeRoute() { const {config} = useGlobalData(); diff --git a/apps/admin/src/settings/app/hooks/use-url-input.ts b/apps/admin/src/settings/hooks/use-url-input.ts similarity index 96% rename from apps/admin/src/settings/app/hooks/use-url-input.ts rename to apps/admin/src/settings/hooks/use-url-input.ts index 937d36347c6..3f4be3bd95c 100644 --- a/apps/admin/src/settings/app/hooks/use-url-input.ts +++ b/apps/admin/src/settings/hooks/use-url-input.ts @@ -1,5 +1,5 @@ import {type FocusEvent, type KeyboardEvent, useCallback, useEffect, useState} from 'react'; -import {formatUrl} from '@/settings/app/utils/format-url'; +import {formatUrl} from '@/settings/utils/format-url'; type UseUrlInputOptions = { baseUrl?: string; diff --git a/apps/admin/src/settings/app/hooks/use-welcome-email-link-suggestions.ts b/apps/admin/src/settings/hooks/use-welcome-email-link-suggestions.ts similarity index 92% rename from apps/admin/src/settings/app/hooks/use-welcome-email-link-suggestions.ts rename to apps/admin/src/settings/hooks/use-welcome-email-link-suggestions.ts index 25a72b3c8f6..9e5f372ac56 100644 --- a/apps/admin/src/settings/app/hooks/use-welcome-email-link-suggestions.ts +++ b/apps/admin/src/settings/hooks/use-welcome-email-link-suggestions.ts @@ -1,5 +1,5 @@ import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; -import {useGlobalData} from '@/settings/app/components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useKoenigLinkSuggestions} from '@tryghost/admin-x-framework'; export const useWelcomeEmailLinkSuggestions = () => { diff --git a/apps/admin/src/settings/app/hooks/use-welcome-email-sender-details.ts b/apps/admin/src/settings/hooks/use-welcome-email-sender-details.ts similarity index 93% rename from apps/admin/src/settings/app/hooks/use-welcome-email-sender-details.ts rename to apps/admin/src/settings/hooks/use-welcome-email-sender-details.ts index f982e162498..816a99c92da 100644 --- a/apps/admin/src/settings/app/hooks/use-welcome-email-sender-details.ts +++ b/apps/admin/src/settings/hooks/use-welcome-email-sender-details.ts @@ -1,5 +1,5 @@ // NOTE: counterpart lives in apps/admin/src/automations/components/email-modal/use-sender-details.ts — the email design modal needs equivalent sender-detail behavior in both apps until Automations GAs; keep in sync -import {resolveWelcomeEmailSenderDetails} from '@/settings/app/utils/welcome-email-sender-details'; +import {resolveWelcomeEmailSenderDetails} from '@/settings/utils/welcome-email-sender-details'; import {useBrowseNewsletters} from '@tryghost/admin-x-framework/api/newsletters'; import {useMemo} from 'react'; import type {AutomatedEmail} from '@tryghost/admin-x-framework/api/automated-emails'; diff --git a/apps/admin/src/settings/app/app.tsx b/apps/admin/src/settings/layout/app.tsx similarity index 64% rename from apps/admin/src/settings/app/app.tsx rename to apps/admin/src/settings/layout/app.tsx index 3db6609d3d5..4189f1ee31e 100644 --- a/apps/admin/src/settings/app/app.tsx +++ b/apps/admin/src/settings/layout/app.tsx @@ -1,10 +1,12 @@ import MainContent from './main-content'; -import SettingsAppProvider, {type UpgradeStatusType} from './components/providers/settings-app-provider'; -import {ConfirmationProvider} from './components/providers/confirmation-provider'; -import {DialogPortalProvider} from './components/providers/dialog-portal'; +import {DirtyNavigationGuard} from './dirty-navigation-guard'; +import SettingsAppProvider from '@/settings/providers/settings-app-provider'; +import {type UpgradeStatusType} from '@/settings/providers/settings-app-context'; +import {ConfirmationProvider} from '@/settings/providers/confirmation-provider'; +import {DialogPortalProvider} from '@/settings/providers/dialog-portal'; import {Outlet, useLocation} from '@tryghost/admin-x-framework'; import {useEffect} from 'react'; -import {useScrollSectionContext} from './hooks/use-scroll-section'; +import {useScrollSectionContext} from '@/settings/hooks/use-scroll-section'; interface AppProps { upgradeStatus?: UpgradeStatusType; @@ -27,12 +29,13 @@ function SettingsLocationSync() { export function App({upgradeStatus}: AppProps) { return ( -
+
+
diff --git a/apps/admin/src/settings/layout/dirty-navigation-guard-identity.ts b/apps/admin/src/settings/layout/dirty-navigation-guard-identity.ts new file mode 100644 index 00000000000..b02ef7c42c9 --- /dev/null +++ b/apps/admin/src/settings/layout/dirty-navigation-guard-identity.ts @@ -0,0 +1,18 @@ +import {matchRoutes} from '@tryghost/admin-x-framework'; +import {type SettingsRouteHandle, settingsRouteChildren} from '@/settings/routes'; + +// Which dialog a settings path renders: routes sharing a `handle.dialogGroup` are one +// dialog instance (tabs/steps) for the same record, any other leaf route is its own, +// and section roots or anything outside settings count as leaving every dialog. +export const dialogIdentity = (pathname: string): string => { + if (pathname !== '/settings' && !pathname.startsWith('/settings/')) { + return 'outside'; + } + const match = matchRoutes(settingsRouteChildren, pathname.slice('/settings'.length) || '/')?.at(-1); + if (!match?.route.lazy) { + return 'settings'; + } + const group = (match.route.handle as SettingsRouteHandle | undefined)?.dialogGroup; + const routeIdentity = group ? `group:${group}` : `route:${match.route.path ?? ''}`; + return `${routeIdentity}:${JSON.stringify(match.params)}`; +}; diff --git a/apps/admin/src/settings/layout/dirty-navigation-guard.test.ts b/apps/admin/src/settings/layout/dirty-navigation-guard.test.ts new file mode 100644 index 00000000000..5dd05aa72af --- /dev/null +++ b/apps/admin/src/settings/layout/dirty-navigation-guard.test.ts @@ -0,0 +1,19 @@ +import {describe, expect, it} from 'vitest'; + +import {dialogIdentity} from './dirty-navigation-guard-identity'; + +describe('dialogIdentity', () => { + it('keeps tabs for one staff record grouped', () => { + expect(dialogIdentity('/settings/staff/owner')).toBe(dialogIdentity('/settings/staff/owner/social-links')); + }); + + it('distinguishes different records using the same dialog route', () => { + expect(dialogIdentity('/settings/staff/owner')).not.toBe(dialogIdentity('/settings/staff/author')); + expect(dialogIdentity('/settings/newsletters/first')).not.toBe(dialogIdentity('/settings/newsletters/second')); + }); + + it('only classifies paths within the exact settings boundary', () => { + expect(dialogIdentity('/settings')).toBe('settings'); + expect(dialogIdentity('/settings-other')).toBe('outside'); + }); +}); diff --git a/apps/admin/src/settings/layout/dirty-navigation-guard.tsx b/apps/admin/src/settings/layout/dirty-navigation-guard.tsx new file mode 100644 index 00000000000..fcaa54f8eae --- /dev/null +++ b/apps/admin/src/settings/layout/dirty-navigation-guard.tsx @@ -0,0 +1,65 @@ +import {DirtyConfirmDialog} from '@tryghost/shade/patterns'; +import {NavigationType, useBlocker, useLocation} from 'react-router'; +import {useConfirmUnload} from '@tryghost/admin-x-framework/hooks'; +import {useGlobalDirtyState} from '@tryghost/shade/utils'; +import {useEffect, useRef} from 'react'; +import {z} from 'zod'; +import {dialogIdentity} from './dirty-navigation-guard-identity'; + +const historyStateSchema = z.looseObject({ + idx: z.number().int().nonnegative().optional() +}).nullable(); + +// Only history (back/forward) navigations are blocked: every in-app way out of a +// dirty settings dialog already runs its own dirty confirmation before navigating. +export const DirtyNavigationGuard: React.FC = () => { + const {isDirty} = useGlobalDirtyState(); + const leaveConfirmedRef = useRef(false); + const location = useLocation(); + // history.state already belongs to the target entry when the blocker runs, so + // remember whether the entry being left was tracked by the router (it has an index). + const onRouterEntryRef = useRef(false); + useEffect(() => { + const historyState = historyStateSchema.safeParse(window.history.state); + onRouterEntryRef.current = historyState.success && historyState.data?.idx !== undefined; + }, [location]); + + useConfirmUnload(isDirty); + + const blocker = useBlocker(({currentLocation, nextLocation, historyAction}) => { + if (!isDirty || historyAction !== NavigationType.Pop) { + return false; + } + // The router can only undo a POP from an entry it created; from a native + // hash-navigation entry it would miscount the delta and reload instead. + if (!onRouterEntryRef.current) { + return false; + } + return dialogIdentity(currentLocation.pathname) !== dialogIdentity(nextLocation.pathname); + }); + const isBlocked = blocker.state === 'blocked'; + + return ( + { + leaveConfirmedRef.current = true; + blocker.proceed?.(); + }} + onOpenChange={(open) => { + if (open) { + return; + } + // Leave closes the dialog while the blocker still reads as blocked; + // don't reset the navigation the user just confirmed. + if (leaveConfirmedRef.current) { + leaveConfirmedRef.current = false; + return; + } + if (isBlocked) { + blocker.reset?.(); + } + }} + /> + ); +}; diff --git a/apps/admin/src/settings/app/main-content.tsx b/apps/admin/src/settings/layout/main-content.tsx similarity index 88% rename from apps/admin/src/settings/app/main-content.tsx rename to apps/admin/src/settings/layout/main-content.tsx index 1d1f482cf22..fc3492a51ac 100644 --- a/apps/admin/src/settings/app/main-content.tsx +++ b/apps/admin/src/settings/layout/main-content.tsx @@ -1,13 +1,13 @@ -import ExitSettingsButton from './components/exit-settings-button'; -import Settings from './components/settings'; -import Sidebar from './components/sidebar'; +import ExitSettingsButton from '@/settings/components/exit-settings-button'; +import Settings from './settings-sections'; +import Sidebar from './sidebar'; import Users from '@/settings/general/users'; import {DirtyConfirmDialog, useDirtyConfirmation} from '@tryghost/shade/patterns'; import {type ReactNode, useEffect} from 'react'; import {Text} from '@tryghost/shade/primitives'; import {canAccessSettings, isEditorUser} from '@tryghost/admin-x-framework/api/users'; import {toast} from 'sonner'; -import {useGlobalData} from './components/providers/global-data-provider'; +import {useGlobalData} from '@/settings/providers/global-data-context'; import {useGlobalDirtyState} from '@tryghost/shade/utils'; import {useNavigate} from '@tryghost/admin-x-framework'; @@ -19,7 +19,7 @@ const Page: React.FC<{children: ReactNode}> = ({children}) => {
-
+
{children}
; @@ -74,7 +74,7 @@ const MainContent: React.FC = () => { return (
-
+
Settings @@ -88,13 +88,13 @@ const MainContent: React.FC = () => { return ( -
+
-
+
diff --git a/apps/admin/src/settings/app/components/settings.tsx b/apps/admin/src/settings/layout/settings-sections.tsx similarity index 72% rename from apps/admin/src/settings/app/components/settings.tsx rename to apps/admin/src/settings/layout/settings-sections.tsx index a6adbde658a..5c65cf6ae90 100644 --- a/apps/admin/src/settings/app/components/settings.tsx +++ b/apps/admin/src/settings/layout/settings-sections.tsx @@ -1,13 +1,13 @@ import React from 'react'; -import AdvancedSettings from './settings/advanced/advanced-settings'; -import EmailSettings from './settings/email/email-settings'; -import Emails from './settings/email/emails'; +import AdvancedSettings from '@/settings/advanced/advanced-settings'; +import EmailSettings from '@/settings/email/email-settings'; +import Emails from '@/settings/email/emails'; import GeneralSettings from '@/settings/general/general-settings'; import GrowthSettings from '@/settings/growth/growth-settings'; import MembershipSettings from '@/settings/membership/membership-settings'; -import SiteSettings from './settings/site/site-settings'; -import useFeatureFlag from '@/settings/app/hooks/use-feature-flag'; +import SiteSettings from '@/settings/site/site-settings'; +import useFeatureFlag from '@/settings/hooks/use-feature-flag'; const Settings: React.FC = () => { const hasAutomations = useFeatureFlag('automations'); diff --git a/apps/admin/src/settings/app/components/sidebar.tsx b/apps/admin/src/settings/layout/sidebar.tsx similarity index 95% rename from apps/admin/src/settings/app/components/sidebar.tsx rename to apps/admin/src/settings/layout/sidebar.tsx index 587823002e3..8bb348e8fe9 100644 --- a/apps/admin/src/settings/app/components/sidebar.tsx +++ b/apps/admin/src/settings/layout/sidebar.tsx @@ -1,4 +1,4 @@ -import GhostLogo from '@/settings/app/assets/images/orb-pink.png'; +import GhostLogo from '@/settings/assets/images/orb-pink.png'; import React, {useEffect, useRef} from 'react'; import clsx from 'clsx'; import {Badge, InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, Kbd, Separator, SidebarMenu} from '@tryghost/shade/components'; @@ -9,19 +9,19 @@ import {useFocusContext} from '@tryghost/shade/app'; import {checkStripeEnabled, getSettingValues} from '@tryghost/admin-x-framework/api/settings'; -import {searchKeywords as advancedSearchKeywords} from './settings/advanced/advanced-settings'; -import {searchKeywords as emailSearchKeywords} from './settings/email/email-settings'; -import {searchKeywords as emailsSearchKeywords} from './settings/email/emails'; +import {searchKeywords as advancedSearchKeywords} from '@/settings/advanced/search-keywords'; +import {searchKeywords as emailSearchKeywords} from '@/settings/email/search-keywords'; +import {searchKeywords as emailsSearchKeywords} from '@/settings/email/emails-search-keywords'; import {searchKeywords as generalSearchKeywords} from '@/settings/general/search-keywords'; import {searchKeywords as growthSearchKeywords} from '@/settings/growth/search-keywords'; import {searchKeywords as membershipSearchKeywords} from '@/settings/membership/search-keywords'; -import {searchKeywords as siteSearchKeywords} from './settings/site/site-settings'; +import {searchKeywords as siteSearchKeywords} from '@/settings/site/search-keywords'; -import useFeatureFlag from '@/settings/app/hooks/use-feature-flag'; -import {useGlobalData} from './providers/global-data-provider'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {useScrollSectionContext, useScrollSectionNav} from '@/settings/app/hooks/use-scroll-section'; -import {useSearch} from './providers/settings-app-provider'; +import useFeatureFlag from '@/settings/hooks/use-feature-flag'; +import {useGlobalData} from '@/settings/providers/global-data-context'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {useScrollSectionContext, useScrollSectionNav} from '@/settings/hooks/use-scroll-section'; +import {useSearch} from '@/settings/providers/settings-app-context'; interface NavItemProps { icon?: React.ReactNode; @@ -207,7 +207,7 @@ const Sidebar: React.FC = () => { setFilter(e.target.value); if (e.target.value) { - document.querySelector('.admin-x-settings')?.scrollTo({top: 0, left: 0}); + document.querySelector('.settings-app')?.scrollTo({top: 0, left: 0}); } }; @@ -243,7 +243,7 @@ const Sidebar: React.FC = () => {
-
diff --git a/apps/admin/src/settings/app/components/settings/site/navigation.tsx b/apps/admin/src/settings/site/navigation.tsx similarity index 76% rename from apps/admin/src/settings/app/components/settings/site/navigation.tsx rename to apps/admin/src/settings/site/navigation.tsx index c261ffb57e9..de375c02a09 100644 --- a/apps/admin/src/settings/app/components/settings/site/navigation.tsx +++ b/apps/admin/src/settings/site/navigation.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import TopLevelGroup from '@/settings/app/components/top-level-group'; +import TopLevelGroup from '@/settings/components/top-level-group'; import {Button} from '@tryghost/shade/components'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {withErrorBoundary} from '@/settings/app/components/error-boundary'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {withErrorBoundary} from '@/settings/components/with-error-boundary'; const Navigation: React.FC<{ keywords: string[] }> = ({keywords}) => { const {updateRoute} = useSettingsNavigation(); diff --git a/apps/admin/src/settings/app/components/settings/site/navigation/navigation-edit-form.tsx b/apps/admin/src/settings/site/navigation/navigation-edit-form.tsx similarity index 52% rename from apps/admin/src/settings/app/components/settings/site/navigation/navigation-edit-form.tsx rename to apps/admin/src/settings/site/navigation/navigation-edit-form.tsx index 926ba7b61b4..97d43fa75fa 100644 --- a/apps/admin/src/settings/app/components/settings/site/navigation/navigation-edit-form.tsx +++ b/apps/admin/src/settings/site/navigation/navigation-edit-form.tsx @@ -3,13 +3,33 @@ import React from 'react'; import {Button, SortableList} from '@tryghost/shade/components'; import {Inline} from '@tryghost/shade/primitives'; import {LucideIcon} from '@tryghost/shade/utils'; -import {type NavigationEditor} from '@/settings/app/hooks/site/use-navigation-editor'; +import {type NavigationEditor} from '@/settings/hooks/site/use-navigation-editor'; +import {navigationColumnClasses, navigationDragHandleSpacerClasses, navigationRowClasses} from './navigation-layout'; + +const columnLabelClasses = 'text-xs font-semibold text-muted-foreground'; const NavigationEditForm: React.FC<{ baseUrl: string; + idPrefix: string; navigation: NavigationEditor; -}> = ({baseUrl, navigation}) => { + showIcon: boolean; + showPaidVisibility: boolean; + showVisibility: boolean; + uploadIcon?: (file: File) => Promise; +}> = ({baseUrl, idPrefix, navigation, showIcon, showPaidVisibility, showVisibility, uploadIcon}) => { return
+ {(showIcon || showVisibility) && ( +
+
+
+ {showIcon &&
Icon
} +
Label
+
URL
+ {showVisibility &&
Visibility
} +
+
+
+ )} `Reorder ${item.label || 'navigation item'}`} @@ -20,13 +40,18 @@ const NavigationEditForm: React.FC<{ action={} baseUrl={baseUrl} clearError={key => navigation.clearError(item.id, key)} + idPrefix={idPrefix} item={item} + showIcon={showIcon} + showPaidVisibility={showPaidVisibility} + showVisibility={showVisibility} updateItem={updates => navigation.updateItem(item.id, updates)} + uploadIcon={uploadIcon} /> )} onMove={navigation.moveItem} /> - + @@ -37,9 +62,14 @@ const NavigationEditForm: React.FC<{ className="mt-1" clearError={key => navigation.clearError(navigation.newItem.id, key)} data-testid="new-navigation-item" + idPrefix={idPrefix} item={navigation.newItem} labelPlaceholder="New item label" + showIcon={showIcon} + showPaidVisibility={showPaidVisibility} + showVisibility={showVisibility} updateItem={navigation.setNewItem} + uploadIcon={uploadIcon} />
; diff --git a/apps/admin/src/settings/site/navigation/navigation-icon-upload.tsx b/apps/admin/src/settings/site/navigation/navigation-icon-upload.tsx new file mode 100644 index 00000000000..6d773b3b697 --- /dev/null +++ b/apps/admin/src/settings/site/navigation/navigation-icon-upload.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import {Trash2, Upload} from 'lucide-react'; +import {ImageUpload, ImageUploadAction, ImageUploadActions, ImageUploadDropzone, ImageUploadImage, ImageUploadPreview} from '@tryghost/shade/patterns'; +import {type EditableItem, type NavigationItem, type NavigationItemErrors} from '@/settings/hooks/site/use-navigation-editor'; + +type NavigationIconUploadProps = { + idPrefix: string; + item: EditableItem; + clearError?: (key: keyof NavigationItemErrors) => void; + updateItem?: (item: Partial) => void; + uploadIcon?: (file: File) => Promise; +} + +const NavigationIconUpload: React.FC = ({idPrefix, item, clearError, updateItem, uploadIcon}) => ( + + {item.icon ? ( + + + + { + clearError?.('icon'); + updateItem?.({icon: ''}); + }} + > + + + + + ) : ( + void uploadIcon?.(file).then((icon) => { + if (icon) { + updateItem?.({icon}); + } + })} + > + + )} + +); + +export default NavigationIconUpload; diff --git a/apps/admin/src/settings/site/navigation/navigation-item-editor.tsx b/apps/admin/src/settings/site/navigation/navigation-item-editor.tsx new file mode 100644 index 00000000000..babf72afcd0 --- /dev/null +++ b/apps/admin/src/settings/site/navigation/navigation-item-editor.tsx @@ -0,0 +1,123 @@ +import NavigationIconUpload from './navigation-icon-upload'; +import NavigationVisibilityDropdown from './navigation-visibility-dropdown'; +import React, {type ReactNode} from 'react'; +import clsx from 'clsx'; +import useUrlInput from '@/settings/hooks/use-url-input'; +import {type EditableItem, type NavigationItem, type NavigationItemErrors} from '@/settings/hooks/site/use-navigation-editor'; +import {Field, FieldError, FieldLabel, Input} from '@tryghost/shade/components'; +import {Inline} from '@tryghost/shade/primitives'; +import {formatUrl} from '@/settings/utils/format-url'; +import {navigationColumnClasses, navigationFieldOffsetClass, navigationRowClasses} from './navigation-layout'; + +export type NavigationItemEditorProps = React.HTMLAttributes & { + baseUrl: string; + idPrefix: string; + item: EditableItem; + clearError?: (key: keyof NavigationItemErrors) => void; + updateItem?: (item: Partial) => void; + uploadIcon?: (file: File) => Promise; + labelPlaceholder?: string + unstyled?: boolean + textFieldClasses?: string + action?: ReactNode + addItem?: () => void + showIcon: boolean + showPaidVisibility: boolean + showVisibility: boolean +} + +const NavigationItemEditor: React.FC = ({baseUrl, idPrefix, item, updateItem, uploadIcon, addItem, clearError, labelPlaceholder, unstyled, textFieldClasses, action, showIcon, showPaidVisibility, showVisibility, className, ...props}) => { + const urlInput = useUrlInput({ + baseUrl, + nullable: true, + value: item.url, + onChange: value => updateItem?.({url: value || ''}) + }); + + return ( +
+ {showIcon && ( +
+ +
+ )} +
+ + Label + updateItem?.({label: e.target.value})} + onKeyDown={(e) => { + updateItem?.({label: (e.target as HTMLInputElement).value}); + if (e.key === 'Enter') { + e.preventDefault(); + addItem?.(); + } + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + !!item.errors.label && clearError?.('label'); + }} + /> + {item.errors.label && {item.errors.label}} + +
+
+ + URL + urlInput.setDisplayValue(event.target.value)} + onFocus={urlInput.handleFocus} + onKeyDown={(e) => { + urlInput.handleKeyDown(e); + const urls = formatUrl((e.target as HTMLInputElement).value, baseUrl, true); + updateItem?.({url: urls.save || ''}); + }} + onKeyUp={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + const urls = formatUrl((e.target as HTMLInputElement).value, baseUrl, true); + updateItem?.({url: urls.save || ''}); + addItem?.(); + } + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + !!item.errors.url && clearError?.('url'); + }} + /> + {item.errors.url && {item.errors.url}} + +
+ {showVisibility && ( +
+ +
+ )} + {action && ( + + {action} + + )} +
+ ); +}; + +export default NavigationItemEditor; diff --git a/apps/admin/src/settings/site/navigation/navigation-layout.ts b/apps/admin/src/settings/site/navigation/navigation-layout.ts new file mode 100644 index 00000000000..1e7f8d320a3 --- /dev/null +++ b/apps/admin/src/settings/site/navigation/navigation-layout.ts @@ -0,0 +1,12 @@ +export const navigationRowClasses = 'flex w-full items-start gap-3 [--control-height:38px]'; + +export const navigationColumnClasses = { + icon: 'w-[38px] shrink-0', + label: 'min-w-0 flex-[0.75]', + url: 'min-w-0 flex-[1.25]', + visibility: 'w-[190px] shrink-0', + action: 'w-10 shrink-0' +} as const; + +export const navigationDragHandleSpacerClasses = 'h-7 w-4 shrink-0'; +export const navigationFieldOffsetClass = 'pt-1'; diff --git a/apps/admin/src/settings/site/navigation/navigation-visibility-dropdown.tsx b/apps/admin/src/settings/site/navigation/navigation-visibility-dropdown.tsx new file mode 100644 index 00000000000..25cf727bceb --- /dev/null +++ b/apps/admin/src/settings/site/navigation/navigation-visibility-dropdown.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import {ChevronDown} from 'lucide-react'; +import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Switch} from '@tryghost/shade/components'; +import {type EditableItem, type NavigationItem, type NavigationItemErrors, type NavigationItemVisibility} from '@/settings/hooks/site/use-navigation-editor'; +import { + type VisibilityAudience, + getAudienceVisibility, + getNavigationVisibility, + getVisibilityFromAudiences, + getVisibilityLabel, + paidVisibilityValues, + visibilityAudienceOptions +} from './navigation-visibility'; + +type NavigationVisibilityDropdownProps = { + idPrefix: string; + item: EditableItem; + clearError?: (key: keyof NavigationItemErrors) => void; + updateItem?: (item: Partial) => void; + showPaidVisibility: boolean; +} + +const NavigationVisibilityDropdown: React.FC = ({idPrefix, item, clearError, updateItem, showPaidVisibility}) => { + const visibility = getNavigationVisibility(item.visibility); + const visibilityLabel = getVisibilityLabel(visibility); + const visibilityLabelId = `${idPrefix}-visibility-${item.id}`; + const visibilityErrorId = `${visibilityLabelId}-error`; + const audienceVisibility = getAudienceVisibility(visibility, showPaidVisibility); + + const updateVisibility = (newVisibility: NavigationItemVisibility) => { + clearError?.('visibility'); + updateItem?.({visibility: newVisibility}); + }; + + const updateAudienceVisibility = (audience: VisibilityAudience, checked: boolean) => { + const nextAudiences = { + ...audienceVisibility, + [audience]: checked + }; + + // When the paid-members control is hidden (Stripe disconnected) there's no + // way to target paid separately, so paid follows free. This keeps results on + // the general values ("Members only"/"Public") rather than collapsing to the + // free-specific ones ("Free only"/"Public + free"). + if (!showPaidVisibility) { + nextAudiences['paid-members'] = nextAudiences['free-members']; + } + + const nextVisibility = getVisibilityFromAudiences(nextAudiences); + + if (!showPaidVisibility && paidVisibilityValues.includes(nextVisibility)) { + return; + } + + updateVisibility(nextVisibility); + }; + + return ( + <> + + + + + + +
+ {visibilityAudienceOptions.filter(option => option.key !== 'paid-members' || showPaidVisibility).map((option) => { + const checked = audienceVisibility[option.key]; + + return ( + { + event.preventDefault(); + updateAudienceVisibility(option.key, !checked); + }} + > + {option.label} + updateAudienceVisibility(option.key, isChecked)} + onClick={event => event.stopPropagation()} + /> + + ); + })} +
+
+
+ {item.errors.visibility && } + + ); +}; + +export default NavigationVisibilityDropdown; diff --git a/apps/admin/src/settings/site/navigation/navigation-visibility.ts b/apps/admin/src/settings/site/navigation/navigation-visibility.ts new file mode 100644 index 00000000000..150ac802a76 --- /dev/null +++ b/apps/admin/src/settings/site/navigation/navigation-visibility.ts @@ -0,0 +1,72 @@ +import {NAVIGATION_ITEM_VISIBILITY, type NavigationItemVisibility} from '@/settings/hooks/site/use-navigation-editor'; + +export const navigationVisibilityLabels = { + public: 'Everyone', + members: 'Members only', + paid: 'Paid-members only', + public_free: 'Public + free', + public_paid: 'Public + paid', + public_only: 'Public only', + free_members: 'Free only', + none: 'Hidden' +} satisfies Record; + +export const navigationVisibilityOptions = NAVIGATION_ITEM_VISIBILITY.map(value => ({ + value, + label: navigationVisibilityLabels[value] +})); + +export const visibilityAudienceOptions = [ + {key: 'public-visitors', label: 'Public visitors'}, + {key: 'free-members', label: 'Free members'}, + {key: 'paid-members', label: 'Paid members'} +] as const; + +export type VisibilityAudience = typeof visibilityAudienceOptions[number]['key']; + +export const visibilityAudienceMap = { + public: {'public-visitors': true, 'free-members': true, 'paid-members': true}, + members: {'public-visitors': false, 'free-members': true, 'paid-members': true}, + paid: {'public-visitors': false, 'free-members': false, 'paid-members': true}, + public_free: {'public-visitors': true, 'free-members': true, 'paid-members': false}, + public_paid: {'public-visitors': true, 'free-members': false, 'paid-members': true}, + public_only: {'public-visitors': true, 'free-members': false, 'paid-members': false}, + free_members: {'public-visitors': false, 'free-members': true, 'paid-members': false}, + none: {'public-visitors': false, 'free-members': false, 'paid-members': false} +} satisfies Record>; + +export const paidVisibilityValues: NavigationItemVisibility[] = ['paid', 'public_paid']; + +type AudienceKey = '000' | '001' | '010' | '011' | '100' | '101' | '110' | '111'; + +const visibilityByAudienceKey = { + '000': 'none', + '001': 'paid', + '010': 'free_members', + '011': 'members', + 100: 'public_only', + 101: 'public_paid', + 110: 'public_free', + 111: 'public' +} satisfies Record; + +export const getNavigationVisibility = (visibility?: NavigationItemVisibility): NavigationItemVisibility => ( + navigationVisibilityOptions.some(option => option.value === visibility) ? visibility as NavigationItemVisibility : 'public' +); + +export const getVisibilityLabel = (visibility: NavigationItemVisibility) => navigationVisibilityLabels[visibility]; + +export const getAudienceVisibility = (visibility: NavigationItemVisibility, showPaidVisibility: boolean) => ({ + ...visibilityAudienceMap[visibility], + ...(!showPaidVisibility ? {'paid-members': false} : {}) +}); + +export const getVisibilityFromAudiences = (audiences: Record): NavigationItemVisibility => { + const key = [ + audiences['public-visitors'], + audiences['free-members'], + audiences['paid-members'] + ].map(value => (value ? '1' : '0')).join('') as AudienceKey; + + return visibilityByAudienceKey[key]; +}; diff --git a/apps/admin/src/settings/site/search-keywords.ts b/apps/admin/src/settings/site/search-keywords.ts new file mode 100644 index 00000000000..afccc5826b5 --- /dev/null +++ b/apps/admin/src/settings/site/search-keywords.ts @@ -0,0 +1,6 @@ +export const searchKeywords = { + design: ['site', 'logo', 'cover', 'colors', 'fonts', 'background', 'themes', 'appearance', 'style', 'design & branding', 'design and branding'], + theme: ['theme', 'template', 'upload'], + navigation: ['site', 'navigation', 'menus', 'primary', 'secondary', 'links'], + announcementBar: ['site', 'announcement bar', 'important', 'banner'] +}; diff --git a/apps/admin/src/settings/app/components/settings/site/site-settings.tsx b/apps/admin/src/settings/site/site-settings.tsx similarity index 61% rename from apps/admin/src/settings/app/components/settings/site/site-settings.tsx rename to apps/admin/src/settings/site/site-settings.tsx index d9e9e2dc782..19ad247726a 100644 --- a/apps/admin/src/settings/app/components/settings/site/site-settings.tsx +++ b/apps/admin/src/settings/site/site-settings.tsx @@ -3,14 +3,8 @@ import ChangeTheme from './change-theme'; import DesignSetting from './design-setting'; import Navigation from './navigation'; import React from 'react'; -import SearchableSection from '@/settings/app/components/searchable-section'; - -export const searchKeywords = { - design: ['site', 'logo', 'cover', 'colors', 'fonts', 'background', 'themes', 'appearance', 'style', 'design & branding', 'design and branding'], - theme: ['theme', 'template', 'upload'], - navigation: ['site', 'navigation', 'menus', 'primary', 'secondary', 'links'], - announcementBar: ['site', 'announcement bar', 'important', 'banner'] -}; +import SearchableSection from '@/settings/components/searchable-section'; +import {searchKeywords} from './search-keywords'; const SiteSettings: React.FC = () => { return ( diff --git a/apps/admin/src/settings/app/components/settings/site/theme-modal.tsx b/apps/admin/src/settings/site/theme-modal.tsx similarity index 97% rename from apps/admin/src/settings/app/components/settings/site/theme-modal.tsx rename to apps/admin/src/settings/site/theme-modal.tsx index b4692bbd3e6..de7e783328b 100644 --- a/apps/admin/src/settings/app/components/settings/site/theme-modal.tsx +++ b/apps/admin/src/settings/site/theme-modal.tsx @@ -7,14 +7,14 @@ import ThemePreview from './theme/theme-preview'; import {Button, Dropzone, LoadingIndicator, Tabs, TabsList, TabsTrigger} from '@tryghost/shade/components'; import {type InstalledTheme, type Theme, type ThemesInstallResponseType, isDefaultOrLegacyTheme, useActivateTheme, useBrowseThemes, useInstallTheme, useUploadTheme} from '@tryghost/admin-x-framework/api/themes'; import {JSONError} from '@tryghost/admin-x-framework/errors'; -import {type OfficialTheme} from '@/settings/app/components/providers/settings-app-provider'; +import {type OfficialTheme} from '@/settings/providers/settings-app-context'; import {PageHeader, SettingsModal} from '@tryghost/shade/patterns'; import {toast} from 'sonner'; -import {useCheckThemeLimitError} from '@/settings/app/hooks/use-check-theme-limit-error'; -import {type ConfirmationHandle, useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {useCheckThemeLimitError} from '@/settings/hooks/use-check-theme-limit-error'; +import {type ConfirmationHandle, useConfirmation} from '@/settings/providers/confirmation-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {useUpgradeRoute} from '@/settings/app/hooks/use-upgrade-route'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {useUpgradeRoute} from '@/settings/hooks/use-upgrade-route'; interface ThemeToolbarProps { selectedTheme: OfficialTheme|null; @@ -75,14 +75,14 @@ const ThemeToolbar: React.FC = ({ } }; - checkUploadLimit(); + void checkUploadLimit(); }, [checkThemeLimitError, isThemeLimited]); const onClose = () => { updateRoute('/'); }; - const onThemeUpload = async (file: File) => { + const onThemeUpload = (file: File) => { const themeFileName = file?.name.replace(/\.zip$/, ''); const existingThemeNames = themes.map(t => t.name); if (isDefaultOrLegacyTheme({name: themeFileName})) { @@ -96,7 +96,7 @@ const ThemeToolbar: React.FC = ({

Rename your zip file and try again.

), - onOk: async (confirmModal) => { + onOk: (confirmModal) => { confirmModal?.remove(); } }); @@ -129,7 +129,7 @@ const ThemeToolbar: React.FC = ({ }); } else { setCurrentTab('installed'); - handleThemeUpload({file, onActivate: onClose}); + void handleThemeUpload({file, onActivate: onClose}); } }; @@ -268,7 +268,7 @@ const ThemeToolbar: React.FC = ({ prompt={<>This theme couldn't be uploaded because Ghost found a blocking validation error. Fix the issue below and upload the theme again.} title='Theme not uploaded' onClose={() => setUploadErrors(null)} - onRetry={async () => { + onRetry={() => { setUploadErrors(null); handleUpload(); }} @@ -397,7 +397,7 @@ const ChangeThemeModal: React.FC = ({source, themeRef}) = } }; - handleUrlInstallation(); + void handleUrlInstallation(); }, [themeRef, source, installTheme, handleError, activateTheme, updateRoute, themes, installedFromMarketplace, checkThemeLimitError, confirm, isMounted]); if (!themes) { diff --git a/apps/admin/src/settings/app/components/settings/site/theme/advanced-theme-settings.tsx b/apps/admin/src/settings/site/theme/advanced-theme-settings.tsx similarity index 89% rename from apps/admin/src/settings/app/components/settings/site/theme/advanced-theme-settings.tsx rename to apps/admin/src/settings/site/theme/advanced-theme-settings.tsx index 3f7de204e3e..c6cfe166e8d 100644 --- a/apps/admin/src/settings/app/components/settings/site/theme/advanced-theme-settings.tsx +++ b/apps/admin/src/settings/site/theme/advanced-theme-settings.tsx @@ -1,6 +1,6 @@ import InvalidThemeModal, {type FatalErrors} from './invalid-theme-modal'; import React, {useState} from 'react'; -import useCustomFonts from '@/settings/app/hooks/use-custom-fonts'; +import useCustomFonts from '@/settings/hooks/use-custom-fonts'; import {ActionList, ActionListItem, ActionListItemActions, ActionListItemContent, Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from '@tryghost/shade/components'; import {JSONError} from '@tryghost/admin-x-framework/errors'; import {LucideIcon} from '@tryghost/shade/utils'; @@ -8,11 +8,11 @@ import {ModalPage} from '@tryghost/shade/page-templates'; import {type Theme, isActiveTheme, isDefaultTheme, isDeletableTheme, isLegacyTheme, useActivateTheme, useDeleteTheme} from '@tryghost/admin-x-framework/api/themes'; import {downloadFile, getGhostPaths} from '@tryghost/admin-x-framework/helpers'; import {toast} from 'sonner'; -import {useCheckThemeLimitError} from '@/settings/app/hooks/use-check-theme-limit-error'; -import {useConfirmation} from '@/settings/app/components/providers/confirmation-provider'; +import {useCheckThemeLimitError} from '@/settings/hooks/use-check-theme-limit-error'; +import {useConfirmation} from '@/settings/providers/confirmation-context'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; -import {useUpgradeRoute} from '@/settings/app/hooks/use-upgrade-route'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; +import {useUpgradeRoute} from '@/settings/hooks/use-upgrade-route'; interface ThemeActionProps { theme: Theme; @@ -23,12 +23,13 @@ interface ThemeSettingProps { } function getThemeLabel(theme: Theme): React.ReactNode { - let label: React.ReactNode = theme.package?.name || theme.name; + const name = theme.package?.name || theme.name; + let label: React.ReactNode = name; if (isDefaultTheme(theme)) { - label += ' (default)'; + label = `${name} (default)`; } else if (isLegacyTheme(theme)) { - label += ' (legacy)'; + label = `${name} (legacy)`; } else if (theme.package?.name !== theme.name) { label = @@ -81,12 +82,12 @@ const ThemeActions: React.FC = ({ } }; - const handleDownload = async () => { + const handleDownload = () => { const {apiRoot} = getGhostPaths(); downloadFile(`${apiRoot}/themes/${theme.name}/download`); }; - const handleDelete = async () => { + const handleDelete = () => { confirm({ title: 'Are you sure you want to delete this?', prompt: ( @@ -96,9 +97,7 @@ const ThemeActions: React.FC = ({ {' '} { - handleDownload(); - }} + onClick={handleDownload} > your theme before continuing @@ -142,7 +141,7 @@ const ThemeActions: React.FC = ({ size='sm' type='button' variant='ghost' - onClick={handleActivate} + onClick={() => void handleActivate()} >Activate ); } @@ -156,7 +155,7 @@ const ThemeActions: React.FC = ({ {/* legacy Modal overlay is z-[1000]; keep the portalled menu above it */} - Edit code + void handleEditCode()}>Edit code Download {isDeletableTheme(theme) && ( Delete @@ -169,9 +168,9 @@ const ThemeActions: React.FC = ({ prompt={<>This theme couldn't be activated because Ghost found a blocking validation error. Fix the issue below and try again.} title='Theme not activated' onClose={() => setActivationErrors(null)} - onRetry={async () => { + onRetry={() => { setActivationErrors(null); - handleActivate(); + void handleActivate(); }} /> )} diff --git a/apps/admin/src/settings/app/components/settings/site/theme/invalid-theme-modal.tsx b/apps/admin/src/settings/site/theme/invalid-theme-modal.tsx similarity index 86% rename from apps/admin/src/settings/app/components/settings/site/theme/invalid-theme-modal.tsx rename to apps/admin/src/settings/site/theme/invalid-theme-modal.tsx index 1cd54100588..e238126f93d 100644 --- a/apps/admin/src/settings/app/components/settings/site/theme/invalid-theme-modal.tsx +++ b/apps/admin/src/settings/site/theme/invalid-theme-modal.tsx @@ -1,10 +1,11 @@ import React, {type ReactNode} from 'react'; -import {ConfirmationModalContent} from '@/settings/app/components/confirmation-modal'; -import {ErrorTextCard, type FatalErrors, ThemeValidationDetailsDisclosure, ValidationProblemCard, getIssuesFromFatalErrors} from './theme-validation-details'; +import {ConfirmationModalContent} from '@/settings/components/confirmation-modal'; +import {ErrorTextCard, ThemeValidationDetailsDisclosure, ValidationProblemCard} from './theme-validation-details'; +import {type FatalErrors, getIssuesFromFatalErrors} from './theme-validation-issues'; import {useBrowseConfig} from '@tryghost/admin-x-framework/api/config'; import {formatNumber} from '@tryghost/shade/utils'; -export type {FatalErrors} from './theme-validation-details'; +export type {FatalErrors} from './theme-validation-issues'; export type InvalidThemeModalProps = { title: string diff --git a/apps/admin/src/settings/app/components/settings/site/theme/official-themes.tsx b/apps/admin/src/settings/site/theme/official-themes.tsx similarity index 97% rename from apps/admin/src/settings/app/components/settings/site/theme/official-themes.tsx rename to apps/admin/src/settings/site/theme/official-themes.tsx index ed012840239..ddd776f8d8a 100644 --- a/apps/admin/src/settings/app/components/settings/site/theme/official-themes.tsx +++ b/apps/admin/src/settings/site/theme/official-themes.tsx @@ -1,8 +1,8 @@ -import MarketplaceBgImage from '@/settings/app/assets/images/footer-marketplace-bg.png'; +import MarketplaceBgImage from '@/settings/assets/images/footer-marketplace-bg.png'; import React, {useEffect, useState} from 'react'; import clsx from 'clsx'; import {ModalPage} from '@tryghost/shade/page-templates'; -import {type OfficialTheme, type ThemeVariant, useOfficialThemes} from '@/settings/app/components/providers/settings-app-provider'; +import {type OfficialTheme, type ThemeVariant, useOfficialThemes} from '@/settings/providers/settings-app-context'; import {Text} from '@tryghost/shade/primitives'; const VARIANT_LOOP_INTERVAL = 3000; diff --git a/apps/admin/src/settings/app/components/settings/site/theme/theme-code-editor-modal.tsx b/apps/admin/src/settings/site/theme/theme-code-editor-modal.tsx similarity index 98% rename from apps/admin/src/settings/app/components/settings/site/theme/theme-code-editor-modal.tsx rename to apps/admin/src/settings/site/theme/theme-code-editor-modal.tsx index 50ba523125e..0dc89fc3c76 100644 --- a/apps/admin/src/settings/app/components/settings/site/theme/theme-code-editor-modal.tsx +++ b/apps/admin/src/settings/site/theme/theme-code-editor-modal.tsx @@ -27,7 +27,7 @@ import {useBrowseThemes} from '@tryghost/admin-x-framework/api/themes'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useQueryClient} from '@tanstack/react-query'; import {formatNumber} from '@tryghost/shade/utils'; -import {useSettingsNavigation} from '@/settings/app/hooks/use-settings-navigation'; +import {useSettingsNavigation} from '@/settings/hooks/use-settings-navigation'; import type {SelectedNode} from './theme-file-tree'; import type {ThemeEditorConfirmModalProps} from './theme-editor-confirm-modal'; import type {ThemeEditorFile} from './theme-editor-utils'; @@ -263,7 +263,7 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { const dialogRequestRef = useRef(null); const [saveErrors, setSaveErrors] = useState(null); const [installedModal, setInstalledModal] = useState(null); - const [editorExtensions, setEditorExtensions] = useState | typeof oneDark | typeof editorSelectionTheme | typeof EditorView.lineWrapping | Awaited>>>([]); + const [editorExtensions, setEditorExtensions] = useState | Awaited>>>([]); useEffect(() => { let isMounted = true; @@ -323,7 +323,7 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { } }; - loadTheme(); + void loadTheme(); return () => { isMounted = false; @@ -444,7 +444,7 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { // the freshest version without re-registering on every render. handleSave // captures state (currentFiles, changes, isSaving, ...) and is recreated // each render — using a ref decouples that churn from the global listener. - const handleSaveRef = useRef<() => void>(() => {}); + const handleSaveRef = useRef<() => Promise>(async () => {}); const hasOpenDialog = Boolean(dialogRequest || saveErrors || installedModal); @@ -810,7 +810,7 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { if (!response.ok) { if (response.status === 422 && data?.errors) { - setSaveErrors(data.errors as FatalErrors); + setSaveErrors(data.errors); return; } @@ -873,7 +873,7 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { role='dialog' onClick={(event) => { if (event.target === event.currentTarget) { - closeEditor(); + void closeEditor(); } }} > @@ -882,7 +882,7 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { changes={changes} currentThemeName={currentThemeName} isSaving={isSaving} - onClose={closeEditor} + onClose={() => void closeEditor()} onSave={() => void handleSave()} /> @@ -901,10 +901,10 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { selectedNode={selectedNode} setExpandedDirectories={setExpandedDirectories} setSelectedNode={setSelectedNode} - onCreateFile={handleCreateFile} - onDeleteSelected={handleDeleteSelected} + onCreateFile={() => void handleCreateFile()} + onDeleteSelected={() => void handleDeleteSelected()} onOpenFile={openFile} - onRenameSelected={handleRenameSelected} + onRenameSelected={() => void handleRenameSelected()} />
diff --git a/apps/admin/src/settings/app/components/settings/site/theme/theme-editor-confirm-modal.tsx b/apps/admin/src/settings/site/theme/theme-editor-confirm-modal.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/site/theme/theme-editor-confirm-modal.tsx rename to apps/admin/src/settings/site/theme/theme-editor-confirm-modal.tsx diff --git a/apps/admin/src/settings/app/components/settings/site/theme/theme-editor-input-modal.tsx b/apps/admin/src/settings/site/theme/theme-editor-input-modal.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/site/theme/theme-editor-input-modal.tsx rename to apps/admin/src/settings/site/theme/theme-editor-input-modal.tsx diff --git a/apps/admin/src/settings/app/components/settings/site/theme/theme-editor-styles.ts b/apps/admin/src/settings/site/theme/theme-editor-styles.ts similarity index 100% rename from apps/admin/src/settings/app/components/settings/site/theme/theme-editor-styles.ts rename to apps/admin/src/settings/site/theme/theme-editor-styles.ts diff --git a/apps/admin/src/settings/app/components/settings/site/theme/theme-editor-toolbar.tsx b/apps/admin/src/settings/site/theme/theme-editor-toolbar.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/site/theme/theme-editor-toolbar.tsx rename to apps/admin/src/settings/site/theme/theme-editor-toolbar.tsx diff --git a/apps/admin/src/settings/app/components/settings/site/theme/theme-editor-utils.test.ts b/apps/admin/src/settings/site/theme/theme-editor-utils.test.ts similarity index 99% rename from apps/admin/src/settings/app/components/settings/site/theme/theme-editor-utils.test.ts rename to apps/admin/src/settings/site/theme/theme-editor-utils.test.ts index 60aec0c0a8c..2a2c9fb4ece 100644 --- a/apps/admin/src/settings/app/components/settings/site/theme/theme-editor-utils.test.ts +++ b/apps/admin/src/settings/site/theme/theme-editor-utils.test.ts @@ -11,7 +11,7 @@ import { isEditablePath, parseEditingThemeRoute, packThemeArchive -} from '@/settings/app/components/settings/site/theme/theme-editor-utils'; +} from '@/settings/site/theme/theme-editor-utils'; const createArchiveBuffer = async (build: (zip: JSZip) => void) => { const zip = new JSZip(); diff --git a/apps/admin/src/settings/app/components/settings/site/theme/theme-editor-utils.ts b/apps/admin/src/settings/site/theme/theme-editor-utils.ts similarity index 100% rename from apps/admin/src/settings/app/components/settings/site/theme/theme-editor-utils.ts rename to apps/admin/src/settings/site/theme/theme-editor-utils.ts diff --git a/apps/admin/src/settings/app/components/settings/site/theme/theme-file-tree.tsx b/apps/admin/src/settings/site/theme/theme-file-tree.tsx similarity index 100% rename from apps/admin/src/settings/app/components/settings/site/theme/theme-file-tree.tsx rename to apps/admin/src/settings/site/theme/theme-file-tree.tsx diff --git a/apps/admin/src/settings/app/components/settings/site/theme/theme-installed-modal.tsx b/apps/admin/src/settings/site/theme/theme-installed-modal.tsx similarity index 92% rename from apps/admin/src/settings/app/components/settings/site/theme/theme-installed-modal.tsx rename to apps/admin/src/settings/site/theme/theme-installed-modal.tsx index 715fe53a898..3d53dd7a1bf 100644 --- a/apps/admin/src/settings/app/components/settings/site/theme/theme-installed-modal.tsx +++ b/apps/admin/src/settings/site/theme/theme-installed-modal.tsx @@ -1,8 +1,9 @@ import React, {type ReactNode} from 'react'; -import useCustomFonts from '@/settings/app/hooks/use-custom-fonts'; -import {ConfirmationModalContent} from '@/settings/app/components/confirmation-modal'; +import useCustomFonts from '@/settings/hooks/use-custom-fonts'; +import {ConfirmationModalContent} from '@/settings/components/confirmation-modal'; import {type InstalledTheme, useActivateTheme} from '@tryghost/admin-x-framework/api/themes'; -import {OutcomeBanner, ThemeValidationDetailsDisclosure, getIssuesFromInstalledTheme} from './theme-validation-details'; +import {OutcomeBanner, ThemeValidationDetailsDisclosure} from './theme-validation-details'; +import {getIssuesFromInstalledTheme} from './theme-validation-issues'; import {getHomepageUrl, useBrowseSite} from '@tryghost/admin-x-framework/api/site'; import {toast} from 'sonner'; import {useBrowseConfig} from '@tryghost/admin-x-framework/api/config'; diff --git a/apps/admin/src/settings/app/components/settings/site/theme/theme-preview.tsx b/apps/admin/src/settings/site/theme/theme-preview.tsx similarity index 97% rename from apps/admin/src/settings/app/components/settings/site/theme/theme-preview.tsx rename to apps/admin/src/settings/site/theme/theme-preview.tsx index 95266a6ed1f..3fc3fa04500 100644 --- a/apps/admin/src/settings/app/components/settings/site/theme/theme-preview.tsx +++ b/apps/admin/src/settings/site/theme/theme-preview.tsx @@ -1,9 +1,9 @@ import React, {useState} from 'react'; -import SettingsBreadcrumbs from '@/settings/app/components/settings/settings-breadcrumbs'; +import SettingsBreadcrumbs from '@/settings/components/settings-breadcrumbs'; import {Button, Field, FieldLabel, PreviewChrome, Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@tryghost/shade/components'; import {Inline} from '@tryghost/shade/primitives'; import {LucideIcon} from '@tryghost/shade/utils'; -import {type OfficialTheme, type ThemeVariant} from '@/settings/app/components/providers/settings-app-provider'; +import {type OfficialTheme, type ThemeVariant} from '@/settings/providers/settings-app-context'; import {PageHeader} from '@tryghost/shade/patterns'; import {type Theme, isDefaultOrLegacyTheme} from '@tryghost/admin-x-framework/api/themes'; @@ -75,7 +75,7 @@ const ThemePreview: React.FC<{ const handleInstall = () => { // The parent component handles all limit checks and confirmation modals - onInstall?.(); + void onInstall?.(); }; const left = diff --git a/apps/admin/src/settings/app/components/settings/site/theme/theme-validation-details.tsx b/apps/admin/src/settings/site/theme/theme-validation-details.tsx similarity index 80% rename from apps/admin/src/settings/app/components/settings/site/theme/theme-validation-details.tsx rename to apps/admin/src/settings/site/theme/theme-validation-details.tsx index 309798fc888..8a25e226bf8 100644 --- a/apps/admin/src/settings/app/components/settings/site/theme/theme-validation-details.tsx +++ b/apps/admin/src/settings/site/theme/theme-validation-details.tsx @@ -1,61 +1,10 @@ import React, {useEffect, useMemo, useState} from 'react'; import {Badge, Banner} from '@tryghost/shade/components'; -import {type InstalledTheme, type ThemeProblem} from '@tryghost/admin-x-framework/api/themes'; +import {type ThemeProblem} from '@tryghost/admin-x-framework/api/themes'; import {LucideIcon} from '@tryghost/shade/utils'; -type ThemeValidationErrorDetails = { - errors?: ThemeProblem[]; - warnings?: ThemeProblem[]; -}; - -type ThemeValidationError = { - details: ThemeValidationErrorDetails | string; -}; - -export type FatalErrors = ThemeValidationError[]; - -type IssueSummary = { - blockingProblems: ThemeProblem[]; - secondaryProblems: ThemeProblem[]; - stringErrors: string[]; -}; - type DisplaySeverity = 'Error' | 'Warning' | 'Recommendation'; -function isDetailsObject(details: ThemeValidationError['details']): details is ThemeValidationErrorDetails { - return typeof details === 'object' && details !== null; -} - -function allProblemsFromDetails(details: ThemeValidationErrorDetails) { - return [...(details.errors || []), ...(details.warnings || [])]; -} - -export function getIssuesFromFatalErrors(fatalErrors: FatalErrors = []): IssueSummary { - const blockingProblems: ThemeProblem[] = []; - const secondaryProblems: ThemeProblem[] = []; - const stringErrors: string[] = []; - - fatalErrors.forEach((error) => { - if (isDetailsObject(error.details)) { - allProblemsFromDetails(error.details).forEach((problem) => { - if (problem.fatal) { - blockingProblems.push(problem); - } else { - secondaryProblems.push(problem); - } - }); - } else { - stringErrors.push(error.details); - } - }); - - return {blockingProblems, secondaryProblems, stringErrors}; -} - -export function getIssuesFromInstalledTheme(installedTheme: InstalledTheme): ThemeProblem[] { - return [...(installedTheme.errors || []), ...(installedTheme.warnings || [])]; -} - function getDisplaySeverity(problem: ThemeProblem): DisplaySeverity { if (problem.level === 'warning') { return 'Warning'; diff --git a/apps/admin/src/settings/site/theme/theme-validation-issues.ts b/apps/admin/src/settings/site/theme/theme-validation-issues.ts new file mode 100644 index 00000000000..61eb3c9b54c --- /dev/null +++ b/apps/admin/src/settings/site/theme/theme-validation-issues.ts @@ -0,0 +1,52 @@ +import {type InstalledTheme, type ThemeProblem} from '@tryghost/admin-x-framework/api/themes'; + +type ThemeValidationErrorDetails = { + errors?: ThemeProblem[]; + warnings?: ThemeProblem[]; +}; + +type ThemeValidationError = { + details: ThemeValidationErrorDetails | string; +}; + +export type FatalErrors = ThemeValidationError[]; + +type IssueSummary = { + blockingProblems: ThemeProblem[]; + secondaryProblems: ThemeProblem[]; + stringErrors: string[]; +}; + +function isDetailsObject(details: ThemeValidationError['details']): details is ThemeValidationErrorDetails { + return typeof details === 'object' && details !== null; +} + +function allProblemsFromDetails(details: ThemeValidationErrorDetails) { + return [...(details.errors || []), ...(details.warnings || [])]; +} + +export function getIssuesFromFatalErrors(fatalErrors: FatalErrors = []): IssueSummary { + const blockingProblems: ThemeProblem[] = []; + const secondaryProblems: ThemeProblem[] = []; + const stringErrors: string[] = []; + + fatalErrors.forEach((error) => { + if (isDetailsObject(error.details)) { + allProblemsFromDetails(error.details).forEach((problem) => { + if (problem.fatal) { + blockingProblems.push(problem); + } else { + secondaryProblems.push(problem); + } + }); + } else { + stringErrors.push(error.details); + } + }); + + return {blockingProblems, secondaryProblems, stringErrors}; +} + +export function getIssuesFromInstalledTheme(installedTheme: InstalledTheme): ThemeProblem[] { + return [...(installedTheme.errors || []), ...(installedTheme.warnings || [])]; +} diff --git a/apps/admin/src/settings/app/utils/analytics.test.ts b/apps/admin/src/settings/utils/analytics.test.ts similarity index 90% rename from apps/admin/src/settings/app/utils/analytics.test.ts rename to apps/admin/src/settings/utils/analytics.test.ts index 79166970f57..1b4fb6d9c96 100644 --- a/apps/admin/src/settings/app/utils/analytics.test.ts +++ b/apps/admin/src/settings/utils/analytics.test.ts @@ -1,5 +1,5 @@ import * as assert from 'assert/strict'; -import trackEvent from '@/settings/app/utils/analytics'; +import trackEvent from '@/settings/utils/analytics'; describe('trackEvent', function () { it('calls plausible with the correct event name and properties', function () { diff --git a/apps/admin/src/settings/app/utils/analytics.ts b/apps/admin/src/settings/utils/analytics.ts similarity index 100% rename from apps/admin/src/settings/app/utils/analytics.ts rename to apps/admin/src/settings/utils/analytics.ts diff --git a/apps/admin/src/settings/app/utils/currency.ts b/apps/admin/src/settings/utils/currency.ts similarity index 100% rename from apps/admin/src/settings/app/utils/currency.ts rename to apps/admin/src/settings/utils/currency.ts diff --git a/apps/admin/src/settings/app/utils/custom-theme-settings.test.ts b/apps/admin/src/settings/utils/custom-theme-settings.test.ts similarity index 91% rename from apps/admin/src/settings/app/utils/custom-theme-settings.test.ts rename to apps/admin/src/settings/utils/custom-theme-settings.test.ts index 1d46c2c9dd1..ae51bd3a90b 100644 --- a/apps/admin/src/settings/app/utils/custom-theme-settings.test.ts +++ b/apps/admin/src/settings/utils/custom-theme-settings.test.ts @@ -1,6 +1,6 @@ import * as assert from 'assert/strict'; import {type CustomThemeSetting} from '@tryghost/admin-x-framework/api/custom-theme-settings'; -import {isCustomThemeSettingVisible} from '@/settings/app/utils/is-custom-theme-settings-visible'; +import {isCustomThemeSettingVisible} from '@/settings/utils/is-custom-theme-settings-visible'; describe('isCustomThemeSettingVisible', function () { it('returns whether or not a custom theme setting is visible', function () { diff --git a/apps/admin/src/settings/app/utils/debounce.ts b/apps/admin/src/settings/utils/debounce.ts similarity index 100% rename from apps/admin/src/settings/app/utils/debounce.ts rename to apps/admin/src/settings/utils/debounce.ts diff --git a/apps/admin/src/settings/app/utils/escape-html.ts b/apps/admin/src/settings/utils/escape-html.ts similarity index 100% rename from apps/admin/src/settings/app/utils/escape-html.ts rename to apps/admin/src/settings/utils/escape-html.ts diff --git a/apps/admin/src/settings/app/utils/format-url.ts b/apps/admin/src/settings/utils/format-url.ts similarity index 100% rename from apps/admin/src/settings/app/utils/format-url.ts rename to apps/admin/src/settings/utils/format-url.ts diff --git a/apps/admin/src/settings/app/utils/generate-embed-code.test.ts b/apps/admin/src/settings/utils/generate-embed-code.test.ts similarity index 98% rename from apps/admin/src/settings/app/utils/generate-embed-code.test.ts rename to apps/admin/src/settings/utils/generate-embed-code.test.ts index eef3492eb46..c8f571df1e7 100644 --- a/apps/admin/src/settings/app/utils/generate-embed-code.test.ts +++ b/apps/admin/src/settings/utils/generate-embed-code.test.ts @@ -1,5 +1,5 @@ import * as assert from 'assert/strict'; -import {type GenerateCodeOptions, generateCode, getEmbedPreviewLayoutMarker} from '@/settings/app/utils/generate-embed-code'; +import {type GenerateCodeOptions, generateCode, getEmbedPreviewLayoutMarker} from '@/settings/utils/generate-embed-code'; describe('generateCode', function () { let genOptions: GenerateCodeOptions; diff --git a/apps/admin/src/settings/app/utils/generate-embed-code.ts b/apps/admin/src/settings/utils/generate-embed-code.ts similarity index 93% rename from apps/admin/src/settings/app/utils/generate-embed-code.ts rename to apps/admin/src/settings/utils/generate-embed-code.ts index f1d6ebd98d1..59f27d6b28f 100644 --- a/apps/admin/src/settings/app/utils/generate-embed-code.ts +++ b/apps/admin/src/settings/utils/generate-embed-code.ts @@ -28,8 +28,7 @@ type OptionsType = { site: string; 'button-color': string; 'button-text-color': string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [key: string]: any; // This allows for computed properties like 'label-1', 'label-2', etc. + [key: string]: string; // This allows for computed properties like 'label-1', 'label-2', etc. }; export const getEmbedPreviewLayoutMarker = (layout: EmbedSignupLayout) => `data-preview-layout="${escapeHtml(layout)}"`; @@ -62,8 +61,8 @@ export const generateCode = ({ if (settings.icon && settings.icon !== '') { options.icon = settings.icon.replace(/\/content\/images\/(?:size\/[^/]+\/)*/, '/content/images/size/w192h192/'); } - options.title = settings.title; - options.description = settings.description; + options.title = settings.title ?? ''; + options.description = settings.description ?? ''; options['background-color'] = backgroundColor; options['text-color'] = textColorForBackgroundColor(backgroundColor).hex(); diff --git a/apps/admin/src/settings/app/utils/get-offers-portal-preview-url.ts b/apps/admin/src/settings/utils/get-offers-portal-preview-url.ts similarity index 100% rename from apps/admin/src/settings/app/utils/get-offers-portal-preview-url.ts rename to apps/admin/src/settings/utils/get-offers-portal-preview-url.ts diff --git a/apps/admin/src/settings/app/utils/get-portal-preview-url.ts b/apps/admin/src/settings/utils/get-portal-preview-url.ts similarity index 97% rename from apps/admin/src/settings/app/utils/get-portal-preview-url.ts rename to apps/admin/src/settings/utils/get-portal-preview-url.ts index 967a4393297..731fb646c78 100644 --- a/apps/admin/src/settings/app/utils/get-portal-preview-url.ts +++ b/apps/admin/src/settings/utils/get-portal-preview-url.ts @@ -23,7 +23,7 @@ export const getPortalPreviewUrl = ({settings, config, tiers, siteData, selected const baseUrl = siteData.url.replace(/\/$/, ''); const portalBase = '/?v=modal-portal-settings#/portal/preview'; - const portalPlans: string[] = JSON.parse(getSettingValue(settings, 'portal_plans') || ''); + const portalPlans = JSON.parse(getSettingValue(settings, 'portal_plans') || '') as string[]; const settingsParam = new URLSearchParams(); settingsParam.append('button', getSettingValue(settings, 'portal_button') ? 'true' : 'false'); diff --git a/apps/admin/src/settings/app/utils/get-tiers-cadences.ts b/apps/admin/src/settings/utils/get-tiers-cadences.ts similarity index 100% rename from apps/admin/src/settings/app/utils/get-tiers-cadences.ts rename to apps/admin/src/settings/utils/get-tiers-cadences.ts diff --git a/apps/admin/src/settings/app/utils/helpers.ts b/apps/admin/src/settings/utils/helpers.ts similarity index 100% rename from apps/admin/src/settings/app/utils/helpers.ts rename to apps/admin/src/settings/utils/helpers.ts diff --git a/apps/admin/src/settings/app/utils/iframe-buffering.test.tsx b/apps/admin/src/settings/utils/iframe-buffering.test.tsx similarity index 92% rename from apps/admin/src/settings/app/utils/iframe-buffering.test.tsx rename to apps/admin/src/settings/utils/iframe-buffering.test.tsx index 30c7672f081..8b7843ac549 100644 --- a/apps/admin/src/settings/app/utils/iframe-buffering.test.tsx +++ b/apps/admin/src/settings/utils/iframe-buffering.test.tsx @@ -1,4 +1,4 @@ -import IframeBuffering from '@/settings/app/utils/iframe-buffering'; +import IframeBuffering from '@/settings/utils/iframe-buffering'; import {render, waitFor} from '@testing-library/react'; describe('IframeBuffering', () => { diff --git a/apps/admin/src/settings/app/utils/iframe-buffering.tsx b/apps/admin/src/settings/utils/iframe-buffering.tsx similarity index 91% rename from apps/admin/src/settings/app/utils/iframe-buffering.tsx rename to apps/admin/src/settings/utils/iframe-buffering.tsx index 257b83023f4..451c5f86ae2 100644 --- a/apps/admin/src/settings/app/utils/iframe-buffering.tsx +++ b/apps/admin/src/settings/utils/iframe-buffering.tsx @@ -1,5 +1,6 @@ import * as Sentry from '@sentry/react'; import React, {useEffect, useRef, useState} from 'react'; +import {debounce} from './debounce'; type IframeBufferingProps = { generateContent: (iframe: HTMLIFrameElement) => void; @@ -13,21 +14,6 @@ type IframeBufferingProps = { addDelay?: boolean; }; -// eslint-disable-next-line -function debounce(func: any, wait: number) { - let timeout: NodeJS.Timeout; - - // eslint-disable-next-line - return function executedFunction(...args: any) { - const later = () => { - clearTimeout(timeout); - func(...args); - }; - clearTimeout(timeout); - timeout = setTimeout(later, wait); - }; -} - const IframeBuffering: React.FC = ({generateContent, updateContent, className, height, width, parentClassName, testId, addDelay = false}) => { const [visibleIframeIndex, setVisibleIframeIndex] = useState(0); const iframes = [useRef(null), useRef(null)]; diff --git a/apps/admin/src/settings/app/utils/is-custom-theme-settings-visible.ts b/apps/admin/src/settings/utils/is-custom-theme-settings-visible.ts similarity index 100% rename from apps/admin/src/settings/app/utils/is-custom-theme-settings-visible.ts rename to apps/admin/src/settings/utils/is-custom-theme-settings-visible.ts diff --git a/apps/admin/src/settings/app/utils/link-to-github-releases.test.ts b/apps/admin/src/settings/utils/link-to-github-releases.test.ts similarity index 95% rename from apps/admin/src/settings/app/utils/link-to-github-releases.test.ts rename to apps/admin/src/settings/utils/link-to-github-releases.test.ts index 266372736a5..1b4eafbf98e 100644 --- a/apps/admin/src/settings/app/utils/link-to-github-releases.test.ts +++ b/apps/admin/src/settings/utils/link-to-github-releases.test.ts @@ -1,5 +1,5 @@ import * as assert from 'assert/strict'; -import {linkToGitHubReleases} from '@/settings/app/utils/link-to-github-releases'; +import {linkToGitHubReleases} from '@/settings/utils/link-to-github-releases'; describe('linkToGithubRelease', function () { it('handles empty version', function () { diff --git a/apps/admin/src/settings/app/utils/link-to-github-releases.ts b/apps/admin/src/settings/utils/link-to-github-releases.ts similarity index 100% rename from apps/admin/src/settings/app/utils/link-to-github-releases.ts rename to apps/admin/src/settings/utils/link-to-github-releases.ts diff --git a/apps/admin/src/settings/app/utils/locale-validation.test.ts b/apps/admin/src/settings/utils/locale-validation.test.ts similarity index 98% rename from apps/admin/src/settings/app/utils/locale-validation.test.ts rename to apps/admin/src/settings/utils/locale-validation.test.ts index b6058a084a7..5c625dfd107 100644 --- a/apps/admin/src/settings/app/utils/locale-validation.test.ts +++ b/apps/admin/src/settings/utils/locale-validation.test.ts @@ -1,5 +1,5 @@ import * as assert from 'assert/strict'; -import {validateLocale} from '@/settings/app/utils/locale-validation'; +import {validateLocale} from '@/settings/utils/locale-validation'; describe('validateLocale', function () { describe('valid locales', function () { diff --git a/apps/admin/src/settings/app/utils/locale-validation.ts b/apps/admin/src/settings/utils/locale-validation.ts similarity index 100% rename from apps/admin/src/settings/app/utils/locale-validation.ts rename to apps/admin/src/settings/utils/locale-validation.ts diff --git a/apps/admin/src/settings/app/utils/newsletter-emails.ts b/apps/admin/src/settings/utils/newsletter-emails.ts similarity index 100% rename from apps/admin/src/settings/app/utils/newsletter-emails.ts rename to apps/admin/src/settings/utils/newsletter-emails.ts diff --git a/apps/admin/src/settings/app/utils/portal.tsx b/apps/admin/src/settings/utils/portal.tsx similarity index 100% rename from apps/admin/src/settings/app/utils/portal.tsx rename to apps/admin/src/settings/utils/portal.tsx diff --git a/apps/admin/src/settings/app/utils/search.tsx b/apps/admin/src/settings/utils/search.tsx similarity index 95% rename from apps/admin/src/settings/app/utils/search.tsx rename to apps/admin/src/settings/utils/search.tsx index 538722fb2e9..78787ddc225 100644 --- a/apps/admin/src/settings/app/utils/search.tsx +++ b/apps/admin/src/settings/utils/search.tsx @@ -89,11 +89,11 @@ const useSearchService = () => { return {part}; }); } else if (Array.isArray(text)) { - return text.map(part => highlightKeywords(part)); + return (text as React.ReactNode[]).map(part => highlightKeywords(part)); } else if (text && typeof text === 'object' && text) { return React.Children.map(text, (child) => { if (child && typeof child === 'object' && 'props' in child) { - return highlightKeywords(child.props.children); + return highlightKeywords((child as React.ReactElement<{children?: React.ReactNode}>).props.children); } return child; }); diff --git a/apps/admin/src/settings/app/utils/show-database-warning.test.ts b/apps/admin/src/settings/utils/show-database-warning.test.ts similarity index 90% rename from apps/admin/src/settings/app/utils/show-database-warning.test.ts rename to apps/admin/src/settings/utils/show-database-warning.test.ts index 4e51135dc83..7d0aa4ed6ef 100644 --- a/apps/admin/src/settings/app/utils/show-database-warning.test.ts +++ b/apps/admin/src/settings/utils/show-database-warning.test.ts @@ -1,5 +1,5 @@ import * as assert from 'assert/strict'; -import {showDatabaseWarning} from '@/settings/app/utils/show-database-warning'; +import {showDatabaseWarning} from '@/settings/utils/show-database-warning'; describe('showDatabaseWarning', function () { it('shows a warning when in production and not using MySQL 8', function () { diff --git a/apps/admin/src/settings/app/utils/show-database-warning.ts b/apps/admin/src/settings/utils/show-database-warning.ts similarity index 100% rename from apps/admin/src/settings/app/utils/show-database-warning.ts rename to apps/admin/src/settings/utils/show-database-warning.ts diff --git a/apps/admin/src/settings/app/utils/social-urls/bluesky.ts b/apps/admin/src/settings/utils/social-urls/bluesky.ts similarity index 100% rename from apps/admin/src/settings/app/utils/social-urls/bluesky.ts rename to apps/admin/src/settings/utils/social-urls/bluesky.ts diff --git a/apps/admin/src/settings/app/utils/social-urls/facebook.ts b/apps/admin/src/settings/utils/social-urls/facebook.ts similarity index 100% rename from apps/admin/src/settings/app/utils/social-urls/facebook.ts rename to apps/admin/src/settings/utils/social-urls/facebook.ts diff --git a/apps/admin/src/settings/app/utils/social-urls/index.ts b/apps/admin/src/settings/utils/social-urls/index.ts similarity index 100% rename from apps/admin/src/settings/app/utils/social-urls/index.ts rename to apps/admin/src/settings/utils/social-urls/index.ts diff --git a/apps/admin/src/settings/app/utils/social-urls/instagram.ts b/apps/admin/src/settings/utils/social-urls/instagram.ts similarity index 100% rename from apps/admin/src/settings/app/utils/social-urls/instagram.ts rename to apps/admin/src/settings/utils/social-urls/instagram.ts diff --git a/apps/admin/src/settings/app/utils/social-urls/linkedin.ts b/apps/admin/src/settings/utils/social-urls/linkedin.ts similarity index 100% rename from apps/admin/src/settings/app/utils/social-urls/linkedin.ts rename to apps/admin/src/settings/utils/social-urls/linkedin.ts diff --git a/apps/admin/src/settings/app/utils/social-urls/mastodon.ts b/apps/admin/src/settings/utils/social-urls/mastodon.ts similarity index 100% rename from apps/admin/src/settings/app/utils/social-urls/mastodon.ts rename to apps/admin/src/settings/utils/social-urls/mastodon.ts diff --git a/apps/admin/src/settings/app/utils/social-urls/platform-config.test.ts b/apps/admin/src/settings/utils/social-urls/platform-config.test.ts similarity index 98% rename from apps/admin/src/settings/app/utils/social-urls/platform-config.test.ts rename to apps/admin/src/settings/utils/social-urls/platform-config.test.ts index 5eda6217a17..d1e84e72039 100644 --- a/apps/admin/src/settings/app/utils/social-urls/platform-config.test.ts +++ b/apps/admin/src/settings/utils/social-urls/platform-config.test.ts @@ -1,5 +1,5 @@ import * as assert from 'assert/strict'; -import {SOCIAL_PLATFORM_CONFIG_BY_KEY, normalizeSocialInput} from '@/settings/app/utils/social-urls/index'; +import {SOCIAL_PLATFORM_CONFIG_BY_KEY, normalizeSocialInput} from '@/settings/utils/social-urls/index'; describe('normalizeSocialInput', () => { it('returns empty display and null stored for empty input', () => { diff --git a/apps/admin/src/settings/app/utils/social-urls/platform-config.ts b/apps/admin/src/settings/utils/social-urls/platform-config.ts similarity index 100% rename from apps/admin/src/settings/app/utils/social-urls/platform-config.ts rename to apps/admin/src/settings/utils/social-urls/platform-config.ts diff --git a/apps/admin/src/settings/app/utils/social-urls/platform-validator.ts b/apps/admin/src/settings/utils/social-urls/platform-validator.ts similarity index 100% rename from apps/admin/src/settings/app/utils/social-urls/platform-validator.ts rename to apps/admin/src/settings/utils/social-urls/platform-validator.ts diff --git a/apps/admin/src/settings/app/utils/social-urls/social-urls.test.ts b/apps/admin/src/settings/utils/social-urls/social-urls.test.ts similarity index 99% rename from apps/admin/src/settings/app/utils/social-urls/social-urls.test.ts rename to apps/admin/src/settings/utils/social-urls/social-urls.test.ts index 37163a02076..1a362ade903 100644 --- a/apps/admin/src/settings/app/utils/social-urls/social-urls.test.ts +++ b/apps/admin/src/settings/utils/social-urls/social-urls.test.ts @@ -19,7 +19,7 @@ import { validateTwitterUrl, validateYouTubeUrl, youtubeHandleToUrl, youtubeUrlToHandle -} from '@/settings/app/utils/social-urls/index'; +} from '@/settings/utils/social-urls/index'; /* * Table-driven coverage for every social platform validator. diff --git a/apps/admin/src/settings/app/utils/social-urls/threads.ts b/apps/admin/src/settings/utils/social-urls/threads.ts similarity index 100% rename from apps/admin/src/settings/app/utils/social-urls/threads.ts rename to apps/admin/src/settings/utils/social-urls/threads.ts diff --git a/apps/admin/src/settings/app/utils/social-urls/tiktok.ts b/apps/admin/src/settings/utils/social-urls/tiktok.ts similarity index 100% rename from apps/admin/src/settings/app/utils/social-urls/tiktok.ts rename to apps/admin/src/settings/utils/social-urls/tiktok.ts diff --git a/apps/admin/src/settings/app/utils/social-urls/twitter.ts b/apps/admin/src/settings/utils/social-urls/twitter.ts similarity index 100% rename from apps/admin/src/settings/app/utils/social-urls/twitter.ts rename to apps/admin/src/settings/utils/social-urls/twitter.ts diff --git a/apps/admin/src/settings/app/utils/social-urls/youtube.ts b/apps/admin/src/settings/utils/social-urls/youtube.ts similarity index 100% rename from apps/admin/src/settings/app/utils/social-urls/youtube.ts rename to apps/admin/src/settings/utils/social-urls/youtube.ts diff --git a/apps/admin/src/settings/app/utils/url.test.ts b/apps/admin/src/settings/utils/url.test.ts similarity index 98% rename from apps/admin/src/settings/app/utils/url.test.ts rename to apps/admin/src/settings/utils/url.test.ts index 9b7bdf0943c..409956be786 100644 --- a/apps/admin/src/settings/app/utils/url.test.ts +++ b/apps/admin/src/settings/utils/url.test.ts @@ -1,5 +1,5 @@ import * as assert from 'assert/strict'; -import {trimHash, trimSearch, trimSearchAndHash} from '@/settings/app/utils/url'; +import {trimHash, trimSearch, trimSearchAndHash} from '@/settings/utils/url'; describe('trimSearch', function () { it('removes the query parameters from a URL', function () { diff --git a/apps/admin/src/settings/app/utils/url.ts b/apps/admin/src/settings/utils/url.ts similarity index 100% rename from apps/admin/src/settings/app/utils/url.ts rename to apps/admin/src/settings/utils/url.ts diff --git a/apps/admin/src/settings/app/utils/welcome-email-sender-details.test.ts b/apps/admin/src/settings/utils/welcome-email-sender-details.test.ts similarity index 97% rename from apps/admin/src/settings/app/utils/welcome-email-sender-details.test.ts rename to apps/admin/src/settings/utils/welcome-email-sender-details.test.ts index 6a987602590..c6e63f836af 100644 --- a/apps/admin/src/settings/app/utils/welcome-email-sender-details.test.ts +++ b/apps/admin/src/settings/utils/welcome-email-sender-details.test.ts @@ -1,5 +1,5 @@ import * as assert from 'assert/strict'; -import {resolveWelcomeEmailSenderDetails} from '@/settings/app/utils/welcome-email-sender-details'; +import {resolveWelcomeEmailSenderDetails} from '@/settings/utils/welcome-email-sender-details'; import type {Config} from '@tryghost/admin-x-framework/api/config'; const config = { diff --git a/apps/admin/src/settings/app/utils/welcome-email-sender-details.ts b/apps/admin/src/settings/utils/welcome-email-sender-details.ts similarity index 100% rename from apps/admin/src/settings/app/utils/welcome-email-sender-details.ts rename to apps/admin/src/settings/utils/welcome-email-sender-details.ts diff --git a/apps/admin/src/shared/filters/filter-types.ts b/apps/admin/src/shared/filters/filter-types.ts index dd20db29ad0..41faf51e852 100644 --- a/apps/admin/src/shared/filters/filter-types.ts +++ b/apps/admin/src/shared/filters/filter-types.ts @@ -19,6 +19,21 @@ export interface FilterCodec { serialize: (predicate: FilterPredicate, ctx: CodecContext) => string[] | null; } +/** A column the list appends while a filter on this field is active. */ +export interface ActiveColumn { + key: string; + label: string; +} + +export interface ActiveColumnContext extends CodecContext { + /** + * The display name the domain resolved for this key, where the schema cannot hold one: + * a publisher-defined field is named at runtime. Absent when the caller supplied no + * names, or has none for this key — a resolver returns null rather than guess. + */ + label?: string; +} + export interface FilterField { operators: readonly string[]; parseKeys?: readonly string[]; @@ -29,11 +44,21 @@ export interface FilterField { }; options?: Array<{value: string; label: string}>; metadata?: { - activeColumn?: { - key: string; - label: string; - include?: string; - }; + /** + * A field whose key is a pattern stands for many columns, one per key it matches, + * so it resolves its column per instance from the matched params instead of + * declaring a fixed one. + */ + activeColumn?: ActiveColumn | ((context: ActiveColumnContext) => ActiveColumn | null); + /** + * What the list must ask the API to return for this field's column to hold values. + * + * Declared apart from the column, because the two questions are answerable at + * different moments: whether to ask follows from the filter alone, while naming + * the column can wait on data that arrives later. Tying them together would make + * the list fetch once without the values and again once the names landed. + */ + columnInclude?: string; }; codec: FilterCodec; } diff --git a/apps/admin/src/tags/detail/tag-detail.tsx b/apps/admin/src/tags/detail/tag-detail.tsx index 516850329e5..d53df6c2fca 100644 --- a/apps/admin/src/tags/detail/tag-detail.tsx +++ b/apps/admin/src/tags/detail/tag-detail.tsx @@ -69,7 +69,7 @@ const TagDetail: React.FC = () => { // Lets our own post-save redirect through the unsaved-changes blocker. const bypassGuardRef = React.useRef(false); const blockedNavigationRef = React.useRef(false); - const proceedBlockedNavigationRef = React.useRef<() => void>(() => {}); + const proceedBlockedNavigationAfterSaveRef = React.useRef(false); const [saveStatus, setSaveStatus] = React.useState('idle'); const [showDelete, setShowDelete] = React.useState(false); const [isDeleting, setIsDeleting] = React.useState(false); @@ -112,6 +112,7 @@ const TagDetail: React.FC = () => { React.useEffect(() => { bypassGuardRef.current = false; blockedNavigationRef.current = false; + proceedBlockedNavigationAfterSaveRef.current = false; setShowDelete(false); setIsDeleting(false); setBusyImageFields(new Set()); @@ -227,7 +228,7 @@ const TagDetail: React.FC = () => { touchedFieldsRef.current.clear(); setSaveStatus('success'); if (blockedNavigationRef.current) { - proceedBlockedNavigationRef.current(); + proceedBlockedNavigationAfterSaveRef.current = true; return; } // Move `/tags/new` to the saved tag and follow slug renames. A @@ -280,21 +281,38 @@ const TagDetail: React.FC = () => { const shouldGuardNavigation = activeMutation.isPending || hasPendingImageUpload || isDeleting || hasUnsavedChanges; useConfirmUnload(shouldGuardNavigation); - const blocker = useBlocker(({currentLocation, nextLocation}) => !bypassGuardRef.current && shouldGuardNavigation && currentLocation.pathname !== nextLocation.pathname); + const blocker = useBlocker(({currentLocation, nextLocation}) => { + const shouldBlock = !bypassGuardRef.current && shouldGuardNavigation && currentLocation.pathname !== nextLocation.pathname; + if (shouldBlock) { + // The mutation can resolve before React rerenders with a blocked + // state, so record the navigation synchronously in the blocker. + blockedNavigationRef.current = true; + } + return shouldBlock; + }); // Native `` navigations (the sidebar, links into Ember // routes) never reach the react-router blocker above. Ember's own guard // can't cover this screen either: with `tagDetailsReact` on, the Ember tag // route aborts and never registers into the `unsaved-changes` service. - const anchorGuard = useHashLinkNavigationGuard(shouldGuardNavigation); + const anchorGuard = useHashLinkNavigationGuard(shouldGuardNavigation, () => { + blockedNavigationRef.current = true; + }); const isBlocked = blocker.state === 'blocked' || anchorGuard.isBlocked; - blockedNavigationRef.current = isBlocked; - proceedBlockedNavigationRef.current = () => { + if (isBlocked) { + blockedNavigationRef.current = true; + } + React.useEffect(() => { + if (!proceedBlockedNavigationAfterSaveRef.current || activeMutation.isPending || !isBlocked) { + return; + } + proceedBlockedNavigationAfterSaveRef.current = false; + blockedNavigationRef.current = false; if (anchorGuard.isBlocked) { anchorGuard.proceed(); } else { blocker.proceed?.(); } - }; + }, [activeMutation.isPending, anchorGuard, blocker, isBlocked]); // DirtyConfirmDialog's confirm action auto-closes the dialog, firing // onOpenChange(false) while the blocker still reads as blocked; without // this flag the close handler would reset the very navigation the user @@ -441,6 +459,8 @@ const TagDetail: React.FC = () => { open={isBlocked && !activeMutation.isPending} onConfirm={() => { leaveConfirmedRef.current = true; + blockedNavigationRef.current = false; + proceedBlockedNavigationAfterSaveRef.current = false; if (anchorGuard.isBlocked) { anchorGuard.proceed(); } else { @@ -456,6 +476,8 @@ const TagDetail: React.FC = () => { return; } if (isBlocked) { + blockedNavigationRef.current = false; + proceedBlockedNavigationAfterSaveRef.current = false; blocker.reset?.(); anchorGuard.reset(); } diff --git a/apps/admin/src/tags/detail/tag-image-field.tsx b/apps/admin/src/tags/detail/tag-image-field.tsx index 589a9c6c2ef..9db836e6913 100644 --- a/apps/admin/src/tags/detail/tag-image-field.tsx +++ b/apps/admin/src/tags/detail/tag-image-field.tsx @@ -8,7 +8,7 @@ import {createPortal} from 'react-dom'; import {getImageUrl, useUploadImage} from '@tryghost/admin-x-framework/api/images'; import {useFramework} from '@tryghost/admin-x-framework'; import {usePinturaEditor} from '@/hooks/use-pintura-editor'; -import BrandIcon from '@/settings/app/components/icons/brand-icon'; +import BrandIcon from '@/settings/components/icons/brand-icon'; import {JSONError, RequestEntityTooLargeError, UnsupportedMediaTypeError} from '@tryghost/admin-x-framework/errors'; import {toast} from 'sonner'; diff --git a/apps/admin/src/vite-env.d.ts b/apps/admin/src/vite-env.d.ts index 650bbe760f5..8a7466db950 100644 --- a/apps/admin/src/vite-env.d.ts +++ b/apps/admin/src/vite-env.d.ts @@ -4,8 +4,22 @@ interface ImportMetaEnv { readonly GHOST_BUILD_VERSION?: string; } -declare module '@tryghost/limit-service' -declare module '@tryghost/nql' +declare module '@tryghost/limit-service' { + type LimitOptions = Record; + export default class LimitService { + loadLimits(config: {limits: object; subscription?: unknown; helpLink?: string; db?: unknown; errors: Record}): void; + isLimited(limitName: string): boolean; + isDisabled(limitName: string): boolean; + checkIsOverLimit(limitName: string, options?: LimitOptions): Promise; + checkWouldGoOverLimit(limitName: string, options?: LimitOptions): Promise; + errorIfIsOverLimit(limitName: string, options?: LimitOptions): Promise; + errorIfWouldGoOverLimit(limitName: string, options?: LimitOptions): Promise; + checkIfAnyOverLimit(options?: LimitOptions): Promise; + } +} +declare module '@tryghost/nql' { + export default function nql(query: string): {queryJSON: (data: unknown) => boolean}; +} declare module '@tryghost/string' { export function slugify(string: string, options?: {requiredChangesOnly?: boolean}): string; } diff --git a/apps/ember-admin/app/models/navigation-item.js b/apps/ember-admin/app/models/navigation-item.js index 3fa1183671a..3c14db87c8c 100644 --- a/apps/ember-admin/app/models/navigation-item.js +++ b/apps/ember-admin/app/models/navigation-item.js @@ -5,20 +5,22 @@ import {isBlank} from '@ember/utils'; export default EmberObject.extend(ValidationEngine, { label: '', url: '', + icon: '', + visibility: 'public', isNew: false, isSecondary: false, validationType: 'navItem', - isComplete: computed('label', 'url', function () { - let {label, url} = this; + isComplete: computed('label', 'url', 'icon', function () { + let {label, url, icon} = this; - return !isBlank(label) && !isBlank(url); + return (!isBlank(label) || !isBlank(icon)) && !isBlank(url); }), - isBlank: computed('label', 'url', function () { - let {label, url} = this; + isBlank: computed('label', 'url', 'icon', function () { + let {label, url, icon} = this; - return isBlank(label) && isBlank(url); + return isBlank(label) && isBlank(url) && isBlank(icon); }) }); diff --git a/apps/ember-admin/app/transforms/navigation-settings.js b/apps/ember-admin/app/transforms/navigation-settings.js index 9c1bd214702..0c84465fb63 100644 --- a/apps/ember-admin/app/transforms/navigation-settings.js +++ b/apps/ember-admin/app/transforms/navigation-settings.js @@ -27,8 +27,15 @@ export default class NavigationSettings extends Transform { settingsArray = deserialized.map((item) => { let label = item.label.trim(); let url = item.url.trim(); - - return {label, url}; + let icon = item.icon && item.icon.trim(); + let visibility = item.visibility || 'public'; + + return { + label, + url, + ...(icon ? {icon} : {}), + ...(visibility !== 'public' ? {visibility} : {}) + }; }).compact(); } else { settingsArray = []; diff --git a/apps/ember-admin/app/validators/nav-item.js b/apps/ember-admin/app/validators/nav-item.js index 0c41ce71855..5d9ea5a78e3 100644 --- a/apps/ember-admin/app/validators/nav-item.js +++ b/apps/ember-admin/app/validators/nav-item.js @@ -7,10 +7,11 @@ export default BaseValidator.create({ label(model) { let label = model.label; + let icon = model.icon; let hasValidated = model.hasValidated; - if (isBlank(label)) { - model.errors.add('label', 'You must specify a label'); + if (isBlank(label) && isBlank(icon)) { + model.errors.add('label', 'You must specify a label or icon'); this.invalidate(); } diff --git a/apps/ember-admin/tests/unit/models/navigation-item-test.js b/apps/ember-admin/tests/unit/models/navigation-item-test.js index 40167bf441a..e68c6799100 100644 --- a/apps/ember-admin/tests/unit/models/navigation-item-test.js +++ b/apps/ember-admin/tests/unit/models/navigation-item-test.js @@ -14,10 +14,21 @@ describe('Unit: Model: navigation-item', function () { expect(model.get('isComplete')).to.be.true; }); - it('isComplete is false when label is blank', function () { + it('isComplete is true when icon and url are filled', function () { let model = this.owner.lookup('model:navigation-item'); model.set('label', ''); + model.set('icon', 'https://example.com/icon.svg'); + model.set('url', 'test'); + + expect(model.get('isComplete')).to.be.true; + }); + + it('isComplete is false when label and icon are blank', function () { + let model = this.owner.lookup('model:navigation-item'); + + model.set('label', ''); + model.set('icon', ''); model.set('url', 'test'); expect(model.get('isComplete')).to.be.false; @@ -32,11 +43,12 @@ describe('Unit: Model: navigation-item', function () { expect(model.get('isComplete')).to.be.false; }); - it('isBlank is true when label and url are blank', function () { + it('isBlank is true when label, url, and icon are blank', function () { let model = this.owner.lookup('model:navigation-item'); model.set('label', ''); model.set('url', ''); + model.set('icon', ''); expect(model.get('isBlank')).to.be.true; }); @@ -58,4 +70,14 @@ describe('Unit: Model: navigation-item', function () { expect(model.get('isBlank')).to.be.false; }); + + it('isBlank is false when icon is present', function () { + let model = this.owner.lookup('model:navigation-item'); + + model.set('label', ''); + model.set('url', ''); + model.set('icon', 'https://example.com/icon.svg'); + + expect(model.get('isBlank')).to.be.false; + }); }); diff --git a/apps/ember-admin/tests/unit/transforms/navigation-settings-test.js b/apps/ember-admin/tests/unit/transforms/navigation-settings-test.js index 5e7adac3262..2df57e4363d 100644 --- a/apps/ember-admin/tests/unit/transforms/navigation-settings-test.js +++ b/apps/ember-admin/tests/unit/transforms/navigation-settings-test.js @@ -9,26 +9,39 @@ describe('Unit: Transform: navigation-settings', function () { it('deserializes navigation json', function () { let transform = this.owner.lookup('transform:navigation-settings'); - let serialized = '[{"label":"One","url":"/one"},{"label":"Two","url":"/two"}]'; + let serialized = '[{"label":"One","url":"/one","icon":"https://example.com/one.svg","visibility":"members"},{"label":"Two","url":"/two"},{"label":"Three","url":"/three","visibility":"public_free"},{"label":"Four","url":"/four","visibility":"none"}]'; let result = transform.deserialize(serialized); - expect(result.length).to.equal(2); + expect(result.length).to.equal(4); expect(result[0]).to.be.instanceof(NavigationItem); expect(result[0].get('label')).to.equal('One'); expect(result[0].get('url')).to.equal('/one'); + expect(result[0].get('icon')).to.equal('https://example.com/one.svg'); + expect(result[0].get('visibility')).to.equal('members'); expect(result[1]).to.be.instanceof(NavigationItem); expect(result[1].get('label')).to.equal('Two'); expect(result[1].get('url')).to.equal('/two'); + expect(result[1].get('visibility')).to.equal('public'); + expect(result[2]).to.be.instanceof(NavigationItem); + expect(result[2].get('label')).to.equal('Three'); + expect(result[2].get('url')).to.equal('/three'); + expect(result[2].get('visibility')).to.equal('public_free'); + expect(result[3]).to.be.instanceof(NavigationItem); + expect(result[3].get('label')).to.equal('Four'); + expect(result[3].get('url')).to.equal('/four'); + expect(result[3].get('visibility')).to.equal('none'); }); it('serializes array of NavigationItems', function () { let transform = this.owner.lookup('transform:navigation-settings'); let deserialized = emberA([ - NavigationItem.create({label: 'One', url: '/one'}), - NavigationItem.create({label: 'Two', url: '/two'}) + NavigationItem.create({label: 'One', url: '/one', icon: 'https://example.com/one.svg', visibility: 'members'}), + NavigationItem.create({label: 'Two', url: '/two', visibility: 'public'}), + NavigationItem.create({label: 'Three', url: '/three', visibility: 'public_free'}), + NavigationItem.create({label: 'Four', url: '/four', visibility: 'none'}) ]); let result = transform.serialize(deserialized); - expect(result).to.equal('[{"label":"One","url":"/one"},{"label":"Two","url":"/two"}]'); + expect(result).to.equal('[{"label":"One","url":"/one","icon":"https://example.com/one.svg","visibility":"members"},{"label":"Two","url":"/two"},{"label":"Three","url":"/three","visibility":"public_free"},{"label":"Four","url":"/four","visibility":"none"}]'); }); }); diff --git a/apps/ember-admin/tests/unit/validators/nav-item-test.js b/apps/ember-admin/tests/unit/validators/nav-item-test.js index a743743f215..e3022357bf2 100644 --- a/apps/ember-admin/tests/unit/validators/nav-item-test.js +++ b/apps/ember-admin/tests/unit/validators/nav-item-test.js @@ -29,7 +29,7 @@ const testValidUrl = function (url) { }; describe('Unit: Validator: nav-item', function () { - it('requires label presence', function () { + it('requires label or icon presence', function () { let navItem = NavItem.create(); validator.check(navItem, 'label'); @@ -37,11 +37,21 @@ describe('Unit: Validator: nav-item', function () { expect(validator.get('passed')).to.be.false; expect(navItem.get('errors').errorsFor('label').toArray()).to.deep.equal([{ attribute: 'label', - message: 'You must specify a label' + message: 'You must specify a label or icon' }]); expect(navItem.get('hasValidated')).to.include('label'); }); + it('allows blank label when icon is present', function () { + let navItem = NavItem.create({icon: 'https://example.com/icon.svg'}); + + validator.check(navItem, 'label'); + + expect(validator.get('passed')).to.be.true; + expect(navItem.get('errors').errorsFor('label')).to.be.empty; + expect(navItem.get('hasValidated')).to.include('label'); + }); + it('requires url presence', function () { let navItem = NavItem.create(); @@ -79,7 +89,7 @@ describe('Unit: Validator: nav-item', function () { 'http://localhost:2368/?query=test&another=example#test', 'tel:01234-567890', 'mailto:test@example.com', - 'http://some:user@example.com:1234', + 'http://example.com:1234', '/relative/path' ]; @@ -97,4 +107,14 @@ describe('Unit: Validator: nav-item', function () { expect(navItem.get('errors').errorsFor('url')).to.not.be.empty; expect(validator.get('passed')).to.be.false; }); + + it('validates url and icon-only item by default', function () { + let navItem = NavItem.create({icon: 'https://example.com/icon.svg', url: '/icon-only/'}); + + validator.check(navItem); + + expect(navItem.get('errors').errorsFor('label')).to.be.empty; + expect(navItem.get('errors').errorsFor('url')).to.be.empty; + expect(validator.get('passed')).to.be.true; + }); }); diff --git a/e2e/helpers/pages/admin/members/member-details-page.ts b/e2e/helpers/pages/admin/members/member-details-page.ts index 531bce41899..2b85757074a 100644 --- a/e2e/helpers/pages/admin/members/member-details-page.ts +++ b/e2e/helpers/pages/admin/members/member-details-page.ts @@ -140,6 +140,35 @@ export class MemberDetailsPage extends AdminPage { await this.customFieldModal.waitFor({state: 'detached'}); } + /** + * Fill a composite field's parts, keyed by the label each part carries in the editor. + * A composite's inputs are labelled per part rather than by the field's own name, so + * they cannot be reached the way a scalar's single input is. + */ + async setCompositeCustomFieldValue(fieldName: string, parts: Record): Promise { + await this.customFieldEditButton(fieldName).click(); + + for (const [partLabel, value] of Object.entries(parts)) { + await this.customFieldModal.getByLabel(partLabel, {exact: true}).fill(value); + } + + await this.customFieldModal.getByRole('button', {name: 'Save', exact: true}).click(); + await this.customFieldModal.waitFor({state: 'detached'}); + } + + /** + * The custom fields card's row for one field, named for the value it is showing. + * The row carries its rendered value in its accessible name, which is the only place + * the detail screen's rendering of a value can be read as one string. + */ + customFieldRow(fieldName: string): Locator { + // A publisher names their own fields, so the name can hold regex metacharacters: + // "Address (home)" would match something other than itself, and "C++" would not + // compile at all. + const escaped = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return this.page.getByRole('button', {name: new RegExp(`^Edit ${escaped}: `)}); + } + /** * Removes a complimentary subscription from the first subscription row. * Removal always asks for confirmation first. diff --git a/e2e/helpers/pages/admin/members/members-list-page.ts b/e2e/helpers/pages/admin/members/members-list-page.ts index fc7d61d8164..37afab0d687 100644 --- a/e2e/helpers/pages/admin/members/members-list-page.ts +++ b/e2e/helpers/pages/admin/members/members-list-page.ts @@ -98,6 +98,25 @@ export class MembersListPage extends AdminPage { await this.page.keyboard.press('Escape'); } + /** + * A column the list appends for the filter applied to it, taken from the accessible + * table rather than the painted one. + * + * At desktop widths the header a reader sees is a sticky duplicate marked + * `aria-hidden`, and the header carrying the real column semantics is the in-flow one, + * collapsed to zero height so the two do not stack. So this locator resolves to a + * header that is present and never visible, and asserting on it means asserting the + * column exists. What the column is *showing* is asserted on the member's row. + */ + getColumnHeader(label: string): Locator { + return this.page.getByRole('columnheader', {name: label, exact: true}); + } + + /** The cell of one member's row holding the given text, whichever column it sits in. */ + getMemberCellWithText(name: string, text: string): Locator { + return this.getMemberByName(name).getByRole('cell').filter({hasText: text}); + } + async getVisibleMemberCount(): Promise { return await this.memberRows.count(); } diff --git a/e2e/helpers/pages/admin/settings/sections/custom-fields-section.ts b/e2e/helpers/pages/admin/settings/sections/custom-fields-section.ts index a251f53724f..67417281726 100644 --- a/e2e/helpers/pages/admin/settings/sections/custom-fields-section.ts +++ b/e2e/helpers/pages/admin/settings/sections/custom-fields-section.ts @@ -35,4 +35,18 @@ export class CustomFieldsSection extends BasePage { await this.modal.getByRole('button', {name: 'Save'}).click(); await this.listItem(name).waitFor(); } + + /** + * Create a composite field, whose value is several parts rather than one string. + * The type can only be chosen at creation — the picker is disabled thereafter. + */ + async createAddressField(name: string): Promise { + await this.addButton.waitFor(); + await this.addButton.click(); + await this.modal.getByLabel('Name').fill(name); + await this.modal.getByLabel('Type').click(); + await this.page.getByRole('option', {name: 'Address'}).click(); + await this.modal.getByRole('button', {name: 'Save'}).click(); + await this.listItem(name).waitFor(); + } } diff --git a/e2e/helpers/services/stripe/builders.ts b/e2e/helpers/services/stripe/builders.ts index cead71ef7a8..b38a657fd3a 100644 --- a/e2e/helpers/services/stripe/builders.ts +++ b/e2e/helpers/services/stripe/builders.ts @@ -345,6 +345,7 @@ export function buildGiftCheckoutCompletedEvent(opts: { id: opts.sessionId, object: 'checkout.session', mode: 'payment', + payment_status: 'paid', amount_total: opts.amount, currency: opts.currency, customer: opts.customerId ?? null, diff --git a/e2e/helpers/services/stripe/stripe-service.ts b/e2e/helpers/services/stripe/stripe-service.ts index f5486c98567..a767b2c5140 100644 --- a/e2e/helpers/services/stripe/stripe-service.ts +++ b/e2e/helpers/services/stripe/stripe-service.ts @@ -108,7 +108,10 @@ export class StripeTestService { name?: string; } = {}): Promise { const session = this.getCheckoutSessions() - .filter(item => item.response.mode === 'payment' && item.response.metadata.ghost_gift === 'true') + .filter((item) => { + return item.response.mode === 'payment' + && (Boolean(item.response.metadata.ghost_gift_id) || item.response.metadata.ghost_gift === 'true'); + }) .at(-1); if (!session) { diff --git a/e2e/tests/admin/members/custom-field-filter-column.test.ts b/e2e/tests/admin/members/custom-field-filter-column.test.ts new file mode 100644 index 00000000000..5c9d3638c63 --- /dev/null +++ b/e2e/tests/admin/members/custom-field-filter-column.test.ts @@ -0,0 +1,100 @@ +import {MemberDetailsPage, MembersListPage, SettingsPage} from '@/admin-pages'; +import {createMemberFactory} from '@/data-factory'; +import {expect, test} from '@/helpers/playwright'; +import {usePerTestIsolation} from '@/helpers/playwright/isolation'; + +/** + * Filtering the members list by a custom field earns a column showing what each matched + * member holds for it. A filter says who matched; the column is what they said. + * + * Driven end to end because the column only appears if every layer agrees: the field is + * defined in Settings, the filter asks the API to return its values, and the list reads + * them back under the name the publisher gave the field. A unit test can pin the column + * derivation but cannot prove the values ever arrive. + * + * React member detail (the value editor is React-only) plus the membersCustomFields flag + * that gates the whole feature. + */ +usePerTestIsolation(); + +test.describe('Ghost Admin - Custom field filter columns', () => { + test.use({labs: {membersCustomFields: true, memberDetailsReact: true}}); + + test('a filtered custom field earns a column showing each member\'s value', async ({page}) => { + test.slow(); + + const stamp = Date.now(); + const fieldName = `Company ${stamp}`; + const memberFactory = createMemberFactory(page.request); + + const memberName = `Ghost Employee ${stamp}`; + // Shares nothing with the member's name or email, so the assertion below can only + // be satisfied by the column and not by text the row already had. + const value = `Wraith Industries ${stamp}`; + + const member = await memberFactory.create({name: memberName, email: `ghost-${stamp}@example.com`}); + + const settingsPage = new SettingsPage(page); + const memberDetailsPage = new MemberDetailsPage(page); + const membersPage = new MembersListPage(page); + + await settingsPage.goto(); + await settingsPage.customFieldsSection.createShortTextField(fieldName); + + await page.goto(`/ghost/#/members/${member.id}`); + await memberDetailsPage.setCustomFieldValue(fieldName, value); + + // Unfiltered, the field has earned nothing: a column is the filter's doing. + await page.goto('/ghost/#/members'); + await expect(membersPage.getMemberByName(memberName)).toBeVisible(); + await expect(membersPage.getColumnHeader(fieldName)).toHaveCount(0); + + await membersPage.addCustomFieldFilter({field: fieldName, value}); + + await expect(membersPage.getColumnHeader(fieldName)).toHaveCount(1); + await expect(membersPage.getMemberCellWithText(memberName, value)).toBeVisible(); + }); + + test('a composite field reads as the same one line in the column and on the member', async ({page}) => { + test.slow(); + + const stamp = Date.now(); + const fieldName = `Shipping ${stamp}`; + const memberName = `Shipped To ${stamp}`; + // State and postal code pair up, and the unfilled line 2 drops out rather than + // leaving a gap, which is what makes this one line rather than a join of parts. + const expectedLine = '1 Main St, Berlin, BE 10115, DE'; + const memberFactory = createMemberFactory(page.request); + + const member = await memberFactory.create({name: memberName, email: `shipped-${stamp}@example.com`}); + + const settingsPage = new SettingsPage(page); + const memberDetailsPage = new MemberDetailsPage(page); + const membersPage = new MembersListPage(page); + + await settingsPage.goto(); + await settingsPage.customFieldsSection.createAddressField(fieldName); + + await page.goto(`/ghost/#/members/${member.id}`); + await memberDetailsPage.setCompositeCustomFieldValue(fieldName, { + 'Address line 1': '1 Main St', + City: 'Berlin', + State: 'BE', + 'Postal code': '10115', + Country: 'DE' + }); + + // The detail screen's rendering, read from the row's accessible name. + await expect(memberDetailsPage.customFieldRow(fieldName)) + .toHaveAccessibleName(`Edit ${fieldName}: ${expectedLine}`); + + await page.goto('/ghost/#/members'); + // Choosing a part leaves the operator on "is set", which takes no value, so the + // operator has to be named before there is anywhere to type one. + await membersPage.addCustomFieldFilter({field: fieldName, subfield: 'City', operator: 'is', value: 'Berlin'}); + + // The same line, from the same formatter, in the column the filter earned. + await expect(membersPage.getColumnHeader(fieldName)).toHaveCount(1); + await expect(membersPage.getMemberCellWithText(memberName, expectedLine)).toBeVisible(); + }); +}); diff --git a/e2e/tests/public/portal-gifts.test.ts b/e2e/tests/public/portal-gifts.test.ts index d752c9e8cb5..a1f3386b770 100644 --- a/e2e/tests/public/portal-gifts.test.ts +++ b/e2e/tests/public/portal-gifts.test.ts @@ -1,6 +1,12 @@ import {FakeStripeCheckoutPage, PortalGiftPage} from '@/helpers/pages'; import {createPaidPortalTier, expect, test} from '@/helpers/playwright'; +interface GiftRedemptionResponse { + gifts: Array<{ + token: string; + }>; +} + test.describe('Ghost Public - Portal Gifts', () => { test.use({ labs: {giftSubCustomization: true}, @@ -43,6 +49,15 @@ test.describe('Ghost Public - Portal Gifts', () => { const checkoutSession = stripe!.getCheckoutSessions().at(-1); expect(checkoutSession).toBeDefined(); + const giftToken = new URL(checkoutSession!.response.success_url).searchParams.get('gift_token'); + expect(giftToken).not.toBeNull(); + + const giftResponse = await page.request.get(`/members/api/gifts/${encodeURIComponent(giftToken!)}/redeem/`); + expect(giftResponse.ok()).toBe(true); + + const giftData = await giftResponse.json() as GiftRedemptionResponse; + expect(giftData.gifts[0]?.token).toBe(giftToken); + await page.goto(checkoutSession!.response.success_url); await portalGiftPage.waitForPortalToOpen(); diff --git a/ghost/core/core/boot.js b/ghost/core/core/boot.js index 20aab9b4f1c..b0a56875d6b 100644 --- a/ghost/core/core/boot.js +++ b/ghost/core/core/boot.js @@ -145,7 +145,7 @@ async function initCore({ghostServer, config}) { ghostServer.registerCleanupTask(async () => { await jobService.shutdown(); - }); + }, 'Job Service'); debug('End: Job Service'); // Mentions Job Service allows mentions to be processed in the background @@ -158,7 +158,7 @@ async function initCore({ghostServer, config}) { ghostServer.registerCleanupTask(async () => { await mentionsJobService.shutdown(); - }); + }, 'Mentions Job Service'); debug('End: Mentions Job Service'); debug('Begin: Jobs Service'); @@ -166,7 +166,7 @@ async function initCore({ghostServer, config}) { ghostServer.registerCleanupTask(async () => { await jobsService.shutdown({timeoutMs: config.get('server:shutdownTimeout')}); - }); + }, 'Jobs Service (in-memory)'); debug('End: Jobs Service'); } @@ -352,6 +352,7 @@ async function initServices({ghostServer, config, prometheusClient}) { const postsPublic = require('./server/services/posts-public'); const slackNotifications = require('./server/services/slack-notifications'); const mediaInliner = require('./server/services/media-inliner'); + const contentImport = require('./server/services/content-import'); const donationService = require('./server/services/donations'); const giftService = require('./server/services/gifts'); const machinePaymentsService = require('./server/services/machine-payments'); @@ -412,6 +413,7 @@ async function initServices({ghostServer, config, prometheusClient}) { emailSuppressionList.init(), slackNotifications.init(), mediaInliner.init(), + contentImport.init(), donationService.init(), recommendationsService.init(), statsService.init(), @@ -470,6 +472,11 @@ async function initBackgroundServices({config}) { logging.error(err); } + // Retry gift deliveries interrupted by a prior shutdown. Not awaited: recovery + // sends sequentially and must not hold up the remaining background services. + const giftService = require('./server/services/gifts'); + giftService.recoverPendingDeliveries(); + const activitypub = require('./server/services/activitypub'); await activitypub.init(); // Load email analytics recurring jobs @@ -580,7 +587,7 @@ async function bootGhost({backend = true, frontend = true, server = true} = {}) if (prometheusClient) { prometheusClient.stop(); } - }); + }, 'Prometheus client'); debug('End: load server + minimal app'); } diff --git a/ghost/core/core/frontend/helpers/navigation.js b/ghost/core/core/frontend/helpers/navigation.js index ccfb8db6c18..c949c43660c 100644 --- a/ghost/core/core/frontend/helpers/navigation.js +++ b/ghost/core/core/frontend/helpers/navigation.js @@ -7,6 +7,7 @@ const errors = require('@tryghost/errors'); const tpl = require('@tryghost/tpl'); const {slugify} = require('@tryghost/string'); const _ = require('lodash'); +const labs = require('../../shared/labs'); const messages = { invalidData: 'navigation data is not an object or is a function', @@ -16,6 +17,84 @@ const messages = { const createFrame = hbs.handlebars.createFrame; +/** + * @typedef {Object} NavigationItem + * @property {string} url + * @property {string} [label] + * @property {string} [icon] + * @property {string} [visibility] + */ + +/** + * @typedef {Object} NavigationMember + * @property {'free' | 'paid' | string} status + */ + +/** + * @param {string | null | undefined} icon + * @returns {string} + */ +function getNavigationIconName(icon) { + if (!icon) { + return ''; + } + + const filename = icon.split(/[?#]/)[0].split('/').filter(Boolean).pop() || ''; + const name = filename.replace(/\.[^.]+$/, ''); + + try { + return decodeURIComponent(name); + } catch (e) { + return name; + } +} + +/** + * @param {string} href + * @param {string | undefined} url + * @returns {boolean} + */ +function isCurrentNavigationUrl(href, url) { + if (!url) { + return false; + } + + const strippedHref = href.replace(/\/+$/, ''); + const strippedCurrentUrl = url.replace(/\/+$/, ''); + return strippedHref === strippedCurrentUrl; +} + +/** + * @param {NavigationItem} item + * @param {NavigationMember | null | undefined} member + * @returns {boolean} + */ +function isNavigationItemVisible(item, member) { + const visibility = item.visibility || 'public'; + const isMember = !!member; + const isFreeMember = isMember && member.status === 'free'; + const isPaidMember = isMember && member.status !== 'free'; + + switch (visibility) { + case 'members': + return isMember; + case 'paid': + return isPaidMember; + case 'public_free': + return !isMember || isFreeMember; + case 'public_paid': + return !isMember || isPaidMember; + case 'public_only': + return !isMember; + case 'free_members': + return isFreeMember; + case 'none': + return false; + default: + return true; + } +} + module.exports = function navigation(options) { options = options || {}; options.hash = options.hash || {}; @@ -29,16 +108,17 @@ module.exports = function navigation(options) { const navigationData = options.data.site[key]; const currentUrl = options.data.root.relativeUrl; + const member = options.data.member || options.data.root.member; let output; - if (!_.isObject(navigationData) || _.isFunction(navigationData)) { + if (!Array.isArray(navigationData)) { throw new errors.IncorrectUsageError({ message: tpl(messages.invalidData) }); } if (navigationData.filter(function (e) { - return (_.isUndefined(e.label) || _.isUndefined(e.url)); + return (_.isUndefined(e.url) || (_.isUndefined(e.label) && _.isUndefined(e.icon))); }).length > 0) { throw new errors.IncorrectUsageError({ message: tpl(messages.valuesMustBeDefined) @@ -47,38 +127,45 @@ module.exports = function navigation(options) { // check for non-null string values if (navigationData.filter(function (e) { - return ((!_.isNull(e.label) && !_.isString(e.label)) || - (!_.isNull(e.url) && !_.isString(e.url))); + return ((!_.isUndefined(e.label) && !_.isNull(e.label) && !_.isString(e.label)) || + (_.isNull(e.url) || !_.isString(e.url))); }).length > 0) { throw new errors.IncorrectUsageError({ message: tpl(messages.valuesMustBeString) }); } - // strips trailing slashes and compares urls - function _isCurrentUrl(href, url) { - if (!url) { - return false; - } - - const strippedHref = href.replace(/\/+$/, ''); - const strippedCurrentUrl = url.replace(/\/+$/, ''); - return strippedHref === strippedCurrentUrl; - } + // Icon and visibility are coerced rather than thrown on (see map below): + // a bad icon is dropped and an unrecognised visibility falls back to public, + // so malformed data can never 500 a whole site's front end. // {{navigation}} should no-op if no data passed in if (navigationData.length === 0) { return new SafeString(''); } - output = navigationData.map(function (e) { - const out = {}; - out.current = _isCurrentUrl(e.url, currentUrl); - out.label = e.label; - out.slug = slugify(e.label); - out.url = e.url; - return out; - }); + const navigationIconsEnabled = labs.isSet('navigationIcons'); + + output = navigationData + .filter(item => navigationIconsEnabled ? isNavigationItemVisible(item, member) : true) + .map(function (e) { + const out = {}; + const icon = navigationIconsEnabled && _.isString(e.icon) ? e.icon : null; + const iconName = getNavigationIconName(icon); + const hasLabel = _.isString(e.label) && e.label.trim().length > 0; + + out.current = isCurrentNavigationUrl(e.url, currentUrl); + out.icon = icon || null; + out.iconAlt = hasLabel ? '' : iconName; + out.label = e.label; + out.slug = slugify(hasLabel ? e.label : iconName); + out.url = e.url; + return out; + }); + + if (output.length === 0) { + return new SafeString(''); + } // CASE: The navigation helper should have access to the navigation items at the top level. this.navigation = output; diff --git a/ghost/core/core/frontend/helpers/tpl/navigation.hbs b/ghost/core/core/frontend/helpers/tpl/navigation.hbs index 93c4bf603fc..08f3344f0bd 100644 --- a/ghost/core/core/frontend/helpers/tpl/navigation.hbs +++ b/ghost/core/core/frontend/helpers/tpl/navigation.hbs @@ -1,5 +1,5 @@ diff --git a/ghost/core/core/server/api/endpoints/posts.js b/ghost/core/core/server/api/endpoints/posts.js index c06d93435f5..33cac71fd69 100644 --- a/ghost/core/core/server/api/endpoints/posts.js +++ b/ghost/core/core/server/api/endpoints/posts.js @@ -2,6 +2,7 @@ const urlUtils = require('../../../shared/url-utils').default; const models = require('../../models'); const {getCSVExportFileName} = require('./utils/csv-export-filename'); const getPostServiceInstance = require('../../services/posts/posts-service-instance'); +const contentImportService = require('../../services/content-import'); const {rejectAdminApiRestrictedFieldsTransformer} = require('./utils/api-filter-utils'); const allowedIncludes = [ 'tags', @@ -131,10 +132,12 @@ const controller = { docName: 'content_imports', method: 'importContent' }, - async query() { - // CSV processing lands in a later milestone — for now the endpoint - // only accepts the upload (gated by the csvContentImporter flag) - return {}; + async query(frame) { + // The CSV must be parsed before the response goes out: the uploaded temp file is + // deleted as soon as it is sent. The posts are written by a background job behind + // the 202. + const {importId, total} = await contentImportService.importCSV({filePath: frame.file.path}); + return {meta: {import_id: importId, total}}; } }, diff --git a/ghost/core/core/server/api/endpoints/utils/validators/input/settings.js b/ghost/core/core/server/api/endpoints/utils/validators/input/settings.js index 0f2cbd32275..af0477758c6 100644 --- a/ghost/core/core/server/api/endpoints/utils/validators/input/settings.js +++ b/ghost/core/core/server/api/endpoints/utils/validators/input/settings.js @@ -9,9 +9,83 @@ const messages = { invalidEmailValueReceived: 'Please enter a valid email address.', invalidEmailTypeReceived: 'Invalid email type received', invalidAnnouncementVisibilityValueReceived: 'Please enter a valid announcement visibility value', - invalidAnnouncementBackgroundValueReceived: 'Please enter a valid announcement background value' + invalidAnnouncementBackgroundValueReceived: 'Please enter a valid announcement background value', + invalidNavigationItemValueReceived: 'Please enter a valid navigation item' }; +const navigationItemVisibilityValues = ['public', 'members', 'paid', 'public_free', 'public_paid', 'public_only', 'free_members', 'none']; +const navUrlRegex = new RegExp(/^(\/|#|[a-zA-Z0-9-]+:)/); +const iconUrlRegex = new RegExp(/^(\/|__GHOST_URL__\/)/); +const iconUrlOptions = {require_protocol: true, protocols: ['http', 'https']}; + +function parseArraySettingValue(value) { + if (_.isArray(value)) { + return value; + } + + return JSON.parse(value); +} + +function isValidNavigationUrl(value) { + return _.isString(value) && !value.match(/\s/) && (validator.isURL(value, {require_protocol: true}) || value.match(navUrlRegex)); +} + +function isValidNavigationIcon(value) { + return _.isString(value) && !value.match(/\s/) && (validator.isURL(value, iconUrlOptions) || value.match(iconUrlRegex)); +} + +function validateNavigationItems(setting, errors) { + const navigationItems = parseArraySettingValue(setting.value); + + navigationItems.forEach((item) => { + if (!_.isObject(item) || _.isFunction(item)) { + errors.push(new ValidationError({ + message: tpl(messages.invalidNavigationItemValueReceived), + property: setting.key + })); + return; + } + + if (_.isUndefined(item.url) || !isValidNavigationUrl(item.url)) { + errors.push(new ValidationError({ + message: tpl(messages.invalidNavigationItemValueReceived), + property: setting.key + })); + } + + const hasLabel = _.isString(item.label) && !item.label.match(/^\s*$/); + const hasIcon = _.isString(item.icon) && !item.icon.match(/^\s*$/); + + if (!_.isUndefined(item.label) && !_.isString(item.label)) { + errors.push(new ValidationError({ + message: tpl(messages.invalidNavigationItemValueReceived), + property: setting.key + })); + } + + if (!_.isUndefined(item.icon) && item.icon !== null && item.icon !== '' && !isValidNavigationIcon(item.icon)) { + errors.push(new ValidationError({ + message: tpl(messages.invalidNavigationItemValueReceived), + property: setting.key + })); + } + + if (!hasLabel && !hasIcon) { + errors.push(new ValidationError({ + message: tpl(messages.invalidNavigationItemValueReceived), + property: setting.key + })); + } + + if (!_.isUndefined(item.visibility) && !navigationItemVisibilityValues.includes(item.visibility)) { + errors.push(new ValidationError({ + message: tpl(messages.invalidNavigationItemValueReceived), + property: setting.key + })); + } + }); +} + module.exports = { edit(apiConfig, frame) { const errors = []; @@ -52,6 +126,14 @@ module.exports = { } } + if (['navigation', 'secondary_navigation'].includes(setting.key)) { + try { + validateNavigationItems(setting, errors); + } catch (err) { + // Array type validation above will return the specific array error. + } + } + if (emailTypeSettings.includes(setting.key)) { const email = setting.value; diff --git a/ghost/core/core/server/data/exporter/table-lists.js b/ghost/core/core/server/data/exporter/table-lists.js index 91c24a397d1..ec32bcb6fb0 100644 --- a/ghost/core/core/server/data/exporter/table-lists.js +++ b/ghost/core/core/server/data/exporter/table-lists.js @@ -61,6 +61,7 @@ const BACKUP_TABLES = [ 'recommendation_click_events', 'recommendation_subscribe_events', 'outbox', + 'gift_deliveries', 'gifts', 'gift_links', 'post_gift_links', diff --git a/ghost/core/core/server/data/migrations/versions/6.59/2026-08-18-17-06-46-add-gift-delivery-fields-to-gifts.js b/ghost/core/core/server/data/migrations/versions/6.59/2026-08-18-17-06-46-add-gift-delivery-fields-to-gifts.js new file mode 100644 index 00000000000..64663cf773a --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/6.59/2026-08-18-17-06-46-add-gift-delivery-fields-to-gifts.js @@ -0,0 +1,26 @@ +const { + combineNonTransactionalMigrations, + createAddColumnMigration, + createAddIndexMigration, + createSetNullableMigration +} = require('../../utils'); + +const addGiftColumn = (name, definition) => createAddColumnMigration( + 'gifts', + name, + definition, + {algorithm: 'auto'} +); + +module.exports = combineNonTransactionalMigrations( + addGiftColumn('buyer_name', {type: 'string', maxlength: 191, nullable: true}), + addGiftColumn('recipient_name', {type: 'string', maxlength: 191, nullable: true}), + addGiftColumn('personal_message', {type: 'text', maxlength: 500, nullable: true}), + createSetNullableMigration('gifts', 'buyer_email'), + createSetNullableMigration('gifts', 'stripe_checkout_session_id'), + createSetNullableMigration('gifts', 'stripe_payment_intent_id'), + createSetNullableMigration('gifts', 'expires_at'), + createSetNullableMigration('gifts', 'purchased_at'), + addGiftColumn('checkout_started_at', {type: 'dateTime', nullable: true}), + createAddIndexMigration('gifts', ['status', 'checkout_started_at']) +); diff --git a/ghost/core/core/server/data/migrations/versions/6.59/2026-08-18-17-07-01-add-gift-deliveries.js b/ghost/core/core/server/data/migrations/versions/6.59/2026-08-18-17-07-01-add-gift-deliveries.js new file mode 100644 index 00000000000..0e57a3429f8 --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/6.59/2026-08-18-17-07-01-add-gift-deliveries.js @@ -0,0 +1,20 @@ +const {addTable} = require('../../utils'); + +const giftDeliveries = { + id: {type: 'string', maxlength: 24, nullable: false, primary: true}, + gift_id: {type: 'string', maxlength: 24, nullable: false, unique: true, references: 'gifts.id', cascadeDelete: true}, + recipient_email: {type: 'string', maxlength: 191, nullable: false, validations: {isEmail: true}}, + status: { + type: 'string', maxlength: 50, nullable: false, defaultTo: 'pending', validations: { + isIn: [['pending', 'sending', 'sent', 'failed', 'cancelled']] + } + }, + started_at: {type: 'dateTime', nullable: true}, + email_sent_at: {type: 'dateTime', nullable: true}, + email_provider_message_id: {type: 'string', maxlength: 1000, nullable: true}, + '@@INDEXES@@': [ + ['status', 'started_at'] + ] +}; + +module.exports = addTable('gift_deliveries', giftDeliveries); diff --git a/ghost/core/core/server/data/schema/schema.js b/ghost/core/core/server/data/schema/schema.js index ae9b0db9690..411909c06ca 100644 --- a/ghost/core/core/server/data/schema/schema.js +++ b/ghost/core/core/server/data/schema/schema.js @@ -1421,8 +1421,11 @@ module.exports = { id: {type: 'string', maxlength: 24, nullable: false, primary: true}, token: {type: 'string', maxlength: 48, nullable: false, unique: true}, - buyer_email: {type: 'string', maxlength: 191, nullable: false, validations: {isEmail: true}}, + buyer_email: {type: 'string', maxlength: 191, nullable: true, validations: {isEmail: true}}, buyer_member_id: {type: 'string', maxlength: 24, nullable: true, unique: false, references: 'members.id', setNullDelete: true}, + buyer_name: {type: 'string', maxlength: 191, nullable: true}, + recipient_name: {type: 'string', maxlength: 191, nullable: true}, + personal_message: {type: 'text', maxlength: 500, nullable: true}, redeemer_member_id: {type: 'string', maxlength: 24, nullable: true, unique: false, references: 'members.id', setNullDelete: true}, @@ -1437,18 +1440,19 @@ module.exports = { currency: {type: 'string', maxlength: 50, nullable: false}, amount: {type: 'integer', nullable: false, unsigned: true}, - stripe_checkout_session_id: {type: 'string', maxlength: 255, nullable: false, unique: true}, - stripe_payment_intent_id: {type: 'string', maxlength: 255, nullable: false, unique: true}, + stripe_checkout_session_id: {type: 'string', maxlength: 255, nullable: true, unique: true}, + stripe_payment_intent_id: {type: 'string', maxlength: 255, nullable: true, unique: true}, + checkout_started_at: {type: 'dateTime', nullable: true}, consumes_at: {type: 'dateTime', nullable: true}, - expires_at: {type: 'dateTime', nullable: false}, + expires_at: {type: 'dateTime', nullable: true}, status: { type: 'string', maxlength: 50, nullable: false, validations: { - isIn: [['purchased', 'redeemed', 'consumed', 'expired', 'refunded']] + isIn: [['payment_pending', 'purchased', 'redeemed', 'consumed', 'expired', 'refunded']] } }, - purchased_at: {type: 'dateTime', nullable: false}, + purchased_at: {type: 'dateTime', nullable: true}, redeemed_at: {type: 'dateTime', nullable: true}, consumed_at: {type: 'dateTime', nullable: true}, expired_at: {type: 'dateTime', nullable: true}, @@ -1456,7 +1460,24 @@ module.exports = { consumes_soon_reminder_sent_at: {type: 'dateTime', nullable: true}, '@@INDEXES@@': [ ['status', 'consumes_at'], - ['status', 'expires_at'] + ['status', 'expires_at'], + ['status', 'checkout_started_at'] + ] + }, + gift_deliveries: { + id: {type: 'string', maxlength: 24, nullable: false, primary: true}, + gift_id: {type: 'string', maxlength: 24, nullable: false, unique: true, references: 'gifts.id', cascadeDelete: true}, + recipient_email: {type: 'string', maxlength: 191, nullable: false, validations: {isEmail: true}}, + status: { + type: 'string', maxlength: 50, nullable: false, defaultTo: 'pending', validations: { + isIn: [['pending', 'sending', 'sent', 'failed', 'cancelled']] + } + }, + started_at: {type: 'dateTime', nullable: true}, + email_sent_at: {type: 'dateTime', nullable: true}, + email_provider_message_id: {type: 'string', maxlength: 1000, nullable: true}, + '@@INDEXES@@': [ + ['status', 'started_at'] ] } }; diff --git a/ghost/core/core/server/data/seeders/importers/automation-action-revisions-importer.ts b/ghost/core/core/server/data/seeders/importers/automation-action-revisions-importer.ts index 5025ed7f463..b9349714799 100644 --- a/ghost/core/core/server/data/seeders/importers/automation-action-revisions-importer.ts +++ b/ghost/core/core/server/data/seeders/importers/automation-action-revisions-importer.ts @@ -2,7 +2,7 @@ import {faker} from '@faker-js/faker'; import errors from '@tryghost/errors'; import type {Knex} from 'knex'; import {TableImporter} from './table-importer'; -import * as databaseDate from '../utils/database-date'; +import {fromDatabaseDate, toDatabaseDate} from '../../../lib/db-date'; import {DEFAULT_EMAIL_DESIGN_SETTING_SLUG} from '../../../services/member-welcome-emails/constants'; type AutomationAction = { @@ -66,13 +66,13 @@ export class AutomationActionRevisionsImporter extends TableImporter const requestedStepCount = clamp(Math.floor(stepsPerRun) + extraStep, 1, actionCount); const runStepCount = fullPathGeneratedForAutomation.has(run.automation_id) ? requestedStepCount : actionCount; fullPathGeneratedForAutomation.add(run.automation_id); - const runCreatedAt = databaseDate.parse(run.created_at); + const runCreatedAt = fromDatabaseDate(run.created_at); const lastStepStatus = faker.helpers.arrayElement(['pending', 'finished', 'failed'] as const); for (let index = 0; index < runStepCount; index += 1) { const createdAt = new Date(runCreatedAt); createdAt.setHours(createdAt.getHours() + index); - const createdAtString = databaseDate.dateToDatabaseString(createdAt); + const createdAtString = toDatabaseDate(createdAt); const status = index === runStepCount - 1 ? lastStepStatus : 'finished'; const isTerminal = status !== 'pending'; diff --git a/ghost/core/core/server/data/seeders/importers/automation-runs-importer.ts b/ghost/core/core/server/data/seeders/importers/automation-runs-importer.ts index ea267402a4f..4abf63a51fd 100644 --- a/ghost/core/core/server/data/seeders/importers/automation-runs-importer.ts +++ b/ghost/core/core/server/data/seeders/importers/automation-runs-importer.ts @@ -4,7 +4,8 @@ import assert from 'node:assert/strict'; import type {Knex} from 'knex'; import {TableImporter} from './table-importer'; import {parseEmailAddress} from '@tryghost/parse-email-address'; -import * as databaseDate from '../utils/database-date'; +import {fromDatabaseDate, toDatabaseDate} from '../../../lib/db-date'; +import {randomDateBetween} from '../utils/random'; type Automation = { id: string; @@ -73,19 +74,16 @@ export class AutomationRunsImporter extends TableImporter { status: faker.helpers.arrayElement(['active', 'inactive']), name, slug, - created_at: databaseDate.dateToDatabaseString(createdAt), - updated_at: databaseDate.dateToDatabaseString(createdAt) + created_at: toDatabaseDate(createdAt), + updated_at: toDatabaseDate(createdAt) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/comment-reports-importer.js b/ghost/core/core/server/data/seeders/importers/comment-reports-importer.js index d18a086d38a..3b724d5d9ea 100644 --- a/ghost/core/core/server/data/seeders/importers/comment-reports-importer.js +++ b/ghost/core/core/server/data/seeders/importers/comment-reports-importer.js @@ -1,7 +1,7 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); -const {luck} = require('../utils/random'); -const databaseDate = require('../utils/database-date'); +const {luck, randomDateBetween} = require('../utils/random'); +const {toDatabaseDate} = require('../../../lib/db-date'); class CommentReportsImporter extends TableImporter { static table = 'comment_reports'; @@ -49,7 +49,7 @@ class CommentReportsImporter extends TableImporter { return null; } - const reportTime = databaseDate.randomBetween(this.model.created_at, new Date()); + const reportTime = randomDateBetween(this.model.created_at, new Date()); const reporter = this.possibleReporters[faker.number.int(this.possibleReporters.length - 1)]; @@ -57,8 +57,8 @@ class CommentReportsImporter extends TableImporter { id: this.fastFakeObjectId(), comment_id: this.model.id, member_id: reporter.id, - created_at: databaseDate.dateToDatabaseString(reportTime), - updated_at: databaseDate.dateToDatabaseString(reportTime) + created_at: toDatabaseDate(reportTime), + updated_at: toDatabaseDate(reportTime) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/comments-importer.js b/ghost/core/core/server/data/seeders/importers/comments-importer.js index 7fdaacceef8..a8aebcdeef2 100644 --- a/ghost/core/core/server/data/seeders/importers/comments-importer.js +++ b/ghost/core/core/server/data/seeders/importers/comments-importer.js @@ -2,7 +2,7 @@ const {faker} = require('@faker-js/faker'); const {TableImporter} = require('./table-importer'); const {luck} = require('../utils/random'); const generateEvents = require('../utils/event-generator'); -const databaseDate = require('../utils/database-date'); +const {fromDatabaseDate, toDatabaseDate} = require('../../../lib/db-date'); class CommentsImporter extends TableImporter { static table = 'comments'; @@ -24,7 +24,7 @@ class CommentsImporter extends TableImporter { setReferencedModel(model) { this.model = model; - const publishedAt = databaseDate.parse(model.published_at); + const publishedAt = fromDatabaseDate(model.published_at); this.commentIds = []; // Store [id, parent_id, timestamp] tuples for reply-to-reply @@ -37,7 +37,7 @@ class CommentsImporter extends TableImporter { endTime: new Date() }).sort((a, b) => a.getTime() - b.getTime()); // Sort chronologically so replies always come after their targets - this.possibleMembers = this.members.filter(member => databaseDate.parse(member.created_at) < publishedAt); + this.possibleMembers = this.members.filter(member => fromDatabaseDate(member.created_at) < publishedAt); } generate() { @@ -97,8 +97,8 @@ class CommentsImporter extends TableImporter { parent_id: parentId, in_reply_to_id: inReplyToId, status: 'published', - created_at: databaseDate.dateToDatabaseString(timestamp), - updated_at: databaseDate.dateToDatabaseString(timestamp), + created_at: toDatabaseDate(timestamp), + updated_at: toDatabaseDate(timestamp), html: `

${faker.lorem.sentence().replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`)}

` }; diff --git a/ghost/core/core/server/data/seeders/importers/email-batches-importer.js b/ghost/core/core/server/data/seeders/importers/email-batches-importer.js index a5028710528..d18e62ac84b 100644 --- a/ghost/core/core/server/data/seeders/importers/email-batches-importer.js +++ b/ghost/core/core/server/data/seeders/importers/email-batches-importer.js @@ -1,6 +1,7 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); -const databaseDate = require('../utils/database-date'); +const {randomDateBetween} = require('../utils/random'); +const {fromDatabaseDate, toDatabaseDate} = require('../../../lib/db-date'); class EmailBatchesImporter extends TableImporter { static table = 'email_batches'; @@ -21,8 +22,8 @@ class EmailBatchesImporter extends TableImporter { } generate() { - const emailSentDate = databaseDate.parse(this.model.created_at); - const latestUpdatedDate = databaseDate.parse(this.model.created_at); + const emailSentDate = fromDatabaseDate(this.model.created_at); + const latestUpdatedDate = new Date(emailSentDate); latestUpdatedDate.setHours(latestUpdatedDate.getHours() + 1); return { @@ -31,7 +32,7 @@ class EmailBatchesImporter extends TableImporter { mailgun_message_id: `${new Date().toISOString().split('.')[0].replace(/[^0-9]/g, '')}.${faker.string.hexadecimal({length: 16, prefix: '', casing: 'lower'})}@m.example.com`, status: 'submitted', // TODO: introduce failures created_at: this.model.created_at, - updated_at: databaseDate.dateToDatabaseString(databaseDate.randomBetween(emailSentDate, latestUpdatedDate)) + updated_at: toDatabaseDate(randomDateBetween(emailSentDate, latestUpdatedDate)) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/email-recipients-importer.js b/ghost/core/core/server/data/seeders/importers/email-recipients-importer.js index 6bbcd62febb..6bf628d9616 100644 --- a/ghost/core/core/server/data/seeders/importers/email-recipients-importer.js +++ b/ghost/core/core/server/data/seeders/importers/email-recipients-importer.js @@ -1,7 +1,8 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); const generateEvents = require('../utils/event-generator'); -const databaseDate = require('../utils/database-date'); +const {randomDateBetween} = require('../utils/random'); +const {fromDatabaseDate, toDatabaseDate} = require('../../../lib/db-date'); const debug = require('@tryghost/debug')('EmailRecipientsImporter'); const emailStatus = { @@ -109,11 +110,7 @@ class EmailRecipientsImporter extends TableImporter { this.membersSubscribeEventsCreatedAtsByNewsletterId.set(memberSubscribeEvent.newsletter_id, []); } - if (!(memberSubscribeEvent.created_at instanceof Date)) { - // SQLite fix - memberSubscribeEvent.created_at = databaseDate.parse(memberSubscribeEvent.created_at); - } - this.membersSubscribeEventsCreatedAtsByNewsletterId.get(memberSubscribeEvent.newsletter_id).push(memberSubscribeEvent.created_at.getTime()); + this.membersSubscribeEventsCreatedAtsByNewsletterId.get(memberSubscribeEvent.newsletter_id).push(fromDatabaseDate(memberSubscribeEvent.created_at).getTime()); } await this.importForEach(this.emailBatches, quantity ? quantity / emails.length : 1000); @@ -123,15 +120,12 @@ class EmailRecipientsImporter extends TableImporter { this.batch = model; this.model = this.emails.get(this.batch.email_id); this.batchIndex = this.batch.index; - - // Shallow clone members list so we can shuffle and modify it - const earliestOpenTime = databaseDate.parse(this.batch.updated_at); - const latestOpenTime = databaseDate.parse(this.batch.updated_at); - latestOpenTime.setDate(latestOpenTime.getDate() + 14); + this.batchUpdatedAt = fromDatabaseDate(this.batch.updated_at); + this.batchProcessedAt = toDatabaseDate(this.batchUpdatedAt); // Get all members that were subscribed to this newsletter BEFORE the batch was sent // We use binary search to speed up it up - const lastIndex = findFirstHigherIndex(this.membersSubscribeEventsCreatedAtsByNewsletterId.get(this.model.newsletter_id), earliestOpenTime); + const lastIndex = findFirstHigherIndex(this.membersSubscribeEventsCreatedAtsByNewsletterId.get(this.model.newsletter_id), this.batchUpdatedAt); this.membersList = this.membersSubscribeEventsByNewsletterId.get(this.model.newsletter_id).slice(0, Math.max(0, lastIndex - 1)) .slice(this.batchIndex * 1000, (this.batchIndex + 1) * 1000) @@ -164,7 +158,7 @@ class EmailRecipientsImporter extends TableImporter { } // The events are generated for a different time, so we need to move them to the batch time - timestamp = new Date(timestamp.getTime() - this.eventStartTimeUsed.getTime() + databaseDate.parse(this.batch.updated_at).getTime()); + timestamp = new Date(timestamp.getTime() - this.eventStartTimeUsed.getTime() + this.batchUpdatedAt.getTime()); if (timestamp > new Date()) { timestamp = new Date(); @@ -187,9 +181,7 @@ class EmailRecipientsImporter extends TableImporter { let deliveredTime; if (status === emailStatus.opened) { - const startDate = databaseDate.parse(this.batch.updated_at); - const endDate = timestamp; - deliveredTime = databaseDate.randomBetween(startDate, endDate); + deliveredTime = randomDateBetween(this.batchUpdatedAt, timestamp); } return { @@ -198,10 +190,10 @@ class EmailRecipientsImporter extends TableImporter { email_id: this.model.id, batch_id: this.batch.id, member_id: member.id, - processed_at: databaseDate.dateToDatabaseString(this.batch.updated_at), - delivered_at: status === emailStatus.opened ? databaseDate.dateToDatabaseString(deliveredTime) : status === emailStatus.delivered ? databaseDate.dateToDatabaseString(timestamp) : null, - opened_at: status === emailStatus.opened ? databaseDate.dateToDatabaseString(timestamp) : null, - failed_at: status === emailStatus.failed ? databaseDate.dateToDatabaseString(timestamp) : null, + processed_at: this.batchProcessedAt, + delivered_at: status === emailStatus.opened ? toDatabaseDate(deliveredTime) : status === emailStatus.delivered ? toDatabaseDate(timestamp) : null, + opened_at: status === emailStatus.opened ? toDatabaseDate(timestamp) : null, + failed_at: status === emailStatus.failed ? toDatabaseDate(timestamp) : null, member_uuid: member.uuid, member_email: member.email, member_name: member.name diff --git a/ghost/core/core/server/data/seeders/importers/emails-importer.js b/ghost/core/core/server/data/seeders/importers/emails-importer.js index 47923c99085..d63af03c798 100644 --- a/ghost/core/core/server/data/seeders/importers/emails-importer.js +++ b/ghost/core/core/server/data/seeders/importers/emails-importer.js @@ -2,7 +2,7 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); const generateEvents = require('../utils/event-generator'); const {luck} = require('../utils/random'); -const databaseDate = require('../utils/database-date'); +const {fromDatabaseDate, toDatabaseDate} = require('../../../lib/db-date'); class EmailsImporter extends TableImporter { static table = 'emails'; @@ -42,7 +42,7 @@ class EmailsImporter extends TableImporter { : this.newsletters[1]; } - const publishedAt = databaseDate.parse(this.model.published_at); + const publishedAt = fromDatabaseDate(this.model.published_at); const timestamp = luck(60) ? publishedAt : generateEvents({ @@ -55,7 +55,7 @@ class EmailsImporter extends TableImporter { const recipientCount = this.membersSubscribeEvents .filter(entry => entry.newsletter_id === newsletter.id) - .filter(entry => databaseDate.parse(entry.created_at) < timestamp).length; + .filter(entry => fromDatabaseDate(entry.created_at) < timestamp).length; const deliveredCount = Math.ceil(recipientCount * faker.number.float({ max: 1, min: 0.9, @@ -87,10 +87,10 @@ class EmailsImporter extends TableImporter { track_opens: true, track_clicks: true, feedback_enabled: true, - submitted_at: databaseDate.dateToDatabaseString(timestamp), + submitted_at: toDatabaseDate(timestamp), newsletter_id: newsletter.id, - created_at: databaseDate.dateToDatabaseString(timestamp), - updated_at: databaseDate.dateToDatabaseString(timestamp) + created_at: toDatabaseDate(timestamp), + updated_at: toDatabaseDate(timestamp) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/labels-importer.js b/ghost/core/core/server/data/seeders/importers/labels-importer.js index e8fef6bfdcb..1832df8f674 100644 --- a/ghost/core/core/server/data/seeders/importers/labels-importer.js +++ b/ghost/core/core/server/data/seeders/importers/labels-importer.js @@ -2,7 +2,7 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); const {slugify} = require('@tryghost/string'); const {blogStartDate} = require('../utils/blog-info'); -const databaseDate = require('../utils/database-date'); +const {toDatabaseDate} = require('../../../lib/db-date'); class LabelsImporter extends TableImporter { static table = 'labels'; @@ -30,8 +30,8 @@ class LabelsImporter extends TableImporter { id: this.fastFakeObjectId(), name: name, slug: `${slugify(name)}`, - created_at: databaseDate.dateToDatabaseString(blogStartDate), - updated_at: databaseDate.dateToDatabaseString(blogStartDate) + created_at: toDatabaseDate(blogStartDate), + updated_at: toDatabaseDate(blogStartDate) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/members-click-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-click-events-importer.js index f9c80d9f0a7..c52905cbf43 100644 --- a/ghost/core/core/server/data/seeders/importers/members-click-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-click-events-importer.js @@ -1,7 +1,7 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); -const {luck} = require('../utils/random'); -const databaseDate = require('../utils/database-date'); +const {luck, randomDateBetween} = require('../utils/random'); +const {fromDatabaseDate, toDatabaseDate} = require('../../../lib/db-date'); class MembersClickEventsImporter extends TableImporter { static table = 'members_click_events'; @@ -50,9 +50,9 @@ class MembersClickEventsImporter extends TableImporter { } this.amount -= 1; - const openedAt = databaseDate.parse(this.model.opened_at); + const openedAt = fromDatabaseDate(this.model.opened_at); const laterOn = new Date(openedAt.getTime() + 1000 * 60 * 15); - const clickTime = faker.date.between({from: openedAt.getTime(), to: laterOn.getTime()}); //added getTime here because it threw random errors + const clickTime = randomDateBetween(openedAt, laterOn); return { id: this.fastFakeObjectId(), @@ -61,7 +61,7 @@ class MembersClickEventsImporter extends TableImporter { min: 0, max: this.redirectList.length - 1 }))].id, - created_at: databaseDate.dateToDatabaseString(clickTime) + created_at: toDatabaseDate(clickTime) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js index 650a5b4f022..641a2ee3fee 100644 --- a/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js @@ -1,7 +1,7 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); const {luck} = require('../utils/random'); -const databaseDate = require('../utils/database-date'); +const {fromDatabaseDate, toDatabaseDate} = require('../../../lib/db-date'); class MembersCreatedEventsImporter extends TableImporter { static table = 'members_created_events'; @@ -13,7 +13,7 @@ class MembersCreatedEventsImporter extends TableImporter { async import(quantity) { const members = await this.transaction.select('id', 'created_at').from('members'); - this.posts = await this.transaction.select('id', 'published_at', 'visibility', 'type', 'slug').from('posts').orderBy('published_at', 'desc'); + this.posts = await this.transaction.select('id', 'published_at', 'visibility', 'type', 'slug').from('posts').whereNotNull('published_at').orderBy('published_at', 'desc'); this.incomingRecommendations = await this.transaction.select('id', 'source', 'created_at').from('mentions'); await this.importForEach(members, quantity ? quantity / members.length : 1); @@ -48,8 +48,8 @@ class MembersCreatedEventsImporter extends TableImporter { }; if (source === 'member' && luck(30)) { - const memberCreatedAt = databaseDate.parse(this.model.created_at); - const post = this.posts.find(p => p.visibility === 'public' && databaseDate.parse(p.published_at) < memberCreatedAt); + const memberCreatedAt = fromDatabaseDate(this.model.created_at); + const post = this.posts.find(p => p.visibility === 'public' && fromDatabaseDate(p.published_at) < memberCreatedAt); if (post) { attribution = { attribution_id: post.id, @@ -93,7 +93,7 @@ class MembersCreatedEventsImporter extends TableImporter { return { id: this.fastFakeObjectId(), - created_at: databaseDate.dateToDatabaseString(this.model.created_at), + created_at: toDatabaseDate(this.model.created_at), member_id: this.model.id, source, ...attribution, diff --git a/ghost/core/core/server/data/seeders/importers/members-feedback-importer.js b/ghost/core/core/server/data/seeders/importers/members-feedback-importer.js index 3b43be3cc4d..9563b87812d 100644 --- a/ghost/core/core/server/data/seeders/importers/members-feedback-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-feedback-importer.js @@ -1,6 +1,6 @@ const {TableImporter} = require('./table-importer'); -const {luck} = require('../utils/random'); -const databaseDate = require('../utils/database-date'); +const {luck, randomDateBetween} = require('../utils/random'); +const {fromDatabaseDate, toDatabaseDate} = require('../../../lib/db-date'); class MembersFeedbackImporter extends TableImporter { static table = 'members_feedback'; @@ -24,10 +24,10 @@ class MembersFeedbackImporter extends TableImporter { return null; } - const openedAt = databaseDate.parse(this.model.opened_at); - const laterOn = databaseDate.parse(this.model.opened_at); + const openedAt = fromDatabaseDate(this.model.opened_at); + const laterOn = new Date(openedAt); laterOn.setMinutes(laterOn.getMinutes() + 60); - const feedbackTime = databaseDate.randomBetween(openedAt, laterOn); + const feedbackTime = randomDateBetween(openedAt, laterOn); const postId = this.emails.find(email => email.id === this.model.email_id).post_id; return { @@ -35,8 +35,8 @@ class MembersFeedbackImporter extends TableImporter { score: luck(70) ? 1 : 0, member_id: this.model.member_id, post_id: postId, - created_at: databaseDate.dateToDatabaseString(feedbackTime), - updated_at: databaseDate.dateToDatabaseString(feedbackTime) + created_at: toDatabaseDate(feedbackTime), + updated_at: toDatabaseDate(feedbackTime) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/members-importer.js b/ghost/core/core/server/data/seeders/importers/members-importer.js index 11bfcf3dbe8..a4f775fee32 100644 --- a/ghost/core/core/server/data/seeders/importers/members-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-importer.js @@ -4,7 +4,7 @@ const {faker: americanFaker} = require('@faker-js/faker/locale/en_US'); const {blogStartDate: startTime} = require('../utils/blog-info'); const generateEvents = require('../utils/event-generator'); const {luck} = require('../utils/random'); -const databaseDate = require('../utils/database-date'); +const {toDatabaseDate} = require('../../../lib/db-date'); const debug = require('@tryghost/debug')('MembersImporter'); class MembersImporter extends TableImporter { @@ -88,9 +88,9 @@ class MembersImporter extends TableImporter { email_opened_count: 0, email_open_rate: null, // 40% of users logged in within a week, 60% sometime since registering - last_seen_at: luck(40) ? databaseDate.dateToDatabaseString(faker.date.recent({days: 7})) : databaseDate.dateToDatabaseString(faker.date.between({from: timestamp, to: new Date()})), - created_at: databaseDate.dateToDatabaseString(timestamp), - updated_at: databaseDate.dateToDatabaseString(timestamp) + last_seen_at: luck(40) ? toDatabaseDate(faker.date.recent({days: 7})) : toDatabaseDate(faker.date.between({from: timestamp, to: new Date()})), + created_at: toDatabaseDate(timestamp), + updated_at: toDatabaseDate(timestamp) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/members-login-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-login-events-importer.js index 3cc32b220b9..9f020569aec 100644 --- a/ghost/core/core/server/data/seeders/importers/members-login-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-login-events-importer.js @@ -1,7 +1,7 @@ const {TableImporter} = require('./table-importer'); const {luck} = require('../utils/random'); const generateEvents = require('../utils/event-generator'); -const databaseDate = require('../utils/database-date'); +const {fromDatabaseDate, toDatabaseDate} = require('../../../lib/db-date'); class MembersLoginEventsImporter extends TableImporter { static table = 'members_login_events'; @@ -34,7 +34,7 @@ class MembersLoginEventsImporter extends TableImporter { setReferencedModel(model) { this.model = model; - const memberCreatedAt = databaseDate.parse(model.created_at); + const memberCreatedAt = fromDatabaseDate(model.created_at); const endDate = new Date(); const daysBetween = Math.ceil((endDate.valueOf() - memberCreatedAt.valueOf()) / (1000 * 60 * 60 * 24)); @@ -60,7 +60,7 @@ class MembersLoginEventsImporter extends TableImporter { } return { id: this.fastFakeObjectId(), - created_at: databaseDate.dateToDatabaseString(timestamp), + created_at: toDatabaseDate(timestamp), member_id: this.model.id }; } diff --git a/ghost/core/core/server/data/seeders/importers/members-products-importer.js b/ghost/core/core/server/data/seeders/importers/members-products-importer.js index 893e2098be2..7fed09eff64 100644 --- a/ghost/core/core/server/data/seeders/importers/members-products-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-products-importer.js @@ -1,7 +1,7 @@ const {faker} = require('@faker-js/faker'); const {TableImporter} = require('./table-importer'); const {luck} = require('../utils/random'); -const databaseDate = require('../utils/database-date'); +const {toDatabaseDate} = require('../../../lib/db-date'); class MembersProductsImporter extends TableImporter { static table = 'members_products'; @@ -34,7 +34,7 @@ class MembersProductsImporter extends TableImporter { member_id: this.model.id, product_id: this.getProduct().id, sort_order: 0, - expiry_at: this.model.status === 'paid' ? null : (luck(50) ? null : databaseDate.dateToDatabaseString(faker.date.future())) + expiry_at: this.model.status === 'paid' ? null : (luck(50) ? null : toDatabaseDate(faker.date.future())) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/members-status-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-status-events-importer.js index d18a602cb5b..be1752ef98d 100644 --- a/ghost/core/core/server/data/seeders/importers/members-status-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-status-events-importer.js @@ -1,5 +1,6 @@ const {TableImporter} = require('./table-importer'); -const databaseDate = require('../utils/database-date'); +const {randomDateBetween} = require('../utils/random'); +const {toDatabaseDate} = require('../../../lib/db-date'); class MembersStatusEventsImporter extends TableImporter { static table = 'members_status_events'; @@ -31,7 +32,7 @@ class MembersStatusEventsImporter extends TableImporter { member_id: model.id, from_status: null, to_status: 'free', - created_at: databaseDate.dateToDatabaseString(model.created_at) + created_at: toDatabaseDate(model.created_at) }]; if (model.status !== 'free') { this.events.push({ @@ -39,7 +40,7 @@ class MembersStatusEventsImporter extends TableImporter { member_id: model.id, from_status: 'free', to_status: model.status, - created_at: databaseDate.dateToDatabaseString(databaseDate.randomBetween(model.created_at, new Date())) + created_at: toDatabaseDate(randomDateBetween(model.created_at, new Date())) }); } } diff --git a/ghost/core/core/server/data/seeders/importers/members-stripe-customers-importer.js b/ghost/core/core/server/data/seeders/importers/members-stripe-customers-importer.js index 34203ad2d2a..1970c99f158 100644 --- a/ghost/core/core/server/data/seeders/importers/members-stripe-customers-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-stripe-customers-importer.js @@ -1,6 +1,6 @@ const {faker} = require('@faker-js/faker'); const {TableImporter} = require('./table-importer'); -const databaseDate = require('../utils/database-date'); +const {fromDatabaseDate} = require('../../../lib/db-date'); class MembersStripeCustomersImporter extends TableImporter { static table = 'members_stripe_customers'; @@ -35,7 +35,7 @@ class MembersStripeCustomersImporter extends TableImporter { // Only 30% of free members should have a stripe customer = have had a subscription in the past or tried to subscribe // The number should increase the older the member is - const daysSinceMemberCreated = Math.floor((new Date() - databaseDate.parse(this.model.created_at)) / (1000 * 60 * 60 * 24)); + const daysSinceMemberCreated = Math.floor((new Date() - fromDatabaseDate(this.model.created_at)) / (1000 * 60 * 60 * 24)); const shouldHaveStripeCustomer = faker.number.int({min: 0, max: 100}) < Math.max(Math.min(daysSinceMemberCreated / 60, 15), 2); if (!shouldHaveStripeCustomer) { diff --git a/ghost/core/core/server/data/seeders/importers/members-stripe-customers-subscriptions-importer.js b/ghost/core/core/server/data/seeders/importers/members-stripe-customers-subscriptions-importer.js index 766eb2f6993..1e3be210c5e 100644 --- a/ghost/core/core/server/data/seeders/importers/members-stripe-customers-subscriptions-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-stripe-customers-subscriptions-importer.js @@ -1,8 +1,8 @@ const {faker} = require('@faker-js/faker'); const {TableImporter} = require('./table-importer'); -const databaseDate = require('../utils/database-date'); const generateEvents = require('../utils/event-generator'); const {luck} = require('../utils/random'); +const {fromDatabaseDate, toDatabaseDate} = require('../../../lib/db-date'); class MembersStripeCustomersSubscriptionsImporter extends TableImporter { static table = 'members_stripe_customers_subscriptions'; @@ -95,7 +95,7 @@ class MembersStripeCustomersSubscriptionsImporter extends TableImporter { (isMonthly ? price.interval === 'month' : price.interval === 'year'); }); const mrr = createValid ? (isMonthly ? stripePrice.amount : Math.floor(stripePrice.amount / 12)) : 0; - const memberCreatedAt = databaseDate.parse(member.created_at); + const memberCreatedAt = fromDatabaseDate(member.created_at); const referenceEndDate = this.lastSubscriptionStart ?? new Date(); @@ -193,8 +193,8 @@ class MembersStripeCustomersSubscriptionsImporter extends TableImporter { validStatusses.push({ status: 'trialing', cancel_at_period_end: false, - trial_end_at: databaseDate.dateToDatabaseString(endDate), - trial_start_at: databaseDate.dateToDatabaseString(startDate) + trial_end_at: toDatabaseDate(endDate), + trial_start_at: toDatabaseDate(startDate) }); } @@ -236,8 +236,8 @@ class MembersStripeCustomersSubscriptionsImporter extends TableImporter { customer_id: customer.customer_id, subscription_id: `sub_${faker.string.alphanumeric(14)}`, stripe_price_id: stripePrice.stripe_price_id, - start_date: databaseDate.dateToDatabaseString(startDate), - created_at: databaseDate.dateToDatabaseString(startDate), + start_date: toDatabaseDate(startDate), + created_at: toDatabaseDate(startDate), mrr, plan_id: stripeProduct.stripe_product_id, plan_nickname: `${ghostProduct.name} - ${stripePrice.nickname}`, @@ -248,7 +248,7 @@ class MembersStripeCustomersSubscriptionsImporter extends TableImporter { // Defaults status: 'active', cancel_at_period_end: false, - current_period_end: databaseDate.dateToDatabaseString(endDate), + current_period_end: toDatabaseDate(endDate), // Override ...status diff --git a/ghost/core/core/server/data/seeders/importers/members-subscribe-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-subscribe-events-importer.js index e05856e8d37..57e3daf3185 100644 --- a/ghost/core/core/server/data/seeders/importers/members-subscribe-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-subscribe-events-importer.js @@ -1,6 +1,6 @@ const {TableImporter} = require('./table-importer'); -const {luck} = require('../utils/random'); -const databaseDate = require('../utils/database-date'); +const {luck, randomDateBetween} = require('../utils/random'); +const {toDatabaseDate} = require('../../../lib/db-date'); class MembersSubscribeEventsImporter extends TableImporter { static table = 'members_subscribe_events'; @@ -50,7 +50,7 @@ class MembersSubscribeEventsImporter extends TableImporter { return null; } - const createdAt = databaseDate.dateToDatabaseString(databaseDate.randomBetween(this.model.created_at, new Date())); + const createdAt = toDatabaseDate(randomDateBetween(this.model.created_at, new Date())); const newsletterId = this.newsletters[count % this.newsletters.length].id; return { diff --git a/ghost/core/core/server/data/seeders/importers/members-subscription-created-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-subscription-created-events-importer.js index 0704cebb035..0611c14e3a8 100644 --- a/ghost/core/core/server/data/seeders/importers/members-subscription-created-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-subscription-created-events-importer.js @@ -1,7 +1,7 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); const {luck} = require('../utils/random'); -const databaseDate = require('../utils/database-date'); +const {fromDatabaseDate} = require('../../../lib/db-date'); class MembersSubscriptionCreatedEventsImporter extends TableImporter { static table = 'members_subscription_created_events'; @@ -48,8 +48,8 @@ class MembersSubscriptionCreatedEventsImporter extends TableImporter { }; if (luck(30)) { - const createdAt = databaseDate.parse(this.model.created_at); - const post = this.posts.find(p => p.visibility === 'public' && databaseDate.parse(p.published_at) < createdAt); + const createdAt = fromDatabaseDate(this.model.created_at); + const post = this.posts.find(p => p.visibility === 'public' && fromDatabaseDate(p.published_at) < createdAt); if (post) { attribution = { attribution_id: post.id, diff --git a/ghost/core/core/server/data/seeders/importers/offer-redemptions-importer.js b/ghost/core/core/server/data/seeders/importers/offer-redemptions-importer.js index 016a44bd882..ba16aee37a9 100644 --- a/ghost/core/core/server/data/seeders/importers/offer-redemptions-importer.js +++ b/ghost/core/core/server/data/seeders/importers/offer-redemptions-importer.js @@ -1,7 +1,8 @@ const {faker} = require('@faker-js/faker'); const errors = require('@tryghost/errors'); const {TableImporter} = require('./table-importer'); -const databaseDate = require('../utils/database-date'); +const {randomDateBetween} = require('../utils/random'); +const {fromDatabaseDate, toDatabaseDate} = require('../../../lib/db-date'); class OfferRedemptionsImporter extends TableImporter { static table = 'offer_redemptions'; @@ -53,7 +54,7 @@ class OfferRedemptionsImporter extends TableImporter { this.subscriptionPool.push({ memberId, subscriptionId: subscription.id, - subscriptionCreatedAt: databaseDate.parse(subscription.created_at), + subscriptionCreatedAt: fromDatabaseDate(subscription.created_at), redemptionEndAt: this.getRedemptionEndDate(subscription.current_period_end), availableOffers: [...matchingOffers], lastRedeemedAt: null @@ -78,7 +79,7 @@ class OfferRedemptionsImporter extends TableImporter { getRedemptionEndDate(currentPeriodEnd) { const now = new Date(); - const endDate = currentPeriodEnd ? databaseDate.parse(currentPeriodEnd) : now; + const endDate = currentPeriodEnd ? fromDatabaseDate(currentPeriodEnd) : now; return endDate > now ? now : endDate; } @@ -86,19 +87,18 @@ class OfferRedemptionsImporter extends TableImporter { getCreatedAt(subscriptionState, offer) { const candidateEarliest = new Date(Math.max( subscriptionState.subscriptionCreatedAt.valueOf(), - databaseDate.parse(offer.created_at).valueOf(), + fromDatabaseDate(offer.created_at).valueOf(), subscriptionState.lastRedeemedAt ? subscriptionState.lastRedeemedAt.valueOf() + 1000 : 0 )); const earliest = new Date(Math.min( candidateEarliest.valueOf(), subscriptionState.redemptionEndAt.valueOf() )); - const latest = subscriptionState.redemptionEndAt > earliest ? subscriptionState.redemptionEndAt : earliest; - const createdAt = latest.valueOf() === earliest.valueOf() ? earliest : faker.date.between({from: earliest, to: latest}); + const createdAt = randomDateBetween(earliest, subscriptionState.redemptionEndAt); subscriptionState.lastRedeemedAt = createdAt; - return databaseDate.dateToDatabaseString(createdAt); + return toDatabaseDate(createdAt); } generate() { diff --git a/ghost/core/core/server/data/seeders/importers/offers-importer.js b/ghost/core/core/server/data/seeders/importers/offers-importer.js index c547eeaf795..ce3e84b6534 100644 --- a/ghost/core/core/server/data/seeders/importers/offers-importer.js +++ b/ghost/core/core/server/data/seeders/importers/offers-importer.js @@ -1,7 +1,7 @@ const {TableImporter} = require('./table-importer'); const {slugify} = require('@tryghost/string'); const {blogStartDate} = require('../utils/blog-info'); -const databaseDate = require('../utils/database-date'); +const {toDatabaseDate} = require('../../../lib/db-date'); const offerTemplates = [{ name: 'Black Friday', @@ -107,8 +107,8 @@ class OffersImporter extends TableImporter { portal_title: name, portal_description: template.description, redemption_type: 'signup', - created_at: databaseDate.dateToDatabaseString(blogStartDate), - updated_at: databaseDate.dateToDatabaseString(blogStartDate) + created_at: toDatabaseDate(blogStartDate), + updated_at: toDatabaseDate(blogStartDate) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/posts-importer.js b/ghost/core/core/server/data/seeders/importers/posts-importer.js index 7757c3d2bba..82746c3d0de 100644 --- a/ghost/core/core/server/data/seeders/importers/posts-importer.js +++ b/ghost/core/core/server/data/seeders/importers/posts-importer.js @@ -2,7 +2,7 @@ const {faker} = require('@faker-js/faker'); const {slugify} = require('@tryghost/string'); const {luck} = require('../utils/random'); const {TableImporter} = require('./table-importer'); -const databaseDate = require('../utils/database-date'); +const {toDatabaseDate} = require('../../../lib/db-date'); class PostsImporter extends TableImporter { static table = 'posts'; @@ -53,9 +53,9 @@ class PostsImporter extends TableImporter { return { id, - created_at: databaseDate.dateToDatabaseString(timestamp), - updated_at: databaseDate.dateToDatabaseString(timestamp), - published_at: status === 'published' ? databaseDate.dateToDatabaseString(timestamp) : status === 'scheduled' ? databaseDate.dateToDatabaseString(faker.date.soon({days: 5, refDate: timestamp})) : null, + created_at: toDatabaseDate(timestamp), + updated_at: toDatabaseDate(timestamp), + published_at: status === 'published' ? toDatabaseDate(timestamp) : status === 'scheduled' ? toDatabaseDate(faker.date.soon({days: 5, refDate: timestamp})) : null, uuid: faker.string.uuid(), comment_id: this.type === 'post' ? id : null, title: title, diff --git a/ghost/core/core/server/data/seeders/importers/table-importer.ts b/ghost/core/core/server/data/seeders/importers/table-importer.ts index 3389bf21bf2..48d8b6287bc 100644 --- a/ghost/core/core/server/data/seeders/importers/table-importer.ts +++ b/ghost/core/core/server/data/seeders/importers/table-importer.ts @@ -1,5 +1,5 @@ import debugFactory from '@tryghost/debug'; -import * as databaseDate from '../utils/database-date'; +import {toDatabaseDate} from '../../../lib/db-date'; import path from 'node:path'; import fs from 'node:fs'; import papaparse from 'papaparse'; @@ -123,7 +123,7 @@ export abstract class TableImporter< if (typeof value === 'boolean') { mutableObj[key] = value ? 1 : 0; } else if (value instanceof Date) { - mutableObj[key] = databaseDate.dateToDatabaseString(value); + mutableObj[key] = toDatabaseDate(value); } else if (value === null) { mutableObj[key] = '\\N'; } diff --git a/ghost/core/core/server/data/seeders/importers/tags-importer.js b/ghost/core/core/server/data/seeders/importers/tags-importer.js index 6f1a2b62751..36c0fbe8c0c 100644 --- a/ghost/core/core/server/data/seeders/importers/tags-importer.js +++ b/ghost/core/core/server/data/seeders/importers/tags-importer.js @@ -1,7 +1,7 @@ const {faker} = require('@faker-js/faker'); const {slugify} = require('@tryghost/string'); const {TableImporter} = require('./table-importer'); -const databaseDate = require('../utils/database-date'); +const {toDatabaseDate} = require('../../../lib/db-date'); class TagsImporter extends TableImporter { static table = 'tags'; @@ -32,7 +32,7 @@ class TagsImporter extends TableImporter { name: name, slug: slugify(name), description: faker.lorem.sentence(), - created_at: databaseDate.dateToDatabaseString(faker.date.between({from: threeYearsAgo, to: twoYearsAgo})) + created_at: toDatabaseDate(faker.date.between({from: threeYearsAgo, to: twoYearsAgo})) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/users-importer.js b/ghost/core/core/server/data/seeders/importers/users-importer.js index 1ae7d07e961..4b904522bf9 100644 --- a/ghost/core/core/server/data/seeders/importers/users-importer.js +++ b/ghost/core/core/server/data/seeders/importers/users-importer.js @@ -2,7 +2,7 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); const {slugify} = require('@tryghost/string'); const security = require('@tryghost/security'); -const databaseDate = require('../utils/database-date'); +const {toDatabaseDate} = require('../../../lib/db-date'); class UsersImporter extends TableImporter { static table = 'users'; @@ -24,7 +24,7 @@ class UsersImporter extends TableImporter { password: await security.password.hash(faker.color.human()), email: faker.internet.email({firstName, lastName}), profile_image: faker.image.avatar(), - created_at: databaseDate.dateToDatabaseString(faker.date.between({from: new Date(2016, 0), to: new Date()})) + created_at: toDatabaseDate(faker.date.between({from: new Date(2016, 0), to: new Date()})) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/web-mentions-importer.js b/ghost/core/core/server/data/seeders/importers/web-mentions-importer.js index 2e1793a9ed7..803a912c5d9 100644 --- a/ghost/core/core/server/data/seeders/importers/web-mentions-importer.js +++ b/ghost/core/core/server/data/seeders/importers/web-mentions-importer.js @@ -1,6 +1,6 @@ const {TableImporter} = require('./table-importer'); const {faker} = require('@faker-js/faker'); -const databaseDate = require('../utils/database-date'); +const {toDatabaseDate} = require('../../../lib/db-date'); class WebMentionsImporter extends TableImporter { static table = 'mentions'; @@ -31,7 +31,7 @@ class WebMentionsImporter extends TableImporter { target: `${this.baseUrl}`, resource_id: null, resource_type: null, - created_at: databaseDate.dateToDatabaseString(faker.date.past()), + created_at: toDatabaseDate(faker.date.past()), payload: JSON.stringify({}), deleted: 0, verified: 1 diff --git a/ghost/core/core/server/data/seeders/utils/database-date.ts b/ghost/core/core/server/data/seeders/utils/database-date.ts deleted file mode 100644 index 39a2512e3c7..00000000000 --- a/ghost/core/core/server/data/seeders/utils/database-date.ts +++ /dev/null @@ -1,30 +0,0 @@ -import {faker} from '@faker-js/faker'; - -const databaseDatePattern = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/; - -export function dateToDatabaseString(date: Date | string): string { - if (typeof date === 'string') { - // SQLite fix when reusing other dates from the db - return date; - } - return date.toISOString().replace('Z', '').replace('T', ' '); -} - -export function parse(date: Date | string | number): Date { - if (date instanceof Date) { - return new Date(date); - } - - if (typeof date === 'string' && databaseDatePattern.test(date)) { - return new Date(date.replace(' ', 'T') + 'Z'); - } - - return new Date(date); -} - -export function randomBetween(start: Date | string | number, end: Date | string | number): Date { - const earliest = parse(start); - const latest = parse(end); - - return latest > earliest ? faker.date.between({from: earliest, to: latest}) : earliest; -} diff --git a/ghost/core/core/server/data/seeders/utils/random.ts b/ghost/core/core/server/data/seeders/utils/random.ts index 357209a3524..0ba0a36de45 100644 --- a/ghost/core/core/server/data/seeders/utils/random.ts +++ b/ghost/core/core/server/data/seeders/utils/random.ts @@ -1,4 +1,5 @@ import {faker} from '@faker-js/faker'; +import {fromDatabaseDate, type DatabaseDate} from '../../../lib/db-date'; /** * Adds another degree of randomness into some decisions @@ -9,3 +10,10 @@ export const luck = (lowerThan: number): boolean => faker.number.int({ min: 1, max: 100 }) <= lowerThan; + +export function randomDateBetween(start: DatabaseDate, end: DatabaseDate): Date { + const earliest = fromDatabaseDate(start); + const latest = fromDatabaseDate(end); + + return latest > earliest ? faker.date.between({from: earliest, to: latest}) : earliest; +} diff --git a/ghost/core/core/server/ghost-server.js b/ghost/core/core/server/ghost-server.js index 9099cf288df..0b97b5dc526 100644 --- a/ghost/core/core/server/ghost-server.js +++ b/ghost/core/core/server/ghost-server.js @@ -200,9 +200,11 @@ class GhostServer { /** * Add a task that should be called on shutdown + * @param {() => Promise} task + * @param {string} [label] - name used in shutdown timing logs */ - registerCleanupTask(task) { - this.cleanupTasks.push(task); + registerCleanupTask(task, label) { + this.cleanupTasks.push({task, label: label || `cleanup task #${this.cleanupTasks.length + 1}`}); } /** @@ -217,12 +219,24 @@ class GhostServer { */ async _stopServer() { const util = require('util'); - return util.promisify(this.httpServer.stop)(); + const startTime = Date.now(); + try { + return await util.promisify(this.httpServer.stop)(); + } finally { + logging.info(`Shutdown: stopped HTTP server in ${Date.now() - startTime}ms`); + } } async _cleanup() { - // Wait for all cleanup tasks to finish - return Promise.all(this.cleanupTasks.map(task => task())); + // Wait for all cleanup tasks to finish, timing each so a slow one is identifiable + return Promise.all(this.cleanupTasks.map(async ({task, label}) => { + const startTime = Date.now(); + try { + return await task(); + } finally { + logging.info(`Shutdown: ${label} finished in ${Date.now() - startTime}ms`); + } + })); } /** diff --git a/ghost/core/core/server/lib/db-date.ts b/ghost/core/core/server/lib/db-date.ts index 558352e5635..be86206be43 100644 --- a/ghost/core/core/server/lib/db-date.ts +++ b/ghost/core/core/server/lib/db-date.ts @@ -1,8 +1,54 @@ +import {DateTime} from 'luxon'; +import * as errors from '@tryghost/errors'; import {z} from 'zod'; +const DATABASE_DATE_FORMAT = 'yyyy-MM-dd HH:mm:ss'; + +const databaseDateInput = z.union([z.date(), z.string(), z.number()]); + +export type DatabaseDate = z.input; + +const parseDatabaseDateString = (value: string): DateTime => { + const sql = DateTime.fromSQL(value, {zone: 'utc'}); + + return sql.isValid ? sql : DateTime.fromISO(value, {zone: 'utc'}); +}; + +const parseDatabaseDate = (date: DatabaseDate): DateTime => { + const input = databaseDateInput.safeParse(date); + + if (!input.success) { + throw new errors.InternalServerError({message: 'Invalid database date'}); + } + + let parsed: DateTime; + + if (typeof input.data === 'string') { + parsed = parseDatabaseDateString(input.data); + } else if (typeof input.data === 'number') { + parsed = DateTime.fromMillis(input.data, {zone: 'utc'}); + } else { + parsed = DateTime.fromJSDate(input.data, {zone: 'utc'}); + } + + if (!parsed.isValid) { + throw new errors.InternalServerError({message: 'Invalid database date'}); + } + + return parsed; +}; + +// Raw Knex queries need this UTC datetime format for consistent MySQL and SQLite behavior. +export const toDatabaseDate = (date: DatabaseDate): string => parseDatabaseDate(date).toFormat(DATABASE_DATE_FORMAT); + +export const fromDatabaseDate = (date: DatabaseDate): Date => parseDatabaseDate(date).toJSDate(); + // A zod codec for datetime columns: MySQL returns a Date, SQLite a string/number; // normalise to a Date on read and pass a Date through on write. -export const DbDate = z.codec(z.union([z.date(), z.string(), z.number()]), z.date(), { - decode: value => new Date(value), +// On SQLite, Knex binds the Date that DbDate passes through as epoch milliseconds (stored as +// INTEGER) while toDatabaseDate writes TEXT; SQLite orders INTEGER before TEXT, so never compare +// or order those two representations. +export const DbDate = z.codec(databaseDateInput, z.date(), { + decode: fromDatabaseDate, encode: date => date }); diff --git a/ghost/core/core/server/models/gift-delivery.ts b/ghost/core/core/server/models/gift-delivery.ts new file mode 100644 index 00000000000..befa241135d --- /dev/null +++ b/ghost/core/core/server/models/gift-delivery.ts @@ -0,0 +1,22 @@ +import errors from '@tryghost/errors'; + +const ghostBookshelf = require('./base'); + +const GiftDeliveryModel = ghostBookshelf.Model.extend({ + tableName: 'gift_deliveries', + hasTimestamps: false, + + defaults: { + status: 'pending' + }, + + gift() { + return this.belongsTo('Gift', 'gift_id', 'id'); + } +}, { + async destroy() { + throw new errors.IncorrectUsageError({message: 'Cannot destroy GiftDelivery'}); + } +}); + +export const GiftDelivery = ghostBookshelf.model('GiftDelivery', GiftDeliveryModel); diff --git a/ghost/core/core/server/models/gift.js b/ghost/core/core/server/models/gift.js index 9d89ebf583c..ef3911951b6 100644 --- a/ghost/core/core/server/models/gift.js +++ b/ghost/core/core/server/models/gift.js @@ -4,6 +4,11 @@ const ghostBookshelf = require('./base'); const Gift = ghostBookshelf.Model.extend({ tableName: 'gifts', hasTimestamps: false, + defaults() { + return { + checkout_started_at: new Date() + }; + }, buyer() { return this.belongsTo('Member', 'buyer_member_id', 'id'); @@ -15,6 +20,10 @@ const Gift = ghostBookshelf.Model.extend({ tier() { return this.belongsTo('Product', 'tier_id', 'id'); + }, + + delivery() { + return this.hasOne('GiftDelivery', 'gift_id', 'id'); } }, { async destroy() { diff --git a/ghost/core/core/server/models/index.js b/ghost/core/core/server/models/index.js index b6f11366fd8..50310e28c42 100644 --- a/ghost/core/core/server/models/index.js +++ b/ghost/core/core/server/models/index.js @@ -25,6 +25,7 @@ const {EmailRecipient, EmailRecipients} = require('./email-recipient'); const {EmailSpamComplaintEvent} = require('./email-spam-complaint-event'); const {Email, Emails} = require('./email'); const {Gift} = require('./gift'); +const {GiftDelivery} = require('./gift-delivery'); const {Integration, Integrations} = require('./integration'); const {Invite, Invites} = require('./invite'); const {Job} = require('./job'); @@ -111,6 +112,7 @@ exports.EmailSpamComplaintEvent = EmailSpamComplaintEvent; exports.Email = Email; exports.Emails = Emails; exports.Gift = Gift; +exports.GiftDelivery = GiftDelivery; exports.Integration = Integration; exports.Integrations = Integrations; exports.Invite = Invite; diff --git a/ghost/core/core/server/models/settings.js b/ghost/core/core/server/models/settings.js index b8d60be3914..5589b466ce1 100644 --- a/ghost/core/core/server/models/settings.js +++ b/ghost/core/core/server/models/settings.js @@ -18,6 +18,8 @@ const messages = { const internalContext = {context: {internal: true}}; let Settings; let defaultSettings; +const imageSettingKeys = ['cover_image', 'logo', 'icon', 'portal_button_icon', 'og_image', 'twitter_image', 'pintura_js_url', 'pintura_css_url']; +const navigationSettingKeys = ['navigation', 'secondary_navigation']; const doBlock = fn => fn(); @@ -103,6 +105,36 @@ function getDefaultSettings() { return defaultSettings; } +function transformNavigationIconUrls(value, transformUrl) { + if (!value) { + return value; + } + + let navigationItems; + try { + navigationItems = _.isString(value) ? JSON.parse(value) : value; + } catch (e) { + return value; + } + + if (!_.isArray(navigationItems)) { + return value; + } + + const transformed = navigationItems.map((item) => { + if (!_.isObject(item) || _.isFunction(item) || !item.icon) { + return item; + } + + return { + ...item, + icon: transformUrl(item.icon) + }; + }); + + return _.isString(value) ? JSON.stringify(transformed) : transformed; +} + // Each setting is saved as a separate row in the database, // but the overlying API treats them as a single key:value mapping Settings = ghostBookshelf.Model.extend({ @@ -179,10 +211,14 @@ Settings = ghostBookshelf.Model.extend({ }, formatOnWrite(attrs) { - if (attrs.value && ['cover_image', 'logo', 'icon', 'portal_button_icon', 'og_image', 'twitter_image', 'pintura_js_url', 'pintura_css_url'].includes(attrs.key)) { + if (attrs.value && imageSettingKeys.includes(attrs.key)) { attrs.value = urlUtils.toTransformReady(attrs.value); } + if (attrs.value && navigationSettingKeys.includes(attrs.key)) { + attrs.value = transformNavigationIconUrls(attrs.value, urlUtils.toTransformReady.bind(urlUtils)); + } + return attrs; }, @@ -201,10 +237,14 @@ Settings = ghostBookshelf.Model.extend({ } // transform URLs from __GHOST_URL__ to absolute - if (['cover_image', 'logo', 'icon', 'portal_button_icon', 'og_image', 'twitter_image', 'pintura_js_url', 'pintura_css_url'].includes(attrs.key)) { + if (imageSettingKeys.includes(attrs.key)) { attrs.value = urlUtils.transformReadyToAbsolute(attrs.value); } + if (navigationSettingKeys.includes(attrs.key)) { + attrs.value = transformNavigationIconUrls(attrs.value, urlUtils.transformReadyToAbsolute.bind(urlUtils)); + } + return attrs; } }, { diff --git a/ghost/core/core/server/services/automations/database-automations-repository.ts b/ghost/core/core/server/services/automations/database-automations-repository.ts index 67b517bf32d..7c80ad94edc 100644 --- a/ghost/core/core/server/services/automations/database-automations-repository.ts +++ b/ghost/core/core/server/services/automations/database-automations-repository.ts @@ -22,7 +22,7 @@ import type { EditAutomationData, Page } from './automations-repository'; -import {fromDatabaseDate, toDatabaseDate, type DatabaseDate} from './database-date'; +import {fromDatabaseDate, toDatabaseDate, type DatabaseDate} from '../../lib/db-date'; import {getStaleLockCutoff} from './stale-lock-cutoff'; import type {ExclusifyUnion, ReadonlyDeep} from 'type-fest'; diff --git a/ghost/core/core/server/services/automations/database-date.ts b/ghost/core/core/server/services/automations/database-date.ts deleted file mode 100644 index 545f7b725cd..00000000000 --- a/ghost/core/core/server/services/automations/database-date.ts +++ /dev/null @@ -1,26 +0,0 @@ -import moment from 'moment'; -import * as errors from '@tryghost/errors'; - -export const DATABASE_DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss'; - -export type DatabaseDate = Date | string | number; - -export const toDatabaseDate = (date: Date | string): string => moment.utc(date).format(DATABASE_DATE_FORMAT); - -export const fromDatabaseDate = (date: DatabaseDate): Date => { - if (date instanceof Date) { - return new Date(date); - } - - if (typeof date === 'string') { - return moment.utc(date, DATABASE_DATE_FORMAT).toDate(); - } - - // Defense-in-depth for legacy SQLite rows stored as epoch milliseconds. - if (typeof date === 'number') { - return moment.utc(date).toDate(); - } - - const exhaustive: never = date; - throw new errors.InternalServerError({message: `Unexpected type for database date: ${exhaustive}`}); -}; diff --git a/ghost/core/core/server/services/content-import/csv/index.ts b/ghost/core/core/server/services/content-import/csv/index.ts new file mode 100644 index 00000000000..1a99a1b2dc2 --- /dev/null +++ b/ghost/core/core/server/services/content-import/csv/index.ts @@ -0,0 +1 @@ +export {default as parse, type Row} from './parse'; diff --git a/ghost/core/core/server/services/content-import/csv/parse.ts b/ghost/core/core/server/services/content-import/csv/parse.ts new file mode 100644 index 00000000000..064270c9cf2 --- /dev/null +++ b/ghost/core/core/server/services/content-import/csv/parse.ts @@ -0,0 +1,68 @@ +import papaparse from 'papaparse'; +import fs from 'fs-extra'; + +const errors = require('@tryghost/errors'); + +// A parsed CSV row: raw string cells, keyed by (renamed) column. +export type Row = Record; + +// A column named after an Object.prototype member is unsafe as a key. +function isSafeColumnName(name: string): boolean { + return !(name in Object.prototype); +} + +// Quote errors mean cells are being glued together and rows can no longer be told +// apart, so the file is refused. Ragged rows (FieldMismatch) and delimiter guesses +// stay tolerated: an overflow cell is dropped per row, not per file. +function isFatal(error: papaparse.ParseError): boolean { + return error.type === 'Quotes'; +} + +// headerMapping renames headers to the columns they emit under; unmapped columns +// carry through, and one mapped to an empty target is dropped. +export default async function parse(path: string, headerMapping?: Record): Promise { + // Buffered rather than streamed: files are bounded by the interim row cap, and + // papaparse's stream mode drops results.errors, which is what catches a + // malformed quoted field before it imports as garbage. + const content = (await fs.readFile(path, 'utf8')).replace(/^\ufeff/, ''); + const parsed = papaparse.parse>(content, { + header: true, + skipEmptyLines: true + }); + + const fatal = parsed.errors.find(isFatal); + if (fatal) { + throw new errors.ValidationError({message: `${fatal.code}: ${fatal.message}`}); + } + + const rows: Row[] = []; + + for (const parsedRow of parsed.data) { + const row: Row = {}; + + for (const [header, value] of Object.entries(parsedRow)) { + // non-string values are papaparse's __parsed_extra overflow from ragged rows + if (typeof value !== 'string') { + continue; + } + + // hasOwn: a prototype-named header must not match an inherited method on the mapping + if (headerMapping && Object.hasOwn(headerMapping, header)) { + if (!headerMapping[header]) { + continue; + } + row[headerMapping[header]] = value; + } else if (isSafeColumnName(header)) { + row[header] = value; + } + } + + if (!Object.keys(row).length) { + continue; + } + + rows.push(row); + } + + return rows; +} diff --git a/ghost/core/core/server/services/content-import/import/importer.ts b/ghost/core/core/server/services/content-import/import/importer.ts new file mode 100644 index 00000000000..72cb4576b6e --- /dev/null +++ b/ghost/core/core/server/services/content-import/import/importer.ts @@ -0,0 +1,246 @@ +import moment from 'moment-timezone'; +import buildPostData, {RowSkipped, type HtmlToLexical, type PostData} from './post-data'; +import type {PostImportRow} from './row'; +import type {Clock, ImportRunStore, RowOutcome} from './store'; + +const errors = require('@tryghost/errors'); +const tpl = require('@tryghost/tpl'); + +// The CSV is parsed inside the request (the uploaded temp file is deleted when the +// response is sent); the parsed rows are handed to an in-process background job +// that writes one post per row. + +export interface ImportRequest { + filePath: string; +} + +// The id is what a completion report will be looked up by. +export interface ImportAccepted { + importId: string; + total: number; +} + +export interface CreatedPost { + id: string; + toJSON(): Record; +} + +export interface PostsRepository { + create(data: PostData, options: object): Promise; +} + +// Must not throw: it is called from catch blocks that exist to stop an error escaping. +export type FailureReporter = (error: unknown) => void; + +type ReadRows = (path: string) => Promise; + +const messages = { + unreadableFile: 'The file could not be parsed as a CSV file.', + tooManyPosts: 'This file contains more than {max} posts. Imports are temporarily limited to {max} posts at a time — please split the file into smaller files and try again.', + allWritesFailed: 'Content import failed to write all {count} attempted {postNoun}.', + urlResolutionFailed: 'Content import could not resolve a URL for {count} created {postNoun}.' +}; + +const MAX_POSTS = 100; + +interface ImporterDeps { + readRows: ReadRows; + posts: PostsRepository; + // A getter so the heavy html->lexical require resolves once per run + getHtmlToLexical: () => HtmlToLexical; + addJob: (job: {job: () => Promise; offloaded: boolean; name: string}) => void; + report: FailureReporter; + store: ImportRunStore; + urlForPost: (post: CreatedPost) => string; + newRunId: () => string; + getTimezone: () => string; + now?: Clock; +} + +// Two batch tags for every imported post: a date stamp matching the JSON +// importer's (api/endpoints/db.js), and a run tag unique to this import, which +// the report milestones key on. +function buildImportTagNames(runId: string, timezone: string, now: Date): string[] { + return [ + `#Import ${moment(now).tz(timezone).format('YYYY-MM-DD HH:mm')}`, + `#Import Run ${runId}` + ]; +} + +class ContentCSVImporter { + private _readRows: ReadRows; + private _posts: PostsRepository; + private _getHtmlToLexical: () => HtmlToLexical; + private _addJob: ImporterDeps['addJob']; + private _report: FailureReporter; + private _store: ImportRunStore; + private _urlForPost: (post: CreatedPost) => string; + private _newRunId: () => string; + private _getTimezone: () => string; + private _now: Clock; + + constructor({readRows, posts, getHtmlToLexical, addJob, report, store, urlForPost, newRunId, getTimezone, now = () => new Date()}: ImporterDeps) { + this._readRows = readRows; + this._posts = posts; + this._getHtmlToLexical = getHtmlToLexical; + this._addJob = addJob; + this._report = report; + this._store = store; + this._urlForPost = urlForPost; + this._newRunId = newRunId; + this._getTimezone = getTimezone; + this._now = now; + } + + async importCSV(request: ImportRequest): Promise { + let rows: PostImportRow[]; + try { + rows = await this._readRows(request.filePath); + } catch (error) { + throw new errors.ValidationError({ + message: tpl(messages.unreadableFile), + err: error + }); + } + + // Temporary while import state is held in memory: the durable job + // system milestone removes the cap. + if (rows.length > MAX_POSTS) { + throw new errors.ValidationError({ + message: tpl(messages.tooManyPosts, {max: MAX_POSTS}) + }); + } + + const runId = this._newRunId(); + const importTagNames = buildImportTagNames(runId, this._getTimezone(), this._now()); + this._store.create(runId, rows.length); + + this._addJob({ + job: () => this.runImportJob(runId, importTagNames, rows), + offloaded: false, + name: 'content-import' + }); + + return {importId: runId, total: rows.length}; + } + + // Must resolve in every case: the job manager reads a rejected inline job as a + // defect in the job itself, and there is no retry behind it. + private async runImportJob(runId: string, importTagNames: string[], rows: PostImportRow[]): Promise { + let urlFailureCount = 0; + let firstUrlFailure: unknown; + + try { + const htmlToLexical = this._getHtmlToLexical(); + let attemptedWrites = 0; + let successfulWrites = 0; + let failedWrites = 0; + let firstWriteFailure: unknown; + + for (const [index, row] of rows.entries()) { + const line = index + 2; + let data: PostData; + + try { + data = buildPostData(row, htmlToLexical, importTagNames); + } catch (error) { + if (error instanceof RowSkipped) { + this._store.record(runId, { + line, + title: row.title || null, + status: 'skipped', + reason: messageOf(error) + }); + continue; + } + + // Anything other than an expected source-row refusal is an importer + // failure. Stop the run before misclassifying it as a lost write. + throw error; + } + + attemptedWrites += 1; + let post: CreatedPost; + try { + // options.importing preserves the supplied timestamps and keeps the import silent: + // the webhook, Slack, IndexNow and mention consumers all stand down on it, and a + // newsletter can only be attached in the API layer (post-scheduling does not check + // it, one reason status is never 'scheduled'). Pinned by + // test/e2e-webhooks/posts-importer.test.js. A fresh options object per row: the + // model layer mutates it. + post = await this._posts.create(data, { + importing: true, + context: {internal: true} + }); + successfulWrites += 1; + } catch (error) { + if (failedWrites === 0) { + firstWriteFailure = error; + } + failedWrites += 1; + this._store.record(runId, { + line, + title: row.title, + status: 'failed', + reason: messageOf(error) + }); + continue; + } + + const outcome: RowOutcome = { + line, + title: row.title, + status: 'created', + postId: post.id + }; + try { + outcome.url = this._urlForPost(post); + } catch (error) { + if (urlFailureCount === 0) { + firstUrlFailure = error; + } + urlFailureCount += 1; + } + this._store.record(runId, outcome); + } + + if (attemptedWrites > 0 && successfulWrites === 0 && failedWrites === attemptedWrites) { + this._report(new errors.InternalServerError({ + message: tpl(messages.allWritesFailed, { + count: failedWrites, + postNoun: failedWrites === 1 ? 'post' : 'posts' + }), + err: firstWriteFailure + })); + } + + this.reportUrlFailures(urlFailureCount, firstUrlFailure); + this._store.finish(runId); + } catch (error) { + this.reportUrlFailures(urlFailureCount, firstUrlFailure); + this._report(error); + this._store.fail(runId, messageOf(error)); + } + } + + private reportUrlFailures(count: number, firstFailure: unknown): void { + if (count > 0) { + this._report(new errors.InternalServerError({ + message: tpl(messages.urlResolutionFailed, { + count, + postNoun: count === 1 ? 'post' : 'posts' + }), + err: firstFailure + })); + } + } +} + +function messageOf(error: unknown): string { + if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string' && error.message) { + return error.message; + } + return 'Unknown error'; +} + +export default ContentCSVImporter; diff --git a/ghost/core/core/server/services/content-import/import/post-data.ts b/ghost/core/core/server/services/content-import/import/post-data.ts new file mode 100644 index 00000000000..f3be1a3fbae --- /dev/null +++ b/ghost/core/core/server/services/content-import/import/post-data.ts @@ -0,0 +1,72 @@ +import {importableRowSchema, type PostImportRow} from './row'; + +const {slugify} = require('@tryghost/string'); + +export type HtmlToLexical = (html: string) => unknown; + +// A malformed row, refused before any write was attempted; distinct from a write +// that failed, which the importer records separately. +export class RowSkipped extends Error { + constructor(reason: string) { + super(reason); + this.name = 'RowSkipped'; + } +} + +// The values handed to models.Post.add. Content is lexical only: under +// options.importing the model strips client-supplied html and regenerates it from +// lexical on save, so the CSV's html must be converted, never passed through. +// No authors key: the model resolves the import's internal context to the owner. +export interface PostData { + title: string; + slug: string; + lexical?: string; + published_at?: string; + created_at?: string; + updated_at?: string; + status: 'published'; + type: 'post'; + visibility: 'public'; + tags: Array<{name: string}>; +} + +export default function buildPostData(row: PostImportRow, htmlToLexical: HtmlToLexical, importTagNames: string[]): PostData { + const check = importableRowSchema.safeParse(row); + if (!check.success) { + throw new RowSkipped(check.error.issues[0].message); + } + + const data: PostData = { + title: row.title, + // Slugified here with the standard rules: left to the model, the + // importing-context slug pass keeps every punctuation dash + // (slugify requiredChangesOnly). + slug: slugify(row.title), + // Explicit rather than left to the model, which would default status to draft and + // visibility to the default_content_visibility setting. Never 'scheduled': the + // post scheduler has no importing check. + status: 'published', + type: 'post', + visibility: 'public', + // The # prefix gives the batch tags internal visibility. + tags: importTagNames.map(name => ({name})) + }; + + if (row.html) { + try { + data.lexical = JSON.stringify(htmlToLexical(row.html)); + } catch { + throw new RowSkipped('html could not be converted'); + } + } + + // The one date column dates the whole post; preserved only because the write runs + // under options.importing, which stops the model stamping its own timestamps. + if (row.published_at) { + data.published_at = row.published_at; + data.created_at = row.published_at; + data.updated_at = row.published_at; + } + + return data; +} diff --git a/ghost/core/core/server/services/content-import/import/reader.ts b/ghost/core/core/server/services/content-import/import/reader.ts new file mode 100644 index 00000000000..0422a352984 --- /dev/null +++ b/ghost/core/core/server/services/content-import/import/reader.ts @@ -0,0 +1,14 @@ +import {parse as parseCSV} from '../csv'; +import {postImportRowSchema, type PostImportRow} from './row'; + +// Identity map until the field-mapping milestone adds caller-supplied mappings. +const FIELD_BY_HEADER: Record = { + title: 'title', + html: 'html', + published_at: 'published_at' +}; + +export default async function readPostRows(path: string): Promise { + const rows = await parseCSV(path, FIELD_BY_HEADER); + return rows.map(row => postImportRowSchema.parse(row)); +} diff --git a/ghost/core/core/server/services/content-import/import/row.ts b/ghost/core/core/server/services/content-import/import/row.ts new file mode 100644 index 00000000000..556051b29e8 --- /dev/null +++ b/ghost/core/core/server/services/content-import/import/row.ts @@ -0,0 +1,57 @@ +import {z} from 'zod'; +import {Temporal} from 'temporal-polyfill'; + +// An empty cell (or the literal 'undefined') reads as absent, not as a value. +const optionalCell = z.string() + .transform(cell => (cell === '' || cell === 'undefined' ? undefined : cell)) + .optional(); + +// title is trimmed here because the model's own trim is skipped under +// options.importing; unknown columns pass through (.loose()) for field mapping. +export const postImportRowSchema = z.object({ + title: z.string().default('').transform(cell => cell.trim()), + html: z.string().default(''), + published_at: optionalCell +}).loose(); + +export type PostImportRow = z.infer; + +// Validation for coerced rows; buildPostData turns the first issue into a row +// skip. A blank title is refused because the model's (Untitled) fallback is +// skipped under options.importing; an invalid date is refused rather than +// silently replaced with the import time (what the JSON importer does). The +// title cap matches the posts schema's isLength validation. +export const importableRowSchema = z.object({ + title: z.string() + .min(1, 'title is required') + .max(255, 'title must be 255 characters or fewer') +}).loose().superRefine((row, ctx) => { + const publishedAt = row.published_at; + if (typeof publishedAt === 'string' && !isValidDate(publishedAt)) { + ctx.addIssue({code: 'custom', message: `published_at is not a valid date: "${publishedAt}"`}); + } +}); + +// new Date() alone is not enough: it normalizes rolled-over calendar dates, so +// 2025-02-30 silently becomes March 2. For ISO-shaped values the calendar components +// are checked strictly with Temporal; other formats stay as loose as new Date(). +function isValidDate(value: string): boolean { + if (Number.isNaN(new Date(value).getTime())) { + return false; + } + + const isoDate = /^(\d{4})-(\d{2})-(\d{2})/.exec(value); + if (!isoDate) { + return true; + } + + try { + Temporal.PlainDate.from( + {year: Number(isoDate[1]), month: Number(isoDate[2]), day: Number(isoDate[3])}, + {overflow: 'reject'} + ); + return true; + } catch { + return false; + } +} diff --git a/ghost/core/core/server/services/content-import/import/store.ts b/ghost/core/core/server/services/content-import/import/store.ts new file mode 100644 index 00000000000..6033d131621 --- /dev/null +++ b/ghost/core/core/server/services/content-import/import/store.ts @@ -0,0 +1,105 @@ +// The in-memory record of what a content import did: one run per upload, one +// outcome per row. Nothing is persisted; the durable job system milestone +// replaces this. + +// skipped = the row was never attempted (the publisher can fix the file); +// failed = the write was attempted and lost. +export type Clock = () => Date; + +export type RowStatus = 'created' | 'skipped' | 'failed'; + +export interface RowOutcome { + // Source line number as a publisher sees it in a spreadsheet: the header is + // line 1, so the first data row is line 2. + line: number; + title: string | null; + status: RowStatus; + reason?: string; + postId?: string; + url?: string; +} + +export interface ImportRun { + id: string; + status: 'running' | 'complete' | 'failed'; + startedAt: Date; + finishedAt?: Date; + failureReason?: string; + total: number; + rows: RowOutcome[]; +} + +// Both bounds are applied lazily on create() so no timer keeps the process alive. +const MAX_RUNS = 10; +const MAX_RUN_AGE_MS = 60 * 60 * 1000; + +export class ImportRunStore { + // Insertion-ordered, so count-eviction drops the oldest run first. + private _runs = new Map(); + private _now: Clock; + + constructor({now = () => new Date()}: {now?: Clock} = {}) { + this._now = now; + } + + create(id: string, total: number): ImportRun { + this.evict(); + + const run: ImportRun = { + id, + status: 'running', + startedAt: this._now(), + total, + rows: [] + }; + this._runs.set(id, run); + return run; + } + + record(id: string, outcome: RowOutcome): void { + this._runs.get(id)?.rows.push(outcome); + } + + finish(id: string): void { + const run = this._runs.get(id); + if (run) { + run.status = 'complete'; + run.finishedAt = this._now(); + } + } + + fail(id: string, reason: string): void { + const run = this._runs.get(id); + if (run) { + run.status = 'failed'; + run.failureReason = reason; + run.finishedAt = this._now(); + } + } + + get(id: string): ImportRun | undefined { + return this._runs.get(id); + } + + // A running run is never evicted, whatever its age: the job holds only the runId, + // so evicting mid-import would silently turn its record()/finish() calls into + // no-ops and lose the report. The count cap can briefly overshoot while several + // imports run at once; the inline job queue bounds how many that can be. + private evict(): void { + const cutoff = this._now().getTime() - MAX_RUN_AGE_MS; + for (const [id, run] of this._runs) { + const lastTouched = (run.finishedAt ?? run.startedAt).getTime(); + if (run.status !== 'running' && lastTouched < cutoff) { + this._runs.delete(id); + } + } + + while (this._runs.size >= MAX_RUNS) { + const oldestEvictable = [...this._runs.values()].find(run => run.status !== 'running'); + if (!oldestEvictable) { + break; + } + this._runs.delete(oldestEvictable.id); + } + } +} diff --git a/ghost/core/core/server/services/content-import/index.ts b/ghost/core/core/server/services/content-import/index.ts new file mode 100644 index 00000000000..d13721c2fee --- /dev/null +++ b/ghost/core/core/server/services/content-import/index.ts @@ -0,0 +1,67 @@ +import {z} from 'zod'; +import ContentCSVImporter, {type ImportRequest, type ImportAccepted, type FailureReporter} from './import/importer'; +import readPostRows from './import/reader'; +import {ImportRunStore} from './import/store'; + +// The request is built from HTTP upload metadata, so it is validated at the +// service boundary rather than trusted. +const importRequestSchema = z.object({filePath: z.string().min(1)}); +// A junk timezone setting falls back to UTC rather than mis-stamping the batch tag. +const timezoneSchema = z.string().min(1).catch('Etc/UTC'); + +const errors = require('@tryghost/errors'); +const logging = require('@tryghost/logging'); +const sentry = require('../../../shared/sentry'); + +// Composition root: models and services are wired behind the collaborators the +// importer declares. +function makeImporter(): ContentCSVImporter { + // Required lazily: boot initialises this service before the model and job layers + // are guaranteed loaded. + const models = require('../../models'); + const lexicalLib = require('../../lib/lexical'); + const jobsService = require('../jobs'); + const settingsCache = require('../../../shared/settings-cache'); + const urlService = require('../url'); + const ObjectID = require('bson-objectid').default; + + // Inline jobs never reach the job manager's Sentry handler, which is wired to the + // offloaded worker path only, so a throw here would be seen by nobody. + const report: FailureReporter = (error) => { + try { + logging.error({event: {name: 'content.import.error'}, err: error}, 'Content import failure'); + sentry.captureException(error); + } catch { + // Callers report from catch blocks, so this must not throw. + } + }; + + return new ContentCSVImporter({ + readRows: readPostRows, + posts: { + create: (data, options) => models.Post.add(data, options) + }, + getHtmlToLexical: () => lexicalLib.htmlToLexicalConverter, + addJob: jobsService.addJob.bind(jobsService), + report, + store: new ImportRunStore(), + // Degrades to the 404 URL for a post the URL service cannot route yet (e.g. a draft). + urlForPost: post => urlService.getUrlForResource({...post.toJSON(), type: 'posts'}, {absolute: true}), + newRunId: () => new ObjectID().toHexString(), + getTimezone: () => timezoneSchema.parse(settingsCache.get('timezone')) + }); +} + +let importer: ContentCSVImporter | undefined; + +// Idempotent because tests may boot more than once per process. +export function init(): void { + importer ??= makeImporter(); +} + +export function importCSV(request: ImportRequest): Promise { + if (!importer) { + throw new errors.InternalServerError({message: 'Content import service used before init'}); + } + return importer.importCSV(importRequestSchema.parse(request)); +} diff --git a/ghost/core/core/server/services/email-service/email-service-wrapper.js b/ghost/core/core/server/services/email-service/email-service-wrapper.js index 7c85ac03568..e1fc5065028 100644 --- a/ghost/core/core/server/services/email-service/email-service-wrapper.js +++ b/ghost/core/core/server/services/email-service/email-service-wrapper.js @@ -136,7 +136,7 @@ class EmailServiceWrapper { }); if (ghostServer) { - ghostServer.registerCleanupTask(() => batchSendingService.onShutdown()); + ghostServer.registerCleanupTask(() => batchSendingService.onShutdown(), 'Email batch sending'); } this.renderer = emailRenderer; diff --git a/ghost/core/core/server/services/gifts/CONTEXT.md b/ghost/core/core/server/services/gifts/CONTEXT.md index 8ca04633388..72651fe6b92 100644 --- a/ghost/core/core/server/services/gifts/CONTEXT.md +++ b/ghost/core/core/server/services/gifts/CONTEXT.md @@ -14,6 +14,10 @@ _Avoid_: Purchaser, recipient, redeemer The member who claims a gift subscription and receives its access. The redeemer is determined at redemption, not purchase. _Avoid_: Recipient, buyer +**Recipient**: +The person for whom a gift subscription is intended. Their email routes delivery but does not reserve redemption; they need not be a member and do not become the redeemer until they claim the gift. +_Avoid_: Receiver, redeemer + ### Purchase and claim **Gift subscription**: @@ -21,17 +25,37 @@ A prepaid, non-renewing entitlement to one membership tier for a fixed period. I _Avoid_: Gift membership, recurring subscription **Gift purchase**: -A one-time purchase that creates a gift subscription for the buyer to share. +A one-time purchase that creates a gift subscription for the buyer to share. Completing a gift purchase does not mean that an emailed gift has been sent. _Avoid_: Gift checkout +**Delivery method**: +The buyer's chosen way of handing over a gift subscription: the publication emails it to a recipient, or the buyer shares the redemption link privately. +_Avoid_: Delivery mode + **Redemption link**: -A single-use, time-limited link through which a purchased gift subscription can be claimed. +A single-use, time-limited link through which a purchased gift subscription can be viewed and claimed. Its bearer may see the gift's buyer name, intended recipient name, and personal message, but not email-routing or delivery details. _Avoid_: Gift Link **Gift redemption**: The act of claiming an eligible gift subscription. The member who claims it becomes the redeemer and gifted access begins at that point. _Avoid_: Gift activation +**Gift delivery**: +Communicating a gift subscription's redemption link to its recipient by email. Delivery does not claim the gift or begin gifted access. +_Avoid_: Gift redemption, gift activation + +**Personal message**: +A note from the buyer that forms part of the gift and can be presented wherever the gift is shown, including email and redemption experiences. +_Avoid_: Delivery message, email message + +**Gift sent**: +The recipient email has been accepted by the publication's configured mail transport. This completes gift delivery. +_Avoid_: Gift delivered, provider delivery + +**Email delivery status**: +The current progress of handing a recipient email to the configured mail transport: pending, sending, sent, failed, or cancelled. +_Avoid_: Gift status + ### Time and lifecycle **Gift duration**: @@ -43,7 +67,7 @@ The monthly or yearly membership price basis used to value a gift subscription. _Avoid_: Gift duration, renewal interval **Gift expiration**: -The end of the claim window for a purchased gift subscription that was never redeemed. +The end of the claim window that begins when a gift subscription is purchased and was never redeemed. _Avoid_: Gift consumption **Gift consumption**: diff --git a/ghost/core/core/server/services/gifts/README.md b/ghost/core/core/server/services/gifts/README.md index 7a16d9c20bc..c1906e8088d 100644 --- a/ghost/core/core/server/services/gifts/README.md +++ b/ghost/core/core/server/services/gifts/README.md @@ -20,9 +20,13 @@ transactions, or Stripe objects. - `getMemberPresentations(memberIds)`, `getPreview(token)`, `browsePurchaseEvents(...)`, and `browseRedemptionEvents(...)` expose stable read models. -- `processReminders()`, `processConsumed()`, and `processExpired()` own due - lifecycle work; scheduler and HTTP triggers remain adapters. +- `processReminders()`, `processConsumed()`, and `processExpired()` own gift + lifecycle work. `GiftDeliveryService` owns email-delivery creation, + post-commit dispatch, cancellation, and processing behind a separate + interface. Email delivery starts immediately after purchase through an + in-process event. Delivery claims are atomic; stale in-progress claims are + retried after a crash, so mail-transport acceptance is at least once. - `reassignRedeemer(...)` is the import capability. -The `Gift`, repository, Bookshelf query, Stripe checkout, email, scheduling, -and notification collaborators are internal adapters. +The `Gift` model, gift-delivery data schema, their repositories, Bookshelf +queries, Stripe checkout and email collaborators are internal adapters. diff --git a/ghost/core/core/server/services/gifts/constants.ts b/ghost/core/core/server/services/gifts/constants.ts index 1edb50db532..bdd7bf33a16 100644 --- a/ghost/core/core/server/services/gifts/constants.ts +++ b/ghost/core/core/server/services/gifts/constants.ts @@ -1,3 +1,4 @@ export const GIFT_EXPIRY_DAYS = 365; export const GIFT_REMINDER_LEAD_DAYS = 7; export const GIFT_REMINDER_FLOOR_DAYS = 3; +export const GIFT_DELIVERY_STALE_AFTER_MS = 60 * 60 * 1000; diff --git a/ghost/core/core/server/services/gifts/email-templates/gift-delivery.hbs b/ghost/core/core/server/services/gifts/email-templates/gift-delivery.hbs new file mode 100644 index 00000000000..a112a31730b --- /dev/null +++ b/ghost/core/core/server/services/gifts/email-templates/gift-delivery.hbs @@ -0,0 +1,20 @@ + + + {{t 'A gift, just for you'}} + + +
+
+ {{#if siteIconUrl}}{{siteTitle}}{{else}}{{siteTitle}}{{/if}} +

{{t 'A gift, just for you'}}

+ {{#if recipientName}}

{{t 'Hi {recipientName},' recipientName=(strong recipientName)}}

{{/if}} +

{{#if gift.isMonthly}}{{t '{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}' buyerName=(strong buyerName) duration=(strong gift.duration) count=gift.duration tierName=(strong gift.tierName) siteTitle=siteTitle}}{{else}}{{t '{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}' buyerName=(strong buyerName) duration=(strong gift.duration) count=gift.duration tierName=(strong gift.tierName) siteTitle=siteTitle}}{{/if}}

+ {{#if personalMessage}}

{{personalMessage}}

{{buyerName}}

{{/if}} + {{#if gift.benefits.length}}{{#each gift.benefits}}{{/each}}

{{t 'What\'s included'}}

✓  {{this}}
{{/if}} + {{t 'Redeem your gift:'}} +

{{t 'This gift can only be redeemed once and expires on {expiresAt}.' expiresAt=(strong gift.expiresAt)}}

+

{{t 'This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).' siteDomain=(linkTag siteUrl siteDomain style="color:#738A94;") email=(linkTag (mailto toEmail) toEmail style="color:#738A94;") buyerName=buyerName buyerEmail=(linkTag (mailto buyerEmail) buyerEmail style="color:#738A94;")}}

+
+
+ + diff --git a/ghost/core/core/server/services/gifts/email-templates/gift-delivery.ts b/ghost/core/core/server/services/gifts/email-templates/gift-delivery.ts new file mode 100644 index 00000000000..d64a87f5ecd --- /dev/null +++ b/ghost/core/core/server/services/gifts/email-templates/gift-delivery.ts @@ -0,0 +1,62 @@ +import type {Translate} from '../gift-email-renderer'; + +export interface GiftDeliveryEmailData { + siteTitle: string; + siteUrl: string; + siteIconUrl: string | null; + siteDomain: string; + accentColor: string; + accentTint: string; + accentShade: string; + toEmail: string; + buyerEmail: string; + buyerName: string; + recipientName: string | null; + personalMessage: string | null; + gift: { + tierName: string; + benefits: string[]; + duration: number; + isMonthly: boolean; + link: string; + expiresAt: string; + }; +} + +export function renderText(data: GiftDeliveryEmailData, t: Translate): string { + const interpolation = {escapeValue: false}; + const greeting = data.recipientName ? `${t('Hi {recipientName},', { + recipientName: data.recipientName, + interpolation + })}\n\n` : ''; + const giftDescription = { + duration: data.gift.duration, + count: data.gift.duration, + tierName: data.gift.tierName, + siteTitle: data.siteTitle + }; + const intro = data.gift.isMonthly + ? t('{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}', { + ...giftDescription, + buyerName: data.buyerName, + interpolation + }) + : t('{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}', { + ...giftDescription, + buyerName: data.buyerName, + interpolation + }); + const message = data.personalMessage ? `\n"${data.personalMessage}"\n— ${data.buyerName}\n` : ''; + const benefits = data.gift.benefits.length ? `\n${t('What\'s included:')}\n${data.gift.benefits.map(benefit => `- ${benefit}`).join('\n')}\n` : ''; + + return `${t('A gift, just for you')}\n\n${greeting}${intro}\n${message}${benefits}\n${t('Redeem your gift:')}\n${data.gift.link}\n\n${t('This gift can only be redeemed once and expires on {expiresAt}.', { + expiresAt: data.gift.expiresAt, + interpolation + })}\n\n---\n${t('This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).', { + siteDomain: data.siteDomain, + email: data.toEmail, + buyerName: data.buyerName, + buyerEmail: data.buyerEmail, + interpolation + })}`; +} diff --git a/ghost/core/core/server/services/gifts/email-templates/gift-purchase-confirmation.hbs b/ghost/core/core/server/services/gifts/email-templates/gift-purchase-confirmation.hbs index 595bc8def4c..b0a27926c12 100644 --- a/ghost/core/core/server/services/gifts/email-templates/gift-purchase-confirmation.hbs +++ b/ghost/core/core/server/services/gifts/email-templates/gift-purchase-confirmation.hbs @@ -39,8 +39,8 @@ {{/if}} -

{{t 'Your gift is ready'}}

-

{{t 'Thank you for your support. Share the link below with whoever you\'d like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.' cadenceLabel=(strong gift.cadenceLabel) tierName=(strong gift.tierName) siteTitle=siteTitle}}

+

{{#if gift.recipientEmail}}{{t 'Your gift is on its way'}}{{else}}{{t 'Your gift is ready'}}{{/if}}

+

{{#if gift.recipientEmail}}{{#if gift.isMonthly}}{{t 'Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.' duration=(strong gift.duration) count=gift.duration tierName=(strong gift.tierName) siteTitle=siteTitle recipientEmail=(strong gift.recipientEmail)}}{{else}}{{t 'Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.' duration=(strong gift.duration) count=gift.duration tierName=(strong gift.tierName) siteTitle=siteTitle recipientEmail=(strong gift.recipientEmail)}}{{/if}}{{else}}{{#if gift.isMonthly}}{{t 'Thank you for your support. Share the link below with whoever you\'d like to gift them a {duration}-month {tierName} membership to {siteTitle}.' duration=(strong gift.duration) count=gift.duration tierName=(strong gift.tierName) siteTitle=siteTitle}}{{else}}{{t 'Thank you for your support. Share the link below with whoever you\'d like to gift them a {duration}-year {tierName} membership to {siteTitle}.' duration=(strong gift.duration) count=gift.duration tierName=(strong gift.tierName) siteTitle=siteTitle}}{{/if}}{{/if}}

diff --git a/ghost/core/core/server/services/gifts/email-templates/gift-purchase-confirmation.ts b/ghost/core/core/server/services/gifts/email-templates/gift-purchase-confirmation.ts index 0e272b4a94a..bcc075072b5 100644 --- a/ghost/core/core/server/services/gifts/email-templates/gift-purchase-confirmation.ts +++ b/ghost/core/core/server/services/gifts/email-templates/gift-purchase-confirmation.ts @@ -9,21 +9,38 @@ export interface GiftPurchaseConfirmationData { toEmail: string; gift: { tierName: string; - cadenceLabel: string; + duration: number; + isMonthly: boolean; link: string; expiresAt: string; + recipientEmail: string | null; }; } export function renderText(data: GiftPurchaseConfirmationData, t: Translate): string { - const intro = t('Thank you for your support. Share the link below with whoever you\'d like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.', { - cadenceLabel: data.gift.cadenceLabel, + const giftDescription = { + duration: data.gift.duration, + count: data.gift.duration, tierName: data.gift.tierName, siteTitle: data.siteTitle, interpolation: {escapeValue: false} - }); + }; + const intro = data.gift.recipientEmail + ? data.gift.isMonthly + ? t('Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.', { + ...giftDescription, + recipientEmail: data.gift.recipientEmail + }) + : t('Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.', { + ...giftDescription, + recipientEmail: data.gift.recipientEmail + }) + : data.gift.isMonthly + ? t('Thank you for your support. Share the link below with whoever you\'d like to gift them a {duration}-month {tierName} membership to {siteTitle}.', giftDescription) + : t('Thank you for your support. Share the link below with whoever you\'d like to gift them a {duration}-year {tierName} membership to {siteTitle}.', giftDescription); + const heading = data.gift.recipientEmail ? t('Your gift is on its way') : t('Your gift is ready'); - return `${t('Your gift is ready')} + return `${heading} ${intro} diff --git a/ghost/core/core/server/services/gifts/events/send-gift-delivery-event.ts b/ghost/core/core/server/services/gifts/events/send-gift-delivery-event.ts new file mode 100644 index 00000000000..bde26cc31fa --- /dev/null +++ b/ghost/core/core/server/services/gifts/events/send-gift-delivery-event.ts @@ -0,0 +1,17 @@ +export interface SendGiftDeliveryEventData { + deliveryId: string; +} + +export class SendGiftDeliveryEvent { + readonly data: SendGiftDeliveryEventData; + readonly timestamp: Date; + + constructor(data: SendGiftDeliveryEventData, timestamp: Date) { + this.data = data; + this.timestamp = timestamp; + } + + static create(data: SendGiftDeliveryEventData, timestamp = new Date()) { + return new SendGiftDeliveryEvent(data, timestamp); + } +} diff --git a/ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts b/ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts index 4e7a2898285..59a1a985789 100644 --- a/ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts +++ b/ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts @@ -4,6 +4,7 @@ import type {Knex} from 'knex'; import {Gift} from './gift'; import {decodeGiftRow, encodeGift} from './gift-codec'; import type {GiftCadence, GiftRow} from './gift-schema'; +import {toDatabaseDate} from '../../lib/db-date'; type ParsedNqlFilter = unknown; @@ -66,12 +67,14 @@ export interface GiftRepository { findPendingExpiration(): Promise; findPendingReminder(options: FindPendingReminderOptions): Promise; findUnsentReminders(): Promise; + deleteAbandonedCheckouts(cutoff: Date): Promise; getActiveByMember(memberId: string, options?: RepositoryTransactionOptions): Promise; getActiveByMembers(memberIds: string[], options?: RepositoryTransactionOptions): Promise>; browsePurchaseEvents(options?: GiftEventBrowseOptions, filter?: ParsedNqlFilter): Promise; browseRedemptionEvents(options?: GiftEventBrowseOptions, filter?: ParsedNqlFilter): Promise; - create(gift: Gift, options?: RepositoryTransactionOptions): Promise; + create(gift: Gift, options?: RepositoryTransactionOptions): Promise; update(gift: Gift, options?: RepositoryTransactionOptions): Promise; + deletePendingCheckout(id: string): Promise; transaction(callback: (transacting: Knex.Transaction) => Promise): Promise; } @@ -89,13 +92,14 @@ type GiftEventQueryOptions = GiftEventBrowseOptions & { }; type BookshelfFindOptions = RepositoryTransactionOptions & { + columns?: string[]; filter?: string; require?: boolean; }; type BookshelfDocument = { save(data: Partial, options?: BookshelfSaveOptions): Promise>; - toJSON(): T; + toJSON(): T & {id?: string}; }; type BookshelfCollection = { @@ -133,9 +137,11 @@ type GiftBookshelfModel = BookshelfModel; export class GiftBookshelfRepository implements GiftRepository { private readonly model: GiftBookshelfModel; + private readonly knex: Knex; - constructor({GiftModel}: {GiftModel: GiftBookshelfModel}) { + constructor({GiftModel, knex}: {GiftModel: GiftBookshelfModel; knex: Knex}) { this.model = GiftModel; + this.knex = knex; } async existsByCheckoutSessionId(checkoutSessionId: string): Promise { @@ -266,8 +272,22 @@ export class GiftBookshelfRepository implements GiftRepository { return collection.models.map(model => this.toGift(model)); } - async create(gift: Gift, options: RepositoryTransactionOptions = {}) { - await this.model.add(this.toRow(gift), options); + async deleteAbandonedCheckouts(cutoff: Date): Promise { + return this.knex('gifts') + .where({status: 'payment_pending'}) + .where('checkout_started_at', '<=', toDatabaseDate(cutoff)) + .del(); + } + + async create(gift: Gift, options: RepositoryTransactionOptions = {}): Promise { + const created = await this.model.add(this.toRow(gift), options); + const id = created.toJSON().id; + + if (!id) { + throw new errors.InternalServerError({message: 'Created gift is missing an id'}); + } + + return id; } async update(gift: Gift, options: RepositoryTransactionOptions = {}) { @@ -287,6 +307,14 @@ export class GiftBookshelfRepository implements GiftRepository { }); } + async deletePendingCheckout(id: string): Promise { + const deleted = await this.knex('gifts') + .where({id, status: 'payment_pending'}) + .del(); + + return deleted === 1; + } + async transaction(callback: (transacting: Knex.Transaction) => Promise): Promise { return await this.model.transaction(callback); } @@ -320,7 +348,7 @@ export class GiftBookshelfRepository implements GiftRepository { const queryOptions: GiftEventQueryOptions = { ...options, withRelated: [relation, 'tier'], - filter: `${memberIdColumn}:-null+custom:true`, + filter: `${memberIdColumn}:-null+${dateColumn}:-null+custom:true`, useBasicCount: true, mongoTransformer: chainTransformers( replaceCustomFilter, diff --git a/ghost/core/core/server/services/gifts/gift-checkout-adapter.js b/ghost/core/core/server/services/gifts/gift-checkout-adapter.js index 387a7351ea6..9bcdaf21da6 100644 --- a/ghost/core/core/server/services/gifts/gift-checkout-adapter.js +++ b/ghost/core/core/server/services/gifts/gift-checkout-adapter.js @@ -48,12 +48,14 @@ module.exports = class GiftCheckoutAdapter { } async createSession(data) { - const {customerId, ...sessionData} = data; + const {customerId, idempotencyKey, ...sessionData} = data; const session = await this.getStripeApi().createGiftCheckoutSession({ ...sessionData, - customer: customerId ? {id: customerId} : null + customer: customerId ? {id: customerId} : null, + idempotencyKey }); - return session.url; + return {id: session.id, url: session.url}; } + }; diff --git a/ghost/core/core/server/services/gifts/gift-codec.ts b/ghost/core/core/server/services/gifts/gift-codec.ts index eff087354af..af079dd870f 100644 --- a/ghost/core/core/server/services/gifts/gift-codec.ts +++ b/ghost/core/core/server/services/gifts/gift-codec.ts @@ -7,6 +7,9 @@ export const giftCodec = z.codec(DbGift, z.instanceof(Gift), { token: row.token, buyerEmail: row.buyer_email, buyerMemberId: row.buyer_member_id, + buyerName: row.buyer_name, + recipientName: row.recipient_name, + personalMessage: row.personal_message, redeemerMemberId: row.redeemer_member_id, tierId: row.tier_id, cadence: row.cadence, @@ -15,6 +18,7 @@ export const giftCodec = z.codec(DbGift, z.instanceof(Gift), { amount: row.amount, stripeCheckoutSessionId: row.stripe_checkout_session_id, stripePaymentIntentId: row.stripe_payment_intent_id, + checkoutStartedAt: row.checkout_started_at, consumesAt: row.consumes_at, expiresAt: row.expires_at, status: row.status, @@ -29,6 +33,9 @@ export const giftCodec = z.codec(DbGift, z.instanceof(Gift), { token: gift.token, buyer_email: gift.buyerEmail, buyer_member_id: gift.buyerMemberId, + buyer_name: gift.buyerName, + recipient_name: gift.recipientName, + personal_message: gift.personalMessage, redeemer_member_id: gift.redeemerMemberId, tier_id: gift.tierId, cadence: gift.cadence, @@ -37,6 +44,7 @@ export const giftCodec = z.codec(DbGift, z.instanceof(Gift), { amount: gift.amount, stripe_checkout_session_id: gift.stripeCheckoutSessionId, stripe_payment_intent_id: gift.stripePaymentIntentId, + checkout_started_at: gift.checkoutStartedAt, consumes_at: gift.consumesAt, expires_at: gift.expiresAt, status: gift.status, diff --git a/ghost/core/core/server/services/gifts/gift-delivery-bookshelf-repository.ts b/ghost/core/core/server/services/gifts/gift-delivery-bookshelf-repository.ts new file mode 100644 index 00000000000..5204ebbd13b --- /dev/null +++ b/ghost/core/core/server/services/gifts/gift-delivery-bookshelf-repository.ts @@ -0,0 +1,153 @@ +import type {Knex} from 'knex'; +import {toDatabaseDate} from '../../lib/db-date'; +import {decodeGiftDeliveryRow, encodeGiftDelivery} from './gift-delivery-codec'; +import type {GiftDeliveryData, GiftDeliveryRow} from './gift-delivery-schema'; +import type {RepositoryTransactionOptions} from './gift-bookshelf-repository'; + +export interface GiftDeliveryRepository { + getById(id: string, options?: RepositoryTransactionOptions): Promise; + getByGiftId(giftId: string, options?: RepositoryTransactionOptions): Promise; + findRecoverableForPurchasedGifts(staleBefore: Date, limit: number): Promise; + tryStartDelivery(id: string, now: Date, staleBefore: Date): Promise; + markSent(id: string, sentAt: Date, providerMessageId: string | null): Promise; + recordCancelledAcceptance(id: string, sentAt: Date, providerMessageId: string | null): Promise; + markFailed(id: string): Promise; + markCancelled(id: string): Promise; + cancelPendingForGift(token: string, options?: RepositoryTransactionOptions): Promise; + create(delivery: GiftDeliveryData, options?: RepositoryTransactionOptions): Promise; +} + +type BookshelfDocument = { + toJSON(): T; +}; + +type BookshelfFindOptions = RepositoryTransactionOptions & { + require?: boolean; +}; + +type GiftDeliveryBookshelfModel = { + add(data: GiftDeliveryRow, options?: RepositoryTransactionOptions): Promise>; + findOne(data: Partial, options?: BookshelfFindOptions): Promise | null>; +}; + +// Deliveries that still need a send attempt: never started, or claimed by a +// process that has since died and left the claim stale +function recoverableDeliveries(query: Knex.QueryBuilder, staleBefore: Date): void { + query.andWhere((recoverable) => { + recoverable.where('gift_deliveries.status', 'pending') + .orWhere((stale) => { + stale.where('gift_deliveries.status', 'sending') + .where('gift_deliveries.started_at', '<=', toDatabaseDate(staleBefore)); + }); + }); +} + +export class GiftDeliveryBookshelfRepository implements GiftDeliveryRepository { + private readonly model: GiftDeliveryBookshelfModel; + private readonly knex: Knex; + + constructor({GiftDeliveryModel, knex}: {GiftDeliveryModel: GiftDeliveryBookshelfModel; knex: Knex}) { + this.model = GiftDeliveryModel; + this.knex = knex; + } + + async getById(id: string, options: RepositoryTransactionOptions = {}): Promise { + const model = await this.model.findOne({id}, {require: false, ...options}); + + return model ? decodeGiftDeliveryRow(model.toJSON()) : null; + } + + async getByGiftId(giftId: string, options: RepositoryTransactionOptions = {}): Promise { + const model = await this.model.findOne({gift_id: giftId}, {require: false, ...options}); + + return model ? decodeGiftDeliveryRow(model.toJSON()) : null; + } + + async findRecoverableForPurchasedGifts(staleBefore: Date, limit: number): Promise { + const rows = await this.knex('gift_deliveries') + .select('gift_deliveries.*') + .join('gifts', 'gifts.id', 'gift_deliveries.gift_id') + .where('gifts.status', 'purchased') + .modify(recoverableDeliveries, staleBefore) + .limit(limit); + + return rows.map(decodeGiftDeliveryRow); + } + + async tryStartDelivery(id: string, now: Date, staleBefore: Date): Promise { + const claimed = await this.knex('gift_deliveries') + .where({id}) + .whereExists((query) => { + query.select('gifts.id') + .from('gifts') + .whereRaw('gifts.id = gift_deliveries.gift_id') + .where('gifts.status', 'purchased'); + }) + .modify(recoverableDeliveries, staleBefore) + .update({ + status: 'sending', + started_at: toDatabaseDate(now) + }); + + if (claimed !== 1) { + return null; + } + + return this.getById(id); + } + + async markSent(id: string, sentAt: Date, providerMessageId: string | null): Promise { + return this.updateState(id, 'sending', { + status: 'sent', + email_sent_at: toDatabaseDate(sentAt), + email_provider_message_id: providerMessageId, + started_at: null + }); + } + + // A delivery cancelled while its email was in flight keeps the acceptance + // details so the message can still be correlated with transport outcomes + async recordCancelledAcceptance(id: string, sentAt: Date, providerMessageId: string | null): Promise { + return this.updateState(id, 'cancelled', { + email_sent_at: toDatabaseDate(sentAt), + email_provider_message_id: providerMessageId + }); + } + + async markFailed(id: string): Promise { + return this.updateState(id, 'sending', { + status: 'failed', + started_at: null + }); + } + + async markCancelled(id: string): Promise { + return this.updateState(id, 'sending', { + status: 'cancelled', + started_at: null + }); + } + + async cancelPendingForGift(token: string, options: RepositoryTransactionOptions = {}): Promise { + const db = options.transacting ?? this.knex; + const gift = db('gifts').select('id').where({token}); + const updated = await db('gift_deliveries') + .whereIn('status', ['pending', 'sending']) + .whereIn('gift_id', gift) + .update({status: 'cancelled', started_at: null}); + + return updated === 1; + } + + async create(delivery: GiftDeliveryData, options: RepositoryTransactionOptions = {}): Promise { + await this.model.add(encodeGiftDelivery(delivery), options); + } + + private async updateState(id: string, from: 'sending' | 'cancelled', data: Record): Promise { + const updated = await this.knex('gift_deliveries') + .where({id, status: from}) + .update(data); + + return updated === 1; + } +} diff --git a/ghost/core/core/server/services/gifts/gift-delivery-codec.ts b/ghost/core/core/server/services/gifts/gift-delivery-codec.ts new file mode 100644 index 00000000000..337b36748aa --- /dev/null +++ b/ghost/core/core/server/services/gifts/gift-delivery-codec.ts @@ -0,0 +1,10 @@ +import {camelKeys, snakeKeys} from '../../lib/case-keys'; +import {DbGiftDelivery, type GiftDeliveryData, type GiftDeliveryRow} from './gift-delivery-schema'; + +export function decodeGiftDeliveryRow(input: unknown): GiftDeliveryData { + return camelKeys(DbGiftDelivery.parse(input)); +} + +export function encodeGiftDelivery(delivery: GiftDeliveryData): GiftDeliveryRow { + return DbGiftDelivery.parse(snakeKeys(delivery)); +} diff --git a/ghost/core/core/server/services/gifts/gift-delivery-schema.ts b/ghost/core/core/server/services/gifts/gift-delivery-schema.ts new file mode 100644 index 00000000000..13531a5a0a3 --- /dev/null +++ b/ghost/core/core/server/services/gifts/gift-delivery-schema.ts @@ -0,0 +1,20 @@ +import {z} from 'zod'; +import {DbDate} from '../../lib/db-date'; +import type {CamelKeys} from '../../lib/case-keys'; + +export const GiftDeliveryStatusSchema = z.enum(['pending', 'sending', 'sent', 'failed', 'cancelled']); + +export type GiftDeliveryStatus = z.infer; + +export const DbGiftDelivery = z.object({ + id: z.string(), + gift_id: z.string(), + recipient_email: z.string().email(), + status: GiftDeliveryStatusSchema.default('pending'), + started_at: DbDate.nullable().default(null), + email_sent_at: DbDate.nullable().default(null), + email_provider_message_id: z.string().nullable().default(null) +}); + +export type GiftDeliveryRow = z.output; +export type GiftDeliveryData = CamelKeys; diff --git a/ghost/core/core/server/services/gifts/gift-delivery-service.ts b/ghost/core/core/server/services/gifts/gift-delivery-service.ts new file mode 100644 index 00000000000..8c33e577d3f --- /dev/null +++ b/ghost/core/core/server/services/gifts/gift-delivery-service.ts @@ -0,0 +1,269 @@ +import ObjectID from 'bson-objectid'; +import logging from '@tryghost/logging'; +import type {GiftRepository, RepositoryTransactionOptions} from './gift-bookshelf-repository'; +import type {GiftDeliveryRepository} from './gift-delivery-bookshelf-repository'; +import type {GiftDeliveryData} from './gift-delivery-schema'; +import type {GiftCadence} from './gift-schema'; +import {SendGiftDeliveryEvent} from './events/send-gift-delivery-event'; +import {GIFT_DELIVERY_STALE_AFTER_MS} from './constants'; + +const DomainEvents = require('@tryghost/domain-events'); + +interface Tier { + name: string; + toJSON(): { + benefits: string[]; + }; +} + +interface TiersService { + api: { + read(idString: string): Promise; + }; +} + +interface GiftEmailService { + sendGiftDelivery(data: { + recipientEmail: string; + recipientName: string | null; + buyerEmail: string; + buyerName: string; + personalMessage: string | null; + token: string; + tierName: string; + benefits: string[]; + cadence: GiftCadence; + duration: number; + expiresAt: Date; + }): Promise<{providerMessageId: string | null}>; +} + +interface GiftDeliveryServiceDeps { + giftRepository: Pick; + giftDeliveryRepository: GiftDeliveryRepository; + tiersService: TiersService; + giftEmailService: GiftEmailService; +} + +export interface GiftDeliveryRecoveryResult { + sentCount: number; + skippedCount: number; + failedCount: number; +} + +export class GiftDeliveryService { + private readonly deps: GiftDeliveryServiceDeps; + + constructor(deps: GiftDeliveryServiceDeps) { + this.deps = deps; + } + + async createForCheckout({giftId, recipientEmail}: {giftId: string; recipientEmail: string}, options: RepositoryTransactionOptions = {}): Promise { + const delivery: GiftDeliveryData = { + id: new ObjectID().toHexString(), + giftId, + recipientEmail, + status: 'pending', + startedAt: null, + emailSentAt: null, + emailProviderMessageId: null + }; + + await this.deps.giftDeliveryRepository.create(delivery, options); + return delivery.id; + } + + async dispatchForGift(giftId: string): Promise { + const delivery = await this.deps.giftDeliveryRepository.getByGiftId(giftId); + if (!delivery || delivery.status !== 'pending') { + return delivery?.recipientEmail ?? null; + } + + DomainEvents.dispatch(SendGiftDeliveryEvent.create({deliveryId: delivery.id})); + return delivery.recipientEmail; + } + + async recoverPending(limit = 1000): Promise { + const staleBefore = new Date(Date.now() - GIFT_DELIVERY_STALE_AFTER_MS); + const deliveries = await this.deps.giftDeliveryRepository.findRecoverableForPurchasedGifts(staleBefore, limit); + const result: GiftDeliveryRecoveryResult = { + sentCount: 0, + skippedCount: 0, + failedCount: 0 + }; + + // Sequential on purpose: recovery can find many deliveries at once and each + // send holds a mail transport call, so fanning out through events would open + // them all concurrently + for (const delivery of deliveries) { + try { + const deliveryResult = await this.send(delivery.id); + result[`${deliveryResult}Count`] += 1; + } catch (err) { + result.failedCount += 1; + logging.error({ + event: {name: 'gift_delivery.recovery_failed'}, + err, + deliveryId: delivery.id + }, 'Failed to recover gift delivery'); + } + } + + return result; + } + + async cancelPendingForGift(token: string, options: RepositoryTransactionOptions = {}): Promise { + return this.deps.giftDeliveryRepository.cancelPendingForGift(token, options); + } + + async send(id: string): Promise<'sent' | 'skipped' | 'failed'> { + const now = new Date(); + const staleBefore = new Date(now.getTime() - GIFT_DELIVERY_STALE_AFTER_MS); + const delivery = await this.deps.giftDeliveryRepository.tryStartDelivery(id, now, staleBefore); + + if (!delivery) { + return 'skipped'; + } + + const gift = await this.deps.giftRepository.getById(delivery.giftId); + if (!gift) { + logging.error({ + event: {name: 'gift_delivery.gift_missing'}, + deliveryId: delivery.id, + giftId: delivery.giftId + }, 'Started gift delivery has no gift'); + await this.deps.giftDeliveryRepository.markFailed(delivery.id); + return 'failed'; + } + + if (gift.status !== 'purchased') { + await this.deps.giftDeliveryRepository.markCancelled(delivery.id); + return 'skipped'; + } + + if (!gift.buyerEmail || !gift.buyerName) { + logging.error({ + event: {name: 'gift_delivery.buyer_details_missing'}, + deliveryId: delivery.id, + giftId: delivery.giftId + }, 'Gift delivery is missing required buyer details'); + await this.deps.giftDeliveryRepository.markFailed(delivery.id); + return 'failed'; + } + + let tier: Tier | null; + try { + tier = await this.deps.tiersService.api.read(gift.tierId); + } catch (err) { + logging.error({ + event: {name: 'gift_delivery.tier_read_failed'}, + err, + deliveryId: delivery.id, + giftId: delivery.giftId, + tierId: gift.tierId + }, 'Failed to read tier for gift delivery'); + await this.deps.giftDeliveryRepository.markFailed(delivery.id); + return 'failed'; + } + + if (!tier) { + logging.error({ + event: {name: 'gift_delivery.tier_missing'}, + deliveryId: delivery.id, + giftId: delivery.giftId, + tierId: gift.tierId + }, 'Tier not found for gift delivery'); + await this.deps.giftDeliveryRepository.markFailed(delivery.id); + return 'failed'; + } + + let result: {providerMessageId: string | null}; + try { + result = await this.deps.giftEmailService.sendGiftDelivery({ + recipientEmail: delivery.recipientEmail, + recipientName: gift.recipientName, + buyerEmail: gift.buyerEmail, + buyerName: gift.buyerName, + personalMessage: gift.personalMessage, + token: gift.token, + tierName: tier.name, + benefits: tier.toJSON().benefits, + cadence: gift.cadence, + duration: gift.duration, + expiresAt: gift.expiresAt! + }); + } catch (err) { + logging.error({ + event: {name: 'gift_delivery.acceptance_failed'}, + // The bulk mailer rejects with {error, messageData}; the rendered + // message and recipient must stay out of the logs + err: isMailgunRejection(err) ? err.error : err, + deliveryId: delivery.id, + giftId: delivery.giftId + }, 'Mail transport did not accept gift delivery'); + await this.deps.giftDeliveryRepository.markFailed(delivery.id); + return 'failed'; + } + + const sentAt = new Date(); + let persisted: boolean; + let cancelledDuringSend = false; + try { + persisted = await this.deps.giftDeliveryRepository.markSent(delivery.id, sentAt, result.providerMessageId); + } catch (err) { + logging.warn({ + event: {name: 'gift_delivery.acceptance_persistence.retrying'}, + err, + deliveryId: delivery.id + }, 'Retrying accepted gift delivery persistence'); + + try { + persisted = await this.deps.giftDeliveryRepository.markSent(delivery.id, sentAt, result.providerMessageId); + } catch (retryErr) { + logging.error({ + event: {name: 'gift_delivery.acceptance_persistence.failed'}, + err: retryErr, + deliveryId: delivery.id + }, 'Failed to persist accepted gift delivery'); + + try { + await this.deps.giftDeliveryRepository.markFailed(delivery.id); + } catch (markFailedErr) { + logging.error({ + event: {name: 'gift_delivery.acceptance_terminal_persistence.failed'}, + err: markFailedErr, + deliveryId: delivery.id + }, 'Failed to move accepted gift delivery out of recovery'); + } + return 'failed'; + } + } + + if (!persisted) { + cancelledDuringSend = await this.deps.giftDeliveryRepository.recordCancelledAcceptance(delivery.id, sentAt, result.providerMessageId); + } + + if (persisted) { + return 'sent'; + } + + if (cancelledDuringSend) { + logging.info({ + event: {name: 'gift_delivery.cancelled_during_send'}, + deliveryId: delivery.id, + giftId: delivery.giftId + }, 'Gift delivery was cancelled while its email was in flight'); + return 'skipped'; + } + + logging.error({ + event: {name: 'gift_delivery.acceptance_persistence.failed'}, + deliveryId: delivery.id + }, 'Failed to persist accepted gift delivery'); + return 'failed'; + } +} + +function isMailgunRejection(err: unknown): err is {error: unknown; messageData: unknown} { + return typeof err === 'object' && err !== null && 'error' in err && 'messageData' in err; +} diff --git a/ghost/core/core/server/services/gifts/gift-email-renderer.ts b/ghost/core/core/server/services/gifts/gift-email-renderer.ts index 6894bc3170b..d5856240aa4 100644 --- a/ghost/core/core/server/services/gifts/gift-email-renderer.ts +++ b/ghost/core/core/server/services/gifts/gift-email-renderer.ts @@ -5,6 +5,8 @@ import type {GiftPurchaseConfirmationData} from './email-templates/gift-purchase 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'; +import type {GiftDeliveryEmailData} from './email-templates/gift-delivery'; +import {renderText as renderDeliveryText} from './email-templates/gift-delivery'; export type Translate = (key: string, options?: Record) => string; @@ -14,6 +16,7 @@ export class GiftEmailRenderer { private purchaseConfirmationTemplate: HandlebarsTemplateDelegate | null = null; private reminderTemplate: HandlebarsTemplateDelegate | null = null; + private deliveryTemplate: HandlebarsTemplateDelegate | null = null; constructor({t}: {t: Translate}) { this.t = t; @@ -47,11 +50,23 @@ export class GiftEmailRenderer { }; } + async renderDelivery(data: GiftDeliveryEmailData): Promise<{html: string; text: string}> { + if (!this.deliveryTemplate) { + const source = await fs.readFile(path.join(__dirname, './email-templates/gift-delivery.hbs'), 'utf8'); + this.deliveryTemplate = this.handlebars.compile(source); + } + + return { + html: this.deliveryTemplate(data), + text: renderDeliveryText(data, this.t) + }; + } + private registerTemplateHelpers(): void { this.handlebars.registerHelper('t', (key: string, options?: Handlebars.HelperOptions) => { const hash = options?.hash || {}; - const escapedHash = Object.entries(hash).reduce>((acc, [name, value]) => { - acc[name] = this.htmlSafeInterpolationValue(value); + const escapedHash = Object.entries(hash).reduce>((acc, [name, value]) => { + acc[name] = typeof value === 'number' ? value : this.htmlSafeInterpolationValue(value); return acc; }, {}); diff --git a/ghost/core/core/server/services/gifts/gift-email-service.ts b/ghost/core/core/server/services/gifts/gift-email-service.ts index 813f5f524a6..6460158408b 100644 --- a/ghost/core/core/server/services/gifts/gift-email-service.ts +++ b/ghost/core/core/server/services/gifts/gift-email-service.ts @@ -1,9 +1,13 @@ import {GiftEmailRenderer, Translate} from './gift-email-renderer'; import type {GiftCadence} from './gift-schema'; +import {Color} from '@tryghost/color-utils'; +import errors from '@tryghost/errors'; +import {getMailgunMessageId} from '../lib/mailgun-message-id'; const DEFAULT_DATE_LOCALE = 'en-gb'; +const DEFAULT_ACCENT_COLOR = '#15212A'; -interface Mailer { +interface TransactionalMailer { send(message: { to: string; subject: string; @@ -11,7 +15,26 @@ interface Mailer { text: string; from: string; forceTextContent: boolean; - }): Promise; + tags?: string[]; + trackOpens?: boolean; + disableTracking?: boolean; + }): Promise; +} + +interface BulkMailer { + isConfigured(): boolean; + send( + message: { + subject: string; + html: string; + plaintext: string; + from: string; + tags: string[]; + disable_tracking: boolean; + }, + recipientData: Record>, + replacements: never[] + ): Promise; } interface SettingsCache { @@ -33,6 +56,7 @@ interface PurchaseConfirmationData { cadence: GiftCadence; duration: number; expiresAt: Date; + recipientEmail?: string | null; } interface ReminderData { @@ -42,8 +66,23 @@ interface ReminderData { consumesAt: Date; } +interface GiftDeliverySendData { + recipientEmail: string; + recipientName: string | null; + buyerEmail: string; + buyerName: string; + personalMessage: string | null; + token: string; + tierName: string; + benefits: string[]; + cadence: GiftCadence; + duration: number; + expiresAt: Date; +} + export class GiftEmailService { - private readonly mailer: Mailer; + private readonly transactionalMailer: TransactionalMailer; + private readonly bulkMailer: BulkMailer; private readonly settingsCache: SettingsCache; private readonly urlUtils: UrlUtils; private readonly getFromAddress: () => string; @@ -51,8 +90,9 @@ export class GiftEmailService { private readonly renderer: GiftEmailRenderer; private readonly t: Translate; - constructor({mailer, settingsCache, urlUtils, getFromAddress, blogIcon, t}: {mailer: Mailer; settingsCache: SettingsCache; urlUtils: UrlUtils; getFromAddress: () => string; blogIcon: BlogIcon; t: Translate}) { - this.mailer = mailer; + constructor({transactionalMailer, bulkMailer, settingsCache, urlUtils, getFromAddress, blogIcon, t}: {transactionalMailer: TransactionalMailer; bulkMailer: BulkMailer; settingsCache: SettingsCache; urlUtils: UrlUtils; getFromAddress: () => string; blogIcon: BlogIcon; t: Translate}) { + this.transactionalMailer = transactionalMailer; + this.bulkMailer = bulkMailer; this.settingsCache = settingsCache; this.urlUtils = urlUtils; this.getFromAddress = getFromAddress; @@ -70,14 +110,24 @@ export class GiftEmailService { } } - private getCadenceLabel(cadence: GiftCadence, duration: number): string { - if (duration === 1) { - return cadence === 'year' ? this.t('one-year') : this.t('one-month'); - } - if (cadence === 'year') { - return this.t('{count} year', {count: duration}); + private get accentColor(): string { + return this.settingsCache.get('accent_color') || DEFAULT_ACCENT_COLOR; + } + + private mixAccentColor(target: string, accentWeight: number, fallback: string): string { + try { + return Color(target).mix(Color(this.accentColor), accentWeight).hex().toLowerCase(); + } catch { + return fallback; } - return this.t('{count} month', {count: duration}); + } + + private get accentTint(): string { + return this.mixAccentColor('#FFFFFF', 0.07, '#F4F5F6'); + } + + private get accentShade(): string { + return this.mixAccentColor('#15212A', 0.72, '#738A94'); } private formatDate(date: Date): string { @@ -90,14 +140,12 @@ export class GiftEmailService { }).format(date); } - async sendPurchaseConfirmation({buyerEmail, token, tierName, cadence, duration, expiresAt}: PurchaseConfirmationData): Promise { + async sendPurchaseConfirmation({buyerEmail, token, tierName, cadence, duration, expiresAt, recipientEmail = null}: PurchaseConfirmationData): Promise { const siteDomain = this.siteDomain; const siteUrl = this.urlUtils.getSiteUrl(); const siteTitle = this.settingsCache.get('title') ?? siteDomain; const giftLink = `${siteUrl.replace(/\/$/, '')}/gift/${token}`; - const cadenceLabel = this.getCadenceLabel(cadence, duration); - const {html, text} = await this.renderer.renderPurchaseConfirmation({ siteTitle, siteUrl, @@ -107,19 +155,22 @@ export class GiftEmailService { toEmail: buyerEmail, gift: { tierName, - cadenceLabel, + duration, + isMonthly: cadence === 'month', link: giftLink, - expiresAt: this.formatDate(expiresAt) + expiresAt: this.formatDate(expiresAt), + recipientEmail } }); - await this.mailer.send({ + await this.transactionalMailer.send({ to: buyerEmail, - subject: this.t('Your gift is ready'), + subject: recipientEmail ? this.t('Your gift is on its way') : this.t('Your gift is ready'), html, text, from: this.getFromAddress(), - forceTextContent: true + forceTextContent: true, + disableTracking: true }); } @@ -146,7 +197,7 @@ export class GiftEmailService { } }); - await this.mailer.send({ + await this.transactionalMailer.send({ to: memberEmail, subject: this.t('Your gift subscription is ending soon'), html, @@ -155,4 +206,71 @@ export class GiftEmailService { forceTextContent: true }); } + + async sendGiftDelivery({recipientEmail, recipientName, buyerEmail, buyerName, personalMessage, token, tierName, benefits, cadence, duration, expiresAt}: GiftDeliverySendData): Promise<{providerMessageId: string | null}> { + const siteDomain = this.siteDomain; + const siteUrl = this.urlUtils.getSiteUrl(); + const siteTitle = this.settingsCache.get('title') ?? siteDomain; + const giftLink = `${siteUrl.replace(/\/$/, '')}/gift/${token}`; + const {html, text} = await this.renderer.renderDelivery({ + siteTitle, + siteUrl, + siteIconUrl: this.blogIcon.getIconUrl({absolute: true, fallbackToDefault: false}), + siteDomain, + accentColor: this.accentColor, + accentTint: this.accentTint, + accentShade: this.accentShade, + toEmail: recipientEmail, + buyerEmail, + buyerName, + recipientName, + personalMessage, + gift: { + tierName, + benefits, + duration, + isMonthly: cadence === 'month', + link: giftLink, + expiresAt: this.formatDate(expiresAt) + } + }); + const subject = this.t('{buyerName} sent you a gift', { + buyerName, + interpolation: {escapeValue: false} + }); + + if (!this.bulkMailer.isConfigured()) { + await this.transactionalMailer.send({ + to: recipientEmail, + subject, + html, + text, + from: this.getFromAddress(), + forceTextContent: true, + tags: ['gift-delivery'], + disableTracking: true + }); + + return {providerMessageId: null}; + } + + const response = await this.bulkMailer.send({ + subject, + html, + plaintext: text, + from: this.getFromAddress(), + tags: ['gift-delivery'], + disable_tracking: true + }, {[recipientEmail]: {}}, []); + const providerMessageId = getMailgunMessageId(response) ?? null; + + if (!providerMessageId) { + throw new errors.EmailError({ + message: 'Bulk Mailgun did not accept gift delivery', + code: 'EMAIL_NOT_ACCEPTED' + }); + } + + return {providerMessageId}; + } } diff --git a/ghost/core/core/server/services/gifts/gift-schema.ts b/ghost/core/core/server/services/gifts/gift-schema.ts index e5f2b9584e1..bc45fc1d1ec 100644 --- a/ghost/core/core/server/services/gifts/gift-schema.ts +++ b/ghost/core/core/server/services/gifts/gift-schema.ts @@ -1,9 +1,10 @@ +import type {OptionalKeysOf, SetOptional} from 'type-fest'; import {z} from 'zod'; import {DbDate} from '../../lib/db-date'; import type {CamelKeys} from '../../lib/case-keys'; export const GiftCadenceSchema = z.enum(['month', 'year']); -export const GiftStatusSchema = z.enum(['purchased', 'redeemed', 'consumed', 'expired', 'refunded']); +export const GiftStatusSchema = z.enum(['payment_pending', 'purchased', 'redeemed', 'consumed', 'expired', 'refunded']); export type GiftCadence = z.infer; export type GiftStatus = z.infer; @@ -14,20 +15,24 @@ export type GiftStatus = z.infer; */ export const DbGift = z.object({ token: z.string(), - buyer_email: z.string(), + buyer_email: z.string().nullable(), buyer_member_id: z.string().nullable(), + buyer_name: z.string().nullable().default(null), + recipient_name: z.string().nullable().default(null), + personal_message: z.string().nullable().default(null), redeemer_member_id: z.string().nullable(), tier_id: z.string(), cadence: GiftCadenceSchema, duration: z.number().int().nonnegative(), currency: z.string(), amount: z.number().int().nonnegative(), - stripe_checkout_session_id: z.string(), - stripe_payment_intent_id: z.string(), + stripe_checkout_session_id: z.string().nullable(), + stripe_payment_intent_id: z.string().nullable(), + checkout_started_at: DbDate.nullable().default(null), consumes_at: DbDate.nullable(), - expires_at: DbDate, + expires_at: DbDate.nullable(), status: GiftStatusSchema, - purchased_at: DbDate, + purchased_at: DbDate.nullable(), redeemed_at: DbDate.nullable(), consumed_at: DbDate.nullable(), expired_at: DbDate.nullable(), @@ -35,5 +40,7 @@ export const DbGift = z.object({ consumes_soon_reminder_sent_at: DbDate.nullable().default(null) }); +type GiftInputRow = z.input; export type GiftRow = z.output; export type GiftData = CamelKeys; +export type GiftDataInput = SetOptional>>; diff --git a/ghost/core/core/server/services/gifts/gift-service.ts b/ghost/core/core/server/services/gifts/gift-service.ts index 93c7c0a298d..627e8f3550f 100644 --- a/ghost/core/core/server/services/gifts/gift-service.ts +++ b/ghost/core/core/server/services/gifts/gift-service.ts @@ -5,6 +5,7 @@ import type {Knex} from 'knex'; import {z} from 'zod'; import {Gift} from './gift'; import type {GiftEventBrowseOptions, GiftEventPage, GiftRepository} from './gift-bookshelf-repository'; +import type {GiftDeliveryService} from './gift-delivery-service'; import type {GiftReminderScheduler} from './gift-reminder-scheduler'; import {GiftCadenceSchema, type GiftCadence} from './gift-schema'; import tpl from '@tryghost/tpl'; @@ -19,6 +20,10 @@ import { const MS_PER_DAY = 24 * 60 * 60 * 1000; const GIFT_REMINDER_LEAD_MS = GIFT_REMINDER_LEAD_DAYS * MS_PER_DAY; const GIFT_REMINDER_FLOOR_MS = GIFT_REMINDER_FLOOR_DAYS * MS_PER_DAY; +const GIFT_NAME_MAX_LENGTH = 191; +const GIFT_EMAIL_MAX_LENGTH = 191; +const GIFT_CHECKOUT_MESSAGE_MAX_LENGTH = 250; +const GIFT_CHECKOUT_RETENTION_DAYS = 30; const errorMessages = { giftNotFound: 'This gift does not exist.', @@ -83,6 +88,7 @@ interface GiftEmailService { cadence: GiftCadence; duration: number; expiresAt: Date; + recipientEmail?: string | null; }): Promise; sendReminder(data: { memberEmail: string; @@ -127,10 +133,30 @@ const GiftPurchaseDataSchema = z.object({ stripePaymentIntentId: z.string().min(1) }); -export type GiftPurchaseData = z.infer; +export type GiftPurchaseData = z.input; + +const StripeBuyerEmailSchema = z.unknown().transform((value): string | null => { + if (typeof value !== 'string') { + return null; + } + + const email = value.trim(); + return email && email.length <= GIFT_EMAIL_MAX_LENGTH ? email : null; +}); + +const GiftPaymentCompletionSchema = z.object({ + giftId: z.string().min(1), + buyerEmail: StripeBuyerEmailSchema, + stripeCustomerId: z.string().min(1).nullable(), + stripeCheckoutSessionId: z.string().min(1), + stripePaymentIntentId: z.string().min(1) +}); + +export type GiftPaymentCompletionData = z.input; interface GiftServiceDeps { giftRepository: GiftRepository; + giftDeliveryService: Pick; memberRepository: MemberRepository; tiersService: TiersService; giftEmailService: GiftEmailService; @@ -138,7 +164,7 @@ interface GiftServiceDeps { giftReminderScheduler: Pick; checkoutAdapter: { getCustomerId(buyer: GiftCheckoutBuyer): Promise; - createSession(data: GiftCheckoutSession): Promise; + createSession(data: GiftCheckoutSession): Promise<{id: string; url: string}>; }; labsService: { isSet(flag: string): boolean; @@ -166,12 +192,48 @@ export interface StartGiftCheckoutInput { offerId?: string; cadence?: string; duration?: number; - metadata: Record; + deliveryMethod?: unknown; + recipientEmail?: unknown; + recipientName?: unknown; + buyerName?: unknown; + personalMessage?: unknown; successUrl: string; cancelUrl?: string; buyer: GiftCheckoutBuyer; } +const NullableCheckoutStringSchema = (max: number) => z.preprocess( + value => typeof value === 'string' && value.trim() === '' ? null : value, + z.string().trim().max(max).nullable().optional().default(null) +); + +const EmptyCheckoutStringSchema = z.preprocess( + value => typeof value === 'string' && value.trim() === '' ? null : value, + z.null().optional().default(null) +); + +const RequiredCheckoutStringSchema = (max: number) => z.string().trim().min(1).max(max); +const CheckoutBuyerEmailSchema = z.string().trim().email().max(GIFT_EMAIL_MAX_LENGTH); + +const GiftCheckoutDeliverySchema = z.discriminatedUnion('deliveryMethod', [ + z.object({ + deliveryMethod: z.literal('link'), + recipientEmail: EmptyCheckoutStringSchema, + recipientName: EmptyCheckoutStringSchema, + personalMessage: EmptyCheckoutStringSchema, + buyerName: NullableCheckoutStringSchema(GIFT_NAME_MAX_LENGTH) + }), + z.object({ + deliveryMethod: z.literal('email'), + recipientEmail: z.string().trim().email().max(GIFT_EMAIL_MAX_LENGTH), + recipientName: NullableCheckoutStringSchema(GIFT_NAME_MAX_LENGTH), + personalMessage: NullableCheckoutStringSchema(GIFT_CHECKOUT_MESSAGE_MAX_LENGTH), + buyerName: RequiredCheckoutStringSchema(GIFT_NAME_MAX_LENGTH) + }) +]); + +type GiftCheckoutDelivery = z.infer; + interface GiftCheckoutSession { amount: number; currency: string; @@ -183,6 +245,7 @@ interface GiftCheckoutSession { cancelUrl?: string; customerId: string | null; customerEmail: string | null; + idempotencyKey: string; } export interface GiftRedemption { @@ -191,6 +254,9 @@ export interface GiftRedemption { duration: number; currency: string; amount: number; + buyer_name: string | null; + recipient_name: string | null; + message: string | null; expires_at: Date; consumes_at: Date | null; tier: { @@ -230,6 +296,50 @@ export class GiftService { } async startCheckout(input: StartGiftCheckoutInput): Promise<{url: string}> { + const customizationEnabled = this.deps.labsService.isSet('giftSubCustomization'); + const populatedDeliveryFields = [ + input.recipientEmail, + input.recipientName, + input.buyerName, + input.personalMessage + ].some(value => value !== undefined && value !== null && value !== ''); + + if (!customizationEnabled && (input.deliveryMethod === 'email' || populatedDeliveryFields)) { + throw new errors.BadRequestError({ + message: 'Bad Request.', + context: 'Gift email delivery is not available' + }); + } + + const parsedBuyerEmail = CheckoutBuyerEmailSchema.safeParse(input.buyer.email); + if (customizationEnabled && !parsedBuyerEmail.success) { + throw new errors.BadRequestError({ + message: 'Bad Request.', + context: `Invalid gift buyer email: ${parsedBuyerEmail.error.issues[0].message}` + }); + } + const buyerEmail = parsedBuyerEmail.success ? parsedBuyerEmail.data : input.buyer.email; + + const parsedDelivery = GiftCheckoutDeliverySchema.safeParse(customizationEnabled ? { + deliveryMethod: input.deliveryMethod ?? 'link', + recipientEmail: input.recipientEmail, + recipientName: input.recipientName, + buyerName: input.buyerName, + personalMessage: input.personalMessage + } : { + deliveryMethod: 'link' + }); + + if (!parsedDelivery.success) { + const issue = parsedDelivery.error.issues[0]; + throw new errors.BadRequestError({ + message: 'Bad Request.', + context: `Invalid gift delivery data: ${issue.message}` + }); + } + + const delivery: GiftCheckoutDelivery = parsedDelivery.data; + if (input.offerId) { throw new errors.BadRequestError({ message: 'Bad Request.', @@ -247,7 +357,7 @@ export class GiftService { let resolvedDuration: ResolvedGiftDuration | null = null; let cadence: GiftCadence; - if (this.deps.labsService.isSet('giftSubCustomization')) { + if (customizationEnabled) { resolvedDuration = resolveGiftDuration(input); cadence = resolvedDuration.cadence; } else { @@ -309,35 +419,70 @@ export class GiftService { successUrl.searchParams.set('gift_token', token); successUrl.searchParams.set('gift_tier', tierId); successUrl.searchParams.set('gift_cadence', cadence); + successUrl.searchParams.set('gift_delivery', delivery.deliveryMethod); if (totalMonths !== undefined) { successUrl.searchParams.set('gift_duration', String(totalMonths)); } - const customerId = input.buyer.isAuthenticated - ? await this.deps.checkoutAdapter.getCustomerId(input.buyer) + const buyer = {...input.buyer, email: buyerEmail}; + const customerId = buyer.isAuthenticated + ? await this.deps.checkoutAdapter.getCustomerId(buyer) : null; - - const url = await this.deps.checkoutAdapter.createSession({ - amount, - currency: tier.currency.toLowerCase(), - tierName: tier.name, + const gift = Gift.fromCheckout({ + token, + buyerEmail, + buyerMemberId: buyer.isAuthenticated ? buyer.memberId : null, + buyerName: delivery.buyerName, + recipientName: delivery.recipientName, + personalMessage: delivery.personalMessage, + tierId, cadence, duration, - metadata: { - ...input.metadata, - ghost_gift: 'true', - gift_token: token, - tier_id: tierId, - cadence, - duration: String(duration) - }, - successUrl: successUrl.toString(), - cancelUrl: input.cancelUrl, - customerId, - customerEmail: customerId ? null : input.buyer.email + currency: tier.currency.toLowerCase(), + amount }); + const giftId = await this.deps.giftRepository.transaction(async (transacting) => { + const id = await this.deps.giftRepository.create(gift, {transacting}); + if (delivery.deliveryMethod === 'email') { + await this.deps.giftDeliveryService.createForCheckout({ + giftId: id, + recipientEmail: delivery.recipientEmail + }, {transacting}); + } + return id; + }); + + let session: {id: string; url: string}; + try { + session = await this.deps.checkoutAdapter.createSession({ + amount, + currency: tier.currency.toLowerCase(), + tierName: tier.name, + cadence, + duration, + metadata: {ghost_gift_id: giftId}, + successUrl: successUrl.toString(), + cancelUrl: input.cancelUrl, + customerId, + customerEmail: customerId ? null : buyerEmail, + idempotencyKey: giftId + }); + } catch (err) { + try { + await this.deps.giftRepository.deletePendingCheckout(giftId); + } catch (cleanupError) { + logging.error(cleanupError, `Failed to clean up gift checkout ${giftId} after Stripe session creation failed`); + } + throw err; + } + + const bound = gift.bindCheckoutSession(session.id); + if (!bound) { + throw new errors.InternalServerError({message: `Failed to bind checkout session to gift: ${giftId}`}); + } + await this.deps.giftRepository.update(bound); - return {url}; + return {url: session.url}; } private generateToken(): string { @@ -355,7 +500,89 @@ export class GiftService { return token; } - async completePurchase(input: GiftPurchaseData): Promise { + async completePurchase(input: GiftPurchaseData | GiftPaymentCompletionData): Promise { + if ('giftId' in input) { + return this.completePendingPurchase(input); + } + + return this.completeLegacyPurchase(input); + } + + private async completePendingPurchase(input: GiftPaymentCompletionData): Promise { + const parsed = GiftPaymentCompletionSchema.safeParse(input); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + throw new errors.ValidationError({ + message: 'Invalid gift purchase data.', + property: issue.path.join('.'), + context: issue.message + }); + } + const data = parsed.data; + const member = data.stripeCustomerId + ? await this.deps.memberRepository.get({customer_id: data.stripeCustomerId}) + : null; + + const completed = await this.deps.giftRepository.transaction(async (transacting) => { + const gift = await this.deps.giftRepository.getById(data.giftId, {transacting, forUpdate: true}); + if (!gift) { + logging.error({ + event: {name: 'gift_purchase.completion_gift_missing'}, + giftId: data.giftId, + stripeCheckoutSessionId: data.stripeCheckoutSessionId, + stripePaymentIntentId: data.stripePaymentIntentId + }, 'Paid checkout completion has no matching gift'); + return null; + } + if (gift.status !== 'payment_pending') { + return null; + } + if (gift.stripeCheckoutSessionId && gift.stripeCheckoutSessionId !== data.stripeCheckoutSessionId) { + throw new errors.ValidationError({message: 'Checkout session does not match gift.'}); + } + const buyerEmail = gift.buyerEmail ?? data.buyerEmail ?? member?.get('email') ?? null; + if (!buyerEmail) { + throw new errors.ValidationError({ + message: 'Invalid gift purchase data.', + property: 'buyerEmail', + context: 'A purchased gift requires a buyer email' + }); + } + // The pre-created gift owns the checkout price. Stripe's total may + // include automatic tax, so completion only adds settlement facts. + const purchased = gift.completePurchase({ + buyerEmail, + buyerMemberId: member?.id ?? gift.buyerMemberId, + stripeCheckoutSessionId: data.stripeCheckoutSessionId, + stripePaymentIntentId: data.stripePaymentIntentId + }); + if (!purchased) { + return null; + } + + await this.deps.giftRepository.update(purchased, {transacting}); + return purchased; + }); + + if (!completed) { + return false; + } + + let recipientEmail: string | null = null; + try { + recipientEmail = await this.deps.giftDeliveryService.dispatchForGift(data.giftId); + } catch (err) { + logging.error({ + event: {name: 'gift_delivery.dispatch_failed'}, + err, + giftId: data.giftId + }, 'Failed to dispatch purchased gift delivery'); + } + await this.sendPurchaseNotifications(completed, member, recipientEmail); + return true; + } + + private async completeLegacyPurchase(input: GiftPurchaseData): Promise { const parsed = GiftPurchaseDataSchema.safeParse(input); if (!parsed.success) { const issue = parsed.error.issues[0]; @@ -389,23 +616,47 @@ export class GiftService { }); await this.deps.giftRepository.create(gift); + await this.sendPurchaseNotifications(gift, member, null); + return true; + } - const tier = await this.deps.tiersService.api.read(data.tierId); + private async sendPurchaseNotifications(gift: Gift, member: MemberModel | null, recipientEmail: string | null): Promise { + let tier: Tier | null; + try { + tier = await this.deps.tiersService.api.read(gift.tierId); + } catch (err) { + logging.error({ + event: {name: 'gift_purchase_notifications.tier_read_failed'}, + err, + tierId: gift.tierId + }, 'Failed to read tier for gift purchase notifications'); + return; + } if (!tier) { - throw new errors.NotFoundError({message: `Tier not found: ${data.tierId}`}); + logging.error({ + event: {name: 'gift_purchase_notifications.tier_missing'}, + tierId: gift.tierId + }, 'Tier not found for gift purchase notifications'); + return; + } + + const buyerEmail = gift.buyerEmail ?? member?.get('email') ?? null; + if (!buyerEmail) { + logging.warn('Skipping purchase notifications because the buyer email is unavailable'); + return; } try { await this.deps.staffServiceEmails.notifyGiftPurchased({ name: member?.get('name') ?? null, - email: member?.get('email') ?? data.buyerEmail, + email: member?.get('email') ?? buyerEmail, memberId: member?.id ?? null, - amount: data.amount, - currency: data.currency, + amount: gift.amount, + currency: gift.currency, tierName: tier.name, - cadence: data.cadence, - duration: data.duration + cadence: gift.cadence, + duration: gift.duration }); } catch (err) { logging.error('Failed to notify staff of gift purchase', err); @@ -413,18 +664,17 @@ export class GiftService { try { await this.deps.giftEmailService.sendPurchaseConfirmation({ - buyerEmail: data.buyerEmail, - token: data.token, + buyerEmail, + token: gift.token, tierName: tier.name, - cadence: data.cadence, - duration: data.duration, - expiresAt: gift.expiresAt + cadence: gift.cadence, + duration: gift.duration, + expiresAt: gift.expiresAt!, + recipientEmail }); } catch (err) { logging.error('Failed to send gift purchase confirmation email', err); } - - return true; } private assertRedeemable(gift: Gift, memberStatus: string | null): Gift { @@ -432,6 +682,8 @@ export class GiftService { if (!redeemableCheck.redeemable) { switch (redeemableCheck.reason) { + case 'payment-pending': + throw new errors.NotFoundError({message: tpl(errorMessages.giftNotFound)}); case 'redeemed': throw new errors.BadRequestError({ message: tpl(errorMessages.giftAlreadyRedeemed), @@ -517,7 +769,7 @@ export class GiftService { tierName: tier.name, cadence: redeemed.cadence, duration: redeemed.duration, - buyerEmail: redeemed.buyerEmail + buyerEmail: redeemed.buyerEmail! }); } catch (err) { logging.error('Failed to notify staff of gift redemption', err); @@ -565,6 +817,7 @@ export class GiftService { }, {id: memberId, transacting}); await this.deps.giftRepository.update(redeemed, {transacting}); + await this.deps.giftDeliveryService.cancelPendingForGift(redeemed.token, {transacting}); // Gift members receive the paid welcome email, as they receive access to paid content await this.deps.memberRepository.triggerMemberSignupAutomation( @@ -651,7 +904,7 @@ export class GiftService { async getPreview(token: string): Promise { const gift = await this.deps.giftRepository.getByToken(token); - if (!gift) { + if (!gift || gift.status === 'payment_pending') { return null; } @@ -771,6 +1024,7 @@ export class GiftService { await this.deps.giftRepository.transaction(async (transacting) => { await this.deps.giftRepository.update(refunded, {transacting}); + await this.deps.giftDeliveryService.cancelPendingForGift(refunded.token, {transacting}); if (gift.redeemerMemberId) { const member = await this.deps.memberRepository.get({id: gift.redeemerMemberId}, {transacting}); @@ -883,6 +1137,7 @@ export class GiftService { } await this.deps.giftRepository.update(expired, {transacting}); + await this.deps.giftDeliveryService.cancelPendingForGift(expired.token, {transacting}); expiredCount += 1; }); @@ -891,6 +1146,13 @@ export class GiftService { return {expiredCount}; } + async processAbandonedCheckouts(): Promise<{deletedCount: number}> { + const cutoff = new Date(Date.now() - GIFT_CHECKOUT_RETENTION_DAYS * MS_PER_DAY); + const deletedCount = await this.deps.giftRepository.deleteAbandonedCheckouts(cutoff); + + return {deletedCount}; + } + async processReminders(): Promise<{remindedCount: number; skippedCount: number; failedCount: number}> { const now = new Date(); const toRemind = await this.deps.giftRepository.findPendingReminder({ @@ -1021,7 +1283,10 @@ export class GiftService { duration: gift.duration, currency: gift.currency, amount: gift.amount, - expires_at: gift.expiresAt, + buyer_name: gift.buyerName, + recipient_name: gift.recipientName, + message: gift.personalMessage, + expires_at: gift.expiresAt!, consumes_at: gift.consumesAt, tier: { id: tierJSON.id, diff --git a/ghost/core/core/server/services/gifts/gift.ts b/ghost/core/core/server/services/gifts/gift.ts index cd14d7f2e00..97f984c7b7b 100644 --- a/ghost/core/core/server/services/gifts/gift.ts +++ b/ghost/core/core/server/services/gifts/gift.ts @@ -1,9 +1,9 @@ import {GIFT_EXPIRY_DAYS} from './constants'; -import type {GiftCadence, GiftData, GiftStatus} from './gift-schema'; +import type {GiftCadence, GiftData, GiftDataInput, GiftStatus} from './gift-schema'; export type {GiftCadence, GiftStatus} from './gift-schema'; -export type RedeemableCheckFailureReason = 'redeemed' | 'consumed' | 'expired' | 'refunded' | 'paid-member'; +export type RedeemableCheckFailureReason = 'payment-pending' | 'redeemed' | 'consumed' | 'expired' | 'refunded' | 'paid-member'; export type RedeemableCheckResult = | {redeemable: true} | {redeemable: false; reason: RedeemableCheckFailureReason}; @@ -13,7 +13,7 @@ export type ReassignableCheckResult = | {reassignable: true} | {reassignable: false; reason: ReassignableCheckFailureReason}; -export type GiftFromPurchaseData = Pick; +export type GiftFromCheckoutData = Pick; + +export interface CompleteGiftPurchaseData { + buyerEmail: string | null; + buyerMemberId: string | null; + stripeCheckoutSessionId: string; + stripePaymentIntentId: string; + purchasedAt?: Date; +} + export class Gift implements GiftData { token: string; - buyerEmail: string; + buyerEmail: string | null; buyerMemberId: string | null; + buyerName: string | null; + recipientName: string | null; + personalMessage: string | null; redeemerMemberId: string | null; tierId: string; cadence: GiftCadence; duration: number; currency: string; amount: number; - stripeCheckoutSessionId: string; - stripePaymentIntentId: string; + stripeCheckoutSessionId: string | null; + stripePaymentIntentId: string | null; + checkoutStartedAt: Date | null; consumesAt: Date | null; - expiresAt: Date; + expiresAt: Date | null; status: GiftStatus; - purchasedAt: Date; + purchasedAt: Date | null; redeemedAt: Date | null; consumedAt: Date | null; expiredAt: Date | null; refundedAt: Date | null; consumesSoonReminderSentAt: Date | null; - constructor(data: GiftData) { + constructor(data: GiftDataInput) { this.token = data.token; this.buyerEmail = data.buyerEmail; this.buyerMemberId = data.buyerMemberId; + this.buyerName = data.buyerName ?? null; + this.recipientName = data.recipientName ?? null; + this.personalMessage = data.personalMessage ?? null; this.redeemerMemberId = data.redeemerMemberId; this.tierId = data.tierId; this.cadence = data.cadence; @@ -60,20 +89,21 @@ export class Gift implements GiftData { this.amount = data.amount; this.stripeCheckoutSessionId = data.stripeCheckoutSessionId; this.stripePaymentIntentId = data.stripePaymentIntentId; + this.checkoutStartedAt = data.checkoutStartedAt ?? null; this.consumesAt = data.consumesAt; + this.purchasedAt = data.purchasedAt; this.expiresAt = data.expiresAt; this.status = data.status; - this.purchasedAt = data.purchasedAt; this.redeemedAt = data.redeemedAt; this.consumedAt = data.consumedAt; this.expiredAt = data.expiredAt; this.refundedAt = data.refundedAt; - this.consumesSoonReminderSentAt = data.consumesSoonReminderSentAt; + this.consumesSoonReminderSentAt = data.consumesSoonReminderSentAt ?? null; } static fromPurchase(data: GiftFromPurchaseData) { - const now = new Date(); - const expiresAt = new Date(now); + const purchasedAt = new Date(); + const expiresAt = new Date(purchasedAt); expiresAt.setDate(expiresAt.getDate() + GIFT_EXPIRY_DAYS); @@ -81,9 +111,10 @@ export class Gift implements GiftData { ...data, redeemerMemberId: null, consumesAt: null, + checkoutStartedAt: purchasedAt, expiresAt, status: 'purchased', - purchasedAt: now, + purchasedAt, redeemedAt: null, consumedAt: null, expiredAt: null, @@ -92,6 +123,54 @@ export class Gift implements GiftData { }); } + static fromCheckout(data: GiftFromCheckoutData) { + return new Gift({ + ...data, + redeemerMemberId: null, + stripeCheckoutSessionId: null, + stripePaymentIntentId: null, + checkoutStartedAt: new Date(), + consumesAt: null, + expiresAt: null, + status: 'payment_pending', + purchasedAt: null, + redeemedAt: null, + consumedAt: null, + expiredAt: null, + refundedAt: null, + consumesSoonReminderSentAt: null + }); + } + + completePurchase(data: CompleteGiftPurchaseData): Gift | null { + if (this.status !== 'payment_pending') { + return null; + } + + const purchasedAt = data.purchasedAt ?? new Date(); + const expiresAt = new Date(purchasedAt); + expiresAt.setDate(expiresAt.getDate() + GIFT_EXPIRY_DAYS); + + return new Gift({ + ...this, + ...data, + purchasedAt, + expiresAt, + status: 'purchased' + }); + } + + bindCheckoutSession(checkoutSessionId: string): Gift | null { + if (this.status !== 'payment_pending' || this.stripeCheckoutSessionId !== null) { + return null; + } + + return new Gift({ + ...this, + stripeCheckoutSessionId: checkoutSessionId + }); + } + isRedeemed() { return this.redeemedAt !== null; } @@ -109,6 +188,10 @@ export class Gift implements GiftData { } checkRedeemable(memberStatus: string | null): RedeemableCheckResult { + if (this.status === 'payment_pending') { + return {redeemable: false, reason: 'payment-pending'}; + } + if (this.isRedeemed()) { return {redeemable: false, reason: 'redeemed'}; } diff --git a/ghost/core/core/server/services/gifts/index.ts b/ghost/core/core/server/services/gifts/index.ts index 32383cc2eb9..383cf3303c7 100644 --- a/ghost/core/core/server/services/gifts/index.ts +++ b/ghost/core/core/server/services/gifts/index.ts @@ -1,10 +1,13 @@ import type {SchedulerAdapter} from '@tryghost/adapter-base-scheduling'; import type {InternalKeys} from '../internal-keys'; import {GiftBookshelfRepository} from './gift-bookshelf-repository'; +import {GiftDeliveryBookshelfRepository} from './gift-delivery-bookshelf-repository'; +import {GiftDeliveryService} from './gift-delivery-service'; import {GiftService} from './gift-service'; import {GiftReminderScheduler} from './gift-reminder-scheduler'; import {GiftEmailService} from './gift-email-service'; import {GiftController} from './gift-controller'; +import {SendGiftDeliveryEvent} from './events/send-gift-delivery-event'; export interface GiftServiceInitOptions { apiUrl: string; @@ -19,12 +22,14 @@ export interface GiftServiceInitOptions { export let controller: GiftController | undefined; export let service: GiftService | undefined; +let deliveryService: GiftDeliveryService | undefined; + export async function init(options: GiftServiceInitOptions): Promise { if (service) { return; } - const {Gift: GiftModel, MemberStripeCustomer: StripeCustomerModel} = require('../../models'); + const {Base: BaseModel, Gift: GiftModel, GiftDelivery: GiftDeliveryModel, MemberStripeCustomer: StripeCustomerModel} = require('../../models'); const GiftCheckoutAdapter = require('./gift-checkout-adapter'); const membersService = require('../members'); const tiersService = require('../tiers'); @@ -37,6 +42,8 @@ export async function init(options: GiftServiceInitOptions): Promise { const jobs = require('./jobs'); const {GhostMailer} = require('../mail'); + const MailgunClient = require('../lib/mailgun-client'); + const config = require('../../../shared/config'); const settingsCache = require('../../../shared/settings-cache'); const labsService = require('../../../shared/labs'); const urlUtils = require('../../../shared/url-utils').default; @@ -46,7 +53,12 @@ export async function init(options: GiftServiceInitOptions): Promise { const {t} = require('../i18n'); const repository = new GiftBookshelfRepository({ - GiftModel + GiftModel, + knex: BaseModel.knex + }); + const deliveryRepository = new GiftDeliveryBookshelfRepository({ + GiftDeliveryModel, + knex: BaseModel.knex }); const checkoutAdapter = new GiftCheckoutAdapter({ StripeCustomerModel, @@ -54,13 +66,20 @@ export async function init(options: GiftServiceInitOptions): Promise { }); const giftEmailService = new GiftEmailService({ - mailer: new GhostMailer(), + transactionalMailer: new GhostMailer(), + bulkMailer: new MailgunClient({config, settings: settingsCache}), settingsCache, urlUtils, getFromAddress: () => EmailAddressParser.stringify(settingsHelpers.getDefaultEmail()), blogIcon, t }); + const giftDeliveryService = new GiftDeliveryService({ + giftRepository: repository, + giftDeliveryRepository: deliveryRepository, + tiersService, + giftEmailService + }); const giftReminderScheduler = new GiftReminderScheduler({ apiUrl: options.apiUrl, @@ -68,9 +87,9 @@ export async function init(options: GiftServiceInitOptions): Promise { internalKeys: options.internalKeys, findUnsentReminders: () => repository.findUnsentReminders() }); - const giftService = new GiftService({ giftRepository: repository, + giftDeliveryService, get memberRepository() { return membersService.api.members; }, @@ -86,6 +105,7 @@ export async function init(options: GiftServiceInitOptions): Promise { }); service = giftService; + deliveryService = giftDeliveryService; controller = new GiftController({service: giftService}); DomainEvents.subscribe(SubscriptionActivatedEvent, async (event: {data: {memberId: string}}) => { @@ -107,7 +127,26 @@ export async function init(options: GiftServiceInitOptions): Promise { } }); + DomainEvents.subscribe(SendGiftDeliveryEvent, async (event: {data: {deliveryId: string}}) => { + const start = Date.now(); + try { + const result = await giftDeliveryService.send(event.data.deliveryId); + logging.info(`Gift delivery ${event.data.deliveryId} ${result} in ${Date.now() - start}ms`); + } catch (err) { + logging.error(err, `Failed to process gift delivery ${event.data.deliveryId}`); + } + }); + DomainEvents.subscribe(StartGiftCleanupEvent, async () => { + const checkoutStart = Date.now(); + try { + const {deletedCount} = await giftService.processAbandonedCheckouts(); + + logging.info(`Deleted ${deletedCount} abandoned gift checkouts in ${Date.now() - checkoutStart}ms`); + } catch (err) { + logging.error(err, 'Failed to clean abandoned gift checkouts'); + } + const consumedStart = Date.now(); try { const {consumedCount, updatedMemberCount} = await giftService.processConsumed(); @@ -125,8 +164,37 @@ export async function init(options: GiftServiceInitOptions): Promise { } catch (err) { logging.error(err, 'Failed to process expired gifts'); } + + try { + const {sentCount, skippedCount, failedCount} = await giftDeliveryService.recoverPending(); + if (sentCount + skippedCount + failedCount > 0) { + logging.info(`Gift delivery recovery during cleanup: ${sentCount} sent, ${skippedCount} skipped, ${failedCount} failed`); + } + } catch (err) { + logging.error(err, 'Failed to recover pending gift deliveries during cleanup'); + } }); jobs.scheduleGiftCleanupJob(); jobs.scheduleGiftReminderJob(); } + +// Retries deliveries interrupted by a previous shutdown. Sending needs the tiers +// and members services, which boot alongside this one, so this must run once +// every service has finished initialising rather than from init(). +export async function recoverPendingDeliveries(): Promise { + if (!deliveryService) { + return; + } + + const logging = require('@tryghost/logging'); + + try { + const {sentCount, skippedCount, failedCount} = await deliveryService.recoverPending(); + if (sentCount + skippedCount + failedCount > 0) { + logging.info(`Gift delivery recovery: ${sentCount} sent, ${skippedCount} skipped, ${failedCount} failed`); + } + } catch (err) { + logging.error(err, 'Failed to recover pending gift deliveries'); + } +} diff --git a/ghost/core/core/server/services/lib/mailgun-client.js b/ghost/core/core/server/services/lib/mailgun-client.js index caed01255a7..7a9667a9957 100644 --- a/ghost/core/core/server/services/lib/mailgun-client.js +++ b/ghost/core/core/server/services/lib/mailgun-client.js @@ -95,8 +95,11 @@ module.exports = class MailgunClient { messageData['o:testmode'] = true; } - // enable tracking if turned on for this email - if (message.track_opens) { + if (message.disable_tracking) { + messageData['o:tracking'] = 'no'; + messageData['o:tracking-clicks'] = 'no'; + messageData['o:tracking-opens'] = 'no'; + } else if (message.track_opens) { messageData['o:tracking-opens'] = true; } diff --git a/ghost/core/core/server/services/mail/ghost-mailer.js b/ghost/core/core/server/services/mail/ghost-mailer.js index 483d62e06df..c4c6bffc50d 100644 --- a/ghost/core/core/server/services/mail/ghost-mailer.js +++ b/ghost/core/core/server/services/mail/ghost-mailer.js @@ -68,6 +68,7 @@ function createMessage(message) { delete cleanMessage.tags; delete cleanMessage.forceTextContent; delete cleanMessage.trackOpens; + delete cleanMessage.disableTracking; const addresses = getFromAddress(message.from, message.replyTo); @@ -132,6 +133,7 @@ module.exports = class GhostMailer { * @param {string} [message.text] - text version of this message * @param {string[]} [message.tags] - optional additional Mailgun tags * @param {boolean} [message.trackOpens] - per-message override for Mailgun open tracking + * @param {boolean} [message.disableTracking] - explicitly disable Mailgun open and click tracking * @param {Record} [message.headers] - optional additional email headers (merged with defaults) * @param {boolean} [message.forceTextContent] - maps to generateTextFromHTML nodemailer option * which is: "if set to true uses HTML to generate plain text body part from the HTML if the text is not defined" @@ -155,7 +157,13 @@ module.exports = class GhostMailer { const trackOpens = typeof message.trackOpens === 'boolean' ? message.trackOpens : settingsCache.get('email_track_opens'); - if (trackOpens) { + // nodemailer-mailgun-transport drops falsy option values, so an explicit + // opt-out must be Mailgun's string form rather than boolean false + if (message.disableTracking === true) { + messageToSend['o:tracking'] = 'no'; + messageToSend['o:tracking-opens'] = 'no'; + messageToSend['o:tracking-clicks'] = 'no'; + } else if (trackOpens) { messageToSend['o:tracking-opens'] = true; } if (messageToSend.headers) { diff --git a/ghost/core/core/server/services/members/members-api/controllers/router-controller.js b/ghost/core/core/server/services/members/members-api/controllers/router-controller.js index e340b7dcd8b..01d66f8ee3e 100644 --- a/ghost/core/core/server/services/members/members-api/controllers/router-controller.js +++ b/ghost/core/core/server/services/members/members-api/controllers/router-controller.js @@ -67,6 +67,7 @@ function extractGiftToken(input) { const RESERVED_CHECKOUT_METADATA_KEYS = new Set([ 'ghost_donation', 'ghost_gift', + 'ghost_gift_id', 'ghostSignupContext', 'gift_token', 'tier_id', @@ -684,10 +685,14 @@ module.exports = class RouterController { * @param {string} [options.offerId] * @param {string} [options.cadence] * @param {number} [options.duration] + * @param {'link'|'email'} [options.deliveryMethod] + * @param {string} [options.recipientEmail] + * @param {string} [options.recipientName] + * @param {string} [options.buyerName] + * @param {string} [options.personalMessage] * @param {string} [options.email] * @param {string} options.successUrl * @param {string} options.cancelUrl - * @param {object} options.metadata * @param {object} [options.member] * @param {boolean} options.isAuthenticated * @returns @@ -705,7 +710,11 @@ module.exports = class RouterController { offerId: options.offerId, cadence: options.cadence, duration: options.duration, - metadata: options.metadata, + deliveryMethod: options.deliveryMethod, + recipientEmail: options.recipientEmail, + recipientName: options.recipientName, + buyerName: options.buyerName, + personalMessage: options.personalMessage, successUrl: options.successUrl, cancelUrl: options.cancelUrl, buyer: { @@ -857,6 +866,11 @@ module.exports = class RouterController { offerId: req.body.offerId, cadence: req.body.cadence, duration: req.body.duration, + deliveryMethod: req.body.deliveryMethod, + recipientEmail: req.body.recipientEmail, + recipientName: req.body.recipientName, + buyerName: req.body.buyerName, + personalMessage: req.body.personalMessage, successUrl: siteUrl, cancelUrl: options.cancelUrl || siteUrl }); diff --git a/ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js b/ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js index b0d01d09729..1e0bc29825a 100644 --- a/ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js +++ b/ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js @@ -14,8 +14,18 @@ function hasStripeMetadataKey(metadata, key) { return Object.prototype.hasOwnProperty.call(metadata || {}, key); } -function hasConflictingCheckoutFlowMetadata(metadata) { - return hasStripeMetadataKey(metadata, 'ghost_donation') && hasStripeMetadataKey(metadata, 'ghost_gift'); +function isGiftCheckoutSession(session) { + return hasStripeMetadataKey(session.metadata, 'ghost_gift_id') || isStripeMetadataTrue(session.metadata?.ghost_gift); +} + +function isDonationCheckoutSession(session) { + return isStripeMetadataTrue(session.metadata?.ghost_donation); +} + +// Matches the donation marker on key presence, not truthiness, so an ill-formed +// donation marker alongside a true gift marker still counts as a conflict. +function hasConflictingCheckoutFlowMetadata(session) { + return hasStripeMetadataKey(session.metadata, 'ghost_donation') && isGiftCheckoutSession(session); } function getStripeResourceId(resource) { @@ -30,6 +40,18 @@ function getStripeResourceId(resource) { return null; } +function getGiftBuyerEmailFromSession(session) { + if (session.customer_details?.email) { + return session.customer_details.email; + } + + if (session.customer && typeof session.customer === 'object' && !session.customer.deleted && session.customer.email) { + return session.customer.email; + } + + return null; +} + /** * Handles `checkout.session.completed` webhook events * @@ -65,8 +87,20 @@ module.exports = class CheckoutSessionEventService { * Handles a `checkout.session.completed` event * Delegates to the appropriate handler based on the session mode and metadata * @param {import('stripe').Stripe.Checkout.Session} session + * @param {import('stripe').Stripe.Event.Type} [eventType] */ - async handleEvent(session) { + async handleEvent(session, eventType = 'checkout.session.completed') { + if (eventType === 'checkout.session.async_payment_failed') { + return; + } + + if (eventType === 'checkout.session.async_payment_succeeded') { + if (session.mode === 'payment' && isGiftCheckoutSession(session)) { + await this.handlePaymentEvent(session); + } + return; + } + if (session.mode === 'setup') { await this.handleSetupEvent(session); } @@ -76,16 +110,33 @@ module.exports = class CheckoutSessionEventService { } if (session.mode === 'payment') { - if (hasConflictingCheckoutFlowMetadata(session.metadata)) { - logging.warn('Ignoring checkout session with conflicting payment flow metadata'); + await this.handlePaymentEvent(session); + } + } + + /** + * Routes a `payment` mode session to the donation or gift handler. Gift purchases + * may complete asynchronously, so their handler only runs once Stripe reports the + * session as paid, whichever event carried it. + * + * @param {import('stripe').Stripe.Checkout.Session} session + */ + async handlePaymentEvent(session) { + if (hasConflictingCheckoutFlowMetadata(session)) { + logging.warn('Ignoring checkout session with conflicting payment flow metadata'); + return; + } + + if (isGiftCheckoutSession(session)) { + if (session.payment_status !== 'paid') { return; } + await this.handleGiftEvent(session); + return; + } - if (isStripeMetadataTrue(session.metadata?.ghost_donation)) { - await this.handleDonationEvent(session); - } else if (isStripeMetadataTrue(session.metadata?.ghost_gift)) { - await this.handleGiftEvent(session); - } + if (isDonationCheckoutSession(session)) { + await this.handleDonationEvent(session); } } @@ -95,10 +146,24 @@ module.exports = class CheckoutSessionEventService { * @param {import('stripe').Stripe.Checkout.Session} session */ async handleGiftEvent(session) { + const stripeCustomerId = getStripeResourceId(session.customer); + + if (session.metadata?.ghost_gift_id) { + await this.deps.giftService.completePurchase({ + giftId: session.metadata.ghost_gift_id, + buyerEmail: getGiftBuyerEmailFromSession(session), + stripeCustomerId, + stripeCheckoutSessionId: session.id, + stripePaymentIntentId: getStripeResourceId(session.payment_intent) + }); + return; + } + + const buyerEmail = await this.getGiftBuyerEmail(session, stripeCustomerId); await this.deps.giftService.completePurchase({ token: session.metadata?.gift_token, - buyerEmail: session.customer_details?.email, - stripeCustomerId: getStripeResourceId(session.customer), + buyerEmail, + stripeCustomerId, tierId: session.metadata?.tier_id, cadence: session.metadata?.cadence, duration: Number(session.metadata?.duration), @@ -109,6 +174,29 @@ module.exports = class CheckoutSessionEventService { }); } + /** + * Legacy gift Checkout sessions predate persisted buyer details. If their + * webhook snapshot has no customer details, recover the required buyer email + * from the Customer associated with the session. + * + * @param {import('stripe').Stripe.Checkout.Session} session + * @param {string|null} stripeCustomerId + * @returns {Promise} + */ + async getGiftBuyerEmail(session, stripeCustomerId) { + const sessionEmail = getGiftBuyerEmailFromSession(session); + if (sessionEmail) { + return sessionEmail; + } + + if (!stripeCustomerId) { + return null; + } + + const customer = await this.api.getCustomer(stripeCustomerId); + return customer.deleted ? null : customer.email ?? null; + } + /** * Handles a `checkout.session.completed` event for a donation * @param {import('stripe').Stripe.Checkout.Session} session diff --git a/ghost/core/core/server/services/stripe/stripe-api.js b/ghost/core/core/server/services/stripe/stripe-api.js index d4caffddafb..6974d35b308 100644 --- a/ghost/core/core/server/services/stripe/stripe-api.js +++ b/ghost/core/core/server/services/stripe/stripe-api.js @@ -706,10 +706,11 @@ module.exports = class StripeAPI { * @param {string} options.cancelUrl * @param {ICustomer|null} options.customer * @param {string} [options.customerEmail] + * @param {string} options.idempotencyKey * * @returns {Promise} */ - async createGiftCheckoutSession({amount, currency, tierName, cadence, duration, metadata, successUrl, cancelUrl, customer, customerEmail}) { + async createGiftCheckoutSession({amount, currency, tierName, cadence, duration, metadata, successUrl, cancelUrl, customer, customerEmail, idempotencyKey}) { await this._rateLimitBucket.throttle(); const cadenceLabel = cadence === 'year' ? @@ -745,7 +746,7 @@ module.exports = class StripeAPI { this._applyAutomaticTaxSessionOptions(stripeSessionOptions, {hasCustomer: Boolean(customer)}); - const session = await this._stripe.checkout.sessions.create(stripeSessionOptions); + const session = await this._stripe.checkout.sessions.create(stripeSessionOptions, {idempotencyKey}); return session; } diff --git a/ghost/core/core/server/services/stripe/webhook-controller.js b/ghost/core/core/server/services/stripe/webhook-controller.js index c4be3ead2c7..fa8384105a5 100644 --- a/ghost/core/core/server/services/stripe/webhook-controller.js +++ b/ghost/core/core/server/services/stripe/webhook-controller.js @@ -21,6 +21,8 @@ module.exports = class WebhookController { 'customer.subscription.created': this.subscriptionEvent, 'invoice.payment_succeeded': this.invoiceEvent, 'checkout.session.completed': this.checkoutSessionEvent, + 'checkout.session.async_payment_succeeded': this.checkoutSessionEvent, + 'checkout.session.async_payment_failed': this.checkoutSessionEvent, 'charge.refunded': this.chargeRefundedEvent }; } @@ -119,7 +121,7 @@ module.exports = class WebhookController { return; } - await this.handlers[event.type].call(this, event.data.object); + await this.handlers[event.type].call(this, event.data.object, event.type); } /** @@ -143,10 +145,11 @@ module.exports = class WebhookController { /** * Delegates any `checkout.session.*` events to the `checkoutSessionEventService` * @param {import('stripe').Stripe.Checkout.Session} session + * @param {import('stripe').Stripe.Event.Type} eventType * @private */ - async checkoutSessionEvent(session) { - await this.checkoutSessionEventService.handleEvent(session); + async checkoutSessionEvent(session, eventType) { + await this.checkoutSessionEventService.handleEvent(session, eventType); } /** diff --git a/ghost/core/core/server/services/stripe/webhook-manager.js b/ghost/core/core/server/services/stripe/webhook-manager.js index 9a16d7febaf..863a6ac5d8f 100644 --- a/ghost/core/core/server/services/stripe/webhook-manager.js +++ b/ghost/core/core/server/services/stripe/webhook-manager.js @@ -43,6 +43,8 @@ module.exports = class WebhookManager { /** @type {WebhookEvent[]} */ static events = [ 'checkout.session.completed', + 'checkout.session.async_payment_succeeded', + 'checkout.session.async_payment_failed', 'customer.subscription.deleted', 'customer.subscription.updated', 'customer.subscription.created', diff --git a/ghost/core/core/shared/labs.js b/ghost/core/core/shared/labs.js index 68114719172..55509c201bb 100644 --- a/ghost/core/core/shared/labs.js +++ b/ghost/core/core/shared/labs.js @@ -51,6 +51,7 @@ const PRIVATE_FEATURES = [ 'themeTranslation', 'pictureImageFormats', 'getHelperDeduplication', + 'navigationIcons', 'membersCustomFields', 'paywallImprovements', 'giftSubCustomization', diff --git a/ghost/core/package.json b/ghost/core/package.json index e1c26132830..39e1cafca2d 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -1,6 +1,6 @@ { "name": "ghost", - "version": "6.58.1-rc.0", + "version": "6.59.0-rc.0", "description": "The professional publishing platform", "author": "Ghost Foundation", "homepage": "https://ghost.org", @@ -235,6 +235,7 @@ "simple-dom": "1.4.0", "stoppable": "1.1.0", "stripe": "8.222.0", + "temporal-polyfill": "catalog:", "terser": "5.48.0", "tiny-glob": "0.2.9", "tldts": "catalog:", @@ -264,6 +265,7 @@ "@types/jsonwebtoken": "9.0.10", "@types/lodash": "4.17.25", "@types/lodash-es": "4.17.12", + "@types/luxon": "catalog:", "@types/mime-types": "3.0.1", "@types/nconf": "catalog:", "@types/node": "catalog:", diff --git a/ghost/core/scripts/build-card-assets.mjs b/ghost/core/scripts/build-card-assets.mjs index 437775196e1..f12743ad789 100644 --- a/ghost/core/scripts/build-card-assets.mjs +++ b/ghost/core/scripts/build-card-assets.mjs @@ -3,7 +3,7 @@ /** * Builds the card asset manifest. * - * Every card ships one optional CSS file and one optional JS file in + * Every card ships optional CSS and JS source files in * core/frontend/src/cards. A theme picks a subset of them via its `card_assets` * config, and Ghost serves that subset as /public/cards.min.{css,js}. * @@ -32,8 +32,15 @@ const LOADERS = { js: {loader: 'js', target: ['es2020']} }; -async function buildType(type) { - const dir = path.join(srcDir, type); +// header_v2.css is a second stylesheet for the public `header` card, not a +// separate configurable card. This exact path override is intentional — `_vN` +// filename suffixes are not a convention for grouping card assets. +const PUBLIC_CARD_ASSET_NAMES = new Map([ + ['css/header_v2.css', 'header'] +]); + +export async function buildType(type, sourceDir = srcDir) { + const dir = path.join(sourceDir, type); const suffix = `.${type}`; const files = fs.readdirSync(dir).filter(file => file.endsWith(suffix)).sort(); @@ -41,7 +48,17 @@ async function buildType(type) { for (const file of files) { const contents = fs.readFileSync(path.join(dir, file), 'utf8'); const {code} = await esbuild.transform(contents, {minify: true, ...LOADERS[type]}); - chunks[file.slice(0, -suffix.length)] = code; + const sourceName = file.slice(0, -suffix.length); + const cardName = PUBLIC_CARD_ASSET_NAMES.get(`${type}/${file}`) || sourceName; + + if (Object.hasOwn(chunks, cardName)) { + // Match the separator used when the runtime service concatenates + // separate chunks, keeping the default bundle bytes stable. + const separator = type === 'js' ? ';\n' : '\n'; + chunks[cardName] += `${separator}${code}`; + } else { + chunks[cardName] = code; + } } logging.debug(`✓ ${files.length} card ${type} files minified`); @@ -49,12 +66,18 @@ async function buildType(type) { return chunks; } -const manifest = {}; -for (const type of Object.keys(LOADERS)) { - manifest[type] = await buildType(type); -} +export async function buildCardAssets() { + const manifest = {}; + for (const type of Object.keys(LOADERS)) { + manifest[type] = await buildType(type); + } -fs.mkdirSync(path.dirname(destFile), {recursive: true}); -fs.writeFileSync(destFile, JSON.stringify(manifest)); + fs.mkdirSync(path.dirname(destFile), {recursive: true}); + fs.writeFileSync(destFile, JSON.stringify(manifest)); -logging.debug(`Card asset manifest written to ${destFile}`); + logging.debug(`Card asset manifest written to ${destFile}`); +} + +if (import.meta.main) { + await buildCardAssets(); +} 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 97026de646e..5bb7080cd97 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 @@ -608,7 +608,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -1001,7 +1001,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -1467,7 +1467,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -1933,7 +1933,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -2444,7 +2444,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -2909,7 +2909,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -3379,7 +3379,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -3923,7 +3923,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -4389,7 +4389,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -4855,7 +4855,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -5325,7 +5325,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -5790,7 +5790,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -6260,7 +6260,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -6729,7 +6729,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -7118,7 +7118,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -7584,7 +7584,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -8050,7 +8050,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -8517,7 +8517,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", @@ -9083,7 +9083,7 @@ Object { }, Object { "key": "navigation", - "value": "[{\\"label\\":\\"label1\\"}]", + "value": "[{\\"label\\":\\"label1\\",\\"url\\":\\"/\\"}]", }, Object { "key": "secondary_navigation", diff --git a/ghost/core/test/e2e-api/admin/posts-importer.test.js b/ghost/core/test/e2e-api/admin/posts-importer.test.js index e1d99fe8b4c..9e624e868b7 100644 --- a/ghost/core/test/e2e-api/admin/posts-importer.test.js +++ b/ghost/core/test/e2e-api/admin/posts-importer.test.js @@ -1,15 +1,34 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); const {agentProvider, fixtureManager, assertions, mockManager, resetRateLimits} = require('../../utils/e2e-framework'); const {cacheInvalidateHeaderNotSet} = assertions; const path = require('path'); +const models = require('../../../core/server/models'); +const jobsService = require('../../../core/server/services/jobs'); const csvPath = path.join(__dirname, '../../utils/fixtures/csv/valid-posts-import.csv'); +// Test CSVs are written inline to a temp dir rather than committed as fixtures. +let tmpDir; + +const csvFile = async (name, content) => { + const filePath = path.join(tmpDir, name); + await fs.writeFile(filePath, content); + return filePath; +}; + describe('Posts Importer API', function () { let agent; beforeAll(async function () { agent = await agentProvider.getAdminAPIAgent(); await fixtureManager.init('users'); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'posts-importer-')); + }); + + afterAll(async function () { + await fs.rm(tmpDir, {recursive: true, force: true}); }); beforeEach(async function () { @@ -18,7 +37,10 @@ describe('Posts Importer API', function () { await resetRateLimits(); }); - afterEach(function () { + afterEach(async function () { + // Every accepted upload schedules a background import — drain it so a job + // doesn't run on into another test (or another file on this fork's DB) + await jobsService.allSettled(); mockManager.restore(); }); @@ -88,6 +110,181 @@ describe('Posts Importer API', function () { .expectStatus(403); }); + it('Imports each CSV row as a post with its content and publish date', async function () { + await agent.loginAsOwner(); + + const contentCsvPath = await csvFile('valid-posts-import-content.csv', + 'title,html,published_at\n' + + 'Content check post one,"

First imported body

",2024-05-01T08:00:00.000Z\n' + + '"Content check post two, with a comma","

Second body, with a comma

",2024-06-15T18:45:00.000Z\n' + ); + + const {body} = await agent + .post('posts/upload/') + .attach('postsfile', contentCsvPath) + .expectStatus(202); + + assert.match(body.meta.import_id, /^[0-9a-f]{24}$/); + assert.equal(body.meta.total, 2); + + await jobsService.allSettled(); + + const {data: posts} = await models.Post.findPage({ + filter: `title:~'Content check post'`, + status: 'all', + limit: 'all', + withRelated: ['tags', 'authors'] + }); + + assert.equal(posts.length, 2); + + const one = posts.find(post => post.get('title') === 'Content check post one'); + const two = posts.find(post => post.get('title') === 'Content check post two, with a comma'); + + assert.ok(one, 'first row was imported'); + assert.ok(two, 'second row was imported'); + + // Fields the CSV doesn't carry fall back to the import defaults + const owner = await models.User.getOwnerUser(); + for (const post of [one, two]) { + assert.equal(post.get('status'), 'published'); + assert.equal(post.get('type'), 'post'); + assert.equal(post.get('visibility'), 'public'); + assert.deepEqual(post.related('authors').map(author => author.get('id')), [owner.get('id')], 'the owner is the sole author'); + } + + // Both rows share the same two internal batch tags: a date stamp and a run tag + const tagsOne = one.related('tags').models; + const tagsTwo = two.related('tags').models; + assert.equal(tagsOne.length, 2); + assert.match(tagsOne[0].get('name'), /^#Import \d{4}-\d{2}-\d{2} \d{2}:\d{2}$/); + assert.equal(tagsOne[1].get('name'), `#Import Run ${body.meta.import_id}`); + for (const tag of tagsOne) { + assert.equal(tag.get('visibility'), 'internal'); + } + assert.deepEqual( + tagsOne.map(tag => tag.get('id')), + tagsTwo.map(tag => tag.get('id')), + 'shared tag rows, not new ones per post' + ); + + // html is rendered from the converted lexical, not passed through + assert.match(one.get('html'), /First imported<\/strong> body/); + assert.match(two.get('html'), /Second body, with a comma/); + + assert.equal(one.get('published_at').toISOString(), '2024-05-01T08:00:00.000Z'); + assert.equal(two.get('published_at').toISOString(), '2024-06-15T18:45:00.000Z'); + + // Slugs come from the importer's own slugify: the model's importing-context + // pass would keep the comma's double dash + assert.equal(one.get('slug'), 'content-check-post-one'); + assert.equal(two.get('slug'), 'content-check-post-two-with-a-comma'); + + // created_at/updated_at follow the CSV date because the write runs under + // options.importing + assert.equal(one.get('created_at').toISOString(), '2024-05-01T08:00:00.000Z'); + assert.equal(one.get('updated_at').toISOString(), '2024-05-01T08:00:00.000Z'); + assert.equal(two.get('created_at').toISOString(), '2024-06-15T18:45:00.000Z'); + assert.equal(two.get('updated_at').toISOString(), '2024-06-15T18:45:00.000Z'); + }); + + it('Imports public posts even when the site default visibility is paid', async function () { + mockManager.mockSetting('default_content_visibility', 'paid'); + await agent.loginAsOwner(); + + const paidSiteCsvPath = await csvFile('posts-import-paid-site.csv', + 'title,html,published_at\n' + + 'Visibility check post,

Body

,2024-04-01T00:00:00.000Z\n' + ); + + await agent + .post('posts/upload/') + .attach('postsfile', paidSiteCsvPath) + .expectStatus(202); + + await jobsService.allSettled(); + + const post = await models.Post.findOne({title: 'Visibility check post', status: 'all'}); + // left to the model, visibility would have followed default_content_visibility + assert.equal(post.get('visibility'), 'public'); + }); + + it('Skips a malformed row on its own and imports the rest', async function () { + await agent.loginAsOwner(); + + const badRowsCsvPath = await csvFile('posts-import-with-bad-rows.csv', + 'title,html,published_at\n' + + 'Bad rows check one,

Before the bad row

,2024-03-01T00:00:00.000Z\n' + + ',

This row has no title

,2024-03-02T00:00:00.000Z\n' + + 'Bad rows check date,

This row has a bad date

,not-a-date\n' + + 'Bad rows check two,

After the bad rows

,2024-03-04T00:00:00.000Z\n' + ); + + await agent + .post('posts/upload/') + .attach('postsfile', badRowsCsvPath) + .expectStatus(202); + + await jobsService.allSettled(); + + const {data: posts} = await models.Post.findPage({ + filter: `title:~'Bad rows check'`, + status: 'all', + limit: 'all' + }); + + assert.deepEqual( + posts.map(post => post.get('title')).sort(), + ['Bad rows check one', 'Bad rows check two'], + 'the good rows imported; the malformed ones did not' + ); + }); + + it('Handles a CSV-named file of garbage bytes without failing', async function () { + await agent.loginAsOwner(); + + const {meta: {pagination: {total: before}}} = await models.Post.findPage({limit: 1, status: 'all'}); + + const garbageCsvPath = await csvFile('posts-import-garbage.csv', + '\u0000\u0001binary\u0002garbage\r\n\u0003more\u0000bytes,\u0004\r\n\u0005end\u0006' + ); + + await agent + .post('posts/upload/') + .attach('postsfile', garbageCsvPath) + .expectStatus(202); + + await jobsService.allSettled(); + + const {meta: {pagination: {total: after}}} = await models.Post.findPage({limit: 1, status: 'all'}); + assert.equal(after, before, 'no posts were created from garbage rows'); + }); + + it('Rejects an upload of more posts than the temporary cap, importing nothing', async function () { + await agent.loginAsOwner(); + + const overCapRows = Array.from({length: 101}, (_, i) => `Over cap post ${i + 1},

${i + 1}

,2025-02-01T00:00:00.000Z`); + const overCapCsvPath = await csvFile('posts-import-over-cap.csv', + 'title,html,published_at\n' + overCapRows.join('\n') + '\n' + ); + + const {body} = await agent + .post('posts/upload/') + .attach('postsfile', overCapCsvPath) + .expectStatus(422); + + assert.match(body.errors[0].message, /more than 100 posts/); + + await jobsService.allSettled(); + + const {data: posts} = await models.Post.findPage({ + filter: `title:~'Over cap post'`, + status: 'all', + limit: 'all' + }); + assert.equal(posts.length, 0, 'no posts were written from the rejected file'); + }); + it('Cannot upload a posts CSV when the csvContentImporter flag is disabled', async function () { mockManager.mockLabsDisabled('csvContentImporter'); await agent.loginAsOwner(); diff --git a/ghost/core/test/e2e-api/admin/settings.test.js b/ghost/core/test/e2e-api/admin/settings.test.js index 14fd59fd8d3..c8982f9ff1d 100644 --- a/ghost/core/test/e2e-api/admin/settings.test.js +++ b/ghost/core/test/e2e-api/admin/settings.test.js @@ -125,7 +125,8 @@ describe('Settings API', function () { { key: 'navigation', value: JSON.stringify([{ - label: 'label1' + label: 'label1', + url: '/' }]) }, { diff --git a/ghost/core/test/e2e-api/members/gift-subscriptions.test.js b/ghost/core/test/e2e-api/members/gift-subscriptions.test.js index 5f06027da26..8a8ea7b3c51 100644 --- a/ghost/core/test/e2e-api/members/gift-subscriptions.test.js +++ b/ghost/core/test/e2e-api/members/gift-subscriptions.test.js @@ -30,6 +30,10 @@ function getLatestCheckoutSession() { return stripeMocker.checkoutSessions[stripeMocker.checkoutSessions.length - 1]; } +function getCheckoutGiftToken(checkoutSession) { + return new URL(checkoutSession.success_url).searchParams.get('gift_token'); +} + function toWebhookMetadata(metadata) { const result = {}; @@ -70,6 +74,7 @@ async function createGift(overrides = {}) { amount: 5000, stripe_checkout_session_id: `cs_gift_${sequence}`, stripe_payment_intent_id: `pi_gift_${sequence}`, + checkout_started_at: now, consumes_at: null, expires_at: expiresAt, status: 'purchased', @@ -110,6 +115,7 @@ async function expectGiftCheckoutError(bodyOverrides = {}) { type: 'gift', tierId: paidTier.id, cadence: 'month', + customerEmail: 'gift-buyer@example.com', metadata: {}, ...bodyOverrides }) @@ -167,11 +173,12 @@ describe('Gift Subscriptions', function () { const checkoutSession = getLatestCheckoutSession(); assert.ok(checkoutSession, 'Checkout session should be captured'); - assert.ok(checkoutSession.metadata.ghost_gift, 'Should have ghost_gift metadata'); - assert.equal(checkoutSession.metadata.tier_id, paidTier.id); - assert.equal(checkoutSession.metadata.cadence, 'month'); - assert.equal(String(checkoutSession.metadata.duration), '1'); - assert.ok(checkoutSession.metadata.gift_token, 'Should have a gift token'); + assert.deepEqual(Object.keys(checkoutSession.metadata), ['ghost_gift_id']); + const pendingGift = await models.Gift.findOne({id: checkoutSession.metadata.ghost_gift_id}, {require: true}); + assert.equal(pendingGift.get('status'), 'payment_pending'); + assert.equal(pendingGift.get('tier_id'), paidTier.id); + assert.equal(pendingGift.get('cadence'), 'month'); + assert.ok(getCheckoutGiftToken(checkoutSession), 'Should have a gift token in the success URL'); await stripeMocker.sendWebhook({ type: 'checkout.session.completed', @@ -179,6 +186,7 @@ describe('Gift Subscriptions', function () { object: { id: checkoutSession.id, mode: 'payment', + payment_status: 'paid', amount_total: paidTier.monthly_price, currency: paidTier.currency.toLowerCase(), customer: checkoutSession.customer, @@ -193,7 +201,7 @@ describe('Gift Subscriptions', function () { // Verify gift record was persisted in the database const gift = await models.Gift.findOne({ - token: checkoutSession.metadata.gift_token + id: checkoutSession.metadata.ghost_gift_id }, {require: true}); assert.equal(gift.get('buyer_email'), 'gift-buyer@example.com'); @@ -222,6 +230,131 @@ describe('Gift Subscriptions', function () { }); }); + it('waits for delayed payment success before completing a gift', async function () { + const paidTier = await getPaidTier(); + + await membersAgent.post('/api/create-stripe-checkout-session/') + .body({ + type: 'gift', + tierId: paidTier.id, + cadence: 'month', + metadata: {} + }) + .expectStatus(200); + + const checkoutSession = getLatestCheckoutSession(); + const baseSession = { + id: checkoutSession.id, + mode: 'payment', + amount_total: paidTier.monthly_price, + currency: paidTier.currency.toLowerCase(), + customer: checkoutSession.customer, + customer_details: {email: 'delayed-gift-buyer@example.com'}, + metadata: toWebhookMetadata(checkoutSession.metadata) + }; + + await stripeMocker.sendWebhook({ + type: 'checkout.session.completed', + data: {object: {...baseSession, payment_status: 'unpaid', payment_intent: null}} + }); + await DomainEvents.allSettled(); + + const gift = await models.Gift.findOne({id: checkoutSession.metadata.ghost_gift_id}, {require: true}); + assert.equal(gift.get('status'), 'payment_pending'); + + await stripeMocker.sendWebhook({ + type: 'checkout.session.async_payment_succeeded', + data: {object: {...baseSession, payment_status: 'paid', payment_intent: 'pi_delayed_gift'}} + }); + await DomainEvents.allSettled(); + await gift.refresh(); + + assert.equal(gift.get('status'), 'purchased'); + assert.equal(gift.get('stripe_payment_intent_id'), 'pi_delayed_gift'); + }); + + it('leaves a gift unpurchased when delayed payment fails', async function () { + const paidTier = await getPaidTier(); + + await membersAgent.post('/api/create-stripe-checkout-session/') + .body({ + type: 'gift', + tierId: paidTier.id, + cadence: 'month', + metadata: {} + }) + .expectStatus(200); + + const checkoutSession = getLatestCheckoutSession(); + const session = { + id: checkoutSession.id, + mode: 'payment', + payment_status: 'unpaid', + amount_total: paidTier.monthly_price, + currency: paidTier.currency.toLowerCase(), + customer: checkoutSession.customer, + customer_details: {email: 'failed-gift-buyer@example.com'}, + metadata: toWebhookMetadata(checkoutSession.metadata), + payment_intent: 'pi_failed_gift' + }; + + await stripeMocker.sendWebhook({ + type: 'checkout.session.completed', + data: {object: session} + }); + await stripeMocker.sendWebhook({ + type: 'checkout.session.async_payment_failed', + data: {object: session} + }); + await DomainEvents.allSettled(); + + const gift = await models.Gift.findOne({id: checkoutSession.metadata.ghost_gift_id}, {require: true}); + assert.equal(gift.get('status'), 'payment_pending'); + assert.equal(gift.get('purchased_at'), null); + }); + + it('uses the persisted buyer email when the paid gift webhook has no customer details', async function () { + enableGiftCustomization(); + + const paidTier = await getPaidTier(); + const buyerEmail = 'persisted-buyer@example.com'; + + await membersAgent.post('/api/create-stripe-checkout-session/') + .body({ + type: 'gift', + tierId: paidTier.id, + duration: 1, + customerEmail: buyerEmail, + metadata: {} + }) + .expectStatus(200); + + const checkoutSession = getLatestCheckoutSession(); + + await stripeMocker.sendWebhook({ + type: 'checkout.session.completed', + data: { + object: { + id: checkoutSession.id, + mode: 'payment', + amount_total: paidTier.monthly_price, + currency: paidTier.currency.toLowerCase(), + customer: checkoutSession.customer, + payment_status: 'paid', + metadata: toWebhookMetadata(checkoutSession.metadata), + payment_intent: 'pi_gift_no_email' + } + } + }); + await DomainEvents.allSettled(); + + const gift = await models.Gift.findOne({id: checkoutSession.metadata.ghost_gift_id}, {require: true}); + assert.equal(gift.get('status'), 'purchased'); + assert.equal(gift.get('buyer_email'), buyerEmail); + mockManager.assert.sentEmail({to: 'jbloggs@example.com'}); + mockManager.assert.sentEmail({to: buyerEmail}); + }); + it('Can purchase a gift as an authenticated member', async function () { const paidTier = await getPaidTier(); const email = 'gift-member-buyer@example.com'; @@ -245,7 +378,8 @@ describe('Gift Subscriptions', function () { const checkoutSession = getLatestCheckoutSession(); assert.ok(checkoutSession, 'Checkout session should be captured'); - assert.equal(checkoutSession.metadata.cadence, 'year'); + const pendingGift = await models.Gift.findOne({id: checkoutSession.metadata.ghost_gift_id}, {require: true}); + assert.equal(pendingGift.get('cadence'), 'year'); await stripeMocker.sendWebhook({ type: 'checkout.session.completed', @@ -253,6 +387,7 @@ describe('Gift Subscriptions', function () { object: { id: checkoutSession.id, mode: 'payment', + payment_status: 'paid', amount_total: paidTier.yearly_price, currency: paidTier.currency.toLowerCase(), customer: checkoutSession.customer, @@ -267,7 +402,7 @@ describe('Gift Subscriptions', function () { // Verify gift record has buyer_member_id populated const gift = await models.Gift.findOne({ - token: checkoutSession.metadata.gift_token + id: checkoutSession.metadata.ghost_gift_id }, {require: true}); assert.equal(gift.get('buyer_email'), email); @@ -290,15 +425,17 @@ describe('Gift Subscriptions', function () { type: 'gift', tierId: paidTier.id, duration: 3, + customerEmail: buyerEmail, metadata: {} }) .expectStatus(200); const checkoutSession = getLatestCheckoutSession(); - assert.equal(checkoutSession.metadata.cadence, 'month'); - assert.equal(String(checkoutSession.metadata.duration), '3'); - assert.equal(checkoutSession.metadata.tier_id, paidTier.id); + const pendingGift = await models.Gift.findOne({id: checkoutSession.metadata.ghost_gift_id}, {require: true}); + assert.equal(pendingGift.get('cadence'), 'month'); + assert.equal(pendingGift.get('duration'), 3); + assert.equal(pendingGift.get('tier_id'), paidTier.id); assert.ok(checkoutSession.success_url.includes('gift_duration=3')); await stripeMocker.sendWebhook({ @@ -307,6 +444,7 @@ describe('Gift Subscriptions', function () { object: { id: checkoutSession.id, mode: 'payment', + payment_status: 'paid', amount_total: expectedAmount, currency: paidTier.currency.toLowerCase(), customer: checkoutSession.customer, @@ -320,7 +458,7 @@ describe('Gift Subscriptions', function () { await DomainEvents.allSettled(); const gift = await models.Gift.findOne({ - token: checkoutSession.metadata.gift_token + id: checkoutSession.metadata.ghost_gift_id }, {require: true}); assert.equal(gift.get('cadence'), 'month'); @@ -401,13 +539,15 @@ describe('Gift Subscriptions', function () { type: 'gift', tierId: paidTier.id, cadence: 'year', + customerEmail: 'legacy-gift-buyer@example.com', metadata: {} }) .expectStatus(200); const checkoutSession = getLatestCheckoutSession(); - assert.equal(checkoutSession.metadata.cadence, 'year'); - assert.equal(String(checkoutSession.metadata.duration), '1'); + const pendingGift = await models.Gift.findOne({id: checkoutSession.metadata.ghost_gift_id}, {require: true}); + assert.equal(pendingGift.get('cadence'), 'year'); + assert.equal(pendingGift.get('duration'), 1); }); it('Includes gift token in the purchase success URL', async function () { @@ -427,7 +567,7 @@ describe('Gift Subscriptions', function () { assert.ok(successUrl, 'Should have a success URL'); assert.ok(successUrl.includes('stripe=gift-purchase-success'), 'Success URL should contain stripe=gift-purchase-success'); - assert.ok(successUrl.includes(`gift_token=${checkoutSession.metadata.gift_token}`), 'Success URL should contain the gift token'); + assert.ok(successUrl.includes(`gift_token=${getCheckoutGiftToken(checkoutSession)}`), 'Success URL should contain the gift token'); }); it('Handles Stripe webhook idempotency for gift purchases', async function () { @@ -450,6 +590,7 @@ describe('Gift Subscriptions', function () { object: { id: checkoutSession.id, mode: 'payment', + payment_status: 'paid', amount_total: paidTier.monthly_price, currency: paidTier.currency.toLowerCase(), customer: checkoutSession.customer, @@ -468,7 +609,7 @@ describe('Gift Subscriptions', function () { // Verify only one gift record exists for this checkout session const gifts = await models.Gift.findAll({ - filter: `token:'${checkoutSession.metadata.gift_token}'` + filter: `id:'${checkoutSession.metadata.ghost_gift_id}'` }); assert.equal(gifts.length, 1, 'Should have exactly one gift record'); @@ -492,6 +633,7 @@ describe('Gift Subscriptions', function () { type: 'gift', tierId: paidTier.id, duration: 3, + customerEmail: 'refund-buyer@example.com', metadata: {} }) .expectStatus(200); @@ -506,6 +648,7 @@ describe('Gift Subscriptions', function () { object: { id: checkoutSession.id, mode: 'payment', + payment_status: 'paid', amount_total: expectedAmount, currency: paidTier.currency.toLowerCase(), customer: checkoutSession.customer, @@ -520,7 +663,7 @@ describe('Gift Subscriptions', function () { // Verify the gift was created const gift = await models.Gift.findOne({ - token: checkoutSession.metadata.gift_token + id: checkoutSession.metadata.ghost_gift_id }, {require: true}); assert.equal(gift.get('status'), 'purchased'); @@ -543,7 +686,7 @@ describe('Gift Subscriptions', function () { // Verify the gift is now refunded const refundedGift = await models.Gift.findOne({ - token: checkoutSession.metadata.gift_token + id: checkoutSession.metadata.ghost_gift_id }, {require: true}); assert.equal(refundedGift.get('status'), 'refunded'); @@ -564,6 +707,7 @@ describe('Gift Subscriptions', function () { amount: 500, stripe_checkout_session_id: 'cs_ignore_non_gift', stripe_payment_intent_id: 'pi_ignore_non_gift', + checkout_started_at: new Date(), expires_at: addYears(new Date(), 10), status: 'purchased', purchased_at: new Date() @@ -602,6 +746,7 @@ describe('Gift Subscriptions', function () { amount: 500, stripe_checkout_session_id: 'cs_ignore_sub', stripe_payment_intent_id: 'pi_ignore_sub', + checkout_started_at: new Date(), expires_at: addYears(new Date(), 10), status: 'purchased', purchased_at: new Date() @@ -629,7 +774,11 @@ describe('Gift Subscriptions', function () { describe('Check if a gift is redeemable', function () { it('returns gift details for an anonymous visitor when the token is redeemable', async function () { - const gift = await createGift(); + const gift = await createGift({ + buyer_name: 'Jamie', + recipient_name: 'Taylor', + personal_message: 'Enjoy!' + }); const {body} = await membersAgent .get(`/api/gifts/${gift.get('token')}/redeem/`) @@ -641,6 +790,9 @@ describe('Gift Subscriptions', function () { assert.equal(body.gifts[0].duration, 1); assert.equal(body.gifts[0].currency, 'usd'); assert.equal(body.gifts[0].amount, 5000); + assert.equal(body.gifts[0].buyer_name, 'Jamie'); + assert.equal(body.gifts[0].recipient_name, 'Taylor'); + assert.equal(body.gifts[0].message, 'Enjoy!'); assert.equal(body.gifts[0].expires_at, new Date(gift.get('expires_at')).toISOString()); assert.deepEqual(body.gifts[0].tier, { id: paidProduct.id, @@ -649,6 +801,8 @@ describe('Gift Subscriptions', function () { benefits: paidProduct.related('benefits').toJSON().map(item => item.name) }); assert.equal(body.gifts[0].buyer_email, undefined); + assert.equal(body.gifts[0].recipient_email, undefined); + assert.equal(body.gifts[0].delivery_status, undefined); assert.equal(body.gifts[0].redeemed_at, undefined); assert.equal(body.gifts[0].status, undefined); assert.equal(body.gifts[0].consumes_at, null); @@ -1235,6 +1389,7 @@ describe('Gift Subscriptions', function () { amount: 5000, stripe_checkout_session_id: `cs_gift_continue_${continueSequence}`, stripe_payment_intent_id: `pi_gift_continue_${continueSequence}`, + checkout_started_at: now, consumes_at: consumesAt, expires_at: new Date('2030-01-01T00:00:00.000Z'), status: 'redeemed', @@ -1405,6 +1560,7 @@ describe('Gift Subscriptions', function () { amount: 5000, stripe_checkout_session_id: 'cs_gift_continue_unrelated', stripe_payment_intent_id: 'pi_gift_continue_unrelated', + checkout_started_at: new Date(), consumes_at: null, expires_at: new Date('2030-01-01T00:00:00.000Z'), status: 'purchased', diff --git a/ghost/core/test/e2e-webhooks/posts-importer.test.ts b/ghost/core/test/e2e-webhooks/posts-importer.test.ts new file mode 100644 index 00000000000..5a1ad558f44 --- /dev/null +++ b/ghost/core/test/e2e-webhooks/posts-importer.test.ts @@ -0,0 +1,125 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import assert from 'node:assert/strict'; + +const DomainEvents = require('@tryghost/domain-events'); +const {agentProvider, mockManager, fixtureManager, dbUtils} = require('../utils/e2e-framework'); +const models = require('../../core/server/models'); +const jobsService = require('../../core/server/services/jobs'); + +// The importer's key safety guarantee: a bulk import sends zero newsletter emails +// and fires zero per-post webhooks. Every consumer of post events checks +// options.importing, one deleted `if` away from breaking; this suite turns that +// into a test failure. +describe('CSV content import side-effects', function () { + // The e2e framework has no published types; any matches the other TS e2e suites + let adminAPIAgent: any; + let webhookMockReceiver: any; + let tmpDir: string; + + beforeAll(async function () { + adminAPIAgent = await agentProvider.getAdminAPIAgent(); + await fixtureManager.init('integrations'); + await adminAPIAgent.loginAsOwner(); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'posts-importer-webhooks-')); + }); + + afterAll(async function () { + await fs.rm(tmpDir, {recursive: true, force: true}); + }); + + beforeEach(async function () { + await dbUtils.truncate('webhooks'); + webhookMockReceiver = mockManager.mockWebhookRequests(); + mockManager.mockMail(); + }); + + afterEach(async function () { + await jobsService.allSettled(); + mockManager.restore(); + }); + + it('publishing through the API does trigger the webhook (the control for the import test)', async function () { + const webhookURL = 'https://test-webhook-receiver.com/post-published-control/'; + await webhookMockReceiver.mock(webhookURL); + await fixtureManager.insertWebhook({ + event: 'post.published', + url: webhookURL + }); + + const res = await adminAPIAgent + .post('posts/') + .body({ + posts: [{ + title: 'Webhook control post', + status: 'draft', + lexical: fixtureManager.get('posts', 1).lexical + }] + }) + .expectStatus(201); + + const updatedPost = res.body.posts[0]; + updatedPost.status = 'published'; + + await adminAPIAgent + .put('posts/' + updatedPost.id) + .body({posts: [updatedPost]}) + .expectStatus(200); + + await webhookMockReceiver.receivedRequest(); + + assert.ok(webhookMockReceiver.body, 'the receiver records a normal API publish — a silent import below means suppression, not a broken mock'); + }); + + it('importing a CSV fires no webhooks and sends nothing', async function () { + // post.added would fire even if a future change stopped importing as + // published, so both events are armed. + const publishedURL = 'https://test-webhook-receiver.com/post-published-import/'; + const addedURL = 'https://test-webhook-receiver.com/post-added-import/'; + await webhookMockReceiver.mock(publishedURL); + await webhookMockReceiver.mock(addedURL); + await fixtureManager.insertWebhook({event: 'post.published', url: publishedURL}); + await fixtureManager.insertWebhook({event: 'post.added', url: addedURL}); + + const csvPath = path.join(tmpDir, 'posts-import-side-effects.csv'); + await fs.writeFile(csvPath, + 'title,html,published_at\n' + + 'Side effect check one,

First

,2024-07-01T00:00:00.000Z\n' + + 'Side effect check two,

Second

,2024-07-02T00:00:00.000Z\n' + ); + + await adminAPIAgent + .post('posts/upload/') + .attach('postsfile', csvPath) + .expectStatus(202); + + await jobsService.allSettled(); + await DomainEvents.allSettled(); + // The negative needs a settle window: a wrongly-fired webhook reaches nock a + // beat after the model event, and asserting too early would pass vacuously + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + + // Guard against a vacuous pass: the import actually ran + const {data: posts} = await models.Post.findPage({ + filter: `title:~'Side effect check'`, + status: 'all', + limit: 'all' + }); + assert.equal(posts.length, 2, 'both rows imported'); + assert.equal(posts[0].get('status'), 'published'); + + // Zero webhooks: the receiver never recorded a request + assert.equal(webhookMockReceiver.body, undefined, 'no webhook fired for the imported posts'); + + // Zero transactional mail + mockManager.assert.sentEmailCount(0); + + // Zero newsletters: a newsletter can only be attached via the API layer, which + // the importer never touches. + const {data: emails} = await models.Email.findPage({limit: 'all'}); + assert.equal(emails.length, 0, 'no newsletter emails were created'); + }); +}); diff --git a/ghost/core/test/integration/exporter/exporter.test.js b/ghost/core/test/integration/exporter/exporter.test.js index 7fba8ab1026..d9c0a6c93bb 100644 --- a/ghost/core/test/integration/exporter/exporter.test.js +++ b/ghost/core/test/integration/exporter/exporter.test.js @@ -47,6 +47,7 @@ describe('Exporter', function () { 'email_recipients', 'email_spam_complaint_events', 'emails', + 'gift_deliveries', 'gift_links', 'gifts', 'integrations', @@ -148,6 +149,7 @@ describe('Exporter', function () { 'members_paid_subscription_events', 'members_subscribe_events', 'outbox', + 'gift_deliveries', 'gifts', 'gift_links', 'post_gift_links' diff --git a/ghost/core/test/integration/services/gifts/gift-delivery-bookshelf-repository.test.ts b/ghost/core/test/integration/services/gifts/gift-delivery-bookshelf-repository.test.ts new file mode 100644 index 00000000000..07c5f4487b8 --- /dev/null +++ b/ghost/core/test/integration/services/gifts/gift-delivery-bookshelf-repository.test.ts @@ -0,0 +1,225 @@ +import assert from 'node:assert/strict'; +import {GiftBookshelfRepository} from '../../../../core/server/services/gifts/gift-bookshelf-repository'; +import {GiftDeliveryBookshelfRepository} from '../../../../core/server/services/gifts/gift-delivery-bookshelf-repository'; + +const {agentProvider, fixtureManager} = require('../../../utils/e2e-framework'); +const models = require('../../../../core/server/models'); + +describe('GiftDeliveryBookshelfRepository (integration)', function () { + let giftRepository: GiftBookshelfRepository; + let deliveryRepository: GiftDeliveryBookshelfRepository; + let paidTierId: string; + let giftSequence = 0; + + beforeAll(async function () { + await agentProvider.getAdminAPIAgent(); + await fixtureManager.init('newsletters', 'members:newsletters'); + + const paidTier = await models.Product.findOne({type: 'paid'}, {require: true}); + paidTierId = paidTier.id; + giftRepository = new GiftBookshelfRepository({GiftModel: models.Gift, knex: models.Base.knex}); + deliveryRepository = new GiftDeliveryBookshelfRepository({ + GiftDeliveryModel: models.GiftDelivery, + knex: models.Base.knex + }); + }); + + afterEach(async function () { + await models.GiftDelivery.query().del(); + await models.Gift.query().del(); + }); + + async function createPendingEmailGift({ + startedAt = null, + deliveryStatus = 'pending', + giftStatus = 'purchased', + purchasedAt = new Date() + }: { + startedAt?: Date | null; + deliveryStatus?: string; + giftStatus?: string; + purchasedAt?: Date; + } = {}) { + giftSequence += 1; + const gift = await models.Gift.add({ + token: `delivery-start-test-token-${giftSequence}`, + buyer_email: `buyer-${giftSequence}@example.com`, + buyer_member_id: null, + buyer_name: 'Gift Buyer', + recipient_name: 'Gift Recipient', + personal_message: null, + redeemer_member_id: null, + tier_id: paidTierId, + cadence: 'year', + duration: 1, + currency: 'usd', + amount: 5000, + stripe_checkout_session_id: `cs_claim_${giftSequence}`, + stripe_payment_intent_id: `pi_claim_${giftSequence}`, + checkout_started_at: purchasedAt, + consumes_at: null, + expires_at: new Date(purchasedAt.getTime() + 365 * 24 * 60 * 60 * 1000), + status: giftStatus, + purchased_at: purchasedAt, + redeemed_at: null, + consumed_at: null, + expired_at: null, + refunded_at: null, + consumes_soon_reminder_sent_at: null + }); + const delivery = await models.GiftDelivery.add({ + gift_id: gift.id, + recipient_email: `recipient-${giftSequence}@example.com`, + status: deliveryStatus, + started_at: startedAt, + email_sent_at: null, + email_provider_message_id: null + }); + + return {gift, delivery}; + } + + async function createAbandonedCheckout(checkoutStartedAt: Date) { + giftSequence += 1; + const gift = await models.Gift.add({ + token: `pending-cleanup-${giftSequence}`, + buyer_email: `buyer-${giftSequence}@example.com`, + buyer_name: 'Gift Buyer', + recipient_name: 'Gift Recipient', + personal_message: 'Private message', + tier_id: paidTierId, + cadence: 'year', + duration: 1, + currency: 'usd', + amount: 5000, + stripe_checkout_session_id: `cs_pending_${giftSequence}`, + stripe_payment_intent_id: null, + checkout_started_at: checkoutStartedAt, + expires_at: null, + status: 'payment_pending', + purchased_at: null + }); + const delivery = await models.GiftDelivery.add({ + gift_id: gift.id, + recipient_email: `recipient-${giftSequence}@example.com`, + status: 'pending' + }); + + return {gift, delivery}; + } + + it('allows exactly one concurrent caller to start a pending delivery', async function () { + const startedAt = new Date(); + startedAt.setMilliseconds(0); + const {delivery} = await createPendingEmailGift(); + + const starts = await Promise.all([ + deliveryRepository.tryStartDelivery(delivery.id, startedAt, new Date(startedAt.getTime() - 60 * 60 * 1000)), + deliveryRepository.tryStartDelivery(delivery.id, startedAt, new Date(startedAt.getTime() - 60 * 60 * 1000)) + ]); + const reloaded = await deliveryRepository.getById(delivery.id); + + assert.equal(starts.filter(Boolean).length, 1); + assert.equal(starts.filter(start => start === null).length, 1); + assert.equal(reloaded?.status, 'sending'); + assert.equal(reloaded?.startedAt?.toISOString(), startedAt.toISOString()); + }); + + it('does not complete a delivery that is not sending', async function () { + const {delivery} = await createPendingEmailGift(); + + assert.equal(await deliveryRepository.markSent(delivery.id, new Date(), 'provider-1'), false); + assert.equal((await deliveryRepository.getById(delivery.id))?.status, 'pending'); + }); + + it('records acceptance details only on a cancelled delivery', async function () { + const sentAt = new Date('2026-08-18T10:00:00.000Z'); + const cancelled = await createPendingEmailGift({deliveryStatus: 'cancelled'}); + const sending = await createPendingEmailGift({deliveryStatus: 'sending', startedAt: new Date()}); + + assert.equal(await deliveryRepository.recordCancelledAcceptance(cancelled.delivery.id, sentAt, 'provider-1'), true); + assert.equal(await deliveryRepository.recordCancelledAcceptance(sending.delivery.id, sentAt, 'provider-2'), false); + + const cancelledReloaded = await deliveryRepository.getById(cancelled.delivery.id); + assert.equal(cancelledReloaded?.status, 'cancelled'); + assert.equal(cancelledReloaded?.emailProviderMessageId, 'provider-1'); + assert.equal(cancelledReloaded?.emailSentAt?.toISOString(), sentAt.toISOString()); + + const sendingReloaded = await deliveryRepository.getById(sending.delivery.id); + assert.equal(sendingReloaded?.status, 'sending'); + assert.equal(sendingReloaded?.emailProviderMessageId, null); + }); + + it('does not start a delivery when the parent gift is no longer purchased', async function () { + const startedAt = new Date(); + const {delivery} = await createPendingEmailGift({ + giftStatus: 'refunded' + }); + + assert.equal(await deliveryRepository.tryStartDelivery(delivery.id, startedAt, new Date(startedAt.getTime() - 60 * 60 * 1000)), null); + }); + + it('finds pending and stale sending deliveries only for purchased gifts', async function () { + const now = new Date('2026-08-18T12:00:00.000Z'); + const staleBefore = new Date('2026-08-18T11:00:00.000Z'); + const purchased = await createPendingEmailGift(); + await createPendingEmailGift({giftStatus: 'payment_pending'}); + const stale = await createPendingEmailGift({ + deliveryStatus: 'sending', + startedAt: new Date('2026-08-18T10:00:00.000Z') + }); + await createPendingEmailGift({ + deliveryStatus: 'sending', + startedAt: now + }); + + const deliveries = await deliveryRepository.findRecoverableForPurchasedGifts(staleBefore, 100); + + assert.deepEqual(new Set(deliveries.map(delivery => delivery.id)), new Set([purchased.delivery.id, stale.delivery.id])); + }); + + it('allows exactly one concurrent caller to reclaim a stale sending delivery', async function () { + const now = new Date('2026-08-18T12:00:00.000Z'); + const staleBefore = new Date('2026-08-18T11:00:00.000Z'); + const {delivery} = await createPendingEmailGift({ + deliveryStatus: 'sending', + startedAt: new Date('2026-08-18T10:00:00.000Z') + }); + + const starts = await Promise.all([ + deliveryRepository.tryStartDelivery(delivery.id, now, staleBefore), + deliveryRepository.tryStartDelivery(delivery.id, now, staleBefore) + ]); + + assert.equal(starts.filter(Boolean).length, 1); + assert.equal(starts.filter(start => start === null).length, 1); + assert.equal((await deliveryRepository.getById(delivery.id))?.startedAt?.toISOString(), now.toISOString()); + }); + + it('deletes abandoned pending checkouts with their delivery PII', async function () { + const checkoutStartedAt = new Date('2026-06-01T00:00:00.000Z'); + const checkouts = await Promise.all([ + createAbandonedCheckout(checkoutStartedAt), + createAbandonedCheckout(checkoutStartedAt) + ]); + const cutoff = new Date('2026-07-01T00:00:00.000Z'); + + assert.equal(await giftRepository.deleteAbandonedCheckouts(cutoff), 2); + for (const {gift, delivery} of checkouts) { + assert.equal(await models.Gift.findOne({id: gift.id}, {require: false}), null); + assert.equal(await models.GiftDelivery.findOne({id: delivery.id}, {require: false}), null); + } + }); + + for (const deliveryStatus of ['pending', 'sending']) { + it(`cancels a ${deliveryStatus} delivery by gift token`, async function () { + const {gift, delivery} = await createPendingEmailGift({ + deliveryStatus, + startedAt: deliveryStatus === 'sending' ? new Date() : null + }); + + assert.equal(await deliveryRepository.cancelPendingForGift(gift.get('token')), true); + assert.equal((await deliveryRepository.getById(delivery.id))?.status, 'cancelled'); + }); + } +}); diff --git a/ghost/core/test/integration/services/gifts/gift-delivery-processing.test.ts b/ghost/core/test/integration/services/gifts/gift-delivery-processing.test.ts new file mode 100644 index 00000000000..154165021b2 --- /dev/null +++ b/ghost/core/test/integration/services/gifts/gift-delivery-processing.test.ts @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict'; +import sinon from 'sinon'; +import {SendGiftDeliveryEvent} from '../../../../core/server/services/gifts/events/send-gift-delivery-event'; + +const DomainEvents = require('@tryghost/domain-events'); +const MailgunClient = require('../../../../core/server/services/lib/mailgun-client'); +const models = require('../../../../core/server/models'); +const {agentProvider, fixtureManager} = require('../../../utils/e2e-framework'); + +describe('Gift delivery processing', function () { + let paidTierId: string; + let deliverySend: sinon.SinonStub; + let giftSequence = 0; + + beforeAll(async function () { + await agentProvider.getAdminAPIAgent(); + await fixtureManager.init('newsletters', 'members:newsletters'); + + const paidTier = await models.Product.findOne({type: 'paid'}, {require: true}); + paidTierId = paidTier.id; + }); + + beforeEach(function () { + sinon.stub(MailgunClient.prototype, 'isConfigured').returns(true); + deliverySend = sinon.stub(MailgunClient.prototype, 'send').resolves({id: ''}); + }); + + afterEach(async function () { + await DomainEvents.allSettled(); + await models.GiftDelivery.query().del(); + await models.Gift.query().del(); + sinon.restore(); + }); + + async function createPendingEmailGift() { + giftSequence += 1; + const now = new Date(); + + const gift = await models.Gift.add({ + token: `delivery-processing-token-${giftSequence}`, + buyer_email: `buyer-${giftSequence}@example.com`, + buyer_member_id: null, + buyer_name: 'Gift Buyer', + recipient_name: 'Gift Recipient', + personal_message: 'Enjoy your gift!', + redeemer_member_id: null, + tier_id: paidTierId, + cadence: 'year', + duration: 1, + currency: 'usd', + amount: 5000, + stripe_checkout_session_id: `cs_delivery_processing_${giftSequence}`, + stripe_payment_intent_id: `pi_delivery_processing_${giftSequence}`, + consumes_at: null, + expires_at: new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000), + status: 'purchased', + purchased_at: now, + redeemed_at: null, + consumed_at: null, + expired_at: null, + refunded_at: null, + consumes_soon_reminder_sent_at: null + }); + const delivery = await models.GiftDelivery.add({ + gift_id: gift.id, + recipient_email: `recipient-${giftSequence}@example.com`, + status: 'pending', + started_at: null, + email_sent_at: null, + email_provider_message_id: null + }); + + return {gift, delivery}; + } + + it('claims, sends, and marks only the requested delivery as sent', async function () { + const requested = await createPendingEmailGift(); + const untouched = await createPendingEmailGift(); + + DomainEvents.dispatch(SendGiftDeliveryEvent.create({deliveryId: requested.delivery.id})); + await DomainEvents.allSettled(); + + sinon.assert.calledOnce(deliverySend); + assert.deepEqual(deliverySend.firstCall.args[1], {[requested.delivery.get('recipient_email')]: {}}); + + const requestedReloaded = await models.GiftDelivery.findOne({gift_id: requested.gift.id}, {require: true}); + assert.equal(requestedReloaded.get('status'), 'sent'); + assert.equal(requestedReloaded.get('started_at'), null); + assert.ok(requestedReloaded.get('email_sent_at')); + + const untouchedReloaded = await models.GiftDelivery.findOne({gift_id: untouched.gift.id}, {require: true}); + assert.equal(untouchedReloaded.get('status'), 'pending'); + assert.equal(untouchedReloaded.get('started_at'), null); + }); + + it('keeps acceptance details on a delivery cancelled while its email was in flight', async function () { + const {gift, delivery} = await createPendingEmailGift(); + deliverySend.callsFake(async () => { + // e.g. the gift is redeemed via the shared link before the email is accepted + await models.GiftDelivery.query().where({id: delivery.id}).update({status: 'cancelled', started_at: null}); + return {id: ''}; + }); + + DomainEvents.dispatch(SendGiftDeliveryEvent.create({deliveryId: delivery.id})); + await DomainEvents.allSettled(); + + sinon.assert.calledOnce(deliverySend); + const reloaded = await models.GiftDelivery.findOne({gift_id: gift.id}, {require: true}); + assert.equal(reloaded.get('status'), 'cancelled'); + assert.equal(reloaded.get('email_provider_message_id'), 'provider-123'); + assert.ok(reloaded.get('email_sent_at')); + }); +}); diff --git a/ghost/core/test/legacy/api/admin/settings.test.js b/ghost/core/test/legacy/api/admin/settings.test.js index afb811be90b..bf87543f849 100644 --- a/ghost/core/test/legacy/api/admin/settings.test.js +++ b/ghost/core/test/legacy/api/admin/settings.test.js @@ -225,6 +225,139 @@ describe('Settings API', function () { .expect(422); }); + it('Can edit navigation items with icon and visibility', async function () { + await checkCanEdit('navigation', JSON.stringify([{ + label: 'Members', + url: '/members/', + icon: `${config.get('url')}/content/images/nav-members.svg`, + visibility: 'members' + }, { + label: 'Free', + url: '/free/', + visibility: 'public_free' + }, { + label: 'Hidden', + url: '/hidden/', + visibility: 'none' + }]), [{ + label: 'Members', + url: '/members/', + icon: `${config.get('url')}/content/images/nav-members.svg`, + visibility: 'members' + }, { + label: 'Free', + url: '/free/', + visibility: 'public_free' + }, { + label: 'Hidden', + url: '/hidden/', + visibility: 'none' + }]); + + const navigationSetting = await db.knex('settings') + .select('value') + .where('key', 'navigation') + .first(); + + assert.equal(navigationSetting.value, '[{"label":"Members","url":"/members/","icon":"__GHOST_URL__/content/images/nav-members.svg","visibility":"members"},{"label":"Free","url":"/free/","visibility":"public_free"},{"label":"Hidden","url":"/hidden/","visibility":"none"}]'); + }); + + it('Can edit icon-only navigation items', async function () { + await checkCanEdit('navigation', JSON.stringify([{ + label: '', + url: '/icon-only/', + icon: `${config.get('url')}/content/images/nav-icon-only.svg` + }]), [{ + label: '', + url: '/icon-only/', + icon: `${config.get('url')}/content/images/nav-icon-only.svg` + }]); + + const navigationSetting = await db.knex('settings') + .select('value') + .where('key', 'navigation') + .first(); + + assert.equal(navigationSetting.value, '[{"label":"","url":"/icon-only/","icon":"__GHOST_URL__/content/images/nav-icon-only.svg"}]'); + }); + + it('Can edit icon-only navigation items without a label property', async function () { + await checkCanEdit('navigation', JSON.stringify([{ + url: '/icon-only-without-label/', + icon: `${config.get('url')}/content/images/nav-icon-only.svg` + }]), [{ + url: '/icon-only-without-label/', + icon: `${config.get('url')}/content/images/nav-icon-only.svg` + }]); + + const navigationSetting = await db.knex('settings') + .select('value') + .where('key', 'navigation') + .first(); + + assert.equal(navigationSetting.value, '[{"url":"/icon-only-without-label/","icon":"__GHOST_URL__/content/images/nav-icon-only.svg"}]'); + }); + + it('Cannot edit navigation items without a label or icon', async function () { + const settingsToChange = { + settings: [ + {key: 'navigation', value: JSON.stringify([{label: '', url: '/invalid/'}])} + ] + }; + + await request.put(localUtils.API.getApiQuery('settings/')) + .set('Origin', config.get('url')) + .send(settingsToChange) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(422); + }); + + it('Cannot edit navigation items with invalid visibility', async function () { + const settingsToChange = { + settings: [ + {key: 'navigation', value: JSON.stringify([{label: 'Invalid', url: '/invalid/', visibility: 'invalid'}])} + ] + }; + + await request.put(localUtils.API.getApiQuery('settings/')) + .set('Origin', config.get('url')) + .send(settingsToChange) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(422); + }); + + it('Cannot edit navigation items with invalid icon', async function () { + const settingsToChange = { + settings: [ + {key: 'navigation', value: JSON.stringify([{label: 'Invalid', url: '/invalid/', icon: 'mailto:test@example.com'}])} + ] + }; + + await request.put(localUtils.API.getApiQuery('settings/')) + .set('Origin', config.get('url')) + .send(settingsToChange) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(422); + }); + + it('Cannot edit navigation items with non-http icon URL protocols', async function () { + const settingsToChange = { + settings: [ + {key: 'navigation', value: JSON.stringify([{label: 'Invalid', url: '/invalid/', icon: 'ftp://example.com/icon.svg'}])} + ] + }; + + await request.put(localUtils.API.getApiQuery('settings/')) + .set('Origin', config.get('url')) + .send(settingsToChange) + .expect('Content-Type', /json/) + .expect('Cache-Control', testUtils.cacheRules.private) + .expect(422); + }); + // If this test fails, it can be safely removed // but the front-end should be updated accordingly, // removing the workaround in place for this specific usecase diff --git a/ghost/core/test/unit/frontend/helpers/navigation.test.js b/ghost/core/test/unit/frontend/helpers/navigation.test.js index d973ec448a8..47a8388ad56 100644 --- a/ghost/core/test/unit/frontend/helpers/navigation.test.js +++ b/ghost/core/test/unit/frontend/helpers/navigation.test.js @@ -1,5 +1,6 @@ const assert = require('node:assert/strict'); const {assertExists} = require('../../../utils/assertions'); +const sinon = require('sinon'); const hbs = require('../../../../core/frontend/services/theme-engine/engine'); const configUtils = require('../../../utils/config-utils'); const path = require('path'); @@ -10,6 +11,7 @@ const foreach = require('../../../../core/frontend/helpers/foreach'); const link_class = require('../../../../core/frontend/helpers/link_class'); const url = require('../../../../core/frontend/helpers/url'); const navigation = require('../../../../core/frontend/helpers/navigation'); +const labs = require('../../../../core/shared/labs'); const handlebars = require('../../../../core/frontend/services/theme-engine/engine').handlebars; @@ -18,6 +20,7 @@ const runHelperThunk = data => () => runHelper(data); describe('{{navigation}} helper', function () { let optionsData; + let labsStub; beforeAll(async function () { hbs.express4({ @@ -36,6 +39,8 @@ describe('{{navigation}} helper', function () { }); beforeEach(function () { + labsStub = sinon.stub(labs, 'isSet').returns(true); + optionsData = { data: { site: { @@ -49,6 +54,10 @@ describe('{{navigation}} helper', function () { }; }); + afterEach(function () { + sinon.restore(); + }); + it('should throw errors on invalid data', function () { // Test 1: navigation = string optionsData.data.site.navigation = 'not an object'; @@ -66,6 +75,33 @@ describe('{{navigation}} helper', function () { // Test 4: invalid url optionsData.data.site.navigation = [{label: 'foo', url: 1}]; assert.throws(runHelperThunk(optionsData), {message: 'Invalid value, Url and Label must be strings'}); + + // Test 5: null url + optionsData.data.site.navigation = [{label: 'foo', url: null}]; + assert.throws(runHelperThunk(optionsData), {message: 'Invalid value, Url and Label must be strings'}); + }); + + it('coerces a non-string icon to no icon instead of throwing', function () { + // Bad data (e.g. from an older Ghost or a direct DB write) must not 500 the + // whole front end — the item renders without an icon. + optionsData.data.site.navigation = [{label: 'foo', url: '/foo', icon: 1}]; + + const rendered = runHelper(optionsData); + + assertExists(rendered); + assert(!rendered.string.includes('class="nav-icon"')); + assert(rendered.string.includes('foo')); + }); + + it('coerces an unrecognised visibility to public instead of throwing', function () { + // An unknown visibility value falls back to public (visible) rather than + // throwing — the link target enforces its own access control regardless. + optionsData.data.site.navigation = [{label: 'foo', url: '/foo', visibility: 'invalid'}]; + + const rendered = runHelper(optionsData); + + assertExists(rendered); + assert(rendered.string.includes('foo')); }); it('can render empty nav', function () { @@ -101,6 +137,41 @@ describe('{{navigation}} helper', function () { assert(rendered.string.includes(testUrl)); }); + it('can render one item with an icon', function () { + const singleItem = {label: 'Foo', url: '/foo', icon: 'https://example.com/icon.svg'}; + let rendered; + + optionsData.data.site.navigation = [singleItem]; + rendered = runHelper(optionsData); + + assertExists(rendered); + assert(rendered.string.includes('class="nav-icon"')); + assert(rendered.string.includes('src="https://example.com/icon.svg"')); + assert(rendered.string.includes('alt=""')); + assert(!rendered.string.includes('alt="icon"')); + assert(!rendered.string.includes('aria-hidden')); + assert(!rendered.string.includes('loading=')); + assert(rendered.string.includes('Foo')); + }); + + it('can render an icon-only item', function () { + const singleItem = {url: '/icon-only', icon: 'https://example.com/icons/Ambulance%20Truck.svg'}; + let rendered; + + optionsData.data.site.navigation = [singleItem]; + rendered = runHelper(optionsData); + + assertExists(rendered); + assert(rendered.string.includes('nav-ambulance-truck')); + assert(!rendered.string.includes('nav- ')); + assert(rendered.string.includes('class="nav-icon"')); + assert(rendered.string.includes('src="https://example.com/icons/Ambulance%20Truck.svg"')); + assert(rendered.string.includes('alt="Ambulance Truck"')); + assert(!rendered.string.includes('aria-hidden')); + assert(!rendered.string.includes('loading=')); + assert(!rendered.string.includes('class="nav-label"')); + }); + it('can render multiple items', function () { const firstItem = {label: 'Foo', url: '/foo'}; const secondItem = {label: 'Bar Baz Qux', url: '/qux'}; @@ -187,6 +258,92 @@ describe('{{navigation}} helper', function () { assert(!rendered.string.includes('foo=space%2520bar')); }); + it('filters navigation items by member visibility', function () { + const hasLabel = (rendered, label) => rendered.string.includes(`${label}`); + optionsData.data.site.navigation = [ + {label: 'Public', url: '/public'}, + {label: 'Members', url: '/members', visibility: 'members'}, + {label: 'Paid', url: '/paid', visibility: 'paid'}, + {label: 'Public Free', url: '/public-free', visibility: 'public_free'}, + {label: 'Public Paid', url: '/public-paid', visibility: 'public_paid'}, + {label: 'Public Only', url: '/public-only', visibility: 'public_only'}, + {label: 'Free Members', url: '/free-members', visibility: 'free_members'}, + {label: 'Hidden', url: '/hidden', visibility: 'none'} + ]; + + let rendered = runHelper(optionsData); + assert(hasLabel(rendered, 'Public')); + assert(!hasLabel(rendered, 'Members')); + assert(!hasLabel(rendered, 'Paid')); + assert(hasLabel(rendered, 'Public Free')); + assert(hasLabel(rendered, 'Public Paid')); + assert(hasLabel(rendered, 'Public Only')); + assert(!hasLabel(rendered, 'Free Members')); + assert(!hasLabel(rendered, 'Hidden')); + + optionsData.data.root.member = {status: 'free'}; + rendered = runHelper(optionsData); + assert(hasLabel(rendered, 'Public')); + assert(hasLabel(rendered, 'Members')); + assert(!hasLabel(rendered, 'Paid')); + assert(hasLabel(rendered, 'Public Free')); + assert(!hasLabel(rendered, 'Public Paid')); + assert(!hasLabel(rendered, 'Public Only')); + assert(hasLabel(rendered, 'Free Members')); + assert(!hasLabel(rendered, 'Hidden')); + + optionsData.data.root.member = {status: 'paid'}; + rendered = runHelper(optionsData); + assert(hasLabel(rendered, 'Public')); + assert(hasLabel(rendered, 'Members')); + assert(hasLabel(rendered, 'Paid')); + assert(!hasLabel(rendered, 'Public Free')); + assert(hasLabel(rendered, 'Public Paid')); + assert(!hasLabel(rendered, 'Public Only')); + assert(!hasLabel(rendered, 'Free Members')); + assert(!hasLabel(rendered, 'Hidden')); + }); + + it('renders empty nav when all items are filtered out', function () { + optionsData.data.site.navigation = [{label: 'Paid', url: '/paid', visibility: 'paid'}]; + + const rendered = runHelper(optionsData); + + assertExists(rendered); + assert.equal(rendered.string, ''); + }); + + describe('when navigationIcons lab is disabled', function () { + beforeEach(function () { + labsStub.withArgs('navigationIcons').returns(false); + }); + + it('does not render icons', function () { + optionsData.data.site.navigation = [ + {label: 'Foo', url: '/foo', icon: 'https://example.com/icon.svg'} + ]; + + const rendered = runHelper(optionsData); + + assertExists(rendered); + assert(!rendered.string.includes('class="nav-icon"')); + assert(rendered.string.includes('Foo')); + }); + + it('does not filter items by visibility', function () { + optionsData.data.site.navigation = [ + {label: 'Hidden', url: '/hidden', visibility: 'none'}, + {label: 'Paid', url: '/paid', visibility: 'paid'} + ]; + + const rendered = runHelper(optionsData); + + assertExists(rendered); + assert(rendered.string.includes('Hidden')); + assert(rendered.string.includes('Paid')); + }); + }); + describe('type="secondary"', function () { it('can render one item', function () { const singleItem = {label: 'Foo', url: '/foo'}; diff --git a/ghost/core/test/unit/frontend/services/card-assets.test.js b/ghost/core/test/unit/frontend/services/card-assets.test.js index 66a0e23c079..d9445a1bc57 100644 --- a/ghost/core/test/unit/frontend/services/card-assets.test.js +++ b/ghost/core/test/unit/frontend/services/card-assets.test.js @@ -23,6 +23,96 @@ const MANIFEST = { const expectedHash = content => crypto.createHash('sha256').update(content).digest('base64url').substring(0, 16); +describe('Card Asset Manifest Builder', function () { + let testDir, + manifestPath, + manifest, + ungroupedCssManifest, + shippedCssManifest; + + beforeAll(async function () { + testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ghost-card-assets-builder-tests-')); + manifestPath = path.join(testDir, 'cards.manifest.json'); + + const cssDir = path.join(testDir, 'css'); + const jsDir = path.join(testDir, 'js'); + const ungroupedCssDir = path.join(testDir, 'ungrouped', 'css'); + await Promise.all([ + fs.mkdir(cssDir), + fs.mkdir(jsDir), + fs.mkdir(ungroupedCssDir, {recursive: true}) + ]); + await Promise.all([ + fs.writeFile(path.join(cssDir, 'gallery.css'), '.gallery { color: red; }'), + fs.writeFile(path.join(cssDir, 'header.css'), '.kg-header-card { color: blue; }'), + fs.writeFile(path.join(cssDir, 'header_v2.css'), '.kg-header-card.kg-v2 { color: green; }'), + fs.writeFile(path.join(jsDir, 'header.js'), 'window.header = true;'), + fs.writeFile(path.join(jsDir, 'header_v2.js'), 'window.headerV2 = true;'), + fs.writeFile(path.join(ungroupedCssDir, 'gallery.css'), '.gallery { color: red; }'), + fs.writeFile(path.join(ungroupedCssDir, 'header.css'), '.kg-header-card { color: blue; }'), + // This sorts directly after header.css without triggering the path override, + // reconstructing the previous separately-keyed bundle for comparison. + fs.writeFile(path.join(ungroupedCssDir, 'headerz.css'), '.kg-header-card.kg-v2 { color: green; }') + ]); + + const {buildType} = await import('../../../../scripts/build-card-assets.mjs'); + manifest = { + css: await buildType('css', testDir), + js: await buildType('js', testDir) + }; + ungroupedCssManifest = await buildType('css', path.join(testDir, 'ungrouped')); + shippedCssManifest = await buildType('css'); + await fs.writeFile(manifestPath, JSON.stringify(manifest)); + }); + + afterAll(async function () { + await fs.rm(testDir, {recursive: true}); + }); + + it('maps header v2 CSS to the public header card name', function () { + assert.deepEqual(Object.keys(manifest.css), ['gallery', 'header']); + assert.match(manifest.css.header, /\.kg-header-card\{/); + assert.match(manifest.css.header, /\.kg-header-card\.kg-v2\{/); + assert.ok(manifest.css.header.indexOf('.kg-header-card{') < manifest.css.header.indexOf('.kg-header-card.kg-v2{')); + }); + + it('does not infer public card names from version suffixes', function () { + assert.deepEqual(Object.keys(manifest.js), ['header', 'header_v2']); + }); + + it('does not expose a versioned header card name from the shipped CSS', function () { + assert.deepEqual(Object.keys(shippedCssManifest).filter(name => name.startsWith('header')), ['header']); + }); + + it('preserves the default bundle bytes when combining the header chunks', function () { + const cardAssets = new CardAssetService({manifest: manifestPath, config: true}); + const previousContent = Object.values(ungroupedCssManifest).join('\n'); + + assert.equal(cardAssets.getBundle('css').content, previousContent); + }); + + it('excludes both header styles by the public header card name', function () { + const cardAssets = new CardAssetService({ + manifest: manifestPath, + config: {exclude: ['header']} + }); + + assert.deepEqual(cardAssets.getCardNames('css'), ['gallery']); + assert.doesNotMatch(cardAssets.getBundle('css').content, /\.kg-header-card/); + }); + + it('includes both header styles by the public header card name', function () { + const cardAssets = new CardAssetService({ + manifest: manifestPath, + config: {include: ['header']} + }); + + assert.deepEqual(cardAssets.getCardNames('css'), ['header']); + assert.match(cardAssets.getBundle('css').content, /\.kg-header-card\{/); + assert.match(cardAssets.getBundle('css').content, /\.kg-header-card\.kg-v2\{/); + }); +}); + describe('Card Asset Service', function () { let testDir, manifestPath; 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 19505941905..47f31844805 100644 --- a/ghost/core/test/unit/server/data/schema/integrity.test.js +++ b/ghost/core/test/unit/server/data/schema/integrity.test.js @@ -35,7 +35,7 @@ const parseYaml = require('../../../../../core/server/services/route-settings/ya */ describe('DB version integrity', function () { // Only these variables should need updating - const currentSchemaHash = 'bbded7a3841350d2d8c5856bc01c3461'; + const currentSchemaHash = '8018ab522bb5cfe2326674a733a0a343'; const currentFixturesHash = 'd4c9e4fabcec3365c42d37642216021b'; const currentSettingsHash = 'f57245b22af55ea5fc1e5e20913de9bb'; const currentRoutesHash = 'd8c25fa01bf6d22a2bcb05ba0de70dc1'; diff --git a/ghost/core/test/unit/server/data/seeders/data-generator.test.js b/ghost/core/test/unit/server/data/seeders/data-generator.test.js index 4fbb051020a..af6b9839a19 100644 --- a/ghost/core/test/unit/server/data/seeders/data-generator.test.js +++ b/ghost/core/test/unit/server/data/seeders/data-generator.test.js @@ -11,7 +11,7 @@ const AutomationsImporter = importers.find(i => i.table === 'automations'); const AutomationRunStepsImporter = importers.find(i => i.table === 'automation_run_steps'); const generateEvents = require('../../../../../core/server/data/seeders/utils/event-generator'); -const databaseDate = require('../../../../../core/server/data/seeders/utils/database-date'); +const {randomDateBetween} = require('../../../../../core/server/data/seeders/utils/random'); const DataGenerator = require('../../../../../core/server/data/seeders/data-generator'); @@ -479,26 +479,11 @@ describe('Importer', function () { }); describe('Events Generator', function () { - it('Parses database timestamps as UTC in non-UTC timezones', function () { - const originalTZ = process.env.TZ; - try { - process.env.TZ = 'America/New_York'; - const result = databaseDate.parse('2026-03-26 11:50:00.000'); - assert.equal(result.toISOString(), '2026-03-26T11:50:00.000Z'); - } finally { - if (originalTZ === undefined) { - delete process.env.TZ; - } else { - process.env.TZ = originalTZ; - } - } - }); - it('Returns the start date when a range is inverted', function () { const startDate = new Date('2026-03-26T11:50:00.000Z'); const endDate = new Date('2026-03-26T10:00:00.000Z'); - assert.equal(databaseDate.randomBetween(startDate, endDate).toISOString(), startDate.toISOString()); + assert.equal(randomDateBetween(startDate, endDate).toISOString(), startDate.toISOString()); }); it('Generates a set of timestamps which meet the criteria', function () { diff --git a/ghost/core/test/unit/server/services/automations/database-date.test.ts b/ghost/core/test/unit/server/lib/db-date.test.ts similarity index 60% rename from ghost/core/test/unit/server/services/automations/database-date.test.ts rename to ghost/core/test/unit/server/lib/db-date.test.ts index 8ba86a91db7..6d1e6ab8257 100644 --- a/ghost/core/test/unit/server/services/automations/database-date.test.ts +++ b/ghost/core/test/unit/server/lib/db-date.test.ts @@ -1,7 +1,8 @@ import assert from 'node:assert/strict'; import {spawn} from 'node:child_process'; import {once} from 'node:events'; -import {fromDatabaseDate, toDatabaseDate} from '../../../../../core/server/services/automations/database-date'; +import * as errors from '@tryghost/errors'; +import {DbDate, fromDatabaseDate, toDatabaseDate} from '../../../../core/server/lib/db-date'; describe('database date utilities', function () { const timezones = [ @@ -19,12 +20,12 @@ describe('database date utilities', function () { const runInOtherTimezones = async (toRun: string) => { // JSON.stringify does a good job wrapping strings in quotes and escaping. const s = JSON.stringify; - const modulePath = require.resolve('../../../../../core/server/services/automations/database-date'); + const modulePath = require.resolve('../../../../core/server/lib/db-date'); await Promise.all(timezones.map(async ({tz, expectedNaive}) => { const source = ` const assert = require('node:assert/strict'); - const {fromDatabaseDate, toDatabaseDate} = require(${s(modulePath)}); + const {DbDate, fromDatabaseDate, toDatabaseDate} = require(${s(modulePath)}); assert.deepEqual(new Date('2020-01-01 12:34:56'), new Date(${s(expectedNaive)}), 'test setup'); @@ -41,6 +42,18 @@ describe('database date utilities', function () { })); }; + describe('DbDate', function () { + it('decodes database date strings as UTC', function () { + assert.deepEqual(DbDate.decode('2020-01-01 12:34:56'), new Date('2020-01-01T12:34:56.000Z')); + }); + + it('decodes database date strings as UTC in other system timezones', async function () { + await runInOtherTimezones(` + assert.deepEqual(DbDate.decode('2020-01-01 12:34:56'), new Date('2020-01-01T12:34:56.000Z')); + `); + }); + }); + describe('toDatabaseDate', function () { it('converts Dates to database date strings', function () { const input = new Date('2024-06-01T12:34:56Z'); @@ -48,6 +61,10 @@ describe('database date utilities', function () { assert.strictEqual(result, '2024-06-01 12:34:56'); }); + it('converts epoch milliseconds to database date strings', function () { + assert.strictEqual(toDatabaseDate(1577882096000), '2020-01-01 12:34:56'); + }); + it('converts Zulu date strings to database date strings', function () { const input = '2024-06-01T12:34:56Z'; const result = toDatabaseDate(input); @@ -71,6 +88,11 @@ describe('database date utilities', function () { assert.deepEqual(toDatabaseDate('2020-01-01 12:34:56'), '2020-01-01 12:34:56'); `); }); + + it('rejects invalid dates', function () { + assert.throws(() => toDatabaseDate('not-a-date'), errors.InternalServerError); + assert.throws(() => toDatabaseDate(new Date('not-a-date')), errors.InternalServerError); + }); }); describe('fromDatabaseDate', function () { @@ -85,14 +107,41 @@ describe('database date utilities', function () { assert.deepEqual(fromDatabaseDate('2020-01-01T12:34:56.000Z'), new Date('2020-01-01T12:34:56.000Z')); }); + it('preserves fractional seconds in database date strings', function () { + assert.deepEqual(fromDatabaseDate('2020-01-01 12:34:56.123'), new Date('2020-01-01T12:34:56.123Z')); + }); + + it('respects timezone offsets in ISO date strings', function () { + assert.deepEqual(fromDatabaseDate('2020-01-01T12:34:56.123-04:00'), new Date('2020-01-01T16:34:56.123Z')); + }); + + it('respects timezone offsets in SQL date strings', function () { + assert.deepEqual(fromDatabaseDate('2020-01-01 12:34:56+00:00'), new Date('2020-01-01T12:34:56.000Z')); + }); + it('converts strings to Date objects, parsing as UTC, in other system timezones', async function () { await runInOtherTimezones(` assert.deepEqual(fromDatabaseDate('2020-01-01 12:34:56'), new Date('2020-01-01T12:34:56.000Z')); + assert.deepEqual(fromDatabaseDate('2020-01-01 12:34:56.123'), new Date('2020-01-01T12:34:56.123Z')); `); }); it('converts numbers to Date objects', function () { assert.deepEqual(fromDatabaseDate(1577882096000), new Date('2020-01-01T12:34:56.000Z')); }); + + it('rejects malformed strings', function () { + assert.throws(() => fromDatabaseDate('not-a-date'), errors.InternalServerError); + }); + + it('rejects invalid Date objects', function () { + assert.throws(() => fromDatabaseDate(new Date('not-a-date')), errors.InternalServerError); + }); + + it('rejects non-finite numbers', function () { + for (const input of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) { + assert.throws(() => fromDatabaseDate(input), errors.InternalServerError); + } + }); }); }); diff --git a/ghost/core/test/unit/server/services/automations/automations-repository.test.ts b/ghost/core/test/unit/server/services/automations/automations-repository.test.ts index e390b2f5bd8..612912293be 100644 --- a/ghost/core/test/unit/server/services/automations/automations-repository.test.ts +++ b/ghost/core/test/unit/server/services/automations/automations-repository.test.ts @@ -8,7 +8,7 @@ import {NON_EMPTY_EMAIL_LEXICAL} from '../../../../utils/automations-fixtures'; import ghostConfig from '../../../../../core/shared/config'; import {createDatabaseAutomationsRepository} from '../../../../../core/server/services/automations/database-automations-repository'; import type {AutomatedEmailEvents, AutomationAction, AutomationsRepository, AutomationStepToRun} from '../../../../../core/server/services/automations/automations-repository'; -import {DATABASE_DATE_FORMAT, fromDatabaseDate, toDatabaseDate} from '../../../../../core/server/services/automations/database-date'; +import {fromDatabaseDate, toDatabaseDate} from '../../../../../core/server/lib/db-date'; const HOUR_MS = 60 * 60 * 1000; const FAKE_WAIT_HOURS_MULTIPLIER = 2500; @@ -1115,7 +1115,7 @@ describe('automations repository', function () { const step = await getStepByRunId(run.id); assert(step); - const readyAtMs = moment(step.ready_at, DATABASE_DATE_FORMAT).valueOf(); + const readyAtMs = fromDatabaseDate(step.ready_at).valueOf(); assert(readyAtMs >= beforeTrigger + (48 * FAKE_WAIT_HOURS_MULTIPLIER) - 999); assert(readyAtMs <= afterTrigger + (48 * FAKE_WAIT_HOURS_MULTIPLIER)); }); diff --git a/ghost/core/test/unit/server/services/content-import/csv/parse.test.ts b/ghost/core/test/unit/server/services/content-import/csv/parse.test.ts new file mode 100644 index 00000000000..1fccd27544c --- /dev/null +++ b/ghost/core/test/unit/server/services/content-import/csv/parse.test.ts @@ -0,0 +1,121 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import assert from 'node:assert/strict'; +import {parse} from '../../../../../../core/server/services/content-import/csv'; + +// The CSVs live inline: each case writes its content to a temp file, so the +// fixture sits next to the assertions that read it. +describe('content import csv parse', function () { + const HEADER_MAPPING = { + title: 'title', + html: 'html', + published_at: 'published_at' + }; + + let dir: string; + let fileCount = 0; + + beforeAll(async function () { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'content-import-parse-')); + }); + + afterAll(async function () { + await fs.rm(dir, {recursive: true, force: true}); + }); + + const csvFile = async (content: string): Promise => { + fileCount += 1; + const filePath = path.join(dir, `case-${fileCount}.csv`); + await fs.writeFile(filePath, content); + return filePath; + }; + + it('parses an empty file to no rows', async function () { + const result = await parse(await csvFile(''), HEADER_MAPPING); + + assert.deepEqual(result, []); + }); + + it('parses title, html and published_at cells, quoting included', async function () { + const result = await parse(await csvFile( + 'title,html,published_at\n' + + 'First post,

Hello

,2025-01-01T00:00:00.000Z\n' + + 'Second post,"

Comma, and a ""quote""

",2025-01-02T12:30:00.000Z\n' + ), HEADER_MAPPING); + + assert.equal(result.length, 2); + assert.deepEqual(result[0], { + title: 'First post', + html: '

Hello

', + published_at: '2025-01-01T00:00:00.000Z' + }); + assert.equal(result[1].title, 'Second post'); + assert.equal(result[1].html, '

Comma, and a "quote"

'); + }); + + it('handles a byte order mark before the header row', async function () { + const result = await parse(await csvFile( + '\ufeff' + 'title,html,published_at\n' + + 'Bom post,

Bom

,2025-01-01T00:00:00.000Z\n' + ), HEADER_MAPPING); + + assert.equal(result.length, 1); + assert.equal(result[0].title, 'Bom post'); + }); + + it('drops a blank line rather than emitting an empty row', async function () { + const result = await parse(await csvFile( + 'title,html,published_at\n' + + 'Before blank,

a

,2025-01-01T00:00:00.000Z\n' + + '\n' + + 'After blank,

b

,2025-01-02T00:00:00.000Z\n' + ), HEADER_MAPPING); + + assert.equal(result.length, 2); + assert.equal(result[0].title, 'Before blank'); + assert.equal(result[1].title, 'After blank'); + }); + + it('ignores the overflow cells of a ragged row', async function () { + const result = await parse(await csvFile( + 'title,html,published_at\n' + + 'Ragged post,

Hi

,2025-01-01T00:00:00.000Z,overflow,cells\n' + ), HEADER_MAPPING); + + assert.equal(result.length, 1); + assert.deepEqual(result[0], { + title: 'Ragged post', + html: '

Hi

', + published_at: '2025-01-01T00:00:00.000Z' + }); + }); + + it('rejects a file with an unterminated quoted field instead of importing garbage', async function () { + await assert.rejects(parse(await csvFile( + 'title,html,published_at\n' + + 'Broken quote,"

never closed,2025-01-01T00:00:00.000Z\n' + + 'Next post,

fine

,2025-01-02T00:00:00.000Z\n' + ), HEADER_MAPPING), /MissingQuotes/); + }); + + it('drops a column named after an Object.prototype member', async function () { + const result = await parse(await csvFile( + 'title,toString\n' + + 'Proto post,shadowed\n' + ), HEADER_MAPPING); + + assert.equal(result.length, 1); + assert.deepEqual(result[0], {title: 'Proto post'}); + }); + + it('carries an unmapped column through untouched', async function () { + const result = await parse(await csvFile( + 'title,html,published_at,custom_thing\n' + + 'Extra post,

Hi

,2025-01-01T00:00:00.000Z,kept\n' + ), HEADER_MAPPING); + + assert.equal(result.length, 1); + assert.equal(result[0].custom_thing, 'kept'); + }); +}); diff --git a/ghost/core/test/unit/server/services/content-import/import/importer.test.ts b/ghost/core/test/unit/server/services/content-import/import/importer.test.ts new file mode 100644 index 00000000000..d1ee6e40ffd --- /dev/null +++ b/ghost/core/test/unit/server/services/content-import/import/importer.test.ts @@ -0,0 +1,284 @@ +import assert from 'node:assert/strict'; +import ContentCSVImporter from '../../../../../../core/server/services/content-import/import/importer'; +import {ImportRunStore} from '../../../../../../core/server/services/content-import/import/store'; +import type {PostImportRow} from '../../../../../../core/server/services/content-import/import/row'; +import type {PostData} from '../../../../../../core/server/services/content-import/import/post-data'; + +const row = (title: string, html = `

${title}

`): PostImportRow => ({ + title, + html, + published_at: '2025-01-01T00:00:00.000Z' +}); + +// Collaborators are handed back as `deps` so a test can repoint the seam it is breaking. +function harness(rows: PostImportRow[] = [row('First'), row('Second')]) { + const created: Array<{data: PostData; options: object}> = []; + const reported: unknown[] = []; + const jobs: Array<{name: string; offloaded: boolean; job: () => Promise}> = []; + const createFailures = new Map(); + const urlFailures = new Map(); + const store = new ImportRunStore(); + let converterResolutions = 0; + // Late-bound: the importer captures deps at construction. + let htmlToLexicalFactory: () => (html: string) => unknown = () => { + converterResolutions += 1; + return (html: string) => ({converted: html}); + }; + + const deps = { + readRows: async () => rows, + posts: { + create: async (data: PostData, options: object) => { + const failure = createFailures.get(data.title); + if (failure) { + throw failure; + } + created.push({data, options}); + const id = `post_${created.length}`; + return {id, toJSON: () => ({id, slug: `slug-${created.length}`})}; + } + }, + getHtmlToLexical: () => htmlToLexicalFactory(), + addJob: (job: {name: string; offloaded: boolean; job: () => Promise}) => { + jobs.push(job); + }, + report: (error: unknown) => { + reported.push(error); + }, + store, + urlForPost: (post: {id: string}) => { + const failure = urlFailures.get(post.id); + if (failure) { + throw failure; + } + return `https://example.com/${post.id}/`; + }, + newRunId: () => 'run_test', + getTimezone: () => 'Europe/Amsterdam', + now: () => new Date('2026-01-01T10:30:00.000Z') + }; + + const importer = new ContentCSVImporter(deps); + + // The scheduled job is invoked directly rather than through the job manager. + const run = async () => { + const accepted = await importer.importCSV({filePath: '/tmp/posts.csv'}); + for (const job of jobs) { + await job.job(); + } + return accepted; + }; + + const setHtmlToLexicalFactory = (factory: () => (html: string) => unknown) => { + htmlToLexicalFactory = factory; + }; + + return {importer, run, deps, created, reported, jobs, createFailures, urlFailures, store, setHtmlToLexicalFactory, converterResolutions: () => converterResolutions}; +} + +describe('ContentCSVImporter', function () { + it('accepts the upload with the row count and defers the writes to one inline job', async function () { + const h = harness(); + + const accepted = await h.importer.importCSV({filePath: '/tmp/posts.csv'}); + + assert.deepEqual(accepted, {importId: 'run_test', total: 2}); + assert.equal(h.jobs.length, 1); + assert.equal(h.jobs[0].name, 'content-import'); + assert.equal(h.jobs[0].offloaded, false); + assert.equal(h.created.length, 0, 'nothing is written until the job runs'); + assert.equal(h.store.get('run_test')?.status, 'running', 'the run is registered before the job starts'); + }); + + it('writes one post per row, in order, under the importing options', async function () { + const h = harness(); + + await h.run(); + + assert.deepEqual(h.created.map(call => call.data.title), ['First', 'Second']); + for (const call of h.created) { + assert.deepEqual(call.options, {importing: true, context: {internal: true}}); + } + }); + + it('files every row of a run under a date-stamped tag and a unique run tag', async function () { + const h = harness(); + + await h.run(); + + // 10:30 UTC is 11:30 in Amsterdam: the stamp follows the site timezone + for (const call of h.created) { + assert.deepEqual(call.data.tags, [ + {name: '#Import 2026-01-01 11:30'}, + {name: '#Import Run run_test'} + ]); + } + }); + + it('rejects a file that cannot be parsed as CSV without scheduling any work', async function () { + const h = harness(); + const importer = new ContentCSVImporter({...h.deps, readRows: async () => { + throw new Error('bad bytes'); + }}); + + await assert.rejects( + importer.importCSV({filePath: '/tmp/posts.csv'}), + (error: {errorType?: string; message?: string}) => { + assert.equal(error.errorType, 'ValidationError'); + assert.match(error.message ?? '', /could not be parsed as a CSV/); + return true; + } + ); + + assert.equal(h.jobs.length, 0, 'no job was scheduled'); + }); + + it('resolves the html converter once per run, not per row', async function () { + const h = harness(); + + await h.run(); + + assert.equal(h.converterResolutions(), 1); + }); + + it('rejects a file over the cap without scheduling any work', async function () { + const h = harness(Array.from({length: 101}, (_, i) => row(`Post ${i + 1}`))); + + await assert.rejects( + h.importer.importCSV({filePath: '/tmp/posts.csv'}), + (error: {errorType?: string; message?: string}) => { + assert.equal(error.errorType, 'ValidationError'); + assert.match(error.message ?? '', /more than 100 posts/); + return true; + } + ); + + assert.equal(h.jobs.length, 0, 'no job was scheduled'); + assert.equal(h.created.length, 0, 'nothing was written'); + assert.equal(h.store.get('run_test'), undefined, 'no run was registered'); + }); + + it('accepts a file exactly at the cap', async function () { + const h = harness(Array.from({length: 100}, (_, i) => row(`Post ${i + 1}`))); + + const accepted = await h.importer.importCSV({filePath: '/tmp/posts.csv'}); + + assert.deepEqual(accepted, {importId: 'run_test', total: 100}); + assert.equal(h.jobs.length, 1); + }); + + it('records a failed write against its row and imports the rest', async function () { + const h = harness([row('First'), row('Second'), row('Third')]); + h.createFailures.set('Second', new Error('insert failed')); + + await h.run(); + + assert.deepEqual(h.created.map(call => call.data.title), ['First', 'Third'], 'the other rows still imported'); + assert.deepEqual(h.reported, [], 'a row outcome is recorded, not reported'); + + const run = h.store.get('run_test'); + assert.equal(run?.status, 'complete'); + assert.deepEqual(run?.rows[1], {line: 3, title: 'Second', status: 'failed', reason: 'insert failed'}); + }); + + it('reports once when every attempted write fails', async function () { + const h = harness([row('First'), row('Second')]); + h.createFailures.set('First', new Error('first insert failed')); + h.createFailures.set('Second', new Error('second insert failed')); + + await h.run(); + + assert.equal(h.created.length, 0); + assert.equal(h.reported.length, 1); + assert.equal((h.reported[0] as {errorType?: string}).errorType, 'InternalServerError'); + assert.equal((h.reported[0] as Error).message, 'Content import failed to write all 2 attempted posts.'); + assert.match((h.reported[0] as Error).stack ?? '', /first insert failed/, 'the first write failure is preserved as the cause'); + assert.equal(h.store.get('run_test')?.status, 'complete'); + assert.deepEqual(h.store.get('run_test')?.rows.map(outcome => outcome.status), ['failed', 'failed']); + }); + + it('skips a malformed row on its own and imports the rest', async function () { + const h = harness([row('First'), {title: '', html: '

No title

', published_at: undefined}, row('Third')]); + + await h.run(); + + assert.deepEqual(h.created.map(call => call.data.title), ['First', 'Third']); + + const run = h.store.get('run_test'); + assert.equal(run?.status, 'complete'); + assert.deepEqual(run?.rows[1], {line: 3, title: null, status: 'skipped', reason: 'title is required'}); + assert.deepEqual(run?.rows.map(r => r.status), ['created', 'skipped', 'created']); + }); + + it('completes without reporting when every row is skipped before a write', async function () { + const h = harness([ + {title: '', html: '

No title

', published_at: undefined}, + {title: '', html: '

Still no title

', published_at: undefined} + ]); + + await h.run(); + + assert.deepEqual(h.reported, []); + assert.equal(h.store.get('run_test')?.status, 'complete'); + assert.deepEqual(h.store.get('run_test')?.rows.map(outcome => outcome.status), ['skipped', 'skipped']); + }); + + it('still reports a run-level failure that is nobody\'s row', async function () { + const h = harness(); + const failure = new Error('converter unavailable'); + h.setHtmlToLexicalFactory(() => { + throw failure; + }); + + await h.run(); + + assert.deepEqual(h.reported, [failure]); + assert.equal(h.store.get('run_test')?.status, 'failed'); + assert.equal(h.store.get('run_test')?.failureReason, 'converter unavailable'); + assert.ok(h.store.get('run_test')?.finishedAt instanceof Date); + }); + + it('keeps a successfully written post created when its URL cannot be resolved', async function () { + const h = harness(); + h.urlFailures.set('post_1', new Error('URL service unavailable')); + + await h.run(); + + assert.equal(h.created.length, 2); + assert.deepEqual(h.store.get('run_test')?.rows, [ + {line: 2, title: 'First', status: 'created', postId: 'post_1'}, + {line: 3, title: 'Second', status: 'created', postId: 'post_2', url: 'https://example.com/post_2/'} + ]); + assert.equal(h.reported.length, 1); + assert.equal((h.reported[0] as Error).message, 'Content import could not resolve a URL for 1 created post.'); + assert.match((h.reported[0] as Error).stack ?? '', /URL service unavailable/); + }); + + it('reports multiple URL resolution failures once per run', async function () { + const h = harness(); + h.urlFailures.set('post_1', new Error('first URL failure')); + h.urlFailures.set('post_2', new Error('second URL failure')); + + await h.run(); + + assert.deepEqual(h.store.get('run_test')?.rows.map(outcome => outcome.status), ['created', 'created']); + assert.equal(h.reported.length, 1); + assert.equal((h.reported[0] as Error).message, 'Content import could not resolve a URL for 2 created posts.'); + assert.match((h.reported[0] as Error).stack ?? '', /first URL failure/, 'the first URL failure is preserved as the cause'); + }); + + it('records a created outcome per row, 1-based, with the post id and URL', async function () { + const h = harness(); + + await h.run(); + + const run = h.store.get('run_test'); + assert.equal(run?.status, 'complete'); + assert.ok(run?.finishedAt instanceof Date); + assert.equal(run?.total, 2); + assert.deepEqual(run?.rows, [ + {line: 2, title: 'First', status: 'created', postId: 'post_1', url: 'https://example.com/post_1/'}, + {line: 3, title: 'Second', status: 'created', postId: 'post_2', url: 'https://example.com/post_2/'} + ]); + }); +}); diff --git a/ghost/core/test/unit/server/services/content-import/import/post-data.test.ts b/ghost/core/test/unit/server/services/content-import/import/post-data.test.ts new file mode 100644 index 00000000000..5ddf44f71bc --- /dev/null +++ b/ghost/core/test/unit/server/services/content-import/import/post-data.test.ts @@ -0,0 +1,125 @@ +import assert from 'node:assert/strict'; +import buildPostData, {RowSkipped} from '../../../../../../core/server/services/content-import/import/post-data'; +import {postImportRowSchema} from '../../../../../../core/server/services/content-import/import/row'; + +// A stand-in converter that shows what it was given, so the test can assert both the +// wiring (called with the row's html) and the stringification. +const htmlToLexical = (html: string) => ({converted: html}); + +const row = (cells: Record) => postImportRowSchema.parse(cells); + +const TAGS = ['#Import 2026-01-01 10:00', '#Import Run run_test']; + +describe('buildPostData', function () { + it('sets lexical from the html cell and never html itself', function () { + const data = buildPostData(row({title: 'T', html: '

Hello

'}), htmlToLexical, TAGS); + + assert.equal(data.lexical, JSON.stringify({converted: '

Hello

'})); + // the model strips client-supplied html when importing, so passing it would be a no-op + assert.equal('html' in data, false); + }); + + it('slugs the title with the standard rules, not the importing-mode pass', function () { + const data = buildPostData(row({title: 'A post with a comma, in its title'}), htmlToLexical, TAGS); + + assert.equal(data.slug, 'a-post-with-a-comma-in-its-title'); + }); + + it('omits lexical for an empty html cell, leaving the model its blank document', function () { + const data = buildPostData(row({title: 'T'}), htmlToLexical, TAGS); + + assert.equal('lexical' in data, false); + }); + + it('dates the whole post from the CSV date: published_at, created_at and updated_at', function () { + const data = buildPostData(row({title: 'T', published_at: '2025-01-01T00:00:00.000Z'}), htmlToLexical, TAGS); + + assert.equal(data.published_at, '2025-01-01T00:00:00.000Z'); + assert.equal(data.created_at, '2025-01-01T00:00:00.000Z'); + assert.equal(data.updated_at, '2025-01-01T00:00:00.000Z'); + }); + + it('fills every unspecified field with the import defaults', function () { + const data = buildPostData(row({title: 'T'}), htmlToLexical, TAGS); + + assert.equal(data.status, 'published'); + assert.equal(data.type, 'post'); + assert.equal(data.visibility, 'public', 'explicit: the model would read the default_content_visibility setting'); + assert.deepEqual(data.tags, TAGS.map(name => ({name}))); + assert.equal('authors' in data, false); + }); + + it('omits every date when the cell is absent, leaving the model to stamp now', function () { + const data = buildPostData(row({title: 'T'}), htmlToLexical, TAGS); + + assert.equal('published_at' in data, false); + assert.equal('created_at' in data, false); + assert.equal('updated_at' in data, false); + }); + + const skipsWith = (cells: Record, reason: string | RegExp) => { + assert.throws(() => buildPostData(row(cells), htmlToLexical, TAGS), (error: unknown) => { + assert.ok(error instanceof RowSkipped, 'skipped, not a hard failure'); + if (reason instanceof RegExp) { + assert.match(error.message, reason); + } else { + assert.equal(error.message, reason); + } + return true; + }); + }; + + it('skips a row without a title', function () { + skipsWith({html: '

No title

'}, 'title is required'); + }); + + it('skips a row whose title is only whitespace', function () { + skipsWith({title: ' '}, 'title is required'); + }); + + it('skips a row whose title is longer than 255 characters', function () { + skipsWith({title: 'x'.repeat(256)}, 'title must be 255 characters or fewer'); + }); + + it('accepts a title of exactly 255 characters', function () { + const data = buildPostData(row({title: 'x'.repeat(255)}), htmlToLexical, TAGS); + + assert.equal(data.title.length, 255); + }); + + it('skips a row whose published_at is not a date, quoting the cell', function () { + skipsWith( + {title: 'T', published_at: 'not-a-date'}, + 'published_at is not a valid date: "not-a-date"' + ); + }); + + it('skips a row whose published_at is a rolled-over calendar date', function () { + // new Date() would normalize this to March 2 and quietly mis-date the post + skipsWith( + {title: 'T', published_at: '2025-02-30T00:00:00.000Z'}, + 'published_at is not a valid date: "2025-02-30T00:00:00.000Z"' + ); + }); + + it('accepts a non-ISO date format that new Date can parse', function () { + const data = buildPostData(row({title: 'T', published_at: 'May 1, 2024'}), htmlToLexical, TAGS); + + assert.equal(data.published_at, 'May 1, 2024'); + }); + + it('skips a row whose html cannot be converted', function () { + const throwingConverter = () => { + throw new Error('parser exploded'); + }; + + assert.throws( + () => buildPostData(row({title: 'T', html: '

bad

'}), throwingConverter, TAGS), + (error: unknown) => { + assert.ok(error instanceof RowSkipped); + assert.equal(error.message, 'html could not be converted'); + return true; + } + ); + }); +}); diff --git a/ghost/core/test/unit/server/services/content-import/import/row.test.ts b/ghost/core/test/unit/server/services/content-import/import/row.test.ts new file mode 100644 index 00000000000..9eb13f53c90 --- /dev/null +++ b/ghost/core/test/unit/server/services/content-import/import/row.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import {postImportRowSchema} from '../../../../../../core/server/services/content-import/import/row'; + +// The schema is where the raw string cells the CSV reader emits become typed post +// fields. Coercion rules live here so the importer reads precise values without +// re-checking them. +describe('post import row schema', function () { + it('trims the title, because the model skips its own trim when importing', function () { + assert.equal(postImportRowSchema.parse({title: ' Padded '}).title, 'Padded'); + assert.equal(postImportRowSchema.parse({title: ' '}).title, ''); + }); + + it('defaults a missing title to the empty string rather than undefined', function () { + assert.equal(postImportRowSchema.parse({}).title, ''); + }); + + it('defaults a missing html cell to the empty string', function () { + assert.equal(postImportRowSchema.parse({}).html, ''); + assert.equal(postImportRowSchema.parse({html: '

Hi

'}).html, '

Hi

'); + }); + + it('reads an empty (or literally "undefined") published_at cell as absent', function () { + assert.equal(postImportRowSchema.parse({published_at: ''}).published_at, undefined); + assert.equal(postImportRowSchema.parse({published_at: 'undefined'}).published_at, undefined); + assert.equal(postImportRowSchema.parse({published_at: '2025-01-01T00:00:00.000Z'}).published_at, '2025-01-01T00:00:00.000Z'); + }); + + it('passes unknown columns through for later milestones to consume', function () { + const parsed = postImportRowSchema.parse({title: 'T', custom_thing: 'kept'}); + assert.equal(parsed.custom_thing, 'kept'); + }); +}); diff --git a/ghost/core/test/unit/server/services/content-import/import/store.test.ts b/ghost/core/test/unit/server/services/content-import/import/store.test.ts new file mode 100644 index 00000000000..8eae300fd59 --- /dev/null +++ b/ghost/core/test/unit/server/services/content-import/import/store.test.ts @@ -0,0 +1,125 @@ +import assert from 'node:assert/strict'; +import {ImportRunStore, type RowOutcome} from '../../../../../../core/server/services/content-import/import/store'; + +const outcome = (line: number): RowOutcome => ({ + line, + title: `Post ${line}`, + status: 'created', + postId: `post_${line}`, + url: `https://example.com/post-${line}/` +}); + +describe('ImportRunStore', function () { + it('tracks a run from created to complete, in order', function () { + const store = new ImportRunStore(); + + store.create('run_1', 2); + store.record('run_1', outcome(1)); + store.record('run_1', outcome(2)); + + const running = store.get('run_1'); + assert.equal(running?.status, 'running'); + assert.equal(running?.total, 2); + assert.deepEqual(running?.rows.map(r => r.line), [1, 2]); + assert.equal(running?.finishedAt, undefined); + + store.finish('run_1'); + + const finished = store.get('run_1'); + assert.equal(finished?.status, 'complete'); + assert.ok(finished?.finishedAt instanceof Date); + }); + + it('ignores writes against an unknown run rather than throwing', function () { + const store = new ImportRunStore(); + + store.record('nope', outcome(1)); + store.finish('nope'); + store.fail('nope', 'failed'); + + assert.equal(store.get('nope'), undefined); + }); + + it('tracks a run-level failure as a finished terminal state', function () { + const finishedAt = new Date('2026-01-01T11:00:00.000Z'); + const store = new ImportRunStore({now: () => finishedAt}); + + store.create('run_failed', 2); + store.fail('run_failed', 'converter unavailable'); + + const failed = store.get('run_failed'); + assert.equal(failed?.status, 'failed'); + assert.equal(failed?.failureReason, 'converter unavailable'); + assert.equal(failed?.finishedAt, finishedAt); + }); + + it('keeps only the most recent finished runs', function () { + const store = new ImportRunStore(); + + for (let i = 1; i <= 12; i += 1) { + store.create(`run_${i}`, 1); + store.finish(`run_${i}`); + } + + assert.equal(store.get('run_1'), undefined, 'the oldest runs were evicted'); + assert.equal(store.get('run_2'), undefined); + assert.ok(store.get('run_3'), 'recent runs survive'); + assert.ok(store.get('run_12')); + }); + + it('never evicts a running run by count', function () { + const store = new ImportRunStore(); + + for (let i = 1; i <= 12; i += 1) { + store.create(`run_${i}`, 1); + } + + assert.ok(store.get('run_1'), 'still running: its job would lose record() and finish()'); + assert.ok(store.get('run_12')); + }); + + it('never evicts a running run by age', function () { + let now = new Date('2026-01-01T10:00:00.000Z'); + const store = new ImportRunStore({now: () => now}); + + store.create('run_stuck', 1); + + now = new Date('2026-01-01T12:00:00.000Z'); + store.create('run_new', 1); + + assert.ok(store.get('run_stuck'), 'two hours old but still running'); + assert.ok(store.get('run_new')); + }); + + it('drops runs older than an hour when a new run arrives', function () { + let now = new Date('2026-01-01T10:00:00.000Z'); + const store = new ImportRunStore({now: () => now}); + + store.create('run_old', 1); + store.finish('run_old'); + + now = new Date('2026-01-01T10:30:00.000Z'); + store.create('run_recent', 1); + + now = new Date('2026-01-01T11:15:00.000Z'); + store.create('run_new', 1); + + assert.equal(store.get('run_old'), undefined, 'aged out'); + assert.ok(store.get('run_recent'), 'not yet an hour old'); + assert.ok(store.get('run_new')); + }); + + it('evicts failed runs by age like completed runs', function () { + let now = new Date('2026-01-01T10:00:00.000Z'); + const store = new ImportRunStore({now: () => now}); + + store.create('run_failed', 1); + store.fail('run_failed', 'converter unavailable'); + + now = new Date('2026-01-01T11:15:00.000Z'); + store.create('run_new', 1); + + assert.equal(store.get('run_failed'), undefined); + assert.ok(store.get('run_new')); + }); +}); diff --git a/ghost/core/test/unit/server/services/gifts/gift-bookshelf-repository.test.ts b/ghost/core/test/unit/server/services/gifts/gift-bookshelf-repository.test.ts index 09ef5c9220e..a0eb65d3b6d 100644 --- a/ghost/core/test/unit/server/services/gifts/gift-bookshelf-repository.test.ts +++ b/ghost/core/test/unit/server/services/gifts/gift-bookshelf-repository.test.ts @@ -15,7 +15,8 @@ describe('GiftBookshelfRepository', function () { GiftModel: { findPage: sinon.stub(), ...GiftModel - } + }, + knex: sinon.stub() as unknown as Knex }); } @@ -92,6 +93,7 @@ describe('GiftBookshelfRepository', function () { assert.ok(gift instanceof Gift); assert.equal(gift?.token, 'gift-token'); assert.equal(gift?.tierId, 'tier_1'); + assert.equal(gift?.checkoutStartedAt, null); }); it('normalizes database date representations before constructing a Gift', async function () { @@ -166,6 +168,64 @@ describe('GiftBookshelfRepository', function () { assert.equal(gift, null); }); + it('returns the persisted id when creating a gift', async function () { + const GiftModel = { + add: sinon.stub().resolves({ + toJSON: () => ({id: 'gift_1'}) + }), + transaction: sinon.stub(), + findOne: sinon.stub(), + findAll: sinon.stub() + }; + const repository = createRepository(GiftModel); + const gift = Gift.fromPurchase({ + token: 'gift-token', + buyerEmail: 'buyer@example.com', + buyerMemberId: null, + tierId: 'tier_1', + cadence: 'year', + duration: 1, + currency: 'usd', + amount: 5000, + stripeCheckoutSessionId: 'cs_123', + stripePaymentIntentId: 'pi_456' + }); + + const id = await repository.create(gift, {transacting}); + + assert.equal(id, 'gift_1'); + sinon.assert.calledOnce(GiftModel.add); + assert.equal(GiftModel.add.firstCall.args[1].transacting, 'trx'); + }); + + it('rejects a created gift without a persisted id', async function () { + const GiftModel = { + add: sinon.stub().resolves({ + toJSON: () => ({}) + }), + transaction: sinon.stub(), + findOne: sinon.stub(), + findAll: sinon.stub() + }; + const repository = createRepository(GiftModel); + const gift = Gift.fromPurchase({ + token: 'gift-token', + buyerEmail: 'buyer@example.com', + buyerMemberId: null, + tierId: 'tier_1', + cadence: 'year', + duration: 1, + currency: 'usd', + amount: 5000, + stripeCheckoutSessionId: 'cs_123', + stripePaymentIntentId: 'pi_456' + }); + + await assert.rejects(() => repository.create(gift), { + message: 'Created gift is missing an id' + }); + }); + it('updates an existing gift', async function () { const existing = { save: sinon.stub().resolves(undefined), @@ -330,7 +390,7 @@ describe('GiftBookshelfRepository', function () { sinon.assert.calledOnceWithMatch(GiftModel.findPage, { withRelated: ['buyer', 'tier'], - filter: 'buyer_member_id:-null+custom:true', + filter: 'buyer_member_id:-null+purchased_at:-null+custom:true', order: 'purchased_at desc, id desc', useBasicCount: true }); @@ -382,7 +442,7 @@ describe('GiftBookshelfRepository', function () { sinon.assert.calledOnceWithMatch(GiftModel.findPage, { withRelated: ['redeemer', 'tier'], - filter: 'redeemer_member_id:-null+custom:true', + filter: 'redeemer_member_id:-null+redeemed_at:-null+custom:true', order: 'redeemed_at asc', useBasicCount: true }); diff --git a/ghost/core/test/unit/server/services/gifts/gift-checkout-adapter.test.js b/ghost/core/test/unit/server/services/gifts/gift-checkout-adapter.test.js index 7d0dc9f1269..8cfdbbcf3af 100644 --- a/ghost/core/test/unit/server/services/gifts/gift-checkout-adapter.test.js +++ b/ghost/core/test/unit/server/services/gifts/gift-checkout-adapter.test.js @@ -33,6 +33,7 @@ describe('GiftCheckoutAdapter', function () { it('translates a stable customer ID into the Stripe customer shape', async function () { const {adapter, stripeApi} = createAdapter(); stripeApi.createGiftCheckoutSession.resolves({ + id: 'cs_123', url: 'https://checkout.stripe.test/session' }); @@ -40,15 +41,17 @@ describe('GiftCheckoutAdapter', function () { amount: 12000, currency: 'usd', customerId: 'cus_123', - customerEmail: null + customerEmail: null, + idempotencyKey: 'gift_123' }); - assert.equal(result, 'https://checkout.stripe.test/session'); + assert.deepEqual(result, {id: 'cs_123', url: 'https://checkout.stripe.test/session'}); sinon.assert.calledOnceWithExactly(stripeApi.createGiftCheckoutSession, { amount: 12000, currency: 'usd', customerEmail: null, - customer: {id: 'cus_123'} + customer: {id: 'cus_123'}, + idempotencyKey: 'gift_123' }); }); diff --git a/ghost/core/test/unit/server/services/gifts/gift-delivery-service.test.ts b/ghost/core/test/unit/server/services/gifts/gift-delivery-service.test.ts new file mode 100644 index 00000000000..627c9bb5eea --- /dev/null +++ b/ghost/core/test/unit/server/services/gifts/gift-delivery-service.test.ts @@ -0,0 +1,305 @@ +import assert from 'node:assert/strict'; +import logging from '@tryghost/logging'; +import sinon from 'sinon'; +import type {Knex} from 'knex'; +import {GiftDeliveryService} from '../../../../../core/server/services/gifts/gift-delivery-service'; +import type {GiftDeliveryRepository} from '../../../../../core/server/services/gifts/gift-delivery-bookshelf-repository'; +import {buildGift, buildGiftDelivery} from './utils'; + +const DomainEvents = require('@tryghost/domain-events'); +const transacting = 'trx' as unknown as Knex.Transaction; + +describe('GiftDeliveryService', function () { + type GiftDeliveryRepositoryStub = { + [K in keyof GiftDeliveryRepository]: sinon.SinonStub; + }; + + let giftRepository: { + getById: sinon.SinonStub; + }; + let giftDeliveryRepository: GiftDeliveryRepositoryStub; + let giftEmailService: { + sendGiftDelivery: sinon.SinonStub; + }; + let dispatchDelivery: sinon.SinonStub; + let tiersService: { + api: { + read: sinon.SinonStub; + }; + }; + + beforeEach(function () { + giftRepository = { + getById: sinon.stub().resolves(buildGift({ + recipientName: 'Recipient', + buyerName: 'Buyer', + personalMessage: 'Enjoy this gift' + })) + }; + giftDeliveryRepository = { + getById: sinon.stub().resolves(null), + getByGiftId: sinon.stub().resolves(null), + findRecoverableForPurchasedGifts: sinon.stub().resolves([]), + tryStartDelivery: sinon.stub().resolves(buildGiftDelivery({status: 'sending'})), + markSent: sinon.stub().resolves(true), + recordCancelledAcceptance: sinon.stub().resolves(false), + markFailed: sinon.stub().resolves(true), + markCancelled: sinon.stub().resolves(true), + cancelPendingForGift: sinon.stub().resolves(false), + create: sinon.stub().resolves(undefined) + }; + giftEmailService = { + sendGiftDelivery: sinon.stub().resolves({providerMessageId: 'provider-123'}) + }; + dispatchDelivery = sinon.stub(DomainEvents, 'dispatch'); + tiersService = { + api: { + read: sinon.stub().resolves({ + name: 'Bronze', + toJSON: () => ({benefits: ['Benefit 1', 'Benefit 2']}) + }) + } + }; + }); + + function createService() { + return new GiftDeliveryService({ + giftRepository, + giftDeliveryRepository, + tiersService, + giftEmailService + }); + } + + afterEach(function () { + sinon.restore(); + }); + + it('dispatches an immediate send for a pending delivery after purchase', async function () { + giftDeliveryRepository.getByGiftId.resolves(buildGiftDelivery({id: 'delivery_1', recipientEmail: 'recipient@example.com'})); + const service = createService(); + + assert.equal(await service.dispatchForGift('gift_1'), 'recipient@example.com'); + assert.deepEqual(dispatchDelivery.firstCall.firstArg.data, {deliveryId: 'delivery_1'}); + }); + + it('recovers pending deliveries for purchased gifts one at a time', async function () { + giftDeliveryRepository.findRecoverableForPurchasedGifts.resolves([ + buildGiftDelivery({id: 'delivery_1'}), + buildGiftDelivery({id: 'delivery_2'}), + buildGiftDelivery({id: 'delivery_3'}) + ]); + giftDeliveryRepository.tryStartDelivery + .withArgs('delivery_1').resolves(buildGiftDelivery({id: 'delivery_1', status: 'sending'})) + .withArgs('delivery_2').resolves(buildGiftDelivery({id: 'delivery_2', status: 'sending'})) + .withArgs('delivery_3').resolves(null); + let inFlight = 0; + let maxInFlight = 0; + giftEmailService.sendGiftDelivery.callsFake(async () => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((resolve) => { + setImmediate(resolve); + }); + inFlight -= 1; + return {providerMessageId: 'provider-123'}; + }); + const service = createService(); + + assert.deepEqual(await service.recoverPending(), { + sentCount: 2, + skippedCount: 1, + failedCount: 0 + }); + sinon.assert.calledOnceWithExactly(giftDeliveryRepository.findRecoverableForPurchasedGifts, sinon.match.date, 1000); + sinon.assert.notCalled(dispatchDelivery); + sinon.assert.calledTwice(giftEmailService.sendGiftDelivery); + assert.equal(maxInFlight, 1); + sinon.assert.calledWith(giftDeliveryRepository.markSent, 'delivery_1'); + sinon.assert.calledWith(giftDeliveryRepository.markSent, 'delivery_2'); + }); + + it('keeps recovering remaining deliveries when one send throws', async function () { + sinon.stub(logging, 'error'); + giftDeliveryRepository.findRecoverableForPurchasedGifts.resolves([ + buildGiftDelivery({id: 'delivery_1'}), + buildGiftDelivery({id: 'delivery_2'}) + ]); + giftDeliveryRepository.tryStartDelivery + .withArgs('delivery_1').rejects(new Error('db down')) + .withArgs('delivery_2').resolves(buildGiftDelivery({id: 'delivery_2', status: 'sending'})); + const service = createService(); + + assert.deepEqual(await service.recoverPending(), { + sentCount: 1, + skippedCount: 0, + failedCount: 1 + }); + sinon.assert.calledOnce(giftEmailService.sendGiftDelivery); + }); + + it('cancels a pending delivery within the gift lifecycle transaction', async function () { + giftDeliveryRepository.cancelPendingForGift.resolves(true); + const service = createService(); + + assert.equal(await service.cancelPendingForGift('gift-token', {transacting}), true); + sinon.assert.calledOnceWithExactly(giftDeliveryRepository.cancelPendingForGift, 'gift-token', {transacting}); + }); + + it('claims, sends, and records mail transport acceptance', async function () { + const service = createService(); + + const result = await service.send('delivery_1'); + + assert.equal(result, 'sent'); + sinon.assert.calledOnceWithExactly(giftDeliveryRepository.tryStartDelivery, 'delivery_1', sinon.match.date, sinon.match.date); + sinon.assert.calledOnceWithExactly(giftDeliveryRepository.markSent, 'delivery_1', sinon.match.date, 'provider-123'); + sinon.assert.calledOnceWithExactly(giftEmailService.sendGiftDelivery, sinon.match({ + buyerEmail: 'buyer@example.com', + buyerName: 'Buyer' + })); + }); + + it('records transport acceptance without a provider message ID', async function () { + giftEmailService.sendGiftDelivery.resolves({providerMessageId: null}); + const service = createService(); + + assert.equal(await service.send('delivery_1'), 'sent'); + sinon.assert.calledOnceWithExactly(giftDeliveryRepository.markSent, 'delivery_1', sinon.match.date, null); + }); + + it('leaves an accepted handoff in sending when the durable sent fact cannot be persisted', async function () { + sinon.stub(logging, 'error'); + giftDeliveryRepository.markSent.resolves(false); + const service = createService(); + + assert.equal(await service.send('delivery_1'), 'failed'); + sinon.assert.notCalled(giftDeliveryRepository.markFailed); + }); + + it('records acceptance details on a delivery cancelled while its email was in flight', async function () { + sinon.stub(logging, 'info'); + const errorLog = sinon.stub(logging, 'error'); + giftDeliveryRepository.markSent.resolves(false); + giftDeliveryRepository.recordCancelledAcceptance.resolves(true); + const service = createService(); + + assert.equal(await service.send('delivery_1'), 'skipped'); + sinon.assert.calledOnceWithExactly(giftDeliveryRepository.recordCancelledAcceptance, 'delivery_1', sinon.match.date, 'provider-123'); + sinon.assert.notCalled(giftDeliveryRepository.markFailed); + sinon.assert.notCalled(errorLog); + }); + + it('retries accepted handoff persistence once', async function () { + sinon.stub(logging, 'warn'); + giftDeliveryRepository.markSent.onFirstCall().rejects({code: 'ECONNREFUSED'}); + giftDeliveryRepository.markSent.onSecondCall().resolves(true); + const service = createService(); + + assert.equal(await service.send('delivery_1'), 'sent'); + sinon.assert.calledTwice(giftDeliveryRepository.markSent); + sinon.assert.notCalled(giftDeliveryRepository.markFailed); + }); + + it('moves an accepted handoff out of recovery when persistence retries fail', async function () { + sinon.stub(logging, 'error'); + sinon.stub(logging, 'warn'); + giftDeliveryRepository.markSent.rejects({code: 'ECONNREFUSED'}); + const service = createService(); + + assert.equal(await service.send('delivery_1'), 'failed'); + sinon.assert.calledTwice(giftDeliveryRepository.markSent); + sinon.assert.calledOnceWithExactly(giftDeliveryRepository.markFailed, 'delivery_1'); + }); + + it('does not send when another worker or lifecycle transition starts the delivery first', async function () { + giftDeliveryRepository.tryStartDelivery.resolves(null); + const service = createService(); + + const result = await service.send('delivery_1'); + + assert.equal(result, 'skipped'); + sinon.assert.notCalled(giftEmailService.sendGiftDelivery); + }); + + it('fails a started delivery whose gift is missing', async function () { + giftRepository.getById.resolves(null); + const service = createService(); + + const result = await service.send('delivery_1'); + + assert.equal(result, 'failed'); + sinon.assert.calledOnceWithExactly(giftDeliveryRepository.markFailed, 'delivery_1'); + sinon.assert.notCalled(giftEmailService.sendGiftDelivery); + }); + + it('fails a started email delivery without required buyer details', async function () { + giftRepository.getById.resolves(buildGift({ + buyerEmail: null, + buyerName: null + })); + const service = createService(); + + const result = await service.send('delivery_1'); + + assert.equal(result, 'failed'); + sinon.assert.calledOnceWithExactly(giftDeliveryRepository.markFailed, 'delivery_1'); + sinon.assert.notCalled(giftEmailService.sendGiftDelivery); + }); + + it('cancels a started delivery when its gift is no longer purchased', async function () { + giftRepository.getById.resolves(buildGift({ + status: 'refunded', + refundedAt: new Date() + })); + const service = createService(); + + const result = await service.send('delivery_1'); + + assert.equal(result, 'skipped'); + sinon.assert.calledOnceWithExactly(giftDeliveryRepository.markCancelled, 'delivery_1'); + sinon.assert.notCalled(giftEmailService.sendGiftDelivery); + }); + + it('fails a delivery when the mail transport does not accept it', async function () { + sinon.stub(logging, 'error'); + giftEmailService.sendGiftDelivery.rejects(new Error('421 Try again later')); + const service = createService(); + + assert.equal(await service.send('delivery_1'), 'failed'); + sinon.assert.calledOnceWithExactly(giftDeliveryRepository.markFailed, 'delivery_1'); + }); + + it('logs only the underlying error when the bulk mailer rejects with the rendered message', async function () { + const errorLog = sinon.stub(logging, 'error'); + const transportError = new Error('Mailgun unavailable'); + giftEmailService.sendGiftDelivery.rejects({error: transportError, messageData: {html: '

secret link

'}}); + const service = createService(); + + const result = await service.send('delivery_1'); + + assert.equal(result, 'failed'); + sinon.assert.calledOnceWithExactly(errorLog, sinon.match({ + event: {name: 'gift_delivery.acceptance_failed'}, + err: transportError + }), sinon.match.string); + assert.equal(JSON.stringify(errorLog.firstCall.args).includes('secret link'), false); + }); + + const unusableTiers = [ + {name: 'cannot be read', arrange: (read: sinon.SinonStub) => read.rejects(new Error('tiers unavailable'))}, + {name: 'is missing', arrange: (read: sinon.SinonStub) => read.resolves(null)} + ]; + + for (const {name, arrange} of unusableTiers) { + it(`fails a delivery when its tier ${name}`, async function () { + sinon.stub(logging, 'error'); + arrange(tiersService.api.read); + const service = createService(); + + assert.equal(await service.send('delivery_1'), 'failed'); + sinon.assert.calledOnceWithExactly(giftDeliveryRepository.markFailed, 'delivery_1'); + sinon.assert.notCalled(giftEmailService.sendGiftDelivery); + }); + } +}); diff --git a/ghost/core/test/unit/server/services/gifts/gift-delivery.test.ts b/ghost/core/test/unit/server/services/gifts/gift-delivery.test.ts new file mode 100644 index 00000000000..47306aad73b --- /dev/null +++ b/ghost/core/test/unit/server/services/gifts/gift-delivery.test.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import ObjectID from 'bson-objectid'; +import {decodeGiftDeliveryRow, encodeGiftDelivery} from '../../../../../core/server/services/gifts/gift-delivery-codec'; + +describe('Gift delivery codec', function () { + it('round-trips the persisted delivery row through the codec', function () { + const row = { + id: new ObjectID().toHexString(), + gift_id: 'gift_1', + recipient_email: 'recipient@example.com', + status: 'sent', + started_at: null, + email_sent_at: '2026-08-11T11:00:00.000Z', + email_provider_message_id: 'provider-123' + } as const; + + const delivery = decodeGiftDeliveryRow(row); + + assert.ok(delivery.emailSentAt instanceof Date); + assert.deepEqual(encodeGiftDelivery(delivery), { + ...row, + email_sent_at: new Date(row.email_sent_at) + }); + }); + + it('rejects an invalid persisted state', function () { + assert.throws(() => decodeGiftDeliveryRow({ + id: new ObjectID().toHexString(), + gift_id: 'gift_1', + recipient_email: 'recipient@example.com', + status: 'waiting' + }), {name: 'ZodError'}); + }); +}); diff --git a/ghost/core/test/unit/server/services/gifts/gift-email-service.test.js b/ghost/core/test/unit/server/services/gifts/gift-email-service.test.js index 9396050b4a6..4f27cf9a904 100644 --- a/ghost/core/test/unit/server/services/gifts/gift-email-service.test.js +++ b/ghost/core/test/unit/server/services/gifts/gift-email-service.test.js @@ -1,8 +1,10 @@ +const assert = require('node:assert/strict'); const sinon = require('sinon'); const {GiftEmailService} = require('../../../../../core/server/services/gifts/gift-email-service'); describe('GiftEmailService', function () { - let mailer; + let transactionalMailer; + let bulkMailer; let service; const settingsCache = { @@ -30,6 +32,7 @@ describe('GiftEmailService', function () { const translate = (translations = {}) => (key, options = {}) => { const translatedKey = translations[key] || key; + const escapeValue = options.interpolation?.escapeValue !== false; const params = {...options}; delete params.interpolation; @@ -37,7 +40,15 @@ describe('GiftEmailService', function () { if (params[name] === undefined) { return `{${name}}`; } - return String(params[name]); + const value = String(params[name]); + + return escapeValue ? value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/\//g, '/') + .replace(/'/g, ''') : value; }); }; @@ -51,8 +62,12 @@ describe('GiftEmailService', function () { }; beforeEach(function () { - mailer = {send: sinon.stub().resolves()}; - service = new GiftEmailService({mailer, settingsCache, urlUtils, getFromAddress, blogIcon, t: translate()}); + transactionalMailer = {send: sinon.stub().resolves()}; + bulkMailer = { + isConfigured: sinon.stub().returns(true), + send: sinon.stub().resolves({id: ''}) + }; + service = new GiftEmailService({transactionalMailer, bulkMailer, settingsCache, urlUtils, getFromAddress, blogIcon, t: translate()}); }); afterEach(function () { @@ -62,30 +77,50 @@ describe('GiftEmailService', function () { it('sends to the buyer email with correct subject and from address', async function () { await service.sendPurchaseConfirmation(defaultData); - sinon.assert.calledOnce(mailer.send); - sinon.assert.calledWith(mailer.send, sinon.match({ + sinon.assert.calledOnce(transactionalMailer.send); + sinon.assert.calledWith(transactionalMailer.send, sinon.match({ to: 'buyer@example.com', subject: 'Your gift is ready', - from: 'Test Site ' + from: 'Test Site ', + disableTracking: true })); }); + it('tells the buyer an email gift is on its way to the recipient', async function () { + await service.sendPurchaseConfirmation({ + ...defaultData, + recipientEmail: 'recipient@example.com' + }); + + const message = transactionalMailer.send.firstCall.firstArg; + assert.equal(message.subject, 'Your gift is on its way'); + for (const field of ['html', 'text']) { + sinon.assert.match(message[field], sinon.match('recipient@example.com')); + sinon.assert.match(message[field], sinon.match('is on its way to')); + sinon.assert.match(message[field], sinon.match(value => !value.includes('has been sent to'))); + sinon.assert.match(message[field], sinon.match('You can also share the link below yourself')); + } + }); + it('includes gift link, tier name, and cadence in both HTML and text', async function () { await service.sendPurchaseConfirmation(defaultData); - const msg = mailer.send.getCall(0).args[0]; + const msg = transactionalMailer.send.getCall(0).args[0]; for (const field of ['html', 'text']) { sinon.assert.match(msg[field], sinon.match('https://example.com/gift/abc-123')); sinon.assert.match(msg[field], sinon.match('Gold')); - sinon.assert.match(msg[field], sinon.match('one-year')); } + sinon.assert.match(msg.html, sinon.match('1-year')); + sinon.assert.match(msg.text, sinon.match('1-year')); }); it('formats month cadence correctly', async function () { await service.sendPurchaseConfirmation({...defaultData, cadence: 'month'}); - sinon.assert.calledWith(mailer.send, sinon.match.has('html', sinon.match('one-month'))); + const message = transactionalMailer.send.firstCall.firstArg; + sinon.assert.match(message.html, sinon.match('1-month')); + sinon.assert.match(message.text, sinon.match('1-month')); }); it('formats the expiry date with the active locale', async function () { @@ -104,11 +139,11 @@ describe('GiftEmailService', function () { return ''; } }; - const localizedService = new GiftEmailService({mailer, settingsCache: localizedSettingsCache, urlUtils, getFromAddress, blogIcon, t: translate()}); + const localizedService = new GiftEmailService({transactionalMailer, bulkMailer, settingsCache: localizedSettingsCache, urlUtils, getFromAddress, blogIcon, t: translate()}); await localizedService.sendPurchaseConfirmation(defaultData); - const msg = mailer.send.getCall(0).args[0]; + const msg = transactionalMailer.send.getCall(0).args[0]; const expectedDate = new Intl.DateTimeFormat('fr', { day: 'numeric', month: 'short', @@ -133,10 +168,10 @@ describe('GiftEmailService', function () { } }; - const noTitleService = new GiftEmailService({mailer, settingsCache: noTitleSettingsCache, urlUtils, getFromAddress, blogIcon, t: translate()}); + const noTitleService = new GiftEmailService({transactionalMailer, bulkMailer, settingsCache: noTitleSettingsCache, urlUtils, getFromAddress, blogIcon, t: translate()}); await noTitleService.sendPurchaseConfirmation(defaultData); - sinon.assert.calledWith(mailer.send, sinon.match.has('text', sinon.match('membership to example.com'))); + sinon.assert.calledWith(transactionalMailer.send, sinon.match.has('text', sinon.match('membership to example.com'))); }); it('escapes user-controlled values containing HTML in the purchase confirmation HTML', async function () { @@ -152,14 +187,14 @@ describe('GiftEmailService', function () { } }; - const hostileService = new GiftEmailService({mailer, settingsCache: hostileSettingsCache, urlUtils, getFromAddress, blogIcon, t: translate()}); + const hostileService = new GiftEmailService({transactionalMailer, bulkMailer, settingsCache: hostileSettingsCache, urlUtils, getFromAddress, blogIcon, t: translate()}); await hostileService.sendPurchaseConfirmation({ ...defaultData, buyerEmail: 'buyer">@example.com', tierName: 'Gold ' }); - const msg = mailer.send.getCall(0).args[0]; + const msg = transactionalMailer.send.getCall(0).args[0]; // raw markup from injected fields must not appear in the HTML body sinon.assert.match(msg.html, sinon.match(value => !value.includes(''))); @@ -171,6 +206,204 @@ describe('GiftEmailService', function () { sinon.assert.match(msg.html, sinon.match(/', + tags: ['gift-delivery'], + disableTracking: true, + forceTextContent: true, + html: sinon.match('https://example.com/gift/abc-123'), + text: sinon.match('https://example.com/gift/abc-123') + })); + }); + + it('uses an attributive plural cadence for multi-month gifts', async function () { + await service.sendGiftDelivery({ + recipientEmail: 'recipient@example.com', + recipientName: null, + buyerEmail: 'buyer@example.com', + buyerName: 'Buyer', + personalMessage: null, + token: 'abc-123', + tierName: 'Gold', + benefits: [], + cadence: 'month', + duration: 3, + expiresAt: new Date('2027-04-07') + }); + + const message = bulkMailer.send.firstCall.firstArg; + sinon.assert.match(message.plaintext, sinon.match('a 3-month Gold membership')); + sinon.assert.match(message.html, sinon.match('3-month')); + sinon.assert.match(message.html, sinon.match(value => !value.includes('3 months'))); + sinon.assert.match(message.plaintext, sinon.match(value => !value.includes('3 months'))); + }); + + it('escapes recipient-controlled delivery content in HTML', async function () { + await service.sendGiftDelivery({ + recipientEmail: 'recipient@example.com', + recipientName: '', + buyerEmail: 'buyer@example.com', + buyerName: '', + personalMessage: 'not markup', + token: 'abc-123', + tierName: 'Gold', + benefits: ['benefit'], + cadence: 'month', + duration: 1, + expiresAt: new Date('2027-04-07') + }); + + const html = bulkMailer.send.firstCall.firstArg.html; + assert.ok(!html.includes('')); + assert.ok(!html.includes('')); + assert.ok(!html.includes('not markup')); + assert.ok(!html.includes('benefit')); + }); + + it('preserves literal interpolation characters in the subject and plain-text body', async function () { + const literalSettingsCache = { + get: (key) => { + if (key === 'title') { + return 'Research & '; + } + if (key === 'accent_color') { + return '#ff5500'; + } + return ''; + } + }; + const literalService = new GiftEmailService({ + transactionalMailer, + bulkMailer, + settingsCache: literalSettingsCache, + urlUtils, + getFromAddress, + blogIcon, + t: translate() + }); + + await literalService.sendGiftDelivery({ + recipientEmail: 'recipient@example.com', + recipientName: 'Pat O\'Neil & Co', + buyerEmail: 'buyer@example.com', + buyerName: 'Sam & Alex ', + personalMessage: null, + token: 'abc-123', + tierName: 'Gold & Silver ', + benefits: [], + cadence: 'year', + duration: 1, + expiresAt: new Date('2027-04-07') + }); + + const message = bulkMailer.send.firstCall.firstArg; + assert.equal(message.subject, 'Sam & Alex sent you a gift'); + assert.match(message.plaintext, /Hi Pat O'Neil & Co,/); + assert.match(message.plaintext, /Sam & Alex has gifted you a 1-year Gold & Silver membership to Research & /); + assert.match(message.plaintext, /on behalf of Sam & Alex \(buyer@example.com\)\./); + assert.doesNotMatch(message.plaintext, /&(amp|lt|gt|#39);/); + }); + + it('rejects delivery when bulk Mailgun does not return an acceptance ID', async function () { + bulkMailer.send.resolves('Message sent'); + + await assert.rejects(service.sendGiftDelivery({ + recipientEmail: 'recipient@example.com', + recipientName: null, + buyerEmail: 'buyer@example.com', + buyerName: 'Buyer', + personalMessage: null, + token: 'abc-123', + tierName: 'Gold', + benefits: [], + cadence: 'month', + duration: 1, + expiresAt: new Date('2027-04-07') + }), {code: 'EMAIL_NOT_ACCEPTED'}); + sinon.assert.notCalled(transactionalMailer.send); + }); + + it('does not fall back when configured bulk Mailgun rejects the send', async function () { + const error = new Error('Mailgun rejected the send'); + bulkMailer.send.rejects(error); + + await assert.rejects(service.sendGiftDelivery({ + recipientEmail: 'recipient@example.com', + recipientName: null, + buyerEmail: 'buyer@example.com', + buyerName: 'Buyer', + personalMessage: null, + token: 'abc-123', + tierName: 'Gold', + benefits: [], + cadence: 'month', + duration: 1, + expiresAt: new Date('2027-04-07') + }), error); + sinon.assert.notCalled(transactionalMailer.send); + }); + }); + describe('sendReminder', function () { const reminderData = { memberEmail: 'member@example.com', @@ -182,8 +415,8 @@ describe('GiftEmailService', function () { it('sends to the redeemer with the correct subject and from address', async function () { await service.sendReminder(reminderData); - sinon.assert.calledOnce(mailer.send); - sinon.assert.calledWith(mailer.send, sinon.match({ + sinon.assert.calledOnce(transactionalMailer.send); + sinon.assert.calledWith(transactionalMailer.send, sinon.match({ to: 'member@example.com', subject: 'Your gift subscription is ending soon', from: 'Test Site ' @@ -193,7 +426,7 @@ describe('GiftEmailService', function () { it('includes consumesAt and manage subscription url in both HTML and text', async function () { await service.sendReminder(reminderData); - const msg = mailer.send.getCall(0).args[0]; + const msg = transactionalMailer.send.getCall(0).args[0]; const expectedDate = new Intl.DateTimeFormat('en-gb', { day: 'numeric', month: 'short', @@ -209,7 +442,7 @@ describe('GiftEmailService', function () { it('renders a "Continue subscription" CTA', async function () { await service.sendReminder(reminderData); - const msg = mailer.send.getCall(0).args[0]; + const msg = transactionalMailer.send.getCall(0).args[0]; sinon.assert.match(msg.html, sinon.match('Continue subscription')); sinon.assert.match(msg.text, sinon.match('Continue subscription')); @@ -218,7 +451,7 @@ describe('GiftEmailService', function () { it('greets the redeemer by first name when a name is available', async function () { await service.sendReminder(reminderData); - const msg = mailer.send.getCall(0).args[0]; + const msg = transactionalMailer.send.getCall(0).args[0]; for (const field of ['html', 'text']) { sinon.assert.match(msg[field], sinon.match('Hi Jamie,')); @@ -228,7 +461,7 @@ describe('GiftEmailService', function () { it('falls back to a generic greeting when no name is available', async function () { await service.sendReminder({...reminderData, memberName: null}); - const msg = mailer.send.getCall(0).args[0]; + const msg = transactionalMailer.send.getCall(0).args[0]; for (const field of ['html', 'text']) { sinon.assert.match(msg[field], sinon.match('Hey there,')); @@ -252,7 +485,8 @@ describe('GiftEmailService', function () { } }; const localizedService = new GiftEmailService({ - mailer, + transactionalMailer, + bulkMailer, settingsCache: localizedSettingsCache, urlUtils, getFromAddress, @@ -262,7 +496,7 @@ describe('GiftEmailService', function () { await localizedService.sendReminder(reminderData); - const msg = mailer.send.getCall(0).args[0]; + const msg = transactionalMailer.send.getCall(0).args[0]; const expectedDate = new Intl.DateTimeFormat('fr', { day: 'numeric', month: 'short', diff --git a/ghost/core/test/unit/server/services/gifts/gift-service-interface.test.ts b/ghost/core/test/unit/server/services/gifts/gift-service-interface.test.ts index e4dcf2f6f36..20280395923 100644 --- a/ghost/core/test/unit/server/services/gifts/gift-service-interface.test.ts +++ b/ghost/core/test/unit/server/services/gifts/gift-service-interface.test.ts @@ -3,6 +3,16 @@ import sinon from 'sinon'; import {GiftService} from '../../../../../core/server/services/gifts/gift-service'; import {buildGift} from './utils'; +function hasInvalidDeliveryContext(error: unknown): boolean { + const context = error && typeof error === 'object' ? (error as {context?: unknown}).context : null; + return typeof context === 'string' && context.startsWith('Invalid gift delivery data:'); +} + +function hasInvalidBuyerEmailContext(error: unknown): boolean { + const context = error && typeof error === 'object' ? (error as {context?: unknown}).context : null; + return typeof context === 'string' && context.startsWith('Invalid gift buyer email:'); +} + describe('GiftService interface', function () { afterEach(function () { sinon.restore(); @@ -32,6 +42,9 @@ describe('GiftService interface', function () { }) }; const giftRepository = { + create: sinon.stub().resolves('gift_1'), + update: sinon.stub().resolves(), + deletePendingCheckout: sinon.stub().resolves(true), getByToken: sinon.stub().resolves(null), getActiveByMember: sinon.stub().resolves(null), getActiveByMembers: sinon.stub().resolves(new Map()), @@ -41,10 +54,16 @@ describe('GiftService interface', function () { }; const checkoutAdapter = { getCustomerId: sinon.stub().resolves('cus_123'), - createSession: sinon.stub().resolves('https://checkout.stripe.test/session') + createSession: sinon.stub().resolves({id: 'cs_123', url: 'https://checkout.stripe.test/session'}) + }; + const giftDeliveryService = { + createForCheckout: sinon.stub().resolves(), + dispatchForGift: sinon.stub().resolves(null), + cancelPendingForGift: sinon.stub().resolves(false) }; const service = new GiftService({ giftRepository, + giftDeliveryService, memberRepository: {}, tiersService: { api: { @@ -67,18 +86,18 @@ describe('GiftService interface', function () { service, tier, giftRepository, - checkoutAdapter + checkoutAdapter, + giftDeliveryService }; } it('owns the complete gift checkout decision', async function () { - const {service, checkoutAdapter} = createService(); + const {service, checkoutAdapter, giftRepository} = createService(); const result = await service.startCheckout({ tierId: 'tier_1', cadence: 'year', duration: 1, - metadata: {attribution_id: 'post_1'}, successUrl: 'https://example.com/', cancelUrl: 'https://example.com/cancel/', buyer: { @@ -103,14 +122,174 @@ describe('GiftService interface', function () { assert.equal(plan.amount, 12000); assert.equal(plan.currency, 'usd'); assert.equal(plan.customerId, 'cus_123'); - assert.equal(plan.metadata.ghost_gift, 'true'); - assert.equal(plan.metadata.tier_id, 'tier_1'); - assert.match(plan.metadata.gift_token, /^[A-Za-z0-9]{12}$/); + assert.deepEqual(plan.metadata, {ghost_gift_id: 'gift_1'}); + assert.equal(plan.idempotencyKey, 'gift_1'); + const createdGift = giftRepository.create.firstCall.firstArg; + assert.match(createdGift.token, /^[A-Za-z0-9]{12}$/); + assert.equal(createdGift.status, 'payment_pending'); assert.equal(successUrl.searchParams.get('stripe'), 'gift-purchase-success'); - assert.equal(successUrl.searchParams.get('gift_token'), plan.metadata.gift_token); + assert.equal(successUrl.searchParams.get('gift_token'), createdGift.token); assert.equal(successUrl.searchParams.get('gift_tier'), 'tier_1'); assert.equal(successUrl.searchParams.get('gift_cadence'), 'year'); assert.equal(successUrl.searchParams.get('gift_duration'), null); + assert.equal(successUrl.searchParams.get('gift_delivery'), 'link'); + }); + + it('validates email delivery and keeps recipient PII out of Stripe metadata', async function () { + const {service, checkoutAdapter, giftRepository, giftDeliveryService} = createService({customizationEnabled: true}); + + await service.startCheckout({ + tierId: 'tier_1', + cadence: 'year', + deliveryMethod: 'email', + recipientEmail: ' recipient@example.com ', + recipientName: ' Recipient ', + buyerName: ' Buyer ', + personalMessage: ' Enjoy your gift ', + successUrl: 'https://example.com/', + buyer: { + memberId: null, + email: 'buyer@example.com', + name: null, + isAuthenticated: false + } + }); + + const metadata = checkoutAdapter.createSession.firstCall.firstArg.metadata; + const successUrl = new URL(checkoutAdapter.createSession.firstCall.firstArg.successUrl); + assert.deepEqual(metadata, {ghost_gift_id: 'gift_1'}); + const createdGift = giftRepository.create.firstCall.firstArg; + assert.equal(createdGift.recipientName, 'Recipient'); + assert.equal(createdGift.buyerName, 'Buyer'); + assert.equal(createdGift.personalMessage, 'Enjoy your gift'); + sinon.assert.calledOnceWithExactly(giftDeliveryService.createForCheckout, { + giftId: 'gift_1', + recipientEmail: 'recipient@example.com' + }, {transacting: 'trx'}); + assert.equal(successUrl.searchParams.get('gift_delivery'), 'email'); + }); + + it('prefers the checkout buyer name over the authenticated member name', async function () { + const {service, giftRepository} = createService({customizationEnabled: true}); + + await service.startCheckout({ + tierId: 'tier_1', + cadence: 'year', + deliveryMethod: 'email', + recipientEmail: 'recipient@example.com', + buyerName: 'Mum', + successUrl: 'https://example.com/', + buyer: { + memberId: 'member_1', + email: 'buyer@example.com', + name: 'Account Name', + isAuthenticated: true + } + }); + + assert.equal(giftRepository.create.firstCall.firstArg.buyerName, 'Mum'); + }); + + const invalidCheckouts = [ + { + name: 'email delivery without a buyer name', + overrides: {deliveryMethod: 'email', recipientEmail: 'recipient@example.com'}, + expected: hasInvalidDeliveryContext + }, + { + name: 'email delivery with a malformed recipient email', + overrides: {deliveryMethod: 'email', recipientEmail: 'not-an-email', buyerName: 'Buyer'}, + expected: hasInvalidDeliveryContext + }, + { + name: 'a personal message over the length limit', + overrides: {deliveryMethod: 'email', recipientEmail: 'recipient@example.com', buyerName: 'Buyer', personalMessage: 'x'.repeat(251)}, + expected: hasInvalidDeliveryContext + }, + { + name: 'email-only fields in link mode', + overrides: {deliveryMethod: 'link', recipientEmail: 'recipient@example.com'}, + expected: hasInvalidDeliveryContext + }, + { + name: 'a buyer without an email', + overrides: {deliveryMethod: 'link', buyer: {memberId: null, email: null, name: null, isAuthenticated: false}}, + expected: hasInvalidBuyerEmailContext + } + ]; + + for (const {name, overrides, expected} of invalidCheckouts) { + it(`rejects ${name}`, async function () { + const {service, checkoutAdapter} = createService({customizationEnabled: true}); + + await assert.rejects(() => service.startCheckout({ + tierId: 'tier_1', + cadence: 'year', + successUrl: 'https://example.com/', + buyer: { + memberId: 'member_1', + email: 'buyer@example.com', + name: 'Account Name', + isAuthenticated: true + }, + ...overrides + }), expected); + + sinon.assert.notCalled(checkoutAdapter.createSession); + }); + } + + it('keeps link gifts anonymous when buyer name is omitted', async function () { + const {service, giftRepository} = createService({customizationEnabled: true}); + + await service.startCheckout({ + tierId: 'tier_1', + cadence: 'year', + deliveryMethod: 'link', + recipientEmail: '', + recipientName: ' ', + personalMessage: '', + successUrl: 'https://example.com/', + buyer: { + memberId: 'member_1', + email: 'buyer@example.com', + name: 'Account Name', + isAuthenticated: true + } + }); + + const gift = giftRepository.create.firstCall.firstArg; + assert.equal(gift.buyerName, null); + assert.equal(gift.recipientName, null); + assert.equal(gift.personalMessage, null); + }); + + it('keeps omitted and explicit link delivery compatible while the flag is disabled', async function () { + const {service, checkoutAdapter} = createService(); + const base = { + tierId: 'tier_1', + cadence: 'year', + successUrl: 'https://example.com/', + buyer: { + memberId: null, + email: 'buyer@example.com', + name: null, + isAuthenticated: false + } + }; + + await service.startCheckout(base); + await service.startCheckout({...base, deliveryMethod: 'link'}); + await assert.rejects(() => service.startCheckout({ + ...base, + deliveryMethod: 'email', + recipientEmail: 'recipient@example.com' + }), {context: 'Gift email delivery is not available'}); + await assert.rejects(() => service.startCheckout({ + ...base, + buyerName: 'Buyer' + }), {context: 'Gift email delivery is not available'}); + assert.equal(checkoutAdapter.createSession.callCount, 2); }); for (const duration of [3, 6]) { @@ -120,7 +299,6 @@ describe('GiftService interface', function () { await service.startCheckout({ tierId: 'tier_1', duration, - metadata: {}, successUrl: 'https://example.com/', buyer: { memberId: null, @@ -136,7 +314,7 @@ describe('GiftService interface', function () { assert.equal(plan.cadence, 'month'); assert.equal(plan.duration, duration); assert.equal(plan.amount, 1000 * duration); - assert.equal(plan.metadata.duration, String(duration)); + assert.deepEqual(plan.metadata, {ghost_gift_id: 'gift_1'}); assert.equal(successUrl.searchParams.get('gift_duration'), String(duration)); }); } @@ -148,7 +326,6 @@ describe('GiftService interface', function () { tierId: 'tier_1', cadence: 'year', duration: 3, - metadata: {}, successUrl: 'https://example.com/', buyer: { memberId: null, @@ -173,7 +350,6 @@ describe('GiftService interface', function () { await service.startCheckout({ tierId: 'tier_1', cadence: 'year', - metadata: {}, successUrl: 'https://example.com/', buyer: { memberId: null, @@ -201,7 +377,6 @@ describe('GiftService interface', function () { await assert.rejects(() => service.startCheckout({ tierId: 'tier_1', duration: 3, - metadata: {}, successUrl: 'https://example.com/', buyer: { memberId: null, @@ -226,7 +401,6 @@ describe('GiftService interface', function () { await assert.rejects(() => service.startCheckout({ tierId: 'tier_1', duration: 2, - metadata: {}, successUrl: 'https://example.com/', buyer }), {context: 'Unsupported gift duration "2"'}); @@ -234,7 +408,6 @@ describe('GiftService interface', function () { tierId: 'tier_1', cadence: 'year', duration: 3, - metadata: {}, successUrl: 'https://example.com/', buyer }), {context: 'Gift duration "3" conflicts with cadence "year"'}); @@ -251,7 +424,6 @@ describe('GiftService interface', function () { offerId: 'offer_1', cadence: 'year', duration: 1, - metadata: {}, successUrl: 'https://example.com/', buyer: { memberId: null, @@ -265,6 +437,25 @@ describe('GiftService interface', function () { sinon.assert.notCalled(checkoutAdapter.createSession); }); + it('deletes the pending gift when Stripe session creation fails', async function () { + const {service, checkoutAdapter, giftRepository} = createService(); + checkoutAdapter.createSession.rejects(new Error('Stripe unavailable')); + + await assert.rejects(() => service.startCheckout({ + tierId: 'tier_1', + cadence: 'year', + successUrl: 'https://example.com/', + buyer: { + memberId: null, + email: 'buyer@example.com', + name: null, + isAuthenticated: false + } + }), /Stripe unavailable/); + + sinon.assert.calledOnceWithExactly(giftRepository.deletePendingCheckout, 'gift_1'); + }); + it('returns a stable continuation decision without exposing the Gift entity', async function () { const {service, giftRepository} = createService(); const gift = buildGift({ @@ -293,7 +484,10 @@ describe('GiftService interface', function () { const {service, giftRepository} = createService(); giftRepository.getByToken.resolves(buildGift({ token: 'gift-token', - tierId: 'tier_1' + tierId: 'tier_1', + buyerName: 'Jamie', + recipientName: 'Taylor', + personalMessage: 'Enjoy!' })); const result = await service.getRedeemable({ @@ -307,6 +501,9 @@ describe('GiftService interface', function () { duration: 1, currency: 'usd', amount: 5000, + buyer_name: 'Jamie', + recipient_name: 'Taylor', + message: 'Enjoy!', expires_at: new Date('2030-01-01T00:00:00.000Z'), consumes_at: null, tier: { diff --git a/ghost/core/test/unit/server/services/gifts/gift-service.test.ts b/ghost/core/test/unit/server/services/gifts/gift-service.test.ts index e0108e68f00..3db9905014a 100644 --- a/ghost/core/test/unit/server/services/gifts/gift-service.test.ts +++ b/ghost/core/test/unit/server/services/gifts/gift-service.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import errors from '@tryghost/errors'; +import logging from '@tryghost/logging'; import sinon from 'sinon'; import type {Knex} from 'knex'; import {GiftService, type GiftPurchaseData} from '../../../../../core/server/services/gifts/gift-service'; @@ -47,14 +48,21 @@ describe('GiftService', function () { findPendingExpiration: sinon.SinonStub<[], Promise>; findPendingReminder: sinon.SinonStub<[FindPendingReminderOptions], Promise>; findUnsentReminders: sinon.SinonStub<[], Promise>; + deleteAbandonedCheckouts: sinon.SinonStub, ReturnType>; browsePurchaseEvents: sinon.SinonStub, ReturnType>; browseRedemptionEvents: sinon.SinonStub, ReturnType>; create: sinon.SinonStub; update: sinon.SinonStub; + deletePendingCheckout: sinon.SinonStub; transaction: sinon.SinonStub, Promise>; }; let giftRepository: GiftRepositoryStub; + let giftDeliveryService: { + createForCheckout: sinon.SinonStub; + dispatchForGift: sinon.SinonStub; + cancelPendingForGift: sinon.SinonStub; + }; let memberRepository: { get: sinon.SinonStub; update: sinon.SinonStub; @@ -98,14 +106,21 @@ describe('GiftService', function () { findPendingExpiration: sinon.stub<[], Promise>().resolves([]), findPendingReminder: sinon.stub<[FindPendingReminderOptions], Promise>().resolves([]), findUnsentReminders: sinon.stub<[], Promise>().resolves([]), + deleteAbandonedCheckouts: sinon.stub, ReturnType>().resolves(0), browsePurchaseEvents: sinon.stub, ReturnType>().resolves({data: [], meta: {}}), browseRedemptionEvents: sinon.stub, ReturnType>().resolves({data: [], meta: {}}), - create: sinon.stub(), + create: sinon.stub().resolves('gift_1'), update: sinon.stub(), + deletePendingCheckout: sinon.stub().resolves(true), transaction: sinon.stub, Promise>().callsFake(async (callback) => { return await callback(transacting); }) }; + giftDeliveryService = { + createForCheckout: sinon.stub().resolves(undefined), + dispatchForGift: sinon.stub().resolves(null), + cancelPendingForGift: sinon.stub().resolves(false), + }; memberRepository = { get: sinon.stub().callsFake(() => { const memberGet = sinon.stub().returns(null); @@ -153,6 +168,10 @@ describe('GiftService', function () { }); let giftReminderScheduler: {scheduleFor: sinon.SinonStub}; + let checkoutAdapter: { + getCustomerId: sinon.SinonStub; + createSession: sinon.SinonStub; + }; function createService(overrides: { giftReminderScheduler?: {scheduleFor: sinon.SinonStub}; @@ -160,17 +179,19 @@ describe('GiftService', function () { giftReminderScheduler = overrides.giftReminderScheduler ?? { scheduleFor: sinon.stub().resolves() }; + checkoutAdapter = { + getCustomerId: sinon.stub().resolves(null), + createSession: sinon.stub().resolves({id: 'cs_checkout', url: 'https://checkout.example/'}) + }; return new GiftService({ giftRepository: giftRepository as any, + giftDeliveryService, memberRepository, tiersService, giftEmailService, staffServiceEmails, giftReminderScheduler, - checkoutAdapter: { - getCustomerId: sinon.stub().resolves(null), - createSession: sinon.stub().resolves('https://checkout.example/') - }, + checkoutAdapter, labsService: { isSet: sinon.stub().returns(false) }, @@ -181,6 +202,187 @@ describe('GiftService', function () { } describe('completePurchase', function () { + it('completes a pre-created pending gift without reconstructing recipient data from Stripe', async function () { + const pending = Gift.fromCheckout({ + token: 'pending-token', + buyerEmail: 'checkout-buyer@example.com', + buyerMemberId: null, + buyerName: 'Buyer', + recipientName: 'Recipient', + personalMessage: 'Enjoy this gift', + tierId: 'tier_1', + cadence: 'year', + duration: 1, + currency: 'usd', + amount: 5000 + }).bindCheckoutSession('cs_pending')!; + giftRepository.getById.resolves(pending); + giftDeliveryService.dispatchForGift.resolves('recipient@example.com'); + const service = createService(); + + assert.equal(await service.completePurchase({ + giftId: 'gift_1', + buyerEmail: null, + stripeCustomerId: null, + stripeCheckoutSessionId: 'cs_pending', + stripePaymentIntentId: 'pi_pending' + }), true); + + const purchased = giftRepository.update.firstCall.firstArg; + assert.equal(purchased.status, 'purchased'); + assert.equal(purchased.recipientName, 'Recipient'); + assert.equal(purchased.buyerEmail, 'checkout-buyer@example.com'); + assert.equal(purchased.currency, 'usd'); + assert.equal(purchased.amount, 5000); + assert.equal(purchased.stripePaymentIntentId, 'pi_pending'); + sinon.assert.notCalled(giftRepository.create); + sinon.assert.calledOnceWithExactly(giftDeliveryService.dispatchForGift, 'gift_1'); + }); + + function buildAnonymousPendingGift() { + return Gift.fromCheckout({ + token: 'pending-token', + buyerEmail: null, + buyerMemberId: null, + buyerName: null, + recipientName: null, + personalMessage: null, + tierId: 'tier_1', + cadence: 'year', + duration: 1, + currency: 'usd', + amount: 5000 + }).bindCheckoutSession('cs_pending')!; + } + + it('logs identifiers when a paid checkout has no matching gift', async function () { + const errorLog = sinon.stub(logging, 'error'); + const service = createService(); + + assert.equal(await service.completePurchase({ + giftId: 'missing_gift', + buyerEmail: 'buyer@example.com', + stripeCustomerId: null, + stripeCheckoutSessionId: 'cs_missing', + stripePaymentIntentId: 'pi_missing' + }), false); + + sinon.assert.calledOnceWithExactly(errorLog, sinon.match({ + event: {name: 'gift_purchase.completion_gift_missing'}, + giftId: 'missing_gift', + stripeCheckoutSessionId: 'cs_missing', + stripePaymentIntentId: 'pi_missing' + }), sinon.match.string); + sinon.assert.notCalled(giftDeliveryService.dispatchForGift); + }); + + it('continues purchase notifications when gift delivery dispatch fails', async function () { + const errorLog = sinon.stub(logging, 'error'); + giftRepository.getById.resolves(buildAnonymousPendingGift()); + giftDeliveryService.dispatchForGift.rejects(new Error('dispatch unavailable')); + const service = createService(); + + assert.equal(await service.completePurchase({ + giftId: 'gift_1', + buyerEmail: 'buyer@example.com', + stripeCustomerId: null, + stripeCheckoutSessionId: 'cs_pending', + stripePaymentIntentId: 'pi_pending' + }), true); + + sinon.assert.calledOnce(staffServiceEmails.notifyGiftPurchased); + sinon.assert.calledOnceWithExactly(giftEmailService.sendPurchaseConfirmation, sinon.match({ + buyerEmail: 'buyer@example.com', + recipientEmail: null + })); + sinon.assert.calledOnceWithExactly(errorLog, sinon.match({ + event: {name: 'gift_delivery.dispatch_failed'}, + giftId: 'gift_1' + }), sinon.match.string); + }); + + const unusableTiers = [ + {name: 'cannot be read', arrange: (read: sinon.SinonStub) => read.rejects(new Error('tiers unavailable'))}, + {name: 'is missing', arrange: (read: sinon.SinonStub) => read.resolves(null)} + ]; + + for (const {name, arrange} of unusableTiers) { + it(`does not fail a completed purchase when its tier ${name} for notifications`, async function () { + sinon.stub(logging, 'error'); + giftRepository.getById.resolves(buildAnonymousPendingGift()); + arrange(tiersService.api.read); + const service = createService(); + + assert.equal(await service.completePurchase({ + giftId: 'gift_1', + buyerEmail: 'buyer@example.com', + stripeCustomerId: null, + stripeCheckoutSessionId: 'cs_pending', + stripePaymentIntentId: 'pi_pending' + }), true); + + sinon.assert.notCalled(staffServiceEmails.notifyGiftPurchased); + sinon.assert.notCalled(giftEmailService.sendPurchaseConfirmation); + }); + } + + it('completes a pending gift without a checkout buyer email using the Stripe customer email', async function () { + giftRepository.getById.resolves(buildAnonymousPendingGift()); + const service = createService(); + + assert.equal(await service.completePurchase({ + giftId: 'gift_1', + buyerEmail: ' stripe-buyer@example.com ', + stripeCustomerId: null, + stripeCheckoutSessionId: 'cs_pending', + stripePaymentIntentId: 'pi_pending' + }), true); + + assert.equal(giftRepository.update.firstCall.firstArg.buyerEmail, 'stripe-buyer@example.com'); + sinon.assert.calledOnceWithExactly(giftEmailService.sendPurchaseConfirmation, sinon.match({ + buyerEmail: 'stripe-buyer@example.com' + })); + }); + + it('falls back to the Stripe customer member email when Stripe omits customer details', async function () { + const memberGet = sinon.stub(); + memberGet.withArgs('name').returns('Member Name'); + memberGet.withArgs('email').returns('member@example.com'); + memberRepository.get.resolves({id: 'member_1', get: memberGet}); + giftRepository.getById.resolves(buildAnonymousPendingGift()); + const service = createService(); + + assert.equal(await service.completePurchase({ + giftId: 'gift_1', + buyerEmail: null, + stripeCustomerId: 'cust_123', + stripeCheckoutSessionId: 'cs_pending', + stripePaymentIntentId: 'pi_pending' + }), true); + + const purchased = giftRepository.update.firstCall.firstArg; + assert.equal(purchased.buyerEmail, 'member@example.com'); + assert.equal(purchased.buyerMemberId, 'member_1'); + }); + + it('refuses to complete a pending gift when no buyer email is available', async function () { + giftRepository.getById.resolves(buildAnonymousPendingGift()); + const service = createService(); + + await assert.rejects(() => service.completePurchase({ + giftId: 'gift_1', + buyerEmail: undefined, + stripeCustomerId: null, + stripeCheckoutSessionId: 'cs_pending', + stripePaymentIntentId: 'pi_pending' + }), (err: unknown) => err instanceof errors.ValidationError && (err as {property?: string}).property === 'buyerEmail'); + + sinon.assert.notCalled(giftRepository.update); + sinon.assert.notCalled(giftDeliveryService.dispatchForGift); + sinon.assert.notCalled(staffServiceEmails.notifyGiftPurchased); + sinon.assert.notCalled(giftEmailService.sendPurchaseConfirmation); + }); + it('creates a Gift entity and saves it', async function () { const service = createService(); @@ -260,6 +462,14 @@ describe('GiftService', function () { assert.equal(gift.duration, 3); }); + it('keeps legacy completion tolerant of Stripe email formats', async function () { + const service = createService(); + + await service.completePurchase({...purchaseData, buyerEmail: 'unusual..local@example.com'}); + + assert.equal(giftRepository.create.firstCall.firstArg.buyerEmail, 'unusual..local@example.com'); + }); + it('throws ValidationError for an unnormalized duration', async function () { const service = createService(); @@ -301,15 +511,13 @@ describe('GiftService', function () { assert.equal(emailData.duration, 1); }); - it('throws when tier is not found', async function () { + it('keeps a legacy purchase completed when its tier is not found for notifications', async function () { + sinon.stub(logging, 'error'); tiersService.api.read.resolves(null); const service = createService(); - await assert.rejects( - () => service.completePurchase(purchaseData), - {message: 'Tier not found: tier_1'} - ); + assert.equal(await service.completePurchase(purchaseData), true); sinon.assert.notCalled(staffServiceEmails.notifyGiftPurchased); sinon.assert.notCalled(giftEmailService.sendPurchaseConfirmation); @@ -640,6 +848,7 @@ describe('GiftService', function () { const savedGift = giftRepository.update.getCall(0).args[0]; assert.equal(savedGift.status, 'expired'); assert.notEqual(savedGift.expiredAt, null); + sinon.assert.calledOnceWithExactly(giftDeliveryService.cancelPendingForGift, gift.token, {transacting}); }); it('skips gifts that are no longer purchased when re-loaded', async function () { @@ -688,6 +897,23 @@ describe('GiftService', function () { }); }); + describe('processAbandonedCheckouts', function () { + it('hard-deletes a pending checkout after 30 days without calling Stripe', async function () { + const clock = sinon.useFakeTimers(new Date('2026-08-18T12:00:00.000Z')); + giftRepository.deleteAbandonedCheckouts.resolves(1); + const service = createService(); + + const result = await service.processAbandonedCheckouts(); + + assert.deepEqual(result, {deletedCount: 1}); + sinon.assert.calledOnceWithExactly( + giftRepository.deleteAbandonedCheckouts, + new Date('2026-07-19T12:00:00.000Z') + ); + clock.restore(); + }); + }); + describe('processReminders', function () { const MS_PER_DAY = 24 * 60 * 60 * 1000; @@ -984,6 +1210,7 @@ describe('GiftService', function () { transacting }); sinon.assert.calledOnceWithExactly(giftRepository.update, redeemed, {transacting}); + sinon.assert.calledOnceWithExactly(giftDeliveryService.cancelPendingForGift, redeemed.token, {transacting}); sinon.assert.calledTwice(tiersService.api.read); sinon.assert.alwaysCalledWithExactly(tiersService.api.read, 'tier_1'); sinon.assert.calledOnceWithExactly(staffServiceEmails.notifyGiftSubscriptionStarted, { @@ -1331,6 +1558,7 @@ describe('GiftService', function () { assert.ok(saved.refundedAt); assert.notEqual(saved, gift); assert.deepEqual(options, {transacting}); + sinon.assert.calledOnceWithExactly(giftDeliveryService.cancelPendingForGift, saved.token, {transacting}); }); it('returns false when no gift matches the payment intent', async function () { diff --git a/ghost/core/test/unit/server/services/gifts/gift.test.ts b/ghost/core/test/unit/server/services/gifts/gift.test.ts index 9e46e8980a0..91868f2ab4f 100644 --- a/ghost/core/test/unit/server/services/gifts/gift.test.ts +++ b/ghost/core/test/unit/server/services/gifts/gift.test.ts @@ -29,14 +29,14 @@ describe('Gift', function () { const gift = Gift.fromPurchase(purchaseData); const after = new Date(); - assert.ok(gift.purchasedAt >= before); - assert.ok(gift.purchasedAt <= after); + assert.ok(gift.purchasedAt! >= before); + assert.ok(gift.purchasedAt! <= after); }); it('sets expiresAt to GIFT_EXPIRY_DAYS after purchasedAt', function () { const gift = Gift.fromPurchase(purchaseData); const daysDiff = Math.round( - (gift.expiresAt.getTime() - gift.purchasedAt.getTime()) / (1000 * 60 * 60 * 24) + (gift.expiresAt!.getTime() - gift.purchasedAt!.getTime()) / (1000 * 60 * 60 * 24) ); assert.equal(daysDiff, GIFT_EXPIRY_DAYS); @@ -70,6 +70,43 @@ describe('Gift', function () { }); }); + describe('fromCheckout', function () { + it('holds local purchase details without creating a redeemable gift', function () { + const gift = Gift.fromCheckout({ + token: 'pending-token', + buyerEmail: null, + buyerMemberId: null, + buyerName: 'Buyer', + recipientName: 'Recipient', + personalMessage: 'Enjoy this gift', + tierId: 'tier_1', + cadence: 'year', + duration: 1, + currency: 'usd', + amount: 5000 + }); + + assert.equal(gift.status, 'payment_pending'); + assert.equal(gift.stripeCheckoutSessionId, null); + assert.equal(gift.stripePaymentIntentId, null); + assert.equal(gift.purchasedAt, null); + assert.equal(gift.expiresAt, null); + assert.deepEqual(gift.checkRedeemable(null), {redeemable: false, reason: 'payment-pending'}); + + const purchased = gift.bindCheckoutSession('cs_123')?.completePurchase({ + buyerEmail: 'buyer@example.com', + buyerMemberId: null, + stripeCheckoutSessionId: 'cs_123', + stripePaymentIntentId: 'pi_123' + }); + assert.equal(purchased?.status, 'purchased'); + assert.equal(purchased?.recipientName, 'Recipient'); + assert.equal(purchased?.currency, 'usd'); + assert.equal(purchased?.amount, 5000); + assert.ok(purchased?.expiresAt); + }); + }); + describe('checkRedeemable', function () { const testCases = [ { diff --git a/ghost/core/test/unit/server/services/gifts/utils.ts b/ghost/core/test/unit/server/services/gifts/utils.ts index d45d0866dfe..eee7b9b2b03 100644 --- a/ghost/core/test/unit/server/services/gifts/utils.ts +++ b/ghost/core/test/unit/server/services/gifts/utils.ts @@ -1,4 +1,5 @@ import {Gift} from '../../../../../core/server/services/gifts/gift'; +import type {GiftDeliveryData} from '../../../../../core/server/services/gifts/gift-delivery-schema'; export function buildGift(overrides: Partial[0]> = {}) { return new Gift({ @@ -13,6 +14,7 @@ export function buildGift(overrides: Partial[ amount: 5000, stripeCheckoutSessionId: 'cs_123', stripePaymentIntentId: 'pi_456', + checkoutStartedAt: new Date('2026-01-01T00:00:00.000Z'), consumesAt: null, expiresAt: new Date('2030-01-01T00:00:00.000Z'), status: 'purchased', @@ -25,3 +27,16 @@ export function buildGift(overrides: Partial[ ...overrides }); } + +export function buildGiftDelivery(overrides: Partial = {}): GiftDeliveryData { + return { + id: 'delivery_1', + giftId: 'gift_1', + recipientEmail: 'recipient@example.com', + status: 'pending', + startedAt: null, + emailSentAt: null, + emailProviderMessageId: null, + ...overrides + }; +} diff --git a/ghost/core/test/unit/server/services/lib/mailgun-client.test.js b/ghost/core/test/unit/server/services/lib/mailgun-client.test.js index 990043c3abb..10fc25ba225 100644 --- a/ghost/core/test/unit/server/services/lib/mailgun-client.test.js +++ b/ghost/core/test/unit/server/services/lib/mailgun-client.test.js @@ -259,7 +259,8 @@ describe('MailgunClient', function () { replyTo: 'replyTo@example.com', html: '

Test Content

', plaintext: 'Test Content', - tags: ['another-tag'] + tags: ['another-tag'], + disable_tracking: true }; const recipientData = { 'test@example.com': { @@ -279,8 +280,11 @@ describe('MailgunClient', function () { /form-data; name="text"[^]*Test Content/m, /form-data; name="to"[^]*test@example.com/m, /form-data; name="recipient-variables"[^]*\{"test@example.com":\{"name":"Test User"\}\}/m, - /form-data; name="o:tag"[^]*ghost-email/m, - /form-data; name="o:tag"[^]*another-tag/m + /form-data; name="o:tag"\r?\n\r?\nghost-email\r?\n--/m, + /form-data; name="o:tag"\r?\n\r?\nanother-tag\r?\n--/m, + /form-data; name="o:tracking"\r?\n\r?\nno\r?\n--/m, + /form-data; name="o:tracking-clicks"\r?\n\r?\nno\r?\n--/m, + /form-data; name="o:tracking-opens"\r?\n\r?\nno\r?\n--/m ]; return regexList.every(regex => regex.test(body)); }) diff --git a/ghost/core/test/unit/server/services/mail/ghost-mailer.test.js b/ghost/core/test/unit/server/services/mail/ghost-mailer.test.js index 50f1da343fa..9543197fe70 100644 --- a/ghost/core/test/unit/server/services/mail/ghost-mailer.test.js +++ b/ghost/core/test/unit/server/services/mail/ghost-mailer.test.js @@ -1,4 +1,5 @@ const sinon = require('sinon'); +const nock = require('nock'); const {deferred} = require('../../../../utils/deferred') const mail = require('../../../../../core/server/services/mail'); const settingsCache = require('../../../../../core/shared/settings-cache'); @@ -424,6 +425,41 @@ describe('Mail: Ghostmailer', function () { assert.equal(sentMessage.trackOpens, undefined); }); + it('should explicitly disable Mailgun open and click tracking for transactional messages that require it', async function () { + configUtils.set({ + mail: { + transport: 'mailgun', + from: 'from@default.com', + options: {auth: {api_key: 'key', domain: 'domain.com'}} + } + }); + sandbox.stub(settingsCache, 'get').withArgs('email_track_opens').returns(true); + + // The transport whitelists and serialises the message before it reaches Mailgun, + // so assert on the request body rather than the object handed to sendMail + const sendMock = nock('https://api.mailgun.net') + .post('/v3/domain.com/messages', function (body) { + const regexList = [ + /form-data; name="o:tracking"\r?\n\r?\nno\r?\n--/m, + /form-data; name="o:tracking-opens"\r?\n\r?\nno\r?\n--/m, + /form-data; name="o:tracking-clicks"\r?\n\r?\nno\r?\n--/m + ]; + return regexList.every(regex => regex.test(body)) && !/name="disableTracking"/.test(body); + }) + .reply(200, {id: '', message: 'Queued. Thank you.'}); + + mailer = new mail.GhostMailer(); + + await mailer.send({ + to: 'recipient@example.com', + subject: 'Gift delivery', + html: 'content', + disableTracking: true + }); + + assert(sendMock.isDone()); + }); + it('should not add site ID tag when site ID is missing', async function () { configUtils.set({ hostSettings: {} // No siteId diff --git a/ghost/core/test/unit/server/services/members/members-api/controllers/router-controller.test.js b/ghost/core/test/unit/server/services/members/members-api/controllers/router-controller.test.js index f49bde1eb24..e91ced25012 100644 --- a/ghost/core/test/unit/server/services/members/members-api/controllers/router-controller.test.js +++ b/ghost/core/test/unit/server/services/members/members-api/controllers/router-controller.test.js @@ -974,6 +974,35 @@ describe('RouterController', function () { })); }); + it('passes gift delivery fields without forwarding client metadata', async function () { + const controller = createGiftController({tiersService: paidTierService()}); + + await controller.createCheckoutSession({ + body: { + type: 'gift', + tierId: 'tier_123', + cadence: 'month', + deliveryMethod: 'email', + recipientEmail: 'recipient@example.com', + recipientName: 'Recipient', + buyerName: 'Buyer', + personalMessage: 'Enjoy this gift', + metadata: { + ghost_gift_id: 'attacker-controlled-id', + custom_key: 'preserved' + } + } + }, mockRes); + + const input = giftService.service.startCheckout.firstCall.firstArg; + assert.equal(input.deliveryMethod, 'email'); + assert.equal(input.recipientEmail, 'recipient@example.com'); + assert.equal(input.recipientName, 'Recipient'); + assert.equal(input.buyerName, 'Buyer'); + assert.equal(input.personalMessage, 'Enjoy this gift'); + assert.equal('metadata' in input, false); + }); + it('rejects when offerId is provided', async function () { giftService.service.startCheckout.rejects(new errors.BadRequestError({ message: 'Bad Request.', diff --git a/ghost/core/test/unit/server/services/stripe/services/webhooks/checkout-session-event-service.test.js b/ghost/core/test/unit/server/services/stripe/services/webhooks/checkout-session-event-service.test.js index 087c27fb63c..70948458fb9 100644 --- a/ghost/core/test/unit/server/services/stripe/services/webhooks/checkout-session-event-service.test.js +++ b/ghost/core/test/unit/server/services/stripe/services/webhooks/checkout-session-event-service.test.js @@ -109,7 +109,7 @@ describe('CheckoutSessionEventService', function () { it('should call handleGiftEvent if session mode is payment and session metadata ghost_gift is present', async function () { const service = createService(); - const session = {mode: 'payment', metadata: {ghost_gift: 'true'}}; + const session = {mode: 'payment', payment_status: 'paid', metadata: {ghost_gift: 'true'}}; const handleGiftEventStub = sinon.stub(service, 'handleGiftEvent'); await service.handleEvent(session); @@ -117,6 +117,59 @@ describe('CheckoutSessionEventService', function () { sinon.assert.calledWith(handleGiftEventStub, session); }); + it('waits for async payment success when a gift checkout is still unpaid', async function () { + const service = createService(); + const session = {mode: 'payment', payment_status: 'unpaid', metadata: {ghost_gift: 'true'}}; + const handleGiftEventStub = sinon.stub(service, 'handleGiftEvent'); + + await service.handleEvent(session); + sinon.assert.notCalled(handleGiftEventStub); + + await service.handleEvent({...session, payment_status: 'paid'}, 'checkout.session.async_payment_succeeded'); + sinon.assert.calledOnce(handleGiftEventStub); + }); + + it('ignores async payment success for a gift that Stripe still reports as unpaid', async function () { + const service = createService(); + const session = {mode: 'payment', payment_status: 'unpaid', metadata: {ghost_gift: 'true'}}; + const handleGiftEventStub = sinon.stub(service, 'handleGiftEvent'); + + await service.handleEvent(session, 'checkout.session.async_payment_succeeded'); + + sinon.assert.notCalled(handleGiftEventStub); + }); + + it('ignores async payment success with conflicting donation and gift markers', async function () { + const service = createService(); + const session = {mode: 'payment', payment_status: 'paid', metadata: {ghost_donation: 'true', ghost_gift: 'true'}}; + const handleDonationEventStub = sinon.stub(service, 'handleDonationEvent'); + const handleGiftEventStub = sinon.stub(service, 'handleGiftEvent'); + + await service.handleEvent(session, 'checkout.session.async_payment_succeeded'); + + sinon.assert.notCalled(handleDonationEventStub); + sinon.assert.notCalled(handleGiftEventStub); + }); + + it('does not handle donations on async payment success', async function () { + const service = createService(); + const session = {mode: 'payment', payment_status: 'paid', metadata: {ghost_donation: 'true'}}; + const handleDonationEventStub = sinon.stub(service, 'handleDonationEvent'); + + await service.handleEvent(session, 'checkout.session.async_payment_succeeded'); + + sinon.assert.notCalled(handleDonationEventStub); + }); + + it('ignores failed async gift payments', async function () { + const service = createService(); + const handleGiftEventStub = sinon.stub(service, 'handleGiftEvent'); + + await service.handleEvent({mode: 'payment', metadata: {ghost_gift: 'true'}}, 'checkout.session.async_payment_failed'); + + sinon.assert.notCalled(handleGiftEventStub); + }); + it('should ignore false gift metadata flags', async function () { const service = createService(); const session = {mode: 'payment', metadata: {ghost_gift: 'false'}}; @@ -621,6 +674,98 @@ describe('CheckoutSessionEventService', function () { }); describe('handleGiftEvent', function () { + it('completes a pre-created gift using only its Stripe metadata identifier', async function () { + const service = createService(); + await service.handleGiftEvent({ + id: 'cs_test_123', + amount_total: 5500, + currency: 'usd', + customer: 'cust_123', + payment_intent: 'pi_test_456', + customer_details: {email: 'buyer@example.com'}, + metadata: {ghost_gift_id: 'gift_123'} + }); + + sinon.assert.calledOnceWithExactly(giftService.completePurchase, { + giftId: 'gift_123', + buyerEmail: 'buyer@example.com', + stripeCustomerId: 'cust_123', + stripeCheckoutSessionId: 'cs_test_123', + stripePaymentIntentId: 'pi_test_456' + }); + }); + + it('defers missing buyer email recovery to the pre-created gift service', async function () { + const service = createService(); + + await service.handleGiftEvent({ + id: 'cs_test_123', + amount_total: 5000, + currency: 'usd', + customer: 'cust_123', + payment_intent: 'pi_test_456', + metadata: {ghost_gift_id: 'gift_123'} + }); + + sinon.assert.notCalled(api.getCustomer); + sinon.assert.calledOnceWithExactly(giftService.completePurchase, { + giftId: 'gift_123', + buyerEmail: null, + stripeCustomerId: 'cust_123', + stripeCheckoutSessionId: 'cs_test_123', + stripePaymentIntentId: 'pi_test_456' + }); + }); + + it('recovers the buyer email from the Stripe Customer for a legacy gift', async function () { + const service = createService(); + api.getCustomer.resolves({ + id: 'cust_123', + email: 'recovered-buyer@example.com' + }); + + await service.handleGiftEvent({ + id: 'cs_test_123', + amount_total: 5000, + currency: 'usd', + customer: 'cust_123', + payment_intent: 'pi_test_456', + metadata: { + ghost_gift: 'true', + gift_token: 'abc-123-token', + tier_id: 'tier_456', + cadence: 'year', + duration: '1' + } + }); + + sinon.assert.calledOnceWithExactly(api.getCustomer, 'cust_123'); + sinon.assert.calledOnceWithExactly(giftService.completePurchase, sinon.match({ + token: 'abc-123-token', + buyerEmail: 'recovered-buyer@example.com', + stripeCustomerId: 'cust_123' + })); + }); + + it('uses the buyer email from expanded Stripe Customer data', async function () { + const service = createService(); + + await service.handleGiftEvent({ + id: 'cs_test_123', + amount_total: 5000, + currency: 'usd', + customer: {id: 'cust_123', email: 'expanded-buyer@example.com'}, + payment_intent: 'pi_test_456', + metadata: {ghost_gift_id: 'gift_123'} + }); + + sinon.assert.notCalled(api.getCustomer); + sinon.assert.calledOnceWithExactly(giftService.completePurchase, sinon.match({ + buyerEmail: 'expanded-buyer@example.com', + stripeCustomerId: 'cust_123' + })); + }); + it('calls giftService.completePurchase with session data', async function () { const service = createService(); const session = { diff --git a/ghost/core/test/unit/server/services/stripe/stripe-api.test.js b/ghost/core/test/unit/server/services/stripe/stripe-api.test.js index 111d60e78f1..0fef7845be5 100644 --- a/ghost/core/test/unit/server/services/stripe/stripe-api.test.js +++ b/ghost/core/test/unit/server/services/stripe/stripe-api.test.js @@ -812,7 +812,8 @@ describe('StripeAPI', function () { duration: 1, successUrl: '/gift-success', cancelUrl: '/gift-cancel', - metadata: {ghost_gift: 'true', gift_token: 'abc-123'} + metadata: {ghost_gift_id: 'gift_123'}, + idempotencyKey: 'gift_123' }); const args = mockStripe.checkout.sessions.create.firstCall.firstArg; @@ -825,6 +826,8 @@ describe('StripeAPI', function () { assert.equal(args.line_items[0].price_data.unit_amount, 5000); assert.equal(args.line_items[0].price_data.currency, 'usd'); assert.equal(args.line_items[0].price_data.product_data.name, 'Gift subscription — Pro (1 year)'); + assert.deepEqual(args.metadata, {ghost_gift_id: 'gift_123'}); + assert.deepEqual(mockStripe.checkout.sessions.create.firstCall.args[1], {idempotencyKey: 'gift_123'}); }); it('explicitly disables Managed Payments', async function () { diff --git a/ghost/core/test/unit/server/services/stripe/webhook-controller.test.js b/ghost/core/test/unit/server/services/stripe/webhook-controller.test.js index 0aa63147ebe..dd3a7867ef8 100644 --- a/ghost/core/test/unit/server/services/stripe/webhook-controller.test.js +++ b/ghost/core/test/unit/server/services/stripe/webhook-controller.test.js @@ -98,6 +98,21 @@ describe('WebhookController', function () { // expect(res.end.called).to.be.true; }); + for (const eventType of ['checkout.session.async_payment_succeeded', 'checkout.session.async_payment_failed']) { + it(`should handle ${eventType} event`, async function () { + const session = {customer: 'cust_123'}; + deps.webhookManager.parseWebhook.returns({ + type: eventType, + data: {object: session} + }); + + await controller.handle(req, res); + + sinon.assert.calledOnceWithExactly(deps.checkoutSessionEventService.handleEvent, session, eventType); + sinon.assert.calledWith(res.writeHead, 200); + }); + } + it('should handle customer subscription updated event', async function () { const event = { type: 'customer.subscription.updated', diff --git a/packages/i18n/locales/af/ghost.json b/packages/i18n/locales/af/ghost.json index c5d31c0e785..371501c53e4 100644 --- a/packages/i18n/locales/af/ghost.json +++ b/packages/i18n/locales/af/ghost.json @@ -1,10 +1,14 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_other": "", "{count} year_one": "", "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "Alles van die beste!", "Become a paid member of {site} to get access to all premium content.": "", "By {authors}": "", @@ -29,6 +33,7 @@ "Hey there,": "Hallo daar,", "Hey there!": "Hallo daar!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Indien u nie hierdie versoek gemaak het nie, kan u hierdie e-pos veilig ignoreer.", "If you did not make this request, you can simply delete this message.": "Indien u nie hierdie versoek gemaak het nie, kan u hierdie boodskap eenvoudig verwyder.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "", @@ -41,14 +46,13 @@ "Name": "", "New comment on {postTitle}": "", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "", "Please confirm your email address with this link:": "Bevestig asseblief u e-posadres met hierdie skakel:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Veilige aanmeldskakel vir {siteTitle}", "See you soon!": "Sien u binnekort weer!", "Sent to {email}": "Gestuur na {email}", @@ -68,10 +72,15 @@ "Thank you for subscribing to {siteTitle}.": "Dankie dat u ingeskryf het by {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Dankie dat u ingeskryf het by {siteTitle}. Kliek op die skakel hieronder om outomaties aan te meld:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Hierdie e-posadres sal nie gebruik word nie.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "", @@ -85,6 +94,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Welkom terug! Gebruik hierdie skakel om veilig aan te meld by u {siteTitle}-rekening:", + "What's included": "", + "What's included:": "", "When:": "", "Where:": "", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", @@ -97,7 +108,9 @@ "You're one tap away from subscribing to {siteTitle}!": "U is een kliek weg van inskrywing by {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/ar/ghost.json b/packages/i18n/locales/ar/ghost.json index d90c0dd7ac0..a301e42f3a1 100644 --- a/packages/i18n/locales/ar/ghost.json +++ b/packages/i18n/locales/ar/ghost.json @@ -1,4 +1,7 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_zero": "", "{count} month_one": "", "{count} month_two": "", @@ -13,6 +16,7 @@ "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "مع خالص التقدير!", "Become a paid member of {site} to get access to all premium content.": "", "By {authors}": "بواسطة {authors}", @@ -37,6 +41,7 @@ "Hey there,": "مرحبًا بك،", "Hey there!": "مرحبًا بك!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "إذا لم تقم بهذا الطلب، يمكنك تجاهل هذه الرسالة بكل امان.", "If you did not make this request, you can simply delete this message.": "إذا لم تقم بهذا الطلب، يمكنك ببساطة حذف هذه الرسالة.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "", @@ -49,14 +54,13 @@ "Name": "الاسم", "New comment on {postTitle}": "", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "مدفوع", "Please confirm your email address with this link:": "برجاء تأكيد بريدك الاكتروني من خلال الرابط:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "رابط تسجيل الدخول الامن الى {siteTitle}", "See you soon!": "نراكم قريبا!", "Sent to {email}": "تم الارسال الى {email}", @@ -76,10 +80,15 @@ "Thank you for subscribing to {siteTitle}.": "شكرا لاشتراكك في {siteTitle}", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "شكرا لاشتراكك في {siteTitle}. انقر الرابط ادناه لتسجيل الدخول:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "هذا البريد الاكتروني لن يتم استخدامه.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "فنرة تجريبية", @@ -93,6 +102,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "مرحبا بك مرة اخرى! استخدم هذا الرابط لتسجيل الدخول في حساب {siteTitle}:", + "What's included": "", + "What's included:": "", "When:": "", "Where:": "", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "انت تتلقى هذه الرسالة لأنك %%{status}%% مشترك فى {site}.", @@ -105,7 +116,9 @@ "You're one tap away from subscribing to {siteTitle}!": "أنت على بعد خطوة من الاشتراك في {siteTitle}", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "سيتم انتهاء فترتك التجريبية فى {date} وبحلول هذا الوقت سيتم محاسبتك على السعر الطبيعيى، بالطبع يمكنك إلغاء الاشتراك قبل هذا التاريخ.", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/bg/ghost.json b/packages/i18n/locales/bg/ghost.json index eb7524e6110..2b96e786751 100644 --- a/packages/i18n/locales/bg/ghost.json +++ b/packages/i18n/locales/bg/ghost.json @@ -1,10 +1,14 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "{count} месец", "{count} month_other": "{count} месеца", "{count} year_one": "{count} година", "{count} year_other": "{count} години", "{date}": "{date}", "{tierName} membership": "План {tierName}", + "A gift, just for you": "", "All the best!": "Сърдечни поздрави!", "Become a paid member of {site} to get access to all premium content.": "Станете плащащ абонат на {site} за да получите достъп до специалното съдържание за абонати.", "By {authors}": "от {authors}", @@ -29,6 +33,7 @@ "Hey there,": "Здравейте,", "Hey there!": "Здравейте!", "Hi {firstName},": "Здравейте, {firstName},", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Ако не сте направили тази заявка, може да игнорирате този имейл.", "If you did not make this request, you can simply delete this message.": "Ако не сте направили тази заявка, може просто да изтриете този имейл.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "Ако наскоро не сте опитвали да влезете, може спокойно да игнорирате този имейл, за да откажете достъп.", @@ -41,14 +46,13 @@ "Name": "Име", "New comment on {postTitle}": "Нов коментар към {postTitle}", "New reply to your comment on {siteTitle}": "Нов отговор на вашия коментар в {siteTitle}", - "one-month": "едномесечен", - "one-year": "едногодишен", "Open this link to redeem your gift.": "Отворете тази връзка, за да активирате подаръка си.", "Or use this link to securely sign in": "Или използвайте този линк, за да влезете по сигурен начин", "Or, skip the code and sign in directly": "Или пропуснете кода и влезте директно", "paid": "платен", "Please confirm your email address with this link:": "Моля, потвърдете своя имейл адрес чрез този линк:", "Redeem your gift subscription": "Активирайте своя подарен абонамент", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Линк за сигурно влизане в сайта {siteTitle}", "See you soon!": "До скоро!", "Sent to {email}": "Изпратено до {email}", @@ -68,10 +72,15 @@ "Thank you for subscribing to {siteTitle}.": "Благодарим Ви, че се абонирахте за {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Благодарим Ви, че се абонирахте за {siteTitle}. Използвайте бутона долу за автоматично влизане в сайта:", "Thank you for supporting {siteTitle}.": "Благодарим ви, че подкрепяте {siteTitle}.", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "Благодарим ви за подкрепата. Споделете връзката по-долу с комуто желаете да подарите {cadenceLabel} абонамент {tierName} за {siteTitle}.", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "Благодарим ви, че четете {siteTitle}.", "The link expires on {expiresAt} and can only be redeemed once.": "Връзката изтича на {expiresAt} и може да бъде активирана само веднъж.", "This email address will not be used.": "Този имейл адрес няма да бъде използван.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "Това съобщение е изпратено от {siteDomain} до {email}.", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "За да запазите {tierName} плана си, преминете към платен абонамент още днес и ние автоматично ще добавим остатъка от подарения ви период като безплатен пробен период.", "trialing": "пробен", @@ -85,6 +94,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "Добре дошли отново в {siteTitle}! Вашият код за проверка е {otc}.", "Welcome back! Here's your code to sign in to {siteTitle}": "Добре дошли отново! Ето вашия код за влизане в {siteTitle}", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Добре дошли отново! Използвайте бутона долу за сигурно влизане в своя профил в {siteTitle}:", + "What's included": "", + "What's included:": "", "When:": "Кога:", "Where:": "Къде:", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "Получавате това, защото имате %%{status}%% абонамент за {site}.", @@ -97,7 +108,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Вие сте на една стъпка от това да се абонирате за {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "Подарен ви е {duration}-месечен абонамент {tierName} за {siteTitle}", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "Подарен ви е {duration}-годишен абонамент {tierName} за {siteTitle}", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Безплатният пробен период приключва на {date}, след което ще бъдете таксувани по редовната цена. Винаги може да се откажете преди това.", + "Your gift is on its way": "", "Your gift is ready": "Подаръкът ви е готов", "Your gift subscription confirmation": "Потвърждение за вашия подарен абонамент", "Your gift subscription is ending soon": "Подареният ви абонамент изтича скоро", diff --git a/packages/i18n/locales/bn/ghost.json b/packages/i18n/locales/bn/ghost.json index 7d29d113f89..5651b5675f8 100644 --- a/packages/i18n/locales/bn/ghost.json +++ b/packages/i18n/locales/bn/ghost.json @@ -1,10 +1,14 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_other": "", "{count} year_one": "", "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "শুভেচ্ছা!", "Become a paid member of {site} to get access to all premium content.": "", "By {authors}": "", @@ -29,6 +33,7 @@ "Hey there,": "হ্যালো,", "Hey there!": "হ্যালো!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "আপনি যদি এই অনুরোধ না করে থাকেন, তবে আপনি নিরাপদে এই ইমেলটি উপেক্ষা করতে পারেন।", "If you did not make this request, you can simply delete this message.": "আপনি যদি এই অনুরোধ না করে থাকেন, তবে আপনি সহজেই এই বার্তাটি মুছে ফেলতে পারেন।", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "", @@ -41,14 +46,13 @@ "Name": "", "New comment on {postTitle}": "", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "", "Please confirm your email address with this link:": "এই লিঙ্ক দিয়ে আপনার ইমেল ঠিকানা নিশ্চিত করুন:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "{siteTitle} এর জন্য নিরাপদ সাইন ইন লিঙ্ক", "See you soon!": "শীঘ্রই দেখা হবে!", "Sent to {email}": "{email} এ পাঠানো হয়েছে", @@ -68,10 +72,15 @@ "Thank you for subscribing to {siteTitle}.": "{siteTitle} এ সাবস্ক্রাইব করার জন্য ধন্যবাদ।", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "{siteTitle} এ সাবস্ক্রাইব করার জন্য ধন্যবাদ। স্বয়ংক্রিয়ভাবে সাইন ইন হওয়ার জন্য নিচের লিঙ্কে ট্যাপ করুন:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "এই ইমেল ঠিকানা ব্যবহার করা হবে না।", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "", @@ -85,6 +94,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "আবার স্বাগতম! আপনার {siteTitle} অ্যাকাউন্টে নিরাপদে সাইন ইন করতে এই লিঙ্কটি ব্যবহার করুন:", + "What's included": "", + "What's included:": "", "When:": "", "Where:": "", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", @@ -97,7 +108,9 @@ "You're one tap away from subscribing to {siteTitle}!": "আপনি {siteTitle} এ সাবস্ক্রাইব করতে এক ট্যাপ দূরে রয়েছেন!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/bs/ghost.json b/packages/i18n/locales/bs/ghost.json index 3949a995da0..c375612e105 100644 --- a/packages/i18n/locales/bs/ghost.json +++ b/packages/i18n/locales/bs/ghost.json @@ -1,4 +1,7 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_few": "", "{count} month_other": "", @@ -7,6 +10,7 @@ "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "Lijep pozdrav!", "Become a paid member of {site} to get access to all premium content.": "", "By {authors}": "", @@ -31,6 +35,7 @@ "Hey there,": "Zdravo,", "Hey there!": "Zdravo!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Ako ovaj zahtjev nije došao od tebe, možeš slobodno ignorisati ovaj email.", "If you did not make this request, you can simply delete this message.": "Ako ovaj zahtjev nije došao od tebe, možeš slobodno izbrisati ovu poruku.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "", @@ -43,14 +48,13 @@ "Name": "", "New comment on {postTitle}": "", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "", "Please confirm your email address with this link:": "Potvrdi svoju email adresu putem slijedećeg linka:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Siguran link za prijavu za {siteTitle}", "See you soon!": "Vidimo se uskoro!", "Sent to {email}": "Poslano na {email}", @@ -70,10 +74,15 @@ "Thank you for subscribing to {siteTitle}.": "Hvala na pretplati za {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Hvala na pretplati za {siteTitle}. Klikni na link ispod za automatsku prijavu:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Ova Email adresa neće biti korištena.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "", @@ -87,6 +96,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Koristi slijedeći link za sigurnu prijavu na tvoj {siteTitle} račun:", + "What's included": "", + "What's included:": "", "When:": "", "Where:": "", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", @@ -99,7 +110,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Jedan klik te dijeli od pretplate na {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/ca/ghost.json b/packages/i18n/locales/ca/ghost.json index c4a395d3ac2..4379fb31c6e 100644 --- a/packages/i18n/locales/ca/ghost.json +++ b/packages/i18n/locales/ca/ghost.json @@ -1,4 +1,7 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_many": "", "{count} month_other": "", @@ -7,6 +10,7 @@ "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "Que et vagi bé!", "Become a paid member of {site} to get access to all premium content.": "", "By {authors}": "Per {authors}", @@ -31,6 +35,7 @@ "Hey there,": "Hola,", "Hey there!": "Hola!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Si no has fet aquesta petició, pots ignorar aquest correu electrònic sense problemes", "If you did not make this request, you can simply delete this message.": "Si no has fet aquesta petició, simplement pots esborrar aquest missatge.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "", @@ -43,14 +48,13 @@ "Name": "Nom", "New comment on {postTitle}": "", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "de pagament", "Please confirm your email address with this link:": "Si us plau, confirma la teva adreça de correu electrònic amb aquest enllaç:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Enllaç segur d'inici de sessió per {siteTitle}", "See you soon!": "A reveure!", "Sent to {email}": "Enviat a {email}", @@ -70,10 +74,15 @@ "Thank you for subscribing to {siteTitle}.": "Gràcies per subscriure't a {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Gràcies per subscriure't a {siteTitle}. Fes clic a l'enllaç a continuació per iniciar sessió automàticament:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Aquesta adreça de correu electrònic no s'utilitzarà.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "de prova", @@ -87,6 +96,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Benvingut de nou! Utilitza aquest enllaç per iniciar sessió de manera segura al teu compte de {siteTitle}:", + "What's included": "", + "What's included:": "", "When:": "", "Where:": "", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "Estàs rebent aquest missatge perquè ets subscriptor/a %%{status}%% de {site}.", @@ -99,7 +110,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Estàs a un sol pas de subscriure't a {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "El teu període de prova s'acaba el {date}. A partir d'aleshores, se't cobrarà el preu estipulat. Sempre pots cancel·lar la teva subscripció abans que això passi.", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/context.json b/packages/i18n/locales/context.json index 3f6159e504c..d8ddb59e616 100644 --- a/packages/i18n/locales/context.json +++ b/packages/i18n/locales/context.json @@ -154,6 +154,7 @@ "Hey there!": "An introduction/opening to an email", "Hey there,": "An introduction/opening to an email", "Hi {firstName},": "Greeting in the gift expiry reminder email, shown when the member's first name is known", + "Hi {recipientName},": "Greeting in the gift delivery email. {recipientName} is the name supplied by the gift buyer", "Hidden for members": "Shown to moderators when a comment is hidden from members", "Hide comment": "Action in the context menu for administrators", "Hide details": "Action button to hide details", @@ -245,6 +246,7 @@ "Read more replies": "Link at the bottom of a nested comment thread to open a focused view showing replies beyond the maximum displayed depth", "Recommendations": "A suggestion by/for another site of interest to users", "Redeem your gift subscription": "Fallback link text shown on the gift preview page when JavaScript is disabled, used to redeem a gift subscription", + "Redeem your gift:": "Call to action for the redemption link in the HTML and plain-text gift delivery emails", "Redeem your membership": "CTA button label on the Portal gift redemption page", "Redeeming...": "Loading state button label on the Portal gift redemption page", "Remove dislike": "Accessible label for the icon-only dislike button on a comment, used when the comment has already been disliked by the current user (clicking removes the dislike)", @@ -322,7 +324,10 @@ "Thank you for supporting {siteTitle}.": "Used in the gift purchase confirmation email. {siteTitle} is the publication name (e.g. 'The Daily')", "Thank you for your support": "A success message, shown in a modal after a non-member has made a tip or donation", "Thank you for your support!": "A success message, shown in a notification after a member has made a tip or donation", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "Body copy in the gift purchase confirmation email. {cadenceLabel} is the duration of the gift (e.g. 1 month), {tierName} is the name of the gifted tier, {siteTitle} is the publication name. {cadenceLabel} and {tierName} are wrapped in tags in the HTML version", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "Body copy in the gift purchase confirmation email for a link-delivery monthly gift. {duration} is the number of months, {tierName} is the membership tier, and {siteTitle} is the publication name.", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "Body copy in the gift purchase confirmation email for a link-delivery yearly gift. {duration} is the number of years, {tierName} is the membership tier, and {siteTitle} is the publication name.", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "Body copy in the gift purchase confirmation email for an email-delivery monthly gift. {duration} is the number of months, {tierName} is the membership tier, {siteTitle} is the publication name, and {recipientEmail} is the recipient address.", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "Body copy in the gift purchase confirmation email for an email-delivery yearly gift. {duration} is the number of years, {tierName} is the membership tier, {siteTitle} is the publication name, and {recipientEmail} is the recipient address.", "Thanks for reading {siteTitle}.": "Closing line in the gift expiry reminder email. {siteTitle} is the publication name (e.g. 'The Daily')", "Thanks for the feedback!": "A confirmation message after submitting member feedback", "That didn't go to plan": "An error message indicating something went wrong", @@ -337,10 +342,12 @@ "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 gift can only be redeemed once and expires on {expiresAt}.": "Expiry notice in the gift delivery email. {expiresAt} is the localized redemption expiry date", "This gift has already been consumed.": "Error message when redeeming a gift subscription", "This gift has already been redeemed.": "Error message when redeeming a gift subscription", "This gift has been refunded.": "Error message when redeeming a gift subscription", "This gift has expired.": "Error message when redeeming a gift subscription", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "Footer text in the gift delivery email. {siteDomain} identifies the publication, {email} is the recipient address, and {buyerName} and {buyerEmail} identify the gift buyer.", "This message was sent from {siteDomain} to {email}.": "Text in the footer of emails, e.g. reply to comments notifications", "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 donations at the moment.": "Error message in Portal when a user visits the donations page but donations are disabled", @@ -391,6 +398,8 @@ "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Magic link email", "Welcome back, {name}!": "Popup when a member logs in", "Welcome to {siteTitle}": "Shown in recommendations section of Portal", + "What's included": "Heading above the membership tier benefits in the gift delivery email", + "What's included:": "Plain-text heading above the membership tier benefits in the gift delivery email", "When an inbox fails to accept an email it is commonly called a bounce. In many cases, this can be temporary. However, in some cases, a bounced email can be returned as a permanent failure when an email address is invalid or non-existent.": "A paragraph from the email suppression FAQ", "When:": "Device verification (forthcoming)", "Where:": "Device verification (forthcoming)", @@ -416,7 +425,7 @@ "You're one tap away from subscribing to {siteTitle} — please confirm your email address with this link:": "Signup confirmation email", "You're one tap away from subscribing to {siteTitle}!": "Signup confirmation email", "You've been gifted a membership": "Subtitle on the Portal gift redemption page when the publication has no title configured (fallback)", - "You've been gifted a membership to {siteTitle}": "Subtitle on the Portal gift redemption page. {siteTitle} is the publication name (e.g. 'The Daily').", + "You've been gifted a membership to {siteTitle}": "Subtitle on the Portal gift redemption page, and the hidden preheader text of the gift delivery email. {siteTitle} is the publication name (e.g. 'The Daily').", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "Title shown in the social media (Open Graph) preview when a gift subscription link is shared, for monthly gifts. {duration} is the number of months (e.g. 1 or 3), {tierName} is the membership tier name (e.g. 'Gold'), {siteTitle} is the publication name (e.g. 'The Daily').", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "Title shown in the social media (Open Graph) preview when a gift subscription link is shared, for yearly gifts. {duration} is the number of years (e.g. 1 or 2), {tierName} is the membership tier name (e.g. 'Gold'), {siteTitle} is the publication name (e.g. 'The Daily').", "You've successfully signed in.": "A notification displayed when the user signs in", @@ -426,6 +435,7 @@ "Your email address": "Placeholder text in an input field", "Your email has failed to resubscribe, please try again": "error message in portal", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Newsletter text - shown to trialing members when receiving the newsletter (if subscription status is being shown)", + "Your gift is on its way": "Subject line and heading of the gift purchase confirmation email when the gift is being delivered by email", "Your gift is ready": "Subject line and heading of the gift purchase confirmation email, and the Portal success screen heading after gift purchase", "Your gift subscription confirmation": "Heading of the gift purchase confirmation email", "Your gift subscription is ending soon": "Heading of the gift expiry reminder email", @@ -448,8 +458,6 @@ "free": "Will be substituted into status for 'You are receiving this because you are a %%{status}%% subscriber to {site}.'", "jamie@example.com": "Placeholder for email input field", "month": "the subscription interval (monthly), following the /", - "one-month": "Duration label for a one-month gift, used as an adjective in the gift purchase confirmation email (e.g. 'a one-month Patron membership')", - "one-year": "Duration label for a one-year gift, used as an adjective in the gift purchase confirmation email (e.g. 'a one-year Patron membership')", "paid": "Will be substituted into status for 'You are receiving this because you are a %%{status}%% subscriber to {site}.'", "trialing": "Will be substituted into status for 'You are receiving this because you are a %%{status}%% subscriber to {site}.'", "year": "the subscription interval (monthly), following the /", @@ -464,6 +472,9 @@ "{amount} off for first {number} months.": "Portal offer page, showing a discount", "{amount} off for first {period}.": "Portal offer page, showing a discount", "{amount} off forever.": "Portal offer page, showing a discount", + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "Gift delivery email introduction for a monthly gift. {buyerName} is the buyer's name, {duration} is the number of months, {tierName} is the membership tier, and {siteTitle} is the publication name.", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "Gift delivery email introduction for a yearly gift. {buyerName} is the buyer's name, {duration} is the number of years, {tierName} is the membership tier, and {siteTitle} is the publication name.", + "{buyerName} sent you a gift": "Subject of the gift delivery email. {buyerName} is the name supplied by the buyer", "{count} month_one": "Singular form of the month duration label used in the gift subscription Open Graph preview, e.g. '1 month'. i18next picks this form when {count} is 1 (English).", "{count} month_other": "Plural form of the month duration label used in the gift subscription Open Graph preview, e.g. '3 months'. Some languages have additional plural forms (few, many) that translators can add as needed.", "{count} year_one": "Singular form of the year duration label used in the gift subscription Open Graph preview, e.g. '1 year'. i18next picks this form when {count} is 1 (English).", diff --git a/packages/i18n/locales/cs/ghost.json b/packages/i18n/locales/cs/ghost.json index 21a3b0364c5..2c106408f5e 100644 --- a/packages/i18n/locales/cs/ghost.json +++ b/packages/i18n/locales/cs/ghost.json @@ -1,4 +1,7 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_few": "", "{count} month_many": "", @@ -9,6 +12,7 @@ "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "Ať se daří!", "Become a paid member of {site} to get access to all premium content.": "", "By {authors}": "Od {authors}", @@ -33,6 +37,7 @@ "Hey there,": "Ahoj,", "Hey there!": "Ahoj!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Pokud jste tuto žádost nezaslali, můžete tento e-mail bezpečně ignorovat.", "If you did not make this request, you can simply delete this message.": "Pokud jste tuto žádost nezaslali, můžete tento e-mail jednoduše smazat.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "", @@ -45,14 +50,13 @@ "Name": "Jméno", "New comment on {postTitle}": "", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "placený", "Please confirm your email address with this link:": "Prosím, potvrďte svou e-mailovou adresu kliknutím na následující odkaz:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Bezpečný přihlašovací odkaz pro {siteTitle}", "See you soon!": "Na viděnou!", "Sent to {email}": "Odesláno na {email}", @@ -72,10 +76,15 @@ "Thank you for subscribing to {siteTitle}.": "Děkujeme za odběr {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Děkujeme za odběr na {siteTitle}. Klikněte na následující odkaz pro automatické přihlášení:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Tato e-mailová adresa nebude použita.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "zkušební", @@ -89,6 +98,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Vítejte zpět! Použijte tento bezpečný odkaz pro přihlášení do svého účtu {siteTitle}:", + "What's included": "", + "What's included:": "", "When:": "", "Where:": "", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "Tohle jste dostali, protože jste %%{status}%% předplatitel {site}.", @@ -101,7 +112,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Jste jen krok od odběru {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Vaše zkušební období končí {date}, poté Vám bude účtována běžná cena. Zkušební období můžete kdykoliv ukončit.", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/da/ghost.json b/packages/i18n/locales/da/ghost.json index 3f8021178db..0f5c0058a8e 100644 --- a/packages/i18n/locales/da/ghost.json +++ b/packages/i18n/locales/da/ghost.json @@ -1,10 +1,14 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_other": "", "{count} year_one": "", "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "Alt det bedste!", "Become a paid member of {site} to get access to all premium content.": "Bliv betalende medlem af {site} for at få adgang til alt premiumindhold.", "By {authors}": "Af {authors}", @@ -29,6 +33,7 @@ "Hey there,": "Hejsa", "Hey there!": "Hejsa!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Hvis du ikke lavede denne anmoding, kan du blot ignorere denne e-mail.", "If you did not make this request, you can simply delete this message.": "Hvis du ikke lavede denne anmodning, kan du blot slette denne besked.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "Hvis du ikke for nylig har forsøgt at logge ind, kan du roligt ignorere denne e-mail for at nægte adgang.", @@ -41,14 +46,13 @@ "Name": "Navn", "New comment on {postTitle}": "", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "betalt", "Please confirm your email address with this link:": "Bekræft venligst din e-mail med dette link:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Sikker log ind link til {siteTitle}", "See you soon!": "Vi ses om lidt!", "Sent to {email}": "Sendt til {email}", @@ -68,10 +72,15 @@ "Thank you for subscribing to {siteTitle}.": "Tak fordi du abonnerer hos {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Tak fordi du abonnerer hos {siteTitle}. Klik på linket nedenfor for at blive logget automatisk ind:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Denne e-mailaddresse vil ikke blive brugt.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "på prøve", @@ -85,6 +94,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Velkommen tilbage! Brug dette link for at logge sikkert ind på din konto hos {siteTitle}:", + "What's included": "", + "What's included:": "", "When:": "Hvornår:", "Where:": "Hvor:", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "Du modtager dette, fordi du er en %%{status}%% abonnent på {site}.", @@ -97,7 +108,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Du er kun et klik væk fra at abonnere på {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Din gratis prøveperiode slutter den {date}, hvorefter du vil blive opkrævet den normale pris. Du kan altid afmelde inden da.", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/de-CH/ghost.json b/packages/i18n/locales/de-CH/ghost.json index c97131c5ef4..2b01d027d84 100644 --- a/packages/i18n/locales/de-CH/ghost.json +++ b/packages/i18n/locales/de-CH/ghost.json @@ -1,10 +1,14 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "{count} Monat", "{count} month_other": "{count} Monate", "{count} year_one": "{count} Jahr", "{count} year_other": "{count} Jahre", "{date}": "{date}", "{tierName} membership": "{tierName}-Abonnement", + "A gift, just for you": "", "All the best!": "Alles Gute!", "Become a paid member of {site} to get access to all premium content.": "Werden Sie zahlendes Mitglied von {site}, um Zugang zu allen Premium-Inhalten zu erhalten.", "By {authors}": "Von {authors}", @@ -29,6 +33,7 @@ "Hey there,": "Hallo", "Hey there!": "Hallo!", "Hi {firstName},": "Hallo {firstName},", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Sollten Sie diese E-Mail nicht angefordert haben, können Sie sie ignorieren.", "If you did not make this request, you can simply delete this message.": "Sollten Sie diese E-Mail nicht angefordert haben, können Sie sie einfach löschen.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "Wenn Sie sich in letzter Zeit nicht angemeldet haben, können Sie dieses E-Mail ignorieren, um den Zugriff zu verweigern.", @@ -41,14 +46,13 @@ "Name": "Name", "New comment on {postTitle}": "Neuer Kommentar zu {postTitle}", "New reply to your comment on {siteTitle}": "Neue Antwort auf Ihren Kommentar zu {siteTitle}", - "one-month": "einmonatig", - "one-year": "einjährig", "Open this link to redeem your gift.": "Klicken Sie auf diesen Link, um Ihr Geschenk einzulösen.", "Or use this link to securely sign in": "Oder verwenden Sie diesen Link, um sich sicher anzumelden", "Or, skip the code and sign in directly": "Oder überspringen Sie den Code und melden Sie sich direkt an", "paid": "zahlendes", "Please confirm your email address with this link:": "Bitte bestätigen Sie Ihre E-Mail-Adresse mit diesem Link:", "Redeem your gift subscription": "Lösen Sie Ihr Geschenkabo ein", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Sicherer Anmeldelink für unsere Seite «{siteTitle}»", "See you soon!": "Bis bald!", "Sent to {email}": "Gesendet an {email}", @@ -68,10 +72,15 @@ "Thank you for subscribing to {siteTitle}.": "Danke für Ihr Abo bei unserer Seite «{siteTitle}».", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Danke für Ihr Abo bei unserer Seite «{siteTitle}». Klicken Sie auf den folgenden Link, um automatisch angemeldet zu werden:", "Thank you for supporting {siteTitle}.": "Vielen Dank für Ihre Unterstützung von {siteTitle}.", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "Vielen Dank für Ihre Unterstützung. Teilen Sie den untenstehenden Link mit allen, denen Sie eine {cadenceLabel} {tierName}-Mitgliedschaft bei {siteTitle} schenken möchten.", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "Vielen Dank, dass Sie {siteTitle} lesen.", "The link expires on {expiresAt} and can only be redeemed once.": "Der Link ist bis {expiresAt} gültig und kann nur einmal eingelöst werden.", "This email address will not be used.": "Diese E-Mail-Adresse wird nicht verwendet.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "Diese Nachricht wurde von {siteDomain} an {email} gesendet.", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "Um Ihr {tierName}-Abonnement weiterhin zu geniessen, wechseln Sie noch heute zu einem kostenpflichtigen Abo. Die restliche Laufzeit Ihres Geschenkabonnements wird Ihnen automatisch als kostenlose Verlängerung angerechnet.", "trialing": "Probeabo", @@ -85,6 +94,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "Willkommen zurück bei {siteTitle}! Ihr Bestätigungscode lautet {otc}.", "Welcome back! Here's your code to sign in to {siteTitle}": "Willkommen zurück! Hier ist Ihr Code, um sich bei {siteTitle} anzumelden", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Willkommen zurück! Verwenden Sie diesen Link, um sich sicher bei Ihrem {siteTitle}-Konto einzuloggen:", + "What's included": "", + "What's included:": "", "When:": "Wann:", "Where:": "Wo:", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "Sie erhalten diese Nachricht, da Sie ein %%{status}%% Mitglied unserer Seite «{site}» sind.", @@ -97,7 +108,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Sie sind nur einen Klick von einem Abo unserer Seite «{siteTitle}» entfernt!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "Sie haben eine {duration}-monatige {tierName}-Mitgliedschaft für {siteTitle} geschenkt erhalten.", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "Sie haben eine {duration}-jährige {tierName}-Mitgliedschaft für {siteTitle} geschenkt erhalten.", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Ihr Probeabo endet am {date}, ab dann wird Ihnen der reguläre Preis in Rechnung gestellt. Sie können Ihr Abo jederzeit zuvor kündigen.", + "Your gift is on its way": "", "Your gift is ready": "Ihr Geschenk wartet auf Sie", "Your gift subscription confirmation": "Bestätigung Ihres Geschenkabonnements", "Your gift subscription is ending soon": "Ihr Geschenkabonnement läuft bald ab", diff --git a/packages/i18n/locales/de/ghost.json b/packages/i18n/locales/de/ghost.json index e8bcb7e7315..e1483c7f792 100644 --- a/packages/i18n/locales/de/ghost.json +++ b/packages/i18n/locales/de/ghost.json @@ -1,10 +1,14 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_other": "", "{count} year_one": "", "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "Alles Gute!", "Become a paid member of {site} to get access to all premium content.": "Werde zahlendes Mitglied bei {site}, um Zugang zu allen Premium-Inhalten zu erhalten.", "By {authors}": "Von {authors}", @@ -29,6 +33,7 @@ "Hey there,": "Hallo,", "Hey there!": "Hallo!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Ignoriere diese E-Mail, wenn du diese nicht angefordert hast.", "If you did not make this request, you can simply delete this message.": "Du kannst diese Nachricht einfach löschen, wenn du diese nicht angefordert hast.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "Wenn du gerade nicht versucht hast, dich einzuloggen, kannst du diese E-Mail getrost ignorieren.", @@ -41,14 +46,13 @@ "Name": "Name", "New comment on {postTitle}": "Neuer Kommentar zu {postTitle}", "New reply to your comment on {siteTitle}": "Neue Antwort auf dein Kommentar bei {siteTitle}", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "Oder benutze diesen Link, um dich sicher einzuloggen", "Or, skip the code and sign in directly": "Oder vergiss den Code und logge dich direkt ein", "paid": "zahlendes", "Please confirm your email address with this link:": "Bitte bestätige deine E-Mail-Adresse mit diesem Link:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Sicherer Anmeldelink für {siteTitle}", "See you soon!": "Bis bald!", "Sent to {email}": "Gesendet an {email}", @@ -68,10 +72,15 @@ "Thank you for subscribing to {siteTitle}.": "Danke für dein Abonnement von {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Danke für dein Abonnement von {siteTitle}. Klicke auf den folgenden Link, um dich automatisch einzuloggen:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Diese E-Mail Adresse wird nicht verwendet.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "Diese Nachricht wurde von {siteDomain} an {email} gesendet.", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "Probeabo", @@ -85,6 +94,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "Willkommen zurück auf {siteTitle}! Dein Bestätigungscode lautet {otc}.", "Welcome back! Here's your code to sign in to {siteTitle}": "Hier ist dein Code, mit dem du dich auf {siteTitle} einloggen kannst", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Willkommen zurück! Benutze diesen Link, um dich sicher bei deinem {siteTitle}-Konto einzuloggen:", + "What's included": "", + "What's included:": "", "When:": "Wann:", "Where:": "Wo:", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "Du erhältst diese Nachricht weil du %%{status}%% Mitglied von {site} bist.", @@ -97,7 +108,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Du bist nur einen Klick von deinem Abonnement von {siteTitle} entfernt!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Dein kostenfreies Probeabo endet am {date}, ab dann wird der reguläre Preis in Rechnung gestellt. Du kannst jederzeit zuvor stornieren.", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/el/ghost.json b/packages/i18n/locales/el/ghost.json index 18b17809015..e5a5fc3f8a4 100644 --- a/packages/i18n/locales/el/ghost.json +++ b/packages/i18n/locales/el/ghost.json @@ -1,10 +1,14 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "{count} μήνας", "{count} month_other": "{count} μήνες", "{count} year_one": "{count} χρόνος", "{count} year_other": "{count} χρόνια", "{date}": "{date}", "{tierName} membership": "Συνδρομή {tierName}", + "A gift, just for you": "", "All the best!": "Ό,τι καλύτερο!", "Become a paid member of {site} to get access to all premium content.": "Γίνετε συνδρομητικό μέλος του {site} για να αποκτήσετε πρόσβαση σε όλο το αποκλειστικό περιεχόμενο.", "By {authors}": "Από {authors}", @@ -29,6 +33,7 @@ "Hey there,": "Γεια σας,", "Hey there!": "Γεια σας!", "Hi {firstName},": "Γεια σας, {firstName},", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Αν δεν κάνατε αυτό το αίτημα, μπορείτε να αγνοήσετε αυτό το email.", "If you did not make this request, you can simply delete this message.": "Αν δεν κάνατε αυτό το αίτημα, μπορείτε απλά να διαγράψετε αυτό το email.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "Αν δεν προσπαθήσατε να συνδεθείτε πρόσφατα, μπορείτε να αγνοήσετε αυτό το email χωρίς κανένα πρόβλημα, για να αρνηθείτε την πρόσβαση.", @@ -41,14 +46,13 @@ "Name": "Όνομα", "New comment on {postTitle}": "Νέο σχόλιο στο {postTitle}", "New reply to your comment on {siteTitle}": "Νέα απάντηση στο σχόλιό σας στο {siteTitle}", - "one-month": "ενός μήνα", - "one-year": "ενός έτους", "Open this link to redeem your gift.": "Ανοίξτε αυτόν τον σύνδεσμο για να εξαργυρώσετε το δώρο σας.", "Or use this link to securely sign in": "Ή χρησιμοποιήστε αυτόν τον σύνδεσμο για να συνδεθείτε με ασφάλεια", "Or, skip the code and sign in directly": "Ή, παραλείψτε τον κωδικό και συνδεθείτε απευθείας", "paid": "συνδρομητικό", "Please confirm your email address with this link:": "Παρακαλούμε επιβεβαιώστε τη διεύθυνση email σας με αυτόν τον σύνδεσμο:", "Redeem your gift subscription": "Εξαργύρωση της συνδρομής δώρου σας", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Ασφαλής σύνδεσμος σύνδεσης για το {siteTitle}", "See you soon!": "Τα λέμε σύντομα!", "Sent to {email}": "Απεστάλη στο {email}", @@ -68,10 +72,15 @@ "Thank you for subscribing to {siteTitle}.": "Ευχαριστούμε που εγγραφήκατε στο {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Ευχαριστούμε που εγγραφήκατε στο {siteTitle}. Πατήστε τον παρακάτω σύνδεσμο για να συνδεθείτε αυτόματα:", "Thank you for supporting {siteTitle}.": "Σας ευχαριστούμε για την υποστήριξή σας στο {siteTitle}.", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "Σας ευχαριστούμε για την υποστήριξή σας. Μοιραστείτε τον παρακάτω σύνδεσμο με όποιον θέλετε να χαρίσετε μια {cadenceLabel} συνδρομή {tierName} στο {siteTitle}.", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "Ευχαριστούμε που διαβάσατε το {siteTitle}.", "The link expires on {expiresAt} and can only be redeemed once.": "Ο σύνδεσμος λήγει στις {expiresAt} και μπορεί να εξαργυρωθεί μόνο μία φορά.", "This email address will not be used.": "Αυτή η διεύθυνση email δεν θα χρησιμοποιηθεί.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "Αυτό το email στάλθηκε από το {siteDomain} στη διεύθυνση {email}.", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "Για να διατηρήσετε τη συνδρομή σας {tierName}, συνεχίστε με συνδρομή επί πληρωμή σήμερα και θα προσθέσουμε αυτόματα το υπόλοιπο διάστημα του δώρου σας ως δωρεάν δοκιμή.", "trialing": "δοκιμαστικό", @@ -85,6 +94,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "Καλώς ήρθατε ξανά στο {siteTitle}! Ο κωδικός επαλήθευσης είναι {otc}.", "Welcome back! Here's your code to sign in to {siteTitle}": "Καλώς ήρθατε ξανά! Αυτός είναι ο κωδικός σας για να συνδεθείτε στο {siteTitle}", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Καλώς ήρθατε ξανά! Χρησιμοποιήστε αυτόν τον σύνδεσμο για να συνδεθείτε με ασφάλεια στον λογαριασμό σας στο {siteTitle}:", + "What's included": "", + "What's included:": "", "When:": "Πότε:", "Where:": "Πού:", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "Λαμβάνετε αυτό το email επειδή είστε %%{status}%% μέλος του {site}.", @@ -97,7 +108,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Είστε ένα πάτημα μακριά από την εγγραφή στο {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "Σας χαρίστηκε μια συνδρομή {tierName} {duration} μηνών στο {siteTitle}", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "Σας χαρίστηκε μια συνδρομή {tierName} {duration} χρόνων στο {siteTitle}", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Η δωρεάν δοκιμή σας λήγει στις {date}, οπότε θα χρεωθείτε την κανονική τιμή. Μπορείτε να ακυρώσετε οποτεδήποτε πριν από τότε.", + "Your gift is on its way": "", "Your gift is ready": "Το δώρο σας είναι έτοιμο", "Your gift subscription confirmation": "Επιβεβαίωση της συνδρομής δώρου σας", "Your gift subscription is ending soon": "Η συνδρομή δώρου σας λήγει σύντομα", diff --git a/packages/i18n/locales/en/ghost.json b/packages/i18n/locales/en/ghost.json index 0fafa97e92d..a2b9d605a78 100644 --- a/packages/i18n/locales/en/ghost.json +++ b/packages/i18n/locales/en/ghost.json @@ -1,10 +1,14 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "{count} month", "{count} month_other": "{count} months", "{count} year_one": "{count} year", "{count} year_other": "{count} years", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "", "Become a paid member of {site} to get access to all premium content.": "", "By {authors}": "", @@ -29,6 +33,7 @@ "Hey there,": "", "Hey there!": "", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "", "If you did not make this request, you can simply delete this message.": "", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "", @@ -41,14 +46,13 @@ "Name": "", "New comment on {postTitle}": "", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "", "Please confirm your email address with this link:": "", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "", "See you soon!": "", "Sent to {email}": "", @@ -68,10 +72,15 @@ "Thank you for subscribing to {siteTitle}.": "", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "", @@ -85,6 +94,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "", + "What's included": "", + "What's included:": "", "When:": "", "Where:": "", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", @@ -97,7 +108,9 @@ "You're one tap away from subscribing to {siteTitle}!": "", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/eo/ghost.json b/packages/i18n/locales/eo/ghost.json index a6d6dd105e4..820eccda53f 100644 --- a/packages/i18n/locales/eo/ghost.json +++ b/packages/i18n/locales/eo/ghost.json @@ -1,10 +1,14 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_other": "", "{count} year_one": "", "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "Ĉion bonan!", "Become a paid member of {site} to get access to all premium content.": "", "By {authors}": "", @@ -29,6 +33,7 @@ "Hey there,": "Saluton,", "Hey there!": "", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Se vi ne petis ĉi tiun, vi povas sekure ignori ĉi tiun retpoŝtan.", "If you did not make this request, you can simply delete this message.": "Se vi ne petis ĉi tiun, vi povas simple forigi ĉi tiun mesaĝon.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "", @@ -41,14 +46,13 @@ "Name": "", "New comment on {postTitle}": "", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "", "Please confirm your email address with this link:": "Bonvolu konfirmi vian retadreson per ĉi tiu ligilo:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Sekura ensaluta ligilo por {siteTitle}", "See you soon!": "Ĝis baldaŭ!", "Sent to {email}": "Sendita al {email}", @@ -68,10 +72,15 @@ "Thank you for subscribing to {siteTitle}.": "", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Dankon pro abono al {siteTitle}. Premu la suban ligilon por ensaluti aŭtomate:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Ne uzos ĉi tiu retadreson.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "", @@ -85,6 +94,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Bonvenon ree! Uzu ĉi tiun ligilon por sekure ensaluti al via konto {siteTitle}:", + "What's included": "", + "What's included:": "", "When:": "", "Where:": "", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", @@ -97,7 +108,9 @@ "You're one tap away from subscribing to {siteTitle}!": "", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/es/ghost.json b/packages/i18n/locales/es/ghost.json index 0e2b5fab9cc..2d63ee70506 100644 --- a/packages/i18n/locales/es/ghost.json +++ b/packages/i18n/locales/es/ghost.json @@ -1,4 +1,7 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "{count} mes", "{count} month_many": "{count} meses", "{count} month_other": "{count} meses", @@ -7,6 +10,7 @@ "{count} year_other": "{count} años", "{date}": "{date}", "{tierName} membership": "Membresía {tierName}", + "A gift, just for you": "", "All the best!": "¡Todo lo mejor para ti!", "Become a paid member of {site} to get access to all premium content.": "Hazte miembro de pago de {site} para acceder a todo el contenido premium.", "By {authors}": "Por {authors}", @@ -31,6 +35,7 @@ "Hey there,": "Hola,", "Hey there!": "¡Hola!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Si no hiciste esta solicitud, ignora este correo electrónico.", "If you did not make this request, you can simply delete this message.": "Si no hiciste esta solicitud, elimina este mensaje.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "Si no intentaste iniciar sesión recientemente, ignora este correo electrónico para denegar el acceso", @@ -43,14 +48,13 @@ "Name": "Nombre", "New comment on {postTitle}": "Nuevo comentario en {postTitle}", "New reply to your comment on {siteTitle}": "Nueva respuesta a tu comentario en {siteTitle}", - "one-month": "un mes", - "one-year": "un año", "Open this link to redeem your gift.": "Abre este enlace para canjear tu regalo.", "Or use this link to securely sign in": "O usa este enlace para iniciar sesión de forma segura", "Or, skip the code and sign in directly": "O ignora el código e inicia sesión directamente", "paid": "de pago", "Please confirm your email address with this link:": "Por favor, confirma tu dirección de correo electrónico haciendo clic en este enlace:", "Redeem your gift subscription": "Canjea tu suscripción de regalo", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Inicio de sesión seguro para {siteTitle}", "See you soon!": "¡Hasta pronto!", "Sent to {email}": "Enviado a {email}", @@ -70,10 +74,15 @@ "Thank you for subscribing to {siteTitle}.": "Gracias por tu suscripción a {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Gracias por tu suscripción a {siteTitle}. Haz click en el siguiente enlace para iniciar sesión automáticamente:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Esta dirección de correo electrónico no se usará.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "Este mensaje fue enviado desde {siteDomain} a {email}.", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "de prueba", @@ -87,6 +96,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "¡Bienvenido de vuelta a {siteTitle}! Tu código de verificación es {otc}.", "Welcome back! Here's your code to sign in to {siteTitle}": "¡Bienvenido de vuelta! Este es tu código para iniciar sesión en {siteTitle}", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "¡Bienvenido de vuelta! Usa este enlace para iniciar sesión de manera segura en tu cuenta de {siteTitle}:", + "What's included": "", + "What's included:": "", "When:": "Cuándo:", "Where:": "Dónde:", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "Recibes este mensaje porque tienes una suscripción %%{status}%% a {site}.", @@ -99,7 +110,9 @@ "You're one tap away from subscribing to {siteTitle}!": "¡Estás muy cerca de suscribirte a {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "Te han regalado una membresía {tierName} de {duration} meses de {siteTitle}", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "Te han regalado una membresía {tierName} de {duration} años de {siteTitle}", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Tu prueba gratuita termina el {date}, fecha en la que se te aplicará la cuota normal. Puedes cancelar en cualquier momento antes de esa fecha.", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/et/ghost.json b/packages/i18n/locales/et/ghost.json index 79426bb855b..58c3851ef99 100644 --- a/packages/i18n/locales/et/ghost.json +++ b/packages/i18n/locales/et/ghost.json @@ -1,10 +1,14 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_other": "", "{count} year_one": "", "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "Kõike head!", "Become a paid member of {site} to get access to all premium content.": "", "By {authors}": "", @@ -29,6 +33,7 @@ "Hey there,": "Tere,", "Hey there!": "Tere!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Kui sa ei teinud seda päringut, võid seda e-kirja julgelt ignoreerida.", "If you did not make this request, you can simply delete this message.": "Kui sa ei teinud seda päringut, võid selle sõnumi lihtsalt ära kustutada.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "", @@ -41,14 +46,13 @@ "Name": "", "New comment on {postTitle}": "", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "", "Please confirm your email address with this link:": "Palun kinnita oma e-posti aadress selle lingiga:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Turvaline sisselogimislink {siteTitle} lehele", "See you soon!": "Kohtumiseni!", "Sent to {email}": "Saadetud aadressile {email}", @@ -68,10 +72,15 @@ "Thank you for subscribing to {siteTitle}.": "Aitäh, et hakkasid {siteTitle} jälgijaks.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Aitäh, et hakkasid {siteTitle} jälgijaks. Vajuta alloleval lingil, et automaatselt sisse logida:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Seda e-posti aadressi ei kasutata.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "", @@ -85,6 +94,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Tere tulemast tagasi! Kasuta seda linki, et turvaliselt oma {siteTitle} kontole sisse logida:", + "What's included": "", + "What's included:": "", "When:": "", "Where:": "", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", @@ -97,7 +108,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Oled vaid ühe klõpsu kaugusel {siteTitle} jälgijaks hakkamisest!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/eu/ghost.json b/packages/i18n/locales/eu/ghost.json index a80ffbbe175..b464906a191 100644 --- a/packages/i18n/locales/eu/ghost.json +++ b/packages/i18n/locales/eu/ghost.json @@ -1,10 +1,14 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_other": "", "{count} year_one": "", "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "Zorte on!", "Become a paid member of {site} to get access to all premium content.": "Egin zaitez ordainpeko kide {site}(e)ko primerako eduki guztira sarbidea izateko.", "By {authors}": "{authors}(r)en eskutik", @@ -29,6 +33,7 @@ "Hey there,": "Kaixo,", "Hey there!": "Aupa!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Eskaera hau egin ez baduzu, arazorik gabe baztertu dezakezu ePosta hau.", "If you did not make this request, you can simply delete this message.": "Eskaera hau egin ez baduzu, arazorik gabe ezabatu dezakezu ePosta hau.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "Oraintsu saioa hasten saiatu ez bazara, arazorik gabe baztertu dezakezu ePosta hau sarbidea ukatzeko.", @@ -41,14 +46,13 @@ "Name": "Izena", "New comment on {postTitle}": "Iruzkin berria {postTitle} argitalpenean", "New reply to your comment on {siteTitle}": "Erantzun berria {siteTitle}(e)ko iruzkinari", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "ordainpekoa", "Please confirm your email address with this link:": "Egiaztatu zure ePosta helbidea honako estekarekin:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "{siteTitle}(e)n saioa hasteko esteka segurua", "See you soon!": "Laster arte!", "Sent to {email}": "{email}(r)i bidali zaio", @@ -68,10 +72,15 @@ "Thank you for subscribing to {siteTitle}.": "Eskerrik asko {siteTitle}(e)ra harpidetzeagatik.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Eskerrik asko {siteTitle}(e)ra harpidetzeagatik. Egin tap beheko estekan saioa automatikoki hasteko:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Ez da erabiliko ePosta helbide hau", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "Mezu hau {siteDomain}(e)k bidali dio {email}(r)i", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "probatzen", @@ -85,6 +94,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Ongi etorri! Erabili honako esteka modu seguruan {siteTitle}(e)ko zure kontuan saioa hasteko:", + "What's included": "", + "What's included:": "", "When:": "Noiz", "Where:": "Non", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "{site}(e)ko %%{status}%% harpidedun zarelako jaso duzu mezu hau.", @@ -97,7 +108,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Tap bakarra baino ez duzu behar {siteTitle}(e)ra harpidetzeko!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Doako probaldia {date}(e)an amaituko da, eta prezio arrunta kobratuko zaizu. Data hori baino lehen ezeztatu dezakezu.", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/fa/ghost.json b/packages/i18n/locales/fa/ghost.json index e985d5ceadf..1b6c8cae32b 100644 --- a/packages/i18n/locales/fa/ghost.json +++ b/packages/i18n/locales/fa/ghost.json @@ -1,10 +1,14 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "{count} ماه", "{count} month_other": "{count} ماه", "{count} year_one": "{count} سال", "{count} year_other": "{count} سال", "{date}": "{date}", "{tierName} membership": "عضویت {tierName}", + "A gift, just for you": "", "All the best!": "با آرزوی بهترین\u200cها!", "Become a paid member of {site} to get access to all premium content.": "برای دسترسی به تمام محتوای ویژه، عضو ویژه {site} شوید.", "By {authors}": "توسط {authors}", @@ -29,6 +33,7 @@ "Hey there,": "سلام،", "Hey there!": "سلام!", "Hi {firstName},": "{firstName} عزیز،", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "اگر شما این درخواست را نداده\u200cاید، می\u200cتوانید با خیال راحت این ایمیل را نادیده بگیرید.", "If you did not make this request, you can simply delete this message.": "اگر شما این درخواست را نداده\u200cاید، می\u200cتوانید به سادگی این پیام را حذف کنید.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "اگر اخیراً تلاشی برای ورود نکرده\u200cاید، می\u200cتوانید با خیال راحت این ایمیل را نادیده بگیرید تا دسترسی رد شود.", @@ -41,14 +46,13 @@ "Name": "نام", "New comment on {postTitle}": "دیدگاه جدید در {postTitle}", "New reply to your comment on {siteTitle}": "پاسخ جدید به دیدگاه شما در {siteTitle}", - "one-month": "یک\u200cماهه", - "one-year": "یک\u200cساله", "Open this link to redeem your gift.": "برای فعال\u200cسازی هدیه خود، این پیوند را باز کنید.", "Or use this link to securely sign in": "یا از این پیوند برای ورود امن استفاده کنید", "Or, skip the code and sign in directly": "یا از کد صرف\u200cنظر کنید و مستقیماً وارد شوید", "paid": "ویژه", "Please confirm your email address with this link:": "لطفاً نشانی ایمیل خود را با این پیوند تأیید کنید:", "Redeem your gift subscription": "اشتراک هدیه خود را فعال کنید", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "پیوند ورود امن به {siteTitle}", "See you soon!": "به\u200cزودی می\u200cبینمتان!", "Sent to {email}": "ارسال شده به {email}", @@ -68,10 +72,15 @@ "Thank you for subscribing to {siteTitle}.": "از مشترک شدن شما در {siteTitle} سپاسگزاریم.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "از مشترک شدن شما در {siteTitle} سپاسگزاریم. برای ورود خودکار، پیوند زیر را باز کنید:", "Thank you for supporting {siteTitle}.": "از حمایت شما از {siteTitle} سپاسگزاریم.", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "از حمایت شما سپاسگزاریم. پیوند زیر را با هر کسی که می\u200cخواهید یک اشتراک {tierName} با {cadenceLabel} از {siteTitle} به او هدیه دهید، به اشتراک بگذارید.", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "از مطالعه {siteTitle} سپاسگزاریم.", "The link expires on {expiresAt} and can only be redeemed once.": "این پیوند در {expiresAt} منقضی می\u200cشود و فقط یک\u200cبار قابل استفاده است.", "This email address will not be used.": "این نشانی ایمیل استفاده نخواهد شد.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "این پیام از {siteDomain} به {email} ارسال شده است.", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "برای حفظ عضویت {tierName} خود، امروز با یک اشتراک ویژه ادامه دهید تا باقی دوره هدیه شما به\u200cطور خودکار به دوره رایگان اضافه شود.", "trialing": "آزمایشی", @@ -85,6 +94,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "بازگشت شما را به {siteTitle} خوش\u200cآمد می\u200cگوییم! کد تأیید شما {otc} است.", "Welcome back! Here's your code to sign in to {siteTitle}": "بازگشتتان خوش\u200cآمد! این کد شما برای ورود به {siteTitle} است", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "بازگشتتان خوش\u200cآمد! از این پیوند برای ورود امن به حساب کاربری {siteTitle} خود استفاده کنید:", + "What's included": "", + "What's included:": "", "When:": "زمان:", "Where:": "مکان:", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "این ایمیل را دریافت کرده\u200cاید چون مشترک %%{status}%% {site} هستید.", @@ -97,7 +108,9 @@ "You're one tap away from subscribing to {siteTitle}!": "شما یک کلیک با مشترک شدن در {siteTitle} فاصله دارید!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "یک اشتراک {duration}-ماهه {tierName} از {siteTitle} به شما هدیه داده شده است", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "یک اشتراک {duration}-ساله {tierName} از {siteTitle} به شما هدیه داده شده است", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "دوره رایگان شما در تاریخ {date} به پایان می\u200cرسد و در آن زمان مبلغ عادی از شما کسر خواهد شد. همیشه می\u200cتوانید پیش از آن لغو کنید.", + "Your gift is on its way": "", "Your gift is ready": "هدیه شما آماده است", "Your gift subscription confirmation": "تأیید اشتراک هدیه شما", "Your gift subscription is ending soon": "اشتراک هدیه شما به\u200cزودی به پایان می\u200cرسد", diff --git a/packages/i18n/locales/fi/ghost.json b/packages/i18n/locales/fi/ghost.json index 741caf576ad..eb420e353a1 100644 --- a/packages/i18n/locales/fi/ghost.json +++ b/packages/i18n/locales/fi/ghost.json @@ -1,10 +1,14 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_other": "", "{count} year_one": "", "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "Kaikkea hyvää!", "Become a paid member of {site} to get access to all premium content.": "Saat kaiken sisällön käyttöösi, kun otat maksullisen jäsenyyden sivulle {site}.", "By {authors}": "Kirjoittajat: {authors}", @@ -29,6 +33,7 @@ "Hey there,": "Hei", "Hey there!": "Hei!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Jos et tehnyt tätä pyyntöä, voit olla reagoimatta viestiin.", "If you did not make this request, you can simply delete this message.": "Jos et tehnyt tätä pyyntöä, voit poistaa tämän viestin.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "Jos et ole äsken yrittänyt kirjautua sisään, voit olla reagoimatta viestiin, jolloin pääsy estetään.", @@ -41,14 +46,13 @@ "Name": "Nimi", "New comment on {postTitle}": "Uusi kommentti kirjoituksessa {postTitle}", "New reply to your comment on {siteTitle}": "Uusi vastaus kommenttiisi sivulla {siteTitle}", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "Tai voit turvallisesti kirjautua sisään käyttämällä seuraavaa linkkiä", "Or, skip the code and sign in directly": "Tai voit ohittaa koodin ja kirjautua suoraan sisään", "paid": "maksullinen ", "Please confirm your email address with this link:": "Vahvista sähköpostisi tästä linkistä:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Turvallinen kirjautumislinkki sivustolle {siteTitle}", "See you soon!": "Nähdään pian!", "Sent to {email}": "Lähetetty {email}", @@ -68,10 +72,15 @@ "Thank you for subscribing to {siteTitle}.": "Kiitos tilauksestasi sivulle {siteTitle}", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Kiitos tilauksestasi sivulle {siteTitle}. Klikkaa alla olevaa linkkiä jatkaaksesi:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Tätä sähköpostia ei käytetä.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "Viesti on lähetetty osoitteesta {siteDomain} osoitteeseen {email}.", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "kokeilu", @@ -85,6 +94,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "Tervetuloa takaisin sivulle {siteTitle}! Vahvistuskoodisi on {otc}.", "Welcome back! Here's your code to sign in to {siteTitle}": "Tervetuloa takaisin! Tällä koodilla voit kirjautua sisään sivulle {siteTitle}", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Tervetuloa takaisin! Käytä tätä linkkiä kirjautuaksesi {siteTitle} tilillesi:", + "What's included": "", + "What's included:": "", "When:": "Milloin:", "Where:": "Missä:", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "Saat tämän viestin, koska sinulla on %%{status}%%tilaus sivulle {site}.", @@ -97,7 +108,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Olet yhden klikkauksen päässä tilauksestasi sivulle {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Ilmainen kokeilusi loppuu {date}, jolloin sinulta veloitetaan tilausmaksu. Voit aina peruuttaa tilauksesi ennen tätä.", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/fr/ghost.json b/packages/i18n/locales/fr/ghost.json index 22297477387..3350a6c8a53 100644 --- a/packages/i18n/locales/fr/ghost.json +++ b/packages/i18n/locales/fr/ghost.json @@ -1,4 +1,7 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "{count} mois", "{count} month_many": "{count} mois", "{count} month_other": "{count} mois", @@ -7,6 +10,7 @@ "{count} year_other": "{count} ans", "{date}": "{date}", "{tierName} membership": "Abonnement {tierName}", + "A gift, just for you": "", "All the best!": "Bien à vous !", "Become a paid member of {site} to get access to all premium content.": "Devenez un(e) abonné(e) payant de {site} pour accéder à du contenu exclusif.", "By {authors}": "Par {authors}", @@ -31,6 +35,7 @@ "Hey there,": "Bonjour,", "Hey there!": "Bonjour !", "Hi {firstName},": "Bonjour {firstName},", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Si vous n’êtes pas à l'origine de cette demande, vous pouvez ignorer cet e-mail.", "If you did not make this request, you can simply delete this message.": "Si vous n’êtes pas à l'origine de cette demande, vous pouvez supprimer cet e-mail.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "Si vous n'avez pas tenté de vous connecter récemment, vous pouvez ignorer cet e-mail pour refuser l'accès.", @@ -43,14 +48,13 @@ "Name": "Nom", "New comment on {postTitle}": "Nouveau commentaire sur {postTitle}", "New reply to your comment on {siteTitle}": "Nouvelle réponse à votre commentaire sur {siteTitle}", - "one-month": "un mois", - "one-year": "un an", "Open this link to redeem your gift.": "Cliquez sur ce lien pour activer votre abonnement offert.", "Or use this link to securely sign in": "Ou utilisez ce lien pour vous connecter en toute sécurité ", "Or, skip the code and sign in directly": "Ou ignorez le code et connectez-vous directement ", "paid": "payé", "Please confirm your email address with this link:": "Veuillez confirmer votre adresse e-mail en cliquant sur ce lien :", "Redeem your gift subscription": "Activer votre abonnement offert", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Lien sécurisé de connexion pour {siteTitle}", "See you soon!": "À bientôt !", "Sent to {email}": "Envoyé à {email}", @@ -70,10 +74,15 @@ "Thank you for subscribing to {siteTitle}.": "Merci pour votre abonnement à {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Merci pour votre abonnement à {siteTitle}. Cliquez le lien ci-dessous pour vous connecter automatiquement :", "Thank you for supporting {siteTitle}.": "Merci pour votre soutien à {siteTitle}.", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "Merci pour votre soutien. Partagez le lien ci-dessous avec la personne de votre choix pour lui offrir un abonnement {tierName} de {cadenceLabel} à {siteTitle}.", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "Merci de lire {siteTitle}.", "The link expires on {expiresAt} and can only be redeemed once.": "Ce lien expire le {expiresAt} et ne peut être utilisé qu'une seule fois.", "This email address will not be used.": "Cette adresse e-mail ne sera pas utilisée.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "Ce message a été envoyé de {siteDomain} à {email}.", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "Pour conserver votre abonnement {tierName}, passez dès aujourd’hui à un abonnement payant et nous ajouterons automatiquement le reste de votre période offerte sous forme d’essai gratuit.", "trialing": "en période d'essai", @@ -87,6 +96,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "Quel plaisir de vous revoir sur {siteTitle} ! Votre code de vérification est {otc}.", "Welcome back! Here's your code to sign in to {siteTitle}": "Quel plaisir de vous revoir ! Voici le code pour vous connecter à {siteTitle} ", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Quel plaisir de vous revoir ! Utilisez ce lien pour vous connecter en toute sécurité à votre compte {siteTitle} :", + "What's included": "", + "What's included:": "", "When:": "Quand :", "Where:": "Où :", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "Vous recevez ceci parce que vous êtes un(e) abonné(e) %%{status}%% de {site}.", @@ -99,7 +110,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Plus qu’une étape pour vous abonner à {siteTitle} !", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "Un abonnement {tierName} de {duration} mois vous a été offert sur {siteTitle}", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "Un abonnement {tierName} de {duration} an(s) vous a été offert sur {siteTitle}", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Votre essai gratuit se termine le {date}, après quoi le prix normal vous sera facturé. Vous pouvez toujours annuler avant cette date.", + "Your gift is on its way": "", "Your gift is ready": "Votre cadeau est prêt", "Your gift subscription confirmation": "Confirmation de votre abonnement offert", "Your gift subscription is ending soon": "Votre abonnement offert se termine bientôt", diff --git a/packages/i18n/locales/gd/ghost.json b/packages/i18n/locales/gd/ghost.json index 3480c25e87f..4bc3c1f4097 100644 --- a/packages/i18n/locales/gd/ghost.json +++ b/packages/i18n/locales/gd/ghost.json @@ -1,4 +1,7 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_two": "", "{count} month_few": "", @@ -9,6 +12,7 @@ "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "Meal do shlàinte!", "Become a paid member of {site} to get access to all premium content.": "", "By {authors}": "Le {authors}", @@ -33,6 +37,7 @@ "Hey there,": "Halò,", "Hey there!": "Halò!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Faodaidh tu leigeil seachad air a' phost-d seo mura dh'iarr thu e.", "If you did not make this request, you can simply delete this message.": "Faodaidh tu am post-d sguabadh às mura dh'iarr thu e.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "", @@ -45,14 +50,13 @@ "Name": "Ainm", "New comment on {postTitle}": "", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "pàighte", "Please confirm your email address with this link:": "Dearbhaich am post-d agad leis a' cheangal seo:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Ceangal tèarainte airson clàradh a-steach ris an làrach-lìn, {siteTitle}", "See you soon!": "Tìoraidh an-dràsta!", "Sent to {email}": "Cuir gu {email}", @@ -72,10 +76,15 @@ "Thank you for subscribing to {siteTitle}.": "Mòran taing airson fo-sgrìobhadh ris an làrach-lìn, {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Mòran taing airson fo-sgrìobhadh ris an làrach-lìn, {siteTitle}. Dèan briog air a' cheangal gu h-ìosal gus clàradh a-steach:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Cha tèid am post-d seo a chleachdadh.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "'fo dheuchainn", @@ -89,6 +98,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Fàilte air ais! Cleachd an ceangail seo gus clàradh a-steach dhan chunntas agad air an làrach-lìn, {siteTitle}:", + "What's included": "", + "What's included:": "", "When:": "", "Where:": "", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "Tha thu air seo fhaighinn o chionn 's gu bheil fo-sgrìobhadh %%{status}%% ris an làrach-lìn, {site}.", @@ -101,7 +112,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Chan eil ach aon cheum ri dhol a-nis gus fo-sgrìobhadh ris an làrach-lìn, {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Falbhaidh an ùine air an tionndadh triail agad {date}, agus bidh agad ris a' phrìs abhaisteach a phàigheadh. Faodaidh tu stad a chur air an fho-sgrìobhadh agad ron a sheo ge-tà.", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/he/ghost.json b/packages/i18n/locales/he/ghost.json index 7e9cc639a2a..a82258c1ae6 100644 --- a/packages/i18n/locales/he/ghost.json +++ b/packages/i18n/locales/he/ghost.json @@ -1,4 +1,7 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_two": "", "{count} month_other": "", @@ -7,6 +10,7 @@ "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "המשך יום טוב!", "Become a paid member of {site} to get access to all premium content.": "", "By {authors}": "מאת {authors}", @@ -31,6 +35,7 @@ "Hey there,": "היי לכם,", "Hey there!": "היי לכם!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "אם לא ביצעתם את הבקשה, ניתן להתעלם מהמייל.", "If you did not make this request, you can simply delete this message.": "אם לא ביצעתם את הבקשה, ניתן למחוק את ההודעה.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "", @@ -43,14 +48,13 @@ "Name": "שם", "New comment on {postTitle}": "", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "בתשלום", "Please confirm your email address with this link:": "אנא אשרו את כתובת המייל שלכם בעזרת הלינק הבא:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "לינק הרשמה מאובטח ל{siteTitle}", "See you soon!": "להתראות בקרוב!", "Sent to {email}": "נשלח אל {email}", @@ -70,10 +74,15 @@ "Thank you for subscribing to {siteTitle}.": "תודה שנרשמתם אל {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "תודה שנרשמתם אל {siteTitle}. לחצו על הלינק למטה כדי להיכנס למערכת באופן אוטומטי:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "כתובת המייל הזו לא תהיה בשימוש.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "בניסיון", @@ -87,6 +96,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "ברוכים השבים! השתמשו בלינק הבא לכניסה מאובטחת לחשבון {siteTitle} שלכם:", + "What's included": "", + "What's included:": "", "When:": "", "Where:": "", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "אתם מקבלים דואר זה מכיוון שאתם %%{status}%% מנויים ל{site}.", @@ -99,7 +110,9 @@ "You're one tap away from subscribing to {siteTitle}!": "אתם רק צעד אחד מההרשמה אל {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "המינוי החינמי שלך יסתיים ב-{date}, ואז תחוייב במחיר הרגיל. תמיד תוכל לבטל לפני כן.", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/hi/ghost.json b/packages/i18n/locales/hi/ghost.json index 528dca21747..ba253db6818 100644 --- a/packages/i18n/locales/hi/ghost.json +++ b/packages/i18n/locales/hi/ghost.json @@ -1,10 +1,14 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_other": "", "{count} year_one": "", "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "सभी को शुभकामनाएं!", "Become a paid member of {site} to get access to all premium content.": "{site} का सशुल्क सदस्य बनें ताकि आपको सभी प्रीमियम सामग्री तक पहुंच मिल सके।", "By {authors}": "{authors} द्वारा", @@ -29,6 +33,7 @@ "Hey there,": "नमस्ते,", "Hey there!": "नमस्ते!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "यदि आपने यह अनुरोध नहीं किया है, तो आप इस ईमेल को सुरक्षित रूप से नजरअंदाज कर सकते हैं।", "If you did not make this request, you can simply delete this message.": "यदि आपने यह अनुरोध नहीं किया है, तो आप इस संदेश को बस हटा सकते हैं।", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "यदि आपने हाल ही में साइन इन करने का प्रयास नहीं किया है, तो आप इस ईमेल को नजरअंदाज कर सकते हैं और पहुंच अस्वीकार कर सकते हैं।", @@ -41,14 +46,13 @@ "Name": "नाम", "New comment on {postTitle}": "{postTitle} पर नई टिप्पणी", "New reply to your comment on {siteTitle}": "{siteTitle} पर आपकी टिप्पणी का नया उत्तर", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "सशुल्क", "Please confirm your email address with this link:": "कृपया इस लिंक से अपना ईमेल पता पुष्टि करें:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "{siteTitle} के लिए सुरक्षित साइन इन लिंक", "See you soon!": "जल्द ही मिलते हैं!", "Sent to {email}": "{email} पर भेजा गया", @@ -68,10 +72,15 @@ "Thank you for subscribing to {siteTitle}.": "{siteTitle} की सदस्यता लेने के लिए धन्यवाद।", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "{siteTitle} की सदस्यता लेने के लिए धन्यवाद। स्वचालित रूप से साइन इन होने के लिए नीचे दिए गए लिंक पर टैप करें:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "इस ईमेल पते का उपयोग नहीं किया जाएगा।", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "यह संदेश {siteDomain} से {email} पर भेजा गया था।", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "परीक्षण अवधि", @@ -85,6 +94,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "फिर से स्वागत है! अपने {siteTitle} खाते में सुरक्षित रूप से साइन इन करने के लिए इस लिंक का उपयोग करें:", + "What's included": "", + "What's included:": "", "When:": "कब:", "Where:": "कहाँ:", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "आपको यह संदेश इसलिए मिला है क्योंकि आप {site} के %%{status}%% सदस्य हैं।", @@ -97,7 +108,9 @@ "You're one tap away from subscribing to {siteTitle}!": "आप {siteTitle} की सदस्यता लेने से एक टैप दूर हैं!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "आपकी नि:शुल्क परीक्षण अवधि {date} को समाप्त होगी, जिसके बाद सामान्य शुल्क लिया जाएगा। आप इससे पहले कभी भी रद्द कर सकते हैं।", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/hr/ghost.json b/packages/i18n/locales/hr/ghost.json index a05840b6dc6..307e1a7ecc3 100644 --- a/packages/i18n/locales/hr/ghost.json +++ b/packages/i18n/locales/hr/ghost.json @@ -1,4 +1,7 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_few": "", "{count} month_other": "", @@ -7,6 +10,7 @@ "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "Sve najbolje!", "Become a paid member of {site} to get access to all premium content.": "Postanite pretplatnik na {site} kako biste dobili pristup svim premijum sadržajima.", "By {authors}": "Od {authors}", @@ -31,6 +35,7 @@ "Hey there,": "Pozdrav,", "Hey there!": "Pozdrav!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Možete zanemariti ovu e-poštu ako niste uputili ovaj zahtjev.", "If you did not make this request, you can simply delete this message.": "Možete obrisati ovu e-poštu ako niste uputili ovaj zahtjev.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "Ako se niste pokušali prijaviti nedavno, možete zanemariti ovaj email kako biste odbili pristup.", @@ -43,14 +48,13 @@ "Name": "Ime", "New comment on {postTitle}": "", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "plaćeno", "Please confirm your email address with this link:": "Molimo vas da potvrdite adresu e-pošte klikom na ovaj link:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Sigurni link za prijavu na {siteTitle}", "See you soon!": "Vidimo se uskoro!", "Sent to {email}": "Poslano na {email}", @@ -70,10 +74,15 @@ "Thank you for subscribing to {siteTitle}.": "Hvala vam na pretplati na {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Hvala vam na pretplati na {siteTitle}. Prijavite se klikom na ovaj link:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Ova adresa e-pošte neće biti korištena.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "probni period", @@ -87,6 +96,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Dobro došli! Prijavite se na {siteTitle} klikom na ovaj link:", + "What's included": "", + "What's included:": "", "When:": "Kada:", "Where:": "Gdje:", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "Primili ste ovu poruku jer ste %%{status}%% pretplatnik na {site}.", @@ -99,7 +110,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Na korak ste od pretplate na {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Vaš besplatni probni period se završava na dan {date}, nakon čega će vam biti naplaćena redovna cijena. Uvijek možete otkazati prije toga.", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/hu/ghost.json b/packages/i18n/locales/hu/ghost.json index f325a61697b..d2214502d0a 100644 --- a/packages/i18n/locales/hu/ghost.json +++ b/packages/i18n/locales/hu/ghost.json @@ -1,10 +1,14 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_other": "", "{count} year_one": "", "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "Üdvözlettel,", "Become a paid member of {site} to get access to all premium content.": "Legyél fizető tag {site} oldalon, hogy hozzáférj minden prémium tartalomhoz.", "By {authors}": "{authors}", @@ -29,6 +33,7 @@ "Hey there,": "Szia!", "Hey there!": "Szia!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Ha nem te kezdeményezted ezt a kérést, akkor nyugodtan hagyd figyelmen kívül ezt a levelet.", "If you did not make this request, you can simply delete this message.": "Ha nem te kezdeményezted ezt a kérést, nyugodtan töröld ezt a levelet.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "Ha nem próbált meg nemrég bejelentkezni, nyugodtan figyelmen kívül hagyhatja ezt az e-mailt.", @@ -41,14 +46,13 @@ "Name": "Név", "New comment on {postTitle}": "Új hozzászólás a {postTitle} bejegyzéshez", "New reply to your comment on {siteTitle}": "Új válasz az egyik hozzászólásodra a {siteTitle} oldalon", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "fizetve", "Please confirm your email address with this link:": "Kattints az alábbi linkre az e-mail-címed megerősítéséért:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "{siteTitle} — Biztonságos bejelentkezési link", "See you soon!": "Üdv!", "Sent to {email}": "Elküldve ide: {email}", @@ -68,10 +72,15 @@ "Thank you for subscribing to {siteTitle}.": "{siteTitle} — Köszönjük a feliratkozást!", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "{siteTitle} - Köszönjük a feliratkozást! Kérjük, kattints az alábbi linkre az automatikus bejelentkezéshez:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Ezt az e-mail-címet nem használjuk fel.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "Ezt az üzenetet a {siteDomain} küldte a következő címre: {email}.", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "próbaidőszak alatt", @@ -85,6 +94,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Üdv újra itt! Használd az alábbi linket, hogy biztonságosan bejelentkezz a(z) {siteTitle} fiókodba:", + "What's included": "", + "What's included:": "", "When:": "Mikor:", "Where:": "Hol:", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "Ezt azért kapod, mert %%{status}%% előfizető vagy itt: {site}", @@ -97,7 +108,9 @@ "You're one tap away from subscribing to {siteTitle}!": "{siteTitle} — Csak egy kattintásra vagy a feliratkozástól!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Az ingyenes próbaidőszak után {date} fiókod automatikusan átvált előfizetésesre. A próbaidőszak alatt bármikor lemondhatod az előfizetésedet.", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/id/ghost.json b/packages/i18n/locales/id/ghost.json index 5f9b27f442c..78e31383147 100644 --- a/packages/i18n/locales/id/ghost.json +++ b/packages/i18n/locales/id/ghost.json @@ -1,8 +1,12 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_other": "", "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "Semoga sukses!", "Become a paid member of {site} to get access to all premium content.": "Jadilah anggota berbayar {site} untuk mendapatkan akses ke semua konten premium.", "By {authors}": "Oleh {authors}", @@ -27,6 +31,7 @@ "Hey there,": "Halo,", "Hey there!": "Halo!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Jika Anda tidak melakukan permintaan ini, Anda dapat dengan aman mengabaikan email ini.", "If you did not make this request, you can simply delete this message.": "Jika Anda tidak melakukan permintaan ini, Anda dapat menghapus pesan ini.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "Jika Anda tidak mencoba masuk ke akun Anda baru-baru ini, sebaiknya abaikan pesan ini untuk memblokir akses.", @@ -39,14 +44,13 @@ "Name": "Nama", "New comment on {postTitle}": "Komentar baru pada {postTitle}", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "berbayar", "Please confirm your email address with this link:": "Harap konfirmasikan alamat email Anda dengan tautan ini:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Tautan masuk yang aman untuk {siteTitle}", "See you soon!": "Sampai jumpa lagi!", "Sent to {email}": "Dikirim ke {email}", @@ -66,10 +70,15 @@ "Thank you for subscribing to {siteTitle}.": "Terima kasih telah berlangganan ke {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Terima kasih telah berlangganan ke {siteTitle}. Ketuk tautan di bawah ini untuk masuk secara otomatis:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Alamat email ini tidak akan digunakan.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "dalam masa uji coba", @@ -83,6 +92,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "Selamat datang kembali di {siteTitle}! Berikut kode verifikasi Anda: {otc}.", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Selamat datang kembali! Gunakan tautan ini untuk masuk dengan aman ke akun {siteTitle} Anda:", + "What's included": "", + "What's included:": "", "When:": "Kapan:", "Where:": "Di mana:", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "Anda menerima ini karena Anda merupakan pelanggan %%{status}%% dari {site}.", @@ -95,7 +106,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Anda hanya perlu satu kali ketuk untuk berlangganan ke {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Masa coba gratis Anda akan berhenti pada {date}, setelah itu Anda akan dikenakan harga reguler. Anda dapat membatalkan langganan sebelum tanggal tersebut.", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/is/ghost.json b/packages/i18n/locales/is/ghost.json index 44ee672da9b..91e3f6373c1 100644 --- a/packages/i18n/locales/is/ghost.json +++ b/packages/i18n/locales/is/ghost.json @@ -1,10 +1,14 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_other": "", "{count} year_one": "", "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "Bestu kveðjur!", "Become a paid member of {site} to get access to all premium content.": "", "By {authors}": "", @@ -29,6 +33,7 @@ "Hey there,": "Góðan dag,", "Hey there!": "Góðan daginn!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Ef þú stendur ekki að baki þessari beiðni geturðu leitt þennan tölvupóst hjá þér", "If you did not make this request, you can simply delete this message.": "Ef þú stendur ekki að baki þessari beiðni geturðu einfaldlega eytt þessum skilaboðum", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "", @@ -41,14 +46,13 @@ "Name": "", "New comment on {postTitle}": "", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "", "Please confirm your email address with this link:": "Vinsamlegast staðfestið netfangið með þessum hlekki", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Innskráningarhlekkur fyrir {siteTitle}", "See you soon!": "Sjáumst von bráðar!", "Sent to {email}": "Senda til {email}", @@ -68,10 +72,15 @@ "Thank you for subscribing to {siteTitle}.": "Takk fyrir áskriftina að {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Takk fyrir að gerast áskrifandi að {siteTitle}. Smellið á hlekkinn að neðan til að virkja sjálfvirka innskráningu:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Þetta netfang verður ekki tekið til notkunar.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "", @@ -85,6 +94,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Velkomin/n aftur! Notið þennan hlekk fyrir örugga innskráningu á {siteTitle}", + "What's included": "", + "What's included:": "", "When:": "", "Where:": "", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", @@ -97,7 +108,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Þú ert einum smelli frá áskrift að {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/it/ghost.json b/packages/i18n/locales/it/ghost.json index e2f6c34f0d7..46362f97206 100644 --- a/packages/i18n/locales/it/ghost.json +++ b/packages/i18n/locales/it/ghost.json @@ -1,4 +1,7 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "{count} mese", "{count} month_many": "{count} mesi", "{count} month_other": "{count} mesi", @@ -7,6 +10,7 @@ "{count} year_other": "{count} anni", "{date}": "{date}", "{tierName} membership": "Abbonamento {tierName}", + "A gift, just for you": "", "All the best!": "A presto!", "Become a paid member of {site} to get access to all premium content.": "Abbonati a {site} per accedere a tutti i contenuti premium.", "By {authors}": "Di {authors}", @@ -31,6 +35,7 @@ "Hey there,": "Ciao,", "Hey there!": "Ciao!", "Hi {firstName},": "Ciao {firstName},", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Se non hai fatto tu questa richiesta, puoi tranquillamente ignorare questa email.", "If you did not make this request, you can simply delete this message.": "Se non hai fatto tu questa richiesta, puoi semplicemente cancellare questo messaggio.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "Se non hai tentato di accedere di recente, puoi tranquillamente ignorare questa email per negare l'accesso.", @@ -43,14 +48,13 @@ "Name": "Nome", "New comment on {postTitle}": "Nuovo commento su {postTitle}", "New reply to your comment on {siteTitle}": "Nuova risposta al tuo commento su {siteTitle}", - "one-month": "un mese", - "one-year": "un anno", "Open this link to redeem your gift.": "Apri questo link per riscattare il tuo regalo.", "Or use this link to securely sign in": "Oppure usa questo link per accedere in modo sicuro", "Or, skip the code and sign in directly": "Oppure, accedi direttamente senza il codice", "paid": "a pagamento", "Please confirm your email address with this link:": "Conferma il tuo indirizzo email a questo link:", "Redeem your gift subscription": "Riscatta il tuo abbonamento regalo", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Link per l'accesso sicuro a {siteTitle}", "See you soon!": "A presto!", "Sent to {email}": "Inviata a {email}", @@ -70,10 +74,15 @@ "Thank you for subscribing to {siteTitle}.": "Grazie per esserti abbonato a {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Grazie per esserti abbonato a {siteTitle}. Clicca il link sottostante per effettuare automaticamente l'accesso:", "Thank you for supporting {siteTitle}.": "Grazie per il tuo supporto a {siteTitle}.", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "Grazie per il tuo supporto. Condividi il link qui sotto con chi vuoi regalare un abbonamento {tierName} {cadenceLabel} a {siteTitle}.", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "Grazie per leggere {siteTitle}.", "The link expires on {expiresAt} and can only be redeemed once.": "Il link scade il {expiresAt} e può essere riscattato una sola volta.", "This email address will not be used.": "Questo indirizzo email non verrà utilizzato.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "Questo messaggio è stato inviato da {siteDomain} a {email}.", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "Per mantenere il tuo abbonamento {tierName}, continua oggi con un abbonamento a pagamento e aggiungeremo automaticamente il restante periodo regalo come prova gratuita.", "trialing": "di prova", @@ -87,6 +96,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "Bentornato su {siteTitle}! Il tuo codice di verifica è {otc}.", "Welcome back! Here's your code to sign in to {siteTitle}": "Bentornato! Questo è il tuo codice per accedere a {siteTitle}", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Bentornato! Usa questo link per accedere in sicurezza al tuo account su {siteTitle}:", + "What's included": "", + "What's included:": "", "When:": "Quando:", "Where:": "Dove:", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "Ricevi questa mail perché hai un abbonamento %%{status}%% a {site}.", @@ -99,7 +110,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Sei a un passo dall'abbonamento a {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "Hai ricevuto in regalo un abbonamento {tierName} a {siteTitle} della durata di {duration} mese/i", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "Hai ricevuto in regalo un abbonamento {tierName} a {siteTitle} della durata di {duration} anno/i", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "La tua prova gratuita finisce il {date}, pagherai solo allora. Puoi cancellare l’abbonamento prima.", + "Your gift is on its way": "", "Your gift is ready": "Il tuo regalo è pronto", "Your gift subscription confirmation": "Conferma del tuo abbonamento regalo", "Your gift subscription is ending soon": "Il tuo abbonamento regalo sta per terminare", diff --git a/packages/i18n/locales/ja/ghost.json b/packages/i18n/locales/ja/ghost.json index 0940da70055..718814c0918 100644 --- a/packages/i18n/locales/ja/ghost.json +++ b/packages/i18n/locales/ja/ghost.json @@ -1,8 +1,12 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_other": "", "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "よろしくお願いします!", "Become a paid member of {site} to get access to all premium content.": "", "By {authors}": "", @@ -27,6 +31,7 @@ "Hey there,": "こんにちは", "Hey there!": "こんにちは!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "このリクエストを行っていない場合は、このメールを無視してください。", "If you did not make this request, you can simply delete this message.": "このリクエストを行っていない場合は、このメールを削除してください。", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "", @@ -39,14 +44,13 @@ "Name": "", "New comment on {postTitle}": "", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "", "Please confirm your email address with this link:": "このリンクを使用してメールアドレスを確認してください:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "{siteTitle}へのログインリンクです", "See you soon!": "引き続きよろしくお願いします。", "Sent to {email}": "{email}に送信", @@ -66,10 +70,15 @@ "Thank you for subscribing to {siteTitle}.": "{siteTitle}の購読ありがとうございます。", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "{siteTitle}の購読ありがとうございます。下のリンクを選択すると自動的にログインされます。", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "このメールアドレスは使用されていません。", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "", @@ -83,6 +92,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "おかえりなさい!このリンクを使用すると、{siteTitle}のアカウントに安全にログインすることができます。", + "What's included": "", + "What's included:": "", "When:": "", "Where:": "", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", @@ -95,7 +106,9 @@ "You're one tap away from subscribing to {siteTitle}!": "{siteTitle}への購読まであと少しです。", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/ko/ghost.json b/packages/i18n/locales/ko/ghost.json index eaa75b453e1..87f1f193d8c 100644 --- a/packages/i18n/locales/ko/ghost.json +++ b/packages/i18n/locales/ko/ghost.json @@ -1,8 +1,12 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_other": "{count}개월", "{count} year_other": "{count}년", "{date}": "{date}", "{tierName} membership": "{tierName} 멤버십", + "A gift, just for you": "", "All the best!": "모든 것이 잘 되기를 바라요!", "Become a paid member of {site} to get access to all premium content.": "{site}의 유료 회원이 되어 모든 프리미엄 콘텐츠를 이용해 보세요.", "By {authors}": "{authors} 작성", @@ -27,6 +31,7 @@ "Hey there,": "안녕하세요,", "Hey there!": "안녕하세요!", "Hi {firstName},": "{firstName}님, 안녕하세요,", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "직접 요청하신게 아니라면, 이 이메일을 무시하셔도 돼요.", "If you did not make this request, you can simply delete this message.": "직접 요청하신게 아니라면, 이 메시지를 삭제하시면 돼요.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "최근에 로그인을 시도하지 않으셨다면, 이 이메일을 무시하여 접근을 거부하실 수 있어요.", @@ -39,14 +44,13 @@ "Name": "이름", "New comment on {postTitle}": "{postTitle}에 새 댓글이 달렸어요", "New reply to your comment on {siteTitle}": "{siteTitle}에 남기신 댓글에 새 답글이 달렸어요", - "one-month": "1개월", - "one-year": "1년", "Open this link to redeem your gift.": "이 링크를 열어 선물을 받아보세요.", "Or use this link to securely sign in": "또는 이 링크를 사용해 안전하게 로그인하세요", "Or, skip the code and sign in directly": "또는 코드를 건너뛰고 바로 로그인하세요", "paid": "유료", "Please confirm your email address with this link:": "아래 링크를 사용하여 이메일 주소를 확인해 주세요:", "Redeem your gift subscription": "선물받은 구독 사용하기", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "{siteTitle}의 안전한 로그인 링크예요.", "See you soon!": "곧 다시 만나요!", "Sent to {email}": "{email}(으)로 전송되었어요.", @@ -66,10 +70,15 @@ "Thank you for subscribing to {siteTitle}.": "{siteTitle} 구독 감사해요.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "{siteTitle} 구독에 감사해요. 아래 링크를 탭하여 자동으로 로그인해 주세요:", "Thank you for supporting {siteTitle}.": "{siteTitle}을(를) 후원해 주셔서 감사해요.", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "후원해 주셔서 감사해요. 아래 링크를 원하는 분께 공유하여 {siteTitle}의 {cadenceLabel} {tierName} 멤버십을 선물하세요.", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "{siteTitle}을(를) 읽어주셔서 감사해요.", "The link expires on {expiresAt} and can only be redeemed once.": "이 링크는 {expiresAt}에 만료되며, 한 번만 사용할 수 있어요.", "This email address will not be used.": "이 이메일 주소는 사용하지 않을게요.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "이 메시지는 {siteDomain}에서 {email}(으)로 전송되었어요.", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "{tierName} 멤버십을 유지하려면 오늘 유료 구독으로 전환해 주세요. 남은 선물 기간은 무료 체험으로 자동 추가해 드려요.", "trialing": "체험 중", @@ -83,6 +92,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "{siteTitle}에 다시 오신 것을 환영해요! 인증 코드는 {otc}예요.", "Welcome back! Here's your code to sign in to {siteTitle}": "다시 오신 것을 환영해요! {siteTitle}에 로그인할 수 있는 코드예요", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "다시 오신 것을 환영해요! 안전한 로그인을 위해 다음 링크를 사용하여 {siteTitle} 계정에 로그인해 주세요:", + "What's included": "", + "What's included:": "", "When:": "일시:", "Where:": "장소:", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "{site}의 %%{status}%% 구독자이기 때문에 이 메일을 받고 계세요.", @@ -95,7 +106,9 @@ "You're one tap away from subscribing to {siteTitle}!": "탭하시면 {siteTitle} 구독이 완료돼요!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "{siteTitle}의 {duration}개월 {tierName} 멤버십을 선물받으셨어요", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "{siteTitle}의 {duration}년 {tierName} 멤버십을 선물받으셨어요", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "무료 체험은 {date}에 종료되며, 이후 정상 가격이 청구돼요. 그 전에 언제든 취소할 수 있어요.", + "Your gift is on its way": "", "Your gift is ready": "선물이 준비됐어요", "Your gift subscription confirmation": "선물 구독 확인", "Your gift subscription is ending soon": "선물 구독이 곧 종료돼요", diff --git a/packages/i18n/locales/kz/ghost.json b/packages/i18n/locales/kz/ghost.json index e5d4c4e2718..666cee9dd1a 100644 --- a/packages/i18n/locales/kz/ghost.json +++ b/packages/i18n/locales/kz/ghost.json @@ -1,4 +1,7 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_many": "", "{count} month_other": "", @@ -7,6 +10,7 @@ "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "Ізгі ниетпен!", "Become a paid member of {site} to get access to all premium content.": "", "By {authors}": "", @@ -31,6 +35,7 @@ "Hey there,": "Сәлем,", "Hey there!": "Сәлем!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Егер мұндай сұраныс жасамаған болсаңыз, бұл хатқа мән бермесеңіз болады.", "If you did not make this request, you can simply delete this message.": "Егер мұндай сұраныс жасамаған болсаңыз, бұл хатты өшіре берсеңіз болады.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "", @@ -43,14 +48,13 @@ "Name": "", "New comment on {postTitle}": "", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "", "Please confirm your email address with this link:": "Email поштаңызды осы сілтеме арқылы растауыңызды сұраймыз:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "{siteTitle} сайтына қауіпсіз кіруге арналған сілтеме", "See you soon!": "Көріскенше!", "Sent to {email}": "{email} поштасына жіберілді", @@ -70,10 +74,15 @@ "Thank you for subscribing to {siteTitle}.": "{siteTitle} сайтына жазылғаныңыз үшін алғыс білдіреміз.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "{siteTitle} сайтына жазылғаныңыз үшін алғыс білдіреміз. Автоматты түрде кіру үшін төмендегі сілтемені басыңыз:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Бұл email адрес қолданылмайды.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "", @@ -87,6 +96,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Қош келдіңіз! {siteTitle} аккаунтыңызға қауіпсіз кіру үшін мына сілтемені пайдаланыңыз:", + "What's included": "", + "What's included:": "", "When:": "", "Where:": "", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", @@ -99,7 +110,9 @@ "You're one tap away from subscribing to {siteTitle}!": "{siteTitle} жазылу үшін бір-ақ қадам қалды!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/lt/ghost.json b/packages/i18n/locales/lt/ghost.json index bcba64bd34e..03977d14bfd 100644 --- a/packages/i18n/locales/lt/ghost.json +++ b/packages/i18n/locales/lt/ghost.json @@ -1,4 +1,7 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_few": "", "{count} month_many": "", @@ -9,6 +12,7 @@ "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "Viso geriausio!", "Become a paid member of {site} to get access to all premium content.": "", "By {authors}": "", @@ -33,6 +37,7 @@ "Hey there,": "Sveiki,", "Hey there!": "Sveiki!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Jei šios užklausos nepateikėte, galite ignoruoti šį el. laišką. ", "If you did not make this request, you can simply delete this message.": "Jei šios užklausos nepateikėte, galite saugiai pašalinti šį el. laišką.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "", @@ -45,14 +50,13 @@ "Name": "", "New comment on {postTitle}": "", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "", "Please confirm your email address with this link:": "Patvirtinkite savo el. pašto adresą su šia nuoroda:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Saugi prisijungimo nuoroda į {siteTitle}", "See you soon!": "Iki pasimatymo!", "Sent to {email}": "Išsiųsta į {email}", @@ -72,10 +76,15 @@ "Thank you for subscribing to {siteTitle}.": "Ačiū, kad prenumeravote {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Ačiū, kad registravotės {siteTitle}. Spauskite nuorodą žemiau, kad prisijungtumėte.", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Šis el. pašto adresas nebus panaudotas.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "", @@ -89,6 +98,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Sveiki sugrįžę! Naudokitės šia nuoroda norėdami saugiai prisijungti prie savo {siteTitle} paskyros:", + "What's included": "", + "What's included:": "", "When:": "", "Where:": "", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", @@ -101,7 +112,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Esate per vieną paspaudimą nuo {siteTitle} prenumeratos!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/lv/ghost.json b/packages/i18n/locales/lv/ghost.json index fd0489d0ce5..d6aab21fe45 100644 --- a/packages/i18n/locales/lv/ghost.json +++ b/packages/i18n/locales/lv/ghost.json @@ -1,4 +1,7 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_zero": "", "{count} month_one": "", "{count} month_other": "", @@ -7,6 +10,7 @@ "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "Visu to labāko!", "Become a paid member of {site} to get access to all premium content.": "", "By {authors}": "Autors: {authors}", @@ -31,6 +35,7 @@ "Hey there,": "Sveiki,", "Hey there!": "Čau!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Ja jūs neiesniedzāt šo pieprasījumu, varat droši ignorēt šo e-pasta ziņojumu.", "If you did not make this request, you can simply delete this message.": "Ja jūs neiesniedzāt šo pieprasījumu, varat vienkārši izdzēst šo ziņojumu.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "", @@ -43,14 +48,13 @@ "Name": "Vārds", "New comment on {postTitle}": "", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "samaksāts", "Please confirm your email address with this link:": "Lūdzu, apstipriniet savu e-pasta adresi, izmantojot šo saiti:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Droša pierakstīšanās saite vietnei {siteTitle}", "See you soon!": "Uz drīzu tikšanos!", "Sent to {email}": "Nosūtīts uz {email}", @@ -70,10 +74,15 @@ "Thank you for subscribing to {siteTitle}.": "Paldies, ka abonējat vietni {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Paldies, ka abonējat vietni {siteTitle}. Pieskarieties tālāk esošajai saitei, lai automātiski pierakstītos:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Šī e-pasta adrese netiks izmantota.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "izmēģinājums", @@ -87,6 +96,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Laipni lūdzam atpakaļ! Izmantojiet šo saiti, lai droši pierakstītos savā {siteTitle} kontā:", + "What's included": "", + "What's included:": "", "When:": "", "Where:": "", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "Jūs to saņemat, jo esat %%{status}%% abonents vietnē {site}.", @@ -99,7 +110,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Atliek tikai viens pieskāriens, lai abonētu vietni {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Jūsu bezmaksas izmēģinājuma periods beidzas {date}, un tad no jums tiks iekasēta parastā cena. Jūs vienmēr varat atcelt pirms tam.", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/mk/ghost.json b/packages/i18n/locales/mk/ghost.json index 9ea9e007f90..0612cad41f6 100644 --- a/packages/i18n/locales/mk/ghost.json +++ b/packages/i18n/locales/mk/ghost.json @@ -1,10 +1,14 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_other": "", "{count} year_one": "", "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "Сѐ најдобро!", "Become a paid member of {site} to get access to all premium content.": "", "By {authors}": "", @@ -29,6 +33,7 @@ "Hey there,": "Здраво", "Hey there!": "Здраво!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Ако ова барање не беше направено од вас, можете безбедно да ја игнорирате оваа порака.", "If you did not make this request, you can simply delete this message.": "Ако ова барање не беше направено од вас, можете безбедно да ја избришете оваа порака.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "", @@ -41,14 +46,13 @@ "Name": "", "New comment on {postTitle}": "", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "", "Please confirm your email address with this link:": "Потврдете ја вашата email адреса со овој линк:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Безбеден линк за најава на {siteTitle}", "See you soon!": "Ќе се видиме наскоро!", "Sent to {email}": "Испратете на {email}", @@ -68,10 +72,15 @@ "Thank you for subscribing to {siteTitle}.": "Ви благодариме за претплатата на {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Ви благодариме за претплатата на {siteTitle}. Кликнете на линкот за автоматска најава:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Оваа email адреса нема да биде употребена.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "", @@ -85,6 +94,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Добредојдовте назад! Искористете го овој линк за безбедно да се најавите на вашата сметка за {siteTitle}:", + "What's included": "", + "What's included:": "", "When:": "", "Where:": "", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", @@ -97,7 +108,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Вие сте на само еден клик до претплатување на {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/mn/ghost.json b/packages/i18n/locales/mn/ghost.json index 171e16564af..11ddb6498f6 100644 --- a/packages/i18n/locales/mn/ghost.json +++ b/packages/i18n/locales/mn/ghost.json @@ -1,10 +1,14 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_other": "", "{count} year_one": "", "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "Баярлалаа!", "Become a paid member of {site} to get access to all premium content.": "{site}-н бүх премиум контентод хандах эрхтэй болохын тулд төлбөртэй гишүүн болно уу.", "By {authors}": "Нийтэлсэн: {authors}", @@ -29,6 +33,7 @@ "Hey there,": "Сайн уу,", "Hey there!": "Сайн уу!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Хэрвээ та энэхүү хүсэлтийг гаргаагүй бол энэ имэйлийг тоолгүй орхиж болно.", "If you did not make this request, you can simply delete this message.": "Хэрвээ та энэхүү хүсэлтийг гаргаагүй бол энэ имэйлийг устгаж болно.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "Хэрвээ та сүүлийн үед нэвтрэх оролдлого хийгээгүй бол энэ имэйлийг тоолгүй орхиж болно.", @@ -41,14 +46,13 @@ "Name": "Нэр", "New comment on {postTitle}": "", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "төлбөртэй", "Please confirm your email address with this link:": "Та өөрийн имэйл хаягаа дараах холбоосоор баталгаажуулна уу:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "{siteTitle}-д нэвтрэх нууцлал бүхий холбоос", "See you soon!": "Удахгүй уулзацгаая!", "Sent to {email}": "{email} хаяг руу илгээлээ", @@ -68,10 +72,15 @@ "Thank you for subscribing to {siteTitle}.": "{siteTitle}-д захиалга өгсөнд баярлалаа.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "{siteTitle}-д захиалга өгсөнд баярлалаа. Доорх холбоосыг дарснаар автоматаар нэвтрэнэ:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Энэхүү имэйл хаяг ашиглагдахгүй.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "туршиж байна", @@ -85,6 +94,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Дахин тавтай морилно уу! Энэхүү холбоосыг ашиглан {siteTitle}-д нэвтрэнэ үү:", + "What's included": "", + "What's included:": "", "When:": "Хэзээ:", "Where:": "Хаана:", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "Та {site}-н %%{status}%% захиалагч учраас үүнийг хүлээн авч байна.", @@ -97,7 +108,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Та {siteTitle}-д захиалга өгөхөд ганцхан алхам үлдлээ!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Таны үнэгүй туршилт {date}-нд дуусах бөгөөд энэ үед танаас үндсэн үнийг авна. Та үүнээс өмнө хэзээ ч цуцалж болно.", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/ms/ghost.json b/packages/i18n/locales/ms/ghost.json index c5a57585cf6..cba6fa4384e 100644 --- a/packages/i18n/locales/ms/ghost.json +++ b/packages/i18n/locales/ms/ghost.json @@ -1,8 +1,12 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_other": "", "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "Semoga berjaya!", "Become a paid member of {site} to get access to all premium content.": "", "By {authors}": "", @@ -27,6 +31,7 @@ "Hey there,": "Hai,", "Hey there!": "Hai!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Jika anda tidak membuat permintaan ini, anda boleh mengabaikan e-mel ini dengan selamat.", "If you did not make this request, you can simply delete this message.": "Jika anda tidak membuat permintaan ini, anda boleh memadamkan mesej ini.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "", @@ -39,14 +44,13 @@ "Name": "", "New comment on {postTitle}": "", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "", "Please confirm your email address with this link:": "Sila sahkan alamat e-mel anda dengan pautan ini:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Pautan log masuk untuk {siteTitle}", "See you soon!": "Jumpa lagi!", "Sent to {email}": "Dihantar ke {email}", @@ -66,10 +70,15 @@ "Thank you for subscribing to {siteTitle}.": "Terima kasih kerana melanggan {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Terima kasih kerana melanggan {siteTitle}. Ketik pautan di bawah untuk log masuk secara automatik:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Alamat e-mel ini tidak akan digunakan.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "", @@ -83,6 +92,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Selamat kembali! Gunakan pautan ini untuk log masuk dengan selamat ke akaun {siteTitle} anda:", + "What's included": "", + "What's included:": "", "When:": "", "Where:": "", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", @@ -95,7 +106,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Anda hanya satu ketikan dari melanggan {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/nb/ghost.json b/packages/i18n/locales/nb/ghost.json index 451cf490faf..88da1897f6f 100644 --- a/packages/i18n/locales/nb/ghost.json +++ b/packages/i18n/locales/nb/ghost.json @@ -1,10 +1,14 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_other": "", "{count} year_one": "", "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "Alt det beste!", "Become a paid member of {site} to get access to all premium content.": "", "By {authors}": "Av {authors}", @@ -29,6 +33,7 @@ "Hey there,": "Hei,", "Hey there!": "Hei!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Hvis du ikke har bedt om dette, kan du trygt ignorere denne e-posten.", "If you did not make this request, you can simply delete this message.": "Hvis du ikke har bedt om dette, kan du fint slette denne meldingen.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "", @@ -41,14 +46,13 @@ "Name": "Navn", "New comment on {postTitle}": "", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "betalt", "Please confirm your email address with this link:": "Vennligst bekreft e-postadressen din med denne lenken:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Sikker påloggingslenke for {siteTitle}", "See you soon!": "Ser deg snart!", "Sent to {email}": "Sendt til {email}", @@ -68,10 +72,15 @@ "Thank you for subscribing to {siteTitle}.": "Takk for at du abonnerer på {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Takk for at du abonnerer på {siteTitle}. Trykk på lenken nedenfor for å bli automatisk logget inn:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Denne e-postadressen vil ikke bli brukt.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "prøveperiode", @@ -85,6 +94,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Velkommen tilbake! Bruk denne lenken for å logge deg sikkert på {siteTitle}-kontoen din:", + "What's included": "", + "What's included:": "", "When:": "", "Where:": "", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "Du mottar dette fordi du er en %%{status}%% abonnent på {site}.", @@ -97,7 +108,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Du er ett klikk unna å abonnere på {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Din gratis prøveperiode avsluttes den {date}, da blir du belastet med normal pris. Du kan alltid kansellere før den tid.", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/ne/ghost.json b/packages/i18n/locales/ne/ghost.json index cc3f2a86d44..fa5715f10ca 100644 --- a/packages/i18n/locales/ne/ghost.json +++ b/packages/i18n/locales/ne/ghost.json @@ -1,10 +1,14 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_other": "", "{count} year_one": "", "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "सबै राम्रो होस्!", "Become a paid member of {site} to get access to all premium content.": "सबै प्रिमियम सामग्रीमा पहुँच प्राप्त गर्न {site} को सशुल्क सदस्य बन्नुहोस्।", "By {authors}": "{authors} द्वारा", @@ -29,6 +33,7 @@ "Hey there,": "नमस्ते,", "Hey there!": "नमस्ते!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "यदि तपाईंले यो अनुरोध गर्नु भएको थिएन भने, तपाईं यो इमेललाई सुरक्षित रूपमा अनदेखा गर्न सक्नुहुन्छ।", "If you did not make this request, you can simply delete this message.": "यदि तपाईंले यो अनुरोध गर्नु भएको थिएन भने, तपाईं यो सन्देशलाई सजिलै मेटाउन सक्नुहुन्छ।", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "", @@ -41,14 +46,13 @@ "Name": "नाम", "New comment on {postTitle}": "{postTitle} मा नयाँ प्रतिक्रिया", "New reply to your comment on {siteTitle}": "{siteTitle} मा तपाईंको प्रतिक्रियाको नयाँ प्रत्युत्तर", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "भुक्तान गरिएको", "Please confirm your email address with this link:": "कृपया यो लिङ्कसँग तपाईंको इमेल ठेगाना पुष्टि गर्नुहोस्:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "{siteTitle} को लागि सुरक्षित साइन इन लिङ्क", "See you soon!": "चाँडै भेटौँला!", "Sent to {email}": "{email} मा पठाइयो", @@ -68,10 +72,15 @@ "Thank you for subscribing to {siteTitle}.": "{siteTitle} मा सदस्यता लिनुभएकोमा धन्यवाद।", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "{siteTitle} मा सदस्यता लिनुभएकोमा धन्यवाद। तलको लिङ्कमा ट्याप गरेर स्वचालित रूपमा साइन इन हुनुहोस्:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "यो इमेल ठेगाना प्रयोग हुने छैन।", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "यो सन्देश {siteDomain} बाट {email} मा पठाइएको हो।", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "परीक्षण गर्दै", @@ -85,6 +94,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "पुन: स्वागत छ! यो लिङ्कलाई प्रयोग गरी तपाईंको {siteTitle} खाता मा सुरक्षित रूपमा साइन इन गर्नुहोस्:", + "What's included": "", + "What's included:": "", "When:": "कहिले:", "Where:": "कहाँ:", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "तपाईं यो प्राप्त गर्दै हुनुहुन्छ किनकि तपाईं {site} को %%{status}%% सदस्य हुनुहुन्छ।", @@ -97,7 +108,9 @@ "You're one tap away from subscribing to {siteTitle}!": "तपाईं {siteTitle} को सदस्यता लिन एक ट्याप टाढा हुनुहुन्छ!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "तपाईंको निःशुल्क परीक्षण {date} मा समाप्त हुन्छ, जुन समयमा तपाईंलाई नियमित मूल्यमा शुल्क लिइनेछ। तपाईं सधैं त्यसअघि रद्द गर्न सक्नुहुन्छ।", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/nl/ghost.json b/packages/i18n/locales/nl/ghost.json index 743e3682935..22d0466bc93 100644 --- a/packages/i18n/locales/nl/ghost.json +++ b/packages/i18n/locales/nl/ghost.json @@ -1,10 +1,14 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_other": "", "{count} year_one": "", "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "Tot snel!", "Become a paid member of {site} to get access to all premium content.": "", "By {authors}": "Door {authors}", @@ -29,6 +33,7 @@ "Hey there,": "Hallo,", "Hey there!": "Hallo!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Als jij deze aanvraag niet hebt gedaan kun je deze e-mail negeren.", "If you did not make this request, you can simply delete this message.": "Als jij deze aanvraag niet hebt gedaan kun je deze e-mail verwijderen.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "", @@ -41,14 +46,13 @@ "Name": "Naam", "New comment on {postTitle}": "", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "betaald", "Please confirm your email address with this link:": "Bevestig je e-mailadres met deze link:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Veilige inloglink voor {siteTitle}", "See you soon!": "Tot snel!", "Sent to {email}": "Verzonden naar {email}", @@ -68,10 +72,15 @@ "Thank you for subscribing to {siteTitle}.": "Bedankt voor je abonnement op {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Bedankt voor je abonnement op {siteTitle}. Klik op de link hieronder om automatisch in te loggen:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Dit e-mailadres zal niet worden niet gebruikt.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "proefperiode", @@ -85,6 +94,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Welkom terug! Gebruik deze link om veilig in te loggen op je {siteTitle} account:", + "What's included": "", + "What's included:": "", "When:": "", "Where:": "", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "Je ontvangt dit omdat je een %%{status}%%-abonnee bent van {site}.", @@ -97,7 +108,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Je bent één klik verwijderd van een abonnement op {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Je gratis proefperiode eindigt op {date}, waarna de reguliere prijs in rekening wordt gebracht. Je kunt altijd voor die tijd annuleren.", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/nn/ghost.json b/packages/i18n/locales/nn/ghost.json index 64e5ace4f80..c486060a64d 100644 --- a/packages/i18n/locales/nn/ghost.json +++ b/packages/i18n/locales/nn/ghost.json @@ -1,10 +1,14 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_other": "", "{count} year_one": "", "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "Beste helsing!", "Become a paid member of {site} to get access to all premium content.": "", "By {authors}": "", @@ -29,6 +33,7 @@ "Hey there,": "Hei,", "Hey there!": "Hei!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Viss du ikkje bad om denne e-posten, kan du trygt ignorera han.", "If you did not make this request, you can simply delete this message.": "Viss du ikkje bad om denne meldinga, kan du trygt sletta han.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "", @@ -41,14 +46,13 @@ "Name": "", "New comment on {postTitle}": "", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "", "Please confirm your email address with this link:": "Ver gild og bekreft e-postadressa di med denne lenka:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Trygg innlogginslenke for {siteTitle}", "See you soon!": "På gjensyn!", "Sent to {email}": "Sendt til {email}", @@ -68,10 +72,15 @@ "Thank you for subscribing to {siteTitle}.": "Takk for at du abonnerer på {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Takk for at du abonnerer på {siteTitle}. Klikk på lenka nedanfor for å logga inn automatisk:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Denne e-postadressa vil ikkje bli brukt.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "", @@ -85,6 +94,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Velkommen tilbake! Bruk denne lenka for å logga inn på {siteTitle}-brukaren din:", + "What's included": "", + "What's included:": "", "When:": "", "Where:": "", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", @@ -97,7 +108,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Du er eitt klikk unna å abonnera på {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/pa/ghost.json b/packages/i18n/locales/pa/ghost.json index fefa63bf719..22b0d1dd578 100644 --- a/packages/i18n/locales/pa/ghost.json +++ b/packages/i18n/locales/pa/ghost.json @@ -1,10 +1,14 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_other": "", "{count} year_one": "", "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "ਸ਼ੁਭਕਾਮਨਾਵਾਂ!", "Become a paid member of {site} to get access to all premium content.": "{site} ਦੇ ਸਹਿਯੋਗੀ ਮੈਂਬਰ ਬਣ ਕੇ ਸਾਰੀ ਪ੍ਰੀਮੀਅਮ ਸਮੱਗਰੀ ਤੱਕ ਪਹੁੰਚ ਪ੍ਰਾਪਤ ਕਰੋ।", "By {authors}": "{authors} ਦੁਆਰਾ", @@ -29,6 +33,7 @@ "Hey there,": "ਸਤਿ ਸ੍ਰੀ ਅਕਾਲ,", "Hey there!": "ਸਤਿ ਸ੍ਰੀ ਅਕਾਲ!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "ਜੇਕਰ ਤੁਸੀਂ ਇਹ ਬੇਨਤੀ ਨਹੀਂ ਕੀਤੀ ਹੈ, ਤਾਂ ਤੁਸੀਂ ਇਸ ਈਮੇਲ ਨੂੰ ਸੁਰੱਖਿਅਤ ਢੰਗ ਨਾਲ ਨਜ਼ਰਅੰਦਾਜ਼ ਕਰ ਸਕਦੇ ਹੋ।", "If you did not make this request, you can simply delete this message.": "ਜੇਕਰ ਤੁਸੀਂ ਇਹ ਬੇਨਤੀ ਨਹੀਂ ਕੀਤੀ ਹੈ, ਤਾਂ ਤੁਸੀਂ ਇਸ ਸੁਨੇਹੇ ਨੂੰ ਬਸ ਮਿਟਾ ਸਕਦੇ ਹੋ।", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "ਜੇਕਰ ਤੁਸੀਂ ਹਾਲ ਹੀ ਵਿੱਚ ਸਾਈਨ ਇਨ ਕਰਨ ਦੀ ਕੋਸ਼ਿਸ਼ ਨਹੀਂ ਕੀਤੀ, ਤਾਂ ਪਹੁੰਚ ਤੋਂ ਇਨਕਾਰ ਕਰਨ ਲਈ ਤੁਸੀਂ ਇਸ ਈਮੇਲ ਨੂੰ ਸੁਰੱਖਿਅਤ ਢੰਗ ਨਾਲ ਨਜ਼ਰਅੰਦਾਜ਼ ਕਰ ਸਕਦੇ ਹੋ।", @@ -41,14 +46,13 @@ "Name": "ਨਾਮ", "New comment on {postTitle}": "{postTitle} 'ਤੇ ਨਵੀਂ ਟਿੱਪਣੀ", "New reply to your comment on {siteTitle}": "{siteTitle} 'ਤੇ ਤੁਹਾਡੀ ਟਿੱਪਣੀ 'ਤੇ ਨਵਾਂ ਜਵਾਬ", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "ਭੁਗਤਾਨ ਕੀਤਾ", "Please confirm your email address with this link:": "ਕਿਰਪਾ ਕਰਕੇ ਇਸ ਲਿੰਕ ਨਾਲ ਆਪਣੇ ਈਮੇਲ ਪਤੇ ਦੀ ਪੁਸ਼ਟੀ ਕਰੋ:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "{siteTitle} ਲਈ ਸੁਰੱਖਿਅਤ ਸਾਈਨ-ਇਨ ਲਿੰਕ", "See you soon!": "ਜਲਦੀ ਮਿਲਦੇ ਹਾਂ!", "Sent to {email}": "{email} ਨੂੰ ਭੇਜਿਆ ਗਿਆ", @@ -68,10 +72,15 @@ "Thank you for subscribing to {siteTitle}.": "{siteTitle} ਤੇ ਸਬਸਕ੍ਰਾਈਬ ਕਰਨ ਲਈ ਤੁਹਾਡਾ ਧੰਨਵਾਦ।", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "{siteTitle} ਤੇ ਸਬਸਕ੍ਰਾਈਬ ਕਰਨ ਲਈ ਤੁਹਾਡਾ ਧੰਨਵਾਦ। ਸਵੈਚਲਿਤ ਤੌਰ 'ਤੇ ਸਾਈਨ ਇਨ ਹੋਣ ਲਈ ਹੇਠਾਂ ਦਿੱਤੇ ਲਿੰਕ ਨੂੰ ਦੱਬੋ:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "ਇਹ ਈਮੇਲ ਪਤਾ ਨਹੀਂ ਵਰਤਿਆ ਜਾਵੇਗਾ।", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "ਇਹ ਸੁਨੇਹਾ {siteDomain} ਵਲੋਂ {email} 'ਤੇ ਭੇਜਿਆ ਗਿਆ ਸੀ।", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "ਅਜ਼ਮਾਇਸ਼ੀ ਸਦੱਸਤਾ", @@ -85,6 +94,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "ਵਾਪਸ ਆਉਣ 'ਤੇ ਸੁਆਗਤ ਹੈ! ਆਪਣੇ {siteTitle} ਖਾਤੇ ਵਿੱਚ ਸੁਰੱਖਿਅਤ ਢੰਗ ਨਾਲ ਸਾਈਨ ਇਨ ਕਰਨ ਲਈ ਇਸ ਲਿੰਕ ਦੀ ਵਰਤੋਂ ਕਰੋ:", + "What's included": "", + "What's included:": "", "When:": "ਕਦੋਂ:", "Where:": "ਕਿੱਥੇ:", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "ਤੁਹਾਨੂੰ ਇਹ ਇਸ ਲਈ ਮਿਲ ਰਿਹਾ ਹੈ ਕਿਉਂਕਿ ਤੁਸੀਂ {site} ਦੇ ਇੱਕ %%{status}%% ਸਹਿਯੋਗੀ ਹੋ।", @@ -97,7 +108,9 @@ "You're one tap away from subscribing to {siteTitle}!": "ਤੁਸੀਂ {siteTitle} ਤੇ ਸਬਸਕ੍ਰਾਈਬ ਕਰਨ ਦੇ ਬਹੁਤ ਨੇੜੇ ਹੋ!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "ਤੁਹਾਡੀ ਅਜ਼ਮਾਇਸ਼ੀ ਸਦੱਸਤਾ {date} ਨੂੰ ਸਮਾਪਤ ਹੋਵੇਗੀ, ਉਸ ਸਮੇਂ ਤੁਹਾਡੇ ਤੋਂ ਨਿਯਮਤ ਕੀਮਤ ਲਈ ਜਾਵੇਗੀ। ਤੁਸੀਂ ਹਮੇਸ਼ਾ ਇਸ ਤਾਰੀਕ ਤੋਂ ਪਹਿਲਾਂ ਰੱਦ ਕਰ ਸਕਦੇ ਹੋ।", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/pl/ghost.json b/packages/i18n/locales/pl/ghost.json index c56783eeb3e..007a9c03dc2 100644 --- a/packages/i18n/locales/pl/ghost.json +++ b/packages/i18n/locales/pl/ghost.json @@ -1,4 +1,7 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_few": "", "{count} month_many": "", @@ -9,6 +12,7 @@ "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "Wszystkiego dobrego!", "Become a paid member of {site} to get access to all premium content.": "Zostań płatnym członkiem {site}, aby uzyskać dostęp do wszystkich treści premium.", "By {authors}": "Przez {authors}", @@ -33,6 +37,7 @@ "Hey there,": "Cześć,", "Hey there!": "Cześć!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Jeśli to nie Ty wysłałeś tę prośbę, możesz bezpiecznie zignorować ten email.", "If you did not make this request, you can simply delete this message.": "Jeśli to nie Ty wysłałeś tę prośbę, możesz po prostu skasować ten email.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "Jeśli ostatnio nie próbowałeś się zalogować, możesz bezpiecznie zignorować tę wiadomość e-mail, aby odmówić dostępu.", @@ -45,14 +50,13 @@ "Name": "Imię", "New comment on {postTitle}": "", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "płatnym", "Please confirm your email address with this link:": "Potwierdź proszę swój adres email, klikając w ten link:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Bezpieczny link logowania do {siteTitle}", "See you soon!": "Do zobaczenia!", "Sent to {email}": "Wysłano do {email}", @@ -72,10 +76,15 @@ "Thank you for subscribing to {siteTitle}.": "Dziękujemy za zapisanie się do {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Dziękujemy za zapisanie się do {siteTitle}. Kliknij w link poniżej, aby zostać zalogowanym:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Ten adres email nie będzie więcej używany.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "próbnym", @@ -89,6 +98,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Witaj ponownie! Użyj tego linku, aby się bezpiecznie zalogować do swojego konta w serwisie {siteTitle}:", + "What's included": "", + "What's included:": "", "When:": "Kiedy", "Where:": "Gdzie", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "Otrzymujesz tę wiadomość, ponieważ jesteś %%{status}%% subskrybentem witryny {site}.", @@ -101,7 +112,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Jesteś jedno kliknięcie od zapisania się do {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Twój bezpłatny okres próbny kończy się {date}, w którym to czasie zostaniesz obciążony standardową ceną. Zawsze możesz anulować przed tym terminem.", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/pt-BR/ghost.json b/packages/i18n/locales/pt-BR/ghost.json index 6c24b939e83..746a689cb02 100644 --- a/packages/i18n/locales/pt-BR/ghost.json +++ b/packages/i18n/locales/pt-BR/ghost.json @@ -1,4 +1,7 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "{count} mês", "{count} month_many": "{count} meses", "{count} month_other": "{count} meses", @@ -7,6 +10,7 @@ "{count} year_other": "{count} anos", "{date}": "{date}", "{tierName} membership": "assinatura {tierName}", + "A gift, just for you": "", "All the best!": "Até breve!", "Become a paid member of {site} to get access to all premium content.": "Torne-se um membro pago do {site} para ter acesso a todo o conteúdo premium.", "By {authors}": "Por {authors}", @@ -31,6 +35,7 @@ "Hey there,": "Olá,", "Hey there!": "Olá!", "Hi {firstName},": "Olá, {firstName},", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Se você não fez esta solicitação, pode ignorar este e-mail.", "If you did not make this request, you can simply delete this message.": "Se você não fez esta solicitação, pode apagar esta mensagem.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "Se você não tentou entrar recentemente, ignore este e-mail. Essa medida impedirá o acesso não autorizado.", @@ -43,14 +48,13 @@ "Name": "Nome", "New comment on {postTitle}": "Novo comentário em {postTitle}", "New reply to your comment on {siteTitle}": "Nova resposta ao seu comentário em {siteTitle}", - "one-month": "um mês", - "one-year": "um ano", "Open this link to redeem your gift.": "Abra este link para resgatar seu presente.", "Or use this link to securely sign in": "Ou use este link para acessar com segurança", "Or, skip the code and sign in directly": "Ou, ignore o código e entre diretamente", "paid": "pago", "Please confirm your email address with this link:": "Por favor, confirme seu endereço de e-mail usando este link:", "Redeem your gift subscription": "Resgatar sua assinatura de presente", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Link seguro para acessar o site {siteTitle}", "See you soon!": "Até logo!", "Sent to {email}": "Enviado para {email}", @@ -70,10 +74,15 @@ "Thank you for subscribing to {siteTitle}.": "Obrigado por se inscrever no site {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Obrigado por se inscrever no site {siteTitle}. Clique no link abaixo para entrar automaticamente:", "Thank you for supporting {siteTitle}.": "Obrigado por apoiar {siteTitle}.", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "Obrigado pelo seu apoio. Compartilhe o link abaixo com quem você quiser para presentear uma assinatura {cadenceLabel} {tierName} para {siteTitle}.", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "Obrigado por ler {siteTitle}.", "The link expires on {expiresAt} and can only be redeemed once.": "O link expira em {expiresAt} e só pode ser resgatado uma vez.", "This email address will not be used.": "Este endereço de e-mail não será usado.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "Esta mensagem foi enviada de {siteDomain} para {email}.", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "Para manter sua assinatura {tierName}, continue hoje com uma assinatura paga e adicionaremos automaticamente o restante do período do presente como teste grátis.", "trialing": "em período de teste", @@ -87,6 +96,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "Bem-vindo de volta ao site {siteTitle}! Seu código de verificação é {otc}.", "Welcome back! Here's your code to sign in to {siteTitle}": "Bem-vindo de volta! Aqui está seu código para entrar no site {siteTitle}", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Bem-vindo de volta! Use este link para entrar com segurança na sua conta do site {siteTitle}:", + "What's included": "", + "What's included:": "", "When:": "Quando:", "Where:": "Onde:", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "Você está recebendo isto porque é um assinante %%{status}%% de {site}.", @@ -99,7 +110,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Você está a um clique de se inscrever no site {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "Você recebeu uma assinatura {tierName} para {siteTitle}, válida por {duration} mês.", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "Você recebeu uma assinatura {tierName} para {siteTitle}, válida por {duration} ano.", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Seu período de teste gratuito termina em {date}, quando será cobrado o preço normal. Você pode cancelar antes disso.", + "Your gift is on its way": "", "Your gift is ready": "Seu presente está pronto", "Your gift subscription confirmation": "Confirmação da sua assinatura de presente", "Your gift subscription is ending soon": "Sua assinatura de presente vai terminar em breve", diff --git a/packages/i18n/locales/pt/ghost.json b/packages/i18n/locales/pt/ghost.json index 791382204cd..ce0391823ba 100644 --- a/packages/i18n/locales/pt/ghost.json +++ b/packages/i18n/locales/pt/ghost.json @@ -1,4 +1,7 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_many": "", "{count} month_other": "", @@ -7,6 +10,7 @@ "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "Tudo de bom para si!", "Become a paid member of {site} to get access to all premium content.": "Torne-se um membro pago de {site} para aceder a todo o conteúdo exclusivo.", "By {authors}": "Por {authors}", @@ -31,6 +35,7 @@ "Hey there,": "Olá,", "Hey there!": "Olá!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Se não fez este pedido, pode ignorar este email.", "If you did not make this request, you can simply delete this message.": "Se não fez este pedido, pode simplesmente apagar esta mensagem.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "", @@ -43,14 +48,13 @@ "Name": "Nome", "New comment on {postTitle}": "Novo comentário em {postTitle}", "New reply to your comment on {siteTitle}": "Nova resposta ao seu comentário em {siteTitle}", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "paga", "Please confirm your email address with this link:": "Por favor, confirme o seu email através deste link:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Ligação segura para entrar em {siteTitle}", "See you soon!": "Até breve!", "Sent to {email}": "Enviado para {email}", @@ -70,10 +74,15 @@ "Thank you for subscribing to {siteTitle}.": "Obrigado por subscrever o {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Obrigado por subscrever o {siteTitle}. Clique no link em baixo para entrar automaticamente:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Este endereço de email não será usado.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "em período de avaliação", @@ -87,6 +96,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Seja bem-vindo de volta! Use este link para entrar de forma segura na sua conta em {siteTitle}:", + "What's included": "", + "What's included:": "", "When:": "Quando:", "Where:": "Onde:", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "Está a receber este e-mail porque é subscritor/a de {site}, com uma subscrição %%{status}%%.", @@ -99,7 +110,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Está a um passo de subscrever o {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "A sua avaliação gratuita termina a {date}, quando será cobrado preço normal. Pode sempre cancelar antes disso.", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/ro/ghost.json b/packages/i18n/locales/ro/ghost.json index 11d6d48a642..33af3bf0ff5 100644 --- a/packages/i18n/locales/ro/ghost.json +++ b/packages/i18n/locales/ro/ghost.json @@ -1,4 +1,7 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_few": "", "{count} month_other": "", @@ -7,6 +10,7 @@ "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "Toate cele bune!", "Become a paid member of {site} to get access to all premium content.": "", "By {authors}": "De {authors}", @@ -31,6 +35,7 @@ "Hey there,": "Bună,", "Hey there!": "Salut!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Dacă nu ai făcut această solicitare, poți ignora în siguranță acest email.", "If you did not make this request, you can simply delete this message.": "Dacă nu ai făcut această solicitare, poți șterge pur și simplu acest mesaj.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "", @@ -43,14 +48,13 @@ "Name": "Nume", "New comment on {postTitle}": "", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "plătit", "Please confirm your email address with this link:": "Te rugăm să confirmi adresa ta de email cu acest link:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Link securizat pentru autentificarea la {siteTitle}", "See you soon!": "Pe curând!", "Sent to {email}": "Trimis către {email}", @@ -70,10 +74,15 @@ "Thank you for subscribing to {siteTitle}.": "Îți mulțumim pentru abonarea la {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Îți mulțumim că te-ai abonat la {siteTitle}. Apasă pe link-ul de mai jos pentru a fi conectat automat:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Această adresă de email nu va fi utilizată.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "în perioadă de probă", @@ -87,6 +96,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Bun venit înapoi! Folosește acest link pentru a te autentifica în siguranță în contul tău {siteTitle}:", + "What's included": "", + "What's included:": "", "When:": "", "Where:": "", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "Primești acest mesaj deoarece ești un abonat %%{status}%% la {site}.", @@ -99,7 +110,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Ești la un singur pas de a te abona la {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Perioada ta de probă gratuită se încheie pe {date}, moment în care vei fi taxat la prețul obișnuit. Poți anula oricând până atunci.", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/ru/ghost.json b/packages/i18n/locales/ru/ghost.json index 652b99266aa..03abbeeb357 100644 --- a/packages/i18n/locales/ru/ghost.json +++ b/packages/i18n/locales/ru/ghost.json @@ -1,4 +1,7 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_few": "", "{count} month_many": "", @@ -9,6 +12,7 @@ "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "Всего наилучшего!", "Become a paid member of {site} to get access to all premium content.": "Станьте платным участником {site}, чтобы получить доступ ко всему премиум-контенту.", "By {authors}": "От {authors}", @@ -33,6 +37,7 @@ "Hey there,": "Привет,", "Hey there!": "Привет!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Если вы этого не делали, можете смело игнорировать это письмо.", "If you did not make this request, you can simply delete this message.": "Если вы этого не делали, можете просто удалить это сообщение.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "Если вы не пытались недавно войти в систему, вы можете спокойно проигнорировать это электронное письмо, чтобы отказать в доступе.", @@ -45,14 +50,13 @@ "Name": "Имя", "New comment on {postTitle}": "Новый комментарий к записи {postTitle}", "New reply to your comment on {siteTitle}": "Новый ответ на ваш комментарий на сайте {siteTitle}", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "Или воспользуйтесь этой ссылкой для безопасного входа", "Or, skip the code and sign in directly": "Или пропустите ввод кода и войдите напрямую", "paid": "платным", "Please confirm your email address with this link:": "Пожалуйста, подтвердите свой email адрес, перейдя по этой ссылке:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Ссылка для безопасного входа в систему на {siteTitle}", "See you soon!": "До скорой встречи!", "Sent to {email}": "Отправлено на {email}", @@ -72,10 +76,15 @@ "Thank you for subscribing to {siteTitle}.": "Спасибо за подписку на {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Спасибо за подписку на {siteTitle}. Нажмите на ссылку ниже, чтобы автоматически войти:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Этот email адрес не будет использоваться.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "Это сообщение было отправлено с {siteDomain} на {email}.", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "бесплатным (пробный период подписки)", @@ -89,6 +98,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "С возвращением на {siteTitle}! Ваш проверочный код — {otc}.", "Welcome back! Here's your code to sign in to {siteTitle}": "С возвращением! Вот ваш код для входа на {siteTitle}", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "С возвращением! Используйте эту ссылку для безопасного входа в свою учётную запись на {siteTitle}:", + "What's included": "", + "What's included:": "", "When:": "Когда:", "Where:": "Где:", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "Вы получаете это, потому что являетесь %%{status}%% подписчиком сайта {site}.", @@ -101,7 +112,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Вы в одном клике от подписки на {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Ваш бесплатный пробный период заканчивается {date}, после чего с вас будет списана стандартная сумма. Вы всегда можете отменить подписку до этого момента.", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/si/ghost.json b/packages/i18n/locales/si/ghost.json index aa20f66131d..898189afbc1 100644 --- a/packages/i18n/locales/si/ghost.json +++ b/packages/i18n/locales/si/ghost.json @@ -1,10 +1,14 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_other": "", "{count} year_one": "", "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "සියලු දේ සාර්ථකත්වයට පත් වේවායි ප්\u200dරාර්ථනා කරන්නෙමු!", "Become a paid member of {site} to get access to all premium content.": "", "By {authors}": "", @@ -29,6 +33,7 @@ "Hey there,": "ආයුබෝවන්,", "Hey there!": "ආයුබෝවන්!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "ඔබ මෙම ඉල්ලීම සිදු නොකලේ නම්, මෙම email එක නොසළකාහරින්න.", "If you did not make this request, you can simply delete this message.": "ඔබ මෙම ඉල්ලීම සිදු නොකලේ නම්, මෙම පණිවිඩය මකා දැමිය හැක.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "", @@ -41,14 +46,13 @@ "Name": "", "New comment on {postTitle}": "", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "", "Please confirm your email address with this link:": "ඔබගේ email ලිපිනය තහවුරු කිරීම සඳහා මෙම link එක භාවිතා කරන්න:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "{siteTitle} වෙත ආරක්ෂිතව sign in වීම සඳහා වන link එක", "See you soon!": "ඉක්මණින් නැවත හමුවෙමු!", "Sent to {email}": "{email} වෙත යවන ලදී", @@ -68,10 +72,15 @@ "Thank you for subscribing to {siteTitle}.": "{siteTitle} වෙත subscribe වීම වෙනුවෙන් ඔබට ස්තූතිවන්ත වෙන්නෙමු.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "{siteTitle} වෙත subscribe වීම වෙනුවෙන් ඔබට ස්තූතිවන්ත වෙන්නෙමු. ස්ව්\u200dයංක්\u200dරීයවම sign in වීම සඳහා පහත link එක මත tap කරන්න:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "මෙම email ලිපිනය භාවිතා නොවනු ඇත.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "", @@ -85,6 +94,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "සාදරයෙන් පිළිගන්නෙමු! ඔබගේ {siteTitle} ගිණුමට ආරක්ෂිතව sign in වීම සඳහා පහත link එක භාවිතා කරන්න", + "What's included": "", + "What's included:": "", "When:": "", "Where:": "", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", @@ -97,7 +108,9 @@ "You're one tap away from subscribing to {siteTitle}!": "{siteTitle} වෙත subscribe වීම සඳහා ඇත්තේ තව එක පියවරක් පමණි!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/sk/ghost.json b/packages/i18n/locales/sk/ghost.json index ba01f2366bf..0d8e887b8eb 100644 --- a/packages/i18n/locales/sk/ghost.json +++ b/packages/i18n/locales/sk/ghost.json @@ -1,4 +1,7 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_few": "", "{count} month_many": "", @@ -9,6 +12,7 @@ "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "Všetko dobré!", "Become a paid member of {site} to get access to all premium content.": "", "By {authors}": "od", @@ -33,6 +37,7 @@ "Hey there,": "Ahoj,", "Hey there!": "Ahoj!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Ak ste túto žiadosť neodoslali, môžte tento e-mail pokojne ignorovať.", "If you did not make this request, you can simply delete this message.": "Ak ste túto žiadosť neodoslali, môžte túto správu pokojne vymazať.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "", @@ -45,14 +50,13 @@ "Name": "Meno", "New comment on {postTitle}": "", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "plateným", "Please confirm your email address with this link:": "Prosím potvrďte svoju e-mailovú adresu kliknutím na nasledujúci odkaz", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Bezpečný prihlasovací odkaz pre {siteTitle}", "See you soon!": "Dovidenia!", "Sent to {email}": "Odoslané na {email}", @@ -72,10 +76,15 @@ "Thank you for subscribing to {siteTitle}.": "Ďakujeme za odber z {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Ďakujeme za odber z {siteTitle}. Kliknite na nasledujúci odkaz pre automatické prihlásenie:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Táto e-mailová adresa nebude použitá.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "skúšobné", @@ -89,6 +98,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Vitajte späť! Použite tento odkaz na bezpečné prihlásenie do účtu {siteTitle}:", + "What's included": "", + "What's included:": "", "When:": "", "Where:": "", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "Tento email ste dostali lebo ste %%{status}%% odberateľom na {site}.", @@ -101,7 +112,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Ste jedno kliknutie od odberu z {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Vaše bezplatné skúšobné obdobie skončí {date} a v tom čase vám bude účtovaný poplatok za predplatné. Môžete to pred tým zrušiť.", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/sl/ghost.json b/packages/i18n/locales/sl/ghost.json index 25302cafd0c..a95fe6ac802 100644 --- a/packages/i18n/locales/sl/ghost.json +++ b/packages/i18n/locales/sl/ghost.json @@ -1,4 +1,7 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_two": "", "{count} month_few": "", @@ -9,6 +12,7 @@ "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "Vse dobro!", "Become a paid member of {site} to get access to all premium content.": "", "By {authors}": "Avtorji: {authors}", @@ -33,6 +37,7 @@ "Hey there,": "Pozdravljeni,", "Hey there!": "Pozdravljeni!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Če niste oddali te zahteve, lahko to e-pošto mirno ignorirate.", "If you did not make this request, you can simply delete this message.": "Če te zahteve niste podali, lahko to sporočilo preprosto izbrišete.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "", @@ -45,14 +50,13 @@ "Name": "Ime", "New comment on {postTitle}": "", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "plačljivi", "Please confirm your email address with this link:": "S to povezavo potrdite svoj e-poštni naslov:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Varna povezava za prijavo na {siteTitle}", "See you soon!": "Se vidimo kmalu!", "Sent to {email}": "Poslano na {email}", @@ -72,10 +76,15 @@ "Thank you for subscribing to {siteTitle}.": "Hvala, ker ste se naročili na {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Hvala, ker ste se naročili na {siteTitle}. Kliknite spodnjo povezavo in se samodejno prijavite:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Ta e-poštni naslov ne bo uporabljen.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "poskusni", @@ -89,6 +98,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Dobrodošli nazaj! S to povezavo se varno prijavite v svoj {siteTitle} račun:", + "What's included": "", + "What's included:": "", "When:": "", "Where:": "", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "To prejemate, ker ste %%{status}%% naročnik na {site}.", @@ -101,7 +112,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Le en klik vas loči od tega, da se naročite na {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Vaše brezplačno poskusno obdobje se konča {date}, nato vam bo zaračunana redna cena. Naročnino lahko vedno prekličete pred tem datumom.", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/sq/ghost.json b/packages/i18n/locales/sq/ghost.json index dcc598e8a22..aea3bd45cff 100644 --- a/packages/i18n/locales/sq/ghost.json +++ b/packages/i18n/locales/sq/ghost.json @@ -1,10 +1,14 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "{count} muaj", "{count} month_other": "{count} muaj", "{count} year_one": "{count} vit", "{count} year_other": "{count} vite", "{date}": "{date}", "{tierName} membership": "Anëtarësimi {tierName}", + "A gift, just for you": "", "All the best!": "Gjithë të mirat!", "Become a paid member of {site} to get access to all premium content.": "Bëhu anëtar me pagesë i {site} për të pasur akses në të gjithë përmbajtjen premium.", "By {authors}": "Nga {authors}", @@ -29,6 +33,7 @@ "Hey there,": "Përshëndetje,", "Hey there!": "Përshëndetje!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Nëse nuk e keni bërë ju këtë kërkesë, mund ta injoroni lirisht këtë email.", "If you did not make this request, you can simply delete this message.": "Nëse nuk e keni bërë ju këtë kërkesë, thjesht fshini këtë mesazh.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "Nëse nuk keni provuar të identifikoheni së fundmi, thjesht injorojeni këtë email.", @@ -41,14 +46,13 @@ "Name": "Emri", "New comment on {postTitle}": "Koment i ri në {postTitle}", "New reply to your comment on {siteTitle}": "Përgjigje e re ndaj komentit tuaj në {siteTitle}", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "Hapni këtë link për të aktivizuar dhuratën tuaj.", "Or use this link to securely sign in": "Ose përdorni këtë link për t'u identifikuar në mënyrë të sigurt", "Or, skip the code and sign in directly": "Ose, anashkalo kodin dhe hyr direkt", "paid": "me pagesë", "Please confirm your email address with this link:": "Ju lutem konfirmoni adresën tuaj të emailit me këtë link:", "Redeem your gift subscription": "Aktivizo abonimin tënd dhuratë", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Linku i sigurt i identifikimit për {siteTitle}", "See you soon!": "Shihemi së shpejti!", "Sent to {email}": "Dërguar te {email}", @@ -68,10 +72,15 @@ "Thank you for subscribing to {siteTitle}.": "Faleminderit që u abonuat në {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Faleminderit që u abonuat në {siteTitle}. Prekni linkun më poshtë për t'u identifikuar automatikisht:", "Thank you for supporting {siteTitle}.": "Faleminderit që mbështetni {siteTitle}.", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Kjo adresë emaili nuk do të përdoret.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "Ky mesazh u dërgua nga {siteDomain} te {email}.", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "në provë", @@ -85,6 +94,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "Mirë se u kthyet në {siteTitle}! Kodi juaj i verifikimit është {otc}.", "Welcome back! Here's your code to sign in to {siteTitle}": "Mirë se u kthyet! Këtu është kodi juaj për t'u identifikuar në {siteTitle}", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Mirë se u kthyet! Përdorni këtë link për t'u identifikuar në mënyrë të sigurt në llogarinë tuaj në {siteTitle}:", + "What's included": "", + "What's included:": "", "When:": "Kur:", "Where:": "Ku:", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "Ju po e merrni këtë sepse jeni një pajtimtar %%{status}%% në {site}.", @@ -97,7 +108,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Jeni vetëm një hap larg abonimit në {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "Ju është dhuruar një anëtarësim {duration}-mujor {tierName} në {siteTitle}", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "Ju është dhuruar një anëtarësim {duration}-vjeçar {tierName} në {siteTitle}", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Prova juaj falas përfundon më {date}, kohë në të cilën do të tarifoheni me çmimin e rregullt. Mund ta anuloni gjithmonë para kësaj date.", + "Your gift is on its way": "", "Your gift is ready": "Dhurata juaj është gati", "Your gift subscription confirmation": "Konfirmimi i abonimit tuaj dhuratë", "Your gift subscription is ending soon": "Abonimi juaj dhuratë po përfundon së shpejti", diff --git a/packages/i18n/locales/sr-Cyrl/ghost.json b/packages/i18n/locales/sr-Cyrl/ghost.json index b8ac920b890..3912816ac4e 100644 --- a/packages/i18n/locales/sr-Cyrl/ghost.json +++ b/packages/i18n/locales/sr-Cyrl/ghost.json @@ -1,4 +1,7 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_few": "", "{count} month_other": "", @@ -7,6 +10,7 @@ "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "Све најбоље!", "Become a paid member of {site} to get access to all premium content.": "Постаните плаћени члан {site}-а да бисте добили приступ премијум садржају.", "By {authors}": "Аутори: {authors}", @@ -31,6 +35,7 @@ "Hey there,": "Поздрав,", "Hey there!": "Поздрав!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Ако нисте затражили ово, слободно игноришите овај email.", "If you did not make this request, you can simply delete this message.": "Ако нисте затражили ово, слободно обришите ову поруку.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "Ако нисте покушали недавно да се пријавите, слободно игноришите овај email да бисте забранили приступ.", @@ -43,14 +48,13 @@ "Name": "Име", "New comment on {postTitle}": "Нови коментар на {postTitle}", "New reply to your comment on {siteTitle}": "Нови одговор на Ваш коментар на {siteTitle}", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "Или искористите овај линк да се сигурно пријавите", "Or, skip the code and sign in directly": "Или, прескочите код и пријавите се директно", "paid": "плаћени", "Please confirm your email address with this link:": "Молимо Вас да потврдите Вашу email адресу путем овог линка:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Сигуран линк за пријаву на {siteTitle}", "See you soon!": "Видимо се ускоро!", "Sent to {email}": "Послато на {email}", @@ -70,10 +74,15 @@ "Thank you for subscribing to {siteTitle}.": "Хвала за претплату на {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Хвала за претплату на {siteTitle}. Кликните на линк испод како бисте се аутоматски пријавили:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Ова email адреса неће бити коришћена.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "Ова порука је послата са {siteDomain} на {email}.", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "пробни", @@ -87,6 +96,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "Добро дошли назад на {siteTitle}! Ваш код за верификацију је {otc}.", "Welcome back! Here's your code to sign in to {siteTitle}": "Добро дошли назад! Ово је Ваш код за пријаву на {siteTitle}", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Добро дошли назад! Користите овај линк како бисте се сигурно пријавили на Ваш налог на {siteTitle}:", + "What's included": "", + "What's included:": "", "When:": "Када:", "Where:": "Где:", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "Примате ово јер сте %%{status}%% претплатник на {site}.", @@ -99,7 +110,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Један сте клик од претплате на {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Ваш бесплатан пробни период истиче {date}, када ће Вам бити наплаћена редовна цена. Увек можете отказати пре тог рока.", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/sr/ghost.json b/packages/i18n/locales/sr/ghost.json index 9a3656ea22d..c2c007ca366 100644 --- a/packages/i18n/locales/sr/ghost.json +++ b/packages/i18n/locales/sr/ghost.json @@ -1,4 +1,7 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "{count} mesec", "{count} month_few": "{count} meseca", "{count} month_other": "{count} meseci", @@ -7,6 +10,7 @@ "{count} year_other": "{count} godina", "{date}": "{date}", "{tierName} membership": "{tierName} članstvo", + "A gift, just for you": "", "All the best!": "Sve najbolje!", "Become a paid member of {site} to get access to all premium content.": "Postanite plaćeni član {site}-a da biste dobili pristup premijum sadržaju.", "By {authors}": "Autori: {authors}", @@ -31,6 +35,7 @@ "Hey there,": "Pozdrav,", "Hey there!": "Pozdrav!", "Hi {firstName},": "Zdravo {firstName},", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Ako niste zatražili ovo, slobodno ignorišite ovaj email.", "If you did not make this request, you can simply delete this message.": "Ako niste zatražili ovo, slobodno obrišite ovu poruku.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "Ako niste pokušali nedavno da se prijavite, slobodno ignorišite ovaj email da biste zabranili pristup.", @@ -43,14 +48,13 @@ "Name": "Ime", "New comment on {postTitle}": "Novi komentar na {postTitle}", "New reply to your comment on {siteTitle}": "Novi odgovor na Vaš komentar na {siteTitle}", - "one-month": "mesec", - "one-year": "godina", "Open this link to redeem your gift.": "Otvorite ovaj link da preuzmete svoj poklon.", "Or use this link to securely sign in": "Ili iskoristite ovaj link da se sigurno prijavite", "Or, skip the code and sign in directly": "Ili, preskočite kod i prijavite se direktno", "paid": "plaćeni", "Please confirm your email address with this link:": "Molimo Vas da potvrdite Vašu email adresu putem ovog linka:", "Redeem your gift subscription": "Preuzmite svoju poklon-pretplatu", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Siguran link za prijavu na {siteTitle}", "See you soon!": "Vidimo se uskoro!", "Sent to {email}": "Poslato na {email}", @@ -70,10 +74,15 @@ "Thank you for subscribing to {siteTitle}.": "Hvala za pretplatu na {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Hvala za pretplatu na {siteTitle}. Kliknite na link ispod kako biste se automatski prijavili:", "Thank you for supporting {siteTitle}.": "Hvala na podršci za {siteTitle}.", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "Hvala Vam na podršci. Podelite link ispod sa osobom kojoj želite da poklonite {cadenceLabel} {tierName} članstvo za {siteTitle}.", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "Hvala na čitanju {siteTitle}.", "The link expires on {expiresAt} and can only be redeemed once.": "Link ističe {expiresAt} i može se iskoristiti samo jednom.", "This email address will not be used.": "Ova email adresa neće biti korišćena.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "Ova poruka je poslata sa {siteDomain} na {email}.", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "Da biste zadržali svoje {tierName} članstvo, nastavite sa plaćenom pretplatom već danas, a mi ćemo automatski dodati ostatak vašeg poklon perioda kao besplatan probni period.", "trialing": "probni", @@ -87,6 +96,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "Dobro došli nazad na {siteTitle}! Vaš kod za verifikaciju je {otc}.", "Welcome back! Here's your code to sign in to {siteTitle}": "Dobro došli nazad! Ovo je Vaš kod za prijavu na {siteTitle}", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Dobro došli nazad! Koristite ovaj link kako biste se sigurno prijavili na Vaš nalog na {siteTitle}:", + "What's included": "", + "What's included:": "", "When:": "Kada:", "Where:": "Gde:", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "Primate ovo jer ste %%{status}%% pretplatnik na {site}.", @@ -99,7 +110,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Jedan ste klik od pretplate na {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "Dobili ste na poklon {duration}-mesečno {tierName} članstvo za {siteTitle}", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "Dobili ste na poklon {duration}-godišnje {tierName} članstvo za {siteTitle}", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Vaš besplatan probni period ističe {date}, kada će Vam biti naplaćena redovna cena. Uvek možete otkazati pre tog roka.", + "Your gift is on its way": "", "Your gift is ready": "Vaš poklon je spreman", "Your gift subscription confirmation": "Potvrda Vaše poklon-pretplate", "Your gift subscription is ending soon": "Vaša poklon-pretplata uskoro ističe", diff --git a/packages/i18n/locales/sv/ghost.json b/packages/i18n/locales/sv/ghost.json index 9409758e472..89102d5559d 100644 --- a/packages/i18n/locales/sv/ghost.json +++ b/packages/i18n/locales/sv/ghost.json @@ -1,10 +1,14 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "{count} månad", "{count} month_other": "{count} månader", "{count} year_one": "{count} år", "{count} year_other": "{count} år", "{date}": "{date}", "{tierName} membership": "{tierName} prenumeration", + "A gift, just for you": "", "All the best!": "Allt gott!", "Become a paid member of {site} to get access to all premium content.": "Bli betalande medlem på {site} för att få tillgång till allt innehåll.", "By {authors}": "Av {authors}", @@ -29,6 +33,7 @@ "Hey there,": "Hallå där,", "Hey there!": "Hallå där!", "Hi {firstName},": "Hej, {firstName}", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Om du inte gjorde denna begäran kan du tryggt ignorera detta e-postmeddelande.", "If you did not make this request, you can simply delete this message.": "Om du inte gjorde denna begäran kan du helt enkelt radera detta meddelande.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "Om du inte har försökt logga in nyligen kan du ignorera detta e-postmeddelande för att hindra åtkomst.", @@ -41,14 +46,13 @@ "Name": "Namn", "New comment on {postTitle}": "Ny kommentar i {postTitle}", "New reply to your comment on {siteTitle}": "Nytt svar till din kommentar på {siteTitle}", - "one-month": "en-månad", - "one-year": "ett-år", "Open this link to redeem your gift.": "Öppna den här länken för att aktivera din gåvoprenumeration.", "Or use this link to securely sign in": "Eller använd denna länk för att logga in säkert", "Or, skip the code and sign in directly": "Eller hoppa över koden och logga in direkt", "paid": "betalande", "Please confirm your email address with this link:": "Vänligen bekräfta din e-postadress med denna länk:", "Redeem your gift subscription": "Aktivera din gåvoprenumeration", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Säker inloggningslänk för {siteTitle}", "See you soon!": "Vi ses snart!", "Sent to {email}": "Skickat till {email}", @@ -68,10 +72,15 @@ "Thank you for subscribing to {siteTitle}.": "Tack för att du prenumererar på {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Tack för att du prenumererar på {siteTitle}. Tryck på länken nedan för att bli automatiskt inloggad:", "Thank you for supporting {siteTitle}.": "Tack för att du stöder {siteTitle}.", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "Tack för ditt stöd. Dela länken nedan med den du vill ge en {cadenceLabel} {tierName}-prenumeration på {siteTitle}.", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "Tack för att du läser {siteTitle}.", "The link expires on {expiresAt} and can only be redeemed once.": "Länken slutar gälla {expiresAt} och kan bara användas en gång.", "This email address will not be used.": "Denna e-postadress kommer inte att användas.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "Detta meddelande skickades från {siteDomain} till {email}.", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "För att behålla din {tierName}-prenumeration, fortsätt med en betald prenumeration idag så lägger vi automatiskt till återstående gåvoperiod som en kostnadsfri provperiod.", "trialing": "prov", @@ -85,6 +94,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "Välkommen tillbaka till {siteTitle}! Din verifieringskod är {otc}.", "Welcome back! Here's your code to sign in to {siteTitle}": "Välkommen tillbaka! Här är din kod för inloggning på {siteTitle}", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Välkommen tillbaka! Använd denna länk för att säkert logga in på ditt {siteTitle}-konto:", + "What's included": "", + "What's included:": "", "When:": "När:", "Where:": "Var:", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "Du får det här eftersom du är en %%{status}%% medlem på {site}.", @@ -97,7 +108,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Du är ett klick bort från att prenumerera på {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "Du har fått en {duration}-månaders {tierName}-prenumeration på {siteTitle}", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "Du har fått en {duration}-årig {tierName}-prenumeration på {siteTitle}", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Din kostnadsfria provperiod slutar {date}, efter vilket du kommer bli debiterad det vanliga priset. Du kan alltid avbryta innan dess.", + "Your gift is on its way": "", "Your gift is ready": "Din gåva är färdig", "Your gift subscription confirmation": "Bekräftelse av din gåvoprenumeration", "Your gift subscription is ending soon": "Din gåvoprenumeration slutar snart", diff --git a/packages/i18n/locales/sw/ghost.json b/packages/i18n/locales/sw/ghost.json index 46fc1309963..70bc4496e5a 100644 --- a/packages/i18n/locales/sw/ghost.json +++ b/packages/i18n/locales/sw/ghost.json @@ -1,10 +1,14 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_other": "", "{count} year_one": "", "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "Kila la kheri!", "Become a paid member of {site} to get access to all premium content.": "", "By {authors}": "", @@ -29,6 +33,7 @@ "Hey there,": "Habari,", "Hey there!": "Habari!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Ikiwa hukufanya ombi hili, unaweza kupuuza barua pepe hii kwa usalama.", "If you did not make this request, you can simply delete this message.": "Ikiwa hukufanya ombi hili, unaweza kufuta ujumbe huu tu.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "", @@ -41,14 +46,13 @@ "Name": "", "New comment on {postTitle}": "", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "", "Please confirm your email address with this link:": "Tafadhali thibitisha anwani yako ya barua pepe na kiungo hiki:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Kiungo salama cha kuingia kwa {siteTitle}", "See you soon!": "Tutaonana hivi karibuni!", "Sent to {email}": "Imetumwa kwa {email}", @@ -68,10 +72,15 @@ "Thank you for subscribing to {siteTitle}.": "Asante kwa kujiunga na {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Asante kwa kujiunga na {siteTitle}. Gusa kiungo kilicho chini kujiandikisha kiotomati:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Anwani hii ya barua pepe haitatumika.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "", @@ -85,6 +94,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Karibu tena! Tumia kiungo hiki kuingia kwa usalama kwenye akaunti yako ya {siteTitle}:", + "What's included": "", + "What's included:": "", "When:": "", "Where:": "", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", @@ -97,7 +108,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Uko umbali wa mguso mmoja tu kujiunga na {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/ta/ghost.json b/packages/i18n/locales/ta/ghost.json index 3518db2d8be..d3e1d1a67ed 100644 --- a/packages/i18n/locales/ta/ghost.json +++ b/packages/i18n/locales/ta/ghost.json @@ -1,10 +1,14 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_other": "", "{count} year_one": "", "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "அனைத்து நல்வாழ்த்துக்களும்!", "Become a paid member of {site} to get access to all premium content.": "", "By {authors}": "", @@ -29,6 +33,7 @@ "Hey there,": "வணக்கம்,", "Hey there!": "வணக்கம்!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "நீங்கள் இந்த கோரிக்கையை செய்ததாக இல்லை என்றால், இந்த மின்னஞ்சலைப் புறக்கணிக்கலாம்.", "If you did not make this request, you can simply delete this message.": "நீங்கள் இந்த கோரிக்கையைச் செய்யவில்லை என்றால், இந்த செய்தியை நீக்கலாம்.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "", @@ -41,14 +46,13 @@ "Name": "", "New comment on {postTitle}": "", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "", "Please confirm your email address with this link:": "இந்த இணைப்புடன் உங்கள் மின்னஞ்சல் முகவரியை உறுதிப்படுத்தவும்:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "{siteTitle} க்கான பாதுகாப்பான உள்நுழைவு இணைப்பு", "See you soon!": "உங்களை விரைவில் சந்திக்கிறோம்!", "Sent to {email}": "{email} க்கு அனுப்பப்பட்டது", @@ -68,10 +72,15 @@ "Thank you for subscribing to {siteTitle}.": "{siteTitle} க்கு சந்தா செய்ததற்கு நன்றி.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "{siteTitle} க்கு சந்தா செய்ததற்கு நன்றி. தானாக உள்நுழைய கீழே உள்ள இணைப்பைத் தட்டவும்:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "இந்த மின்னஞ்சல் முகவரி பயன்படுத்தப்படாது.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "", @@ -85,6 +94,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "மீண்டும் வருக! உங்கள் {siteTitle} கணக்கில் பாதுகாப்பாக உள்நுழைய இந்த இணைப்பைப் பயன்படுத்தவும்:", + "What's included": "", + "What's included:": "", "When:": "", "Where:": "", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", @@ -97,7 +108,9 @@ "You're one tap away from subscribing to {siteTitle}!": "{siteTitle} க்கு சந்தா செய்ய ஒரு தட்டல் மட்டுமே தூரத்தில் உள்ளீர்கள்!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/th/ghost.json b/packages/i18n/locales/th/ghost.json index b4fd09dcf72..28f7e7f468c 100644 --- a/packages/i18n/locales/th/ghost.json +++ b/packages/i18n/locales/th/ghost.json @@ -1,8 +1,12 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_other": "", "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "ขอให้โชคดี!", "Become a paid member of {site} to get access to all premium content.": "", "By {authors}": "", @@ -27,6 +31,7 @@ "Hey there,": "สวัสดี,", "Hey there!": "สวัสดี!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "หากคุณไม่ได้ส่งคำขอนี้ คุณสามารถเพิกเฉยต่ออีเมลนี้ได้อย่างปลอดภัย", "If you did not make this request, you can simply delete this message.": "หากคุณไม่ได้ส่งคำขอนี้ คุณสามารถลบข้อความนี้ได้", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "", @@ -39,14 +44,13 @@ "Name": "", "New comment on {postTitle}": "", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "", "Please confirm your email address with this link:": "โปรดยืนยันที่อยู่อีเมลของคุณด้วยลิงก์นี้:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "ลิงก์ลงชื่อเข้าใช้อย่างปลอดภัยสำหรับ {siteTitle}", "See you soon!": "แล้วพบกัน!", "Sent to {email}": "ส่งไปที่ {email}", @@ -66,10 +70,15 @@ "Thank you for subscribing to {siteTitle}.": "ขอบคุณที่รับสมัครข้อมูลจาก {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "ขอบคุณที่รับสมัครข้อมูลจาก {siteTitle}. แตะลิงก์ด้านล่างเพื่อลงชื่อเข้าใช้โดยอัตโนมัติ", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "ที่อยู่อีเมลนี้จะไม่ถูกใช้งาน", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "", @@ -83,6 +92,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "ยินดีต้อนรับกลับ! ใช้ลิงก์นี้เพื่อลงชื่อเข้าใช้บัญชีของคุณบน {siteTitle} อย่างปลอดภัย:", + "What's included": "", + "What's included:": "", "When:": "", "Where:": "", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", @@ -95,7 +106,9 @@ "You're one tap away from subscribing to {siteTitle}!": "เพียงแตะครั้งเดียวคุณก็จะสมัครรับข้อมูลจาก {siteTitle} แล้ว!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/tr/ghost.json b/packages/i18n/locales/tr/ghost.json index 60cfb416c13..ff8668a5ec9 100644 --- a/packages/i18n/locales/tr/ghost.json +++ b/packages/i18n/locales/tr/ghost.json @@ -1,10 +1,14 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_other": "", "{count} year_one": "", "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "{tierName} aboneliği", + "A gift, just for you": "", "All the best!": "En iyi dileklerimizle!", "Become a paid member of {site} to get access to all premium content.": "Tüm premium içeriklere erişebilmek için {site} ücretli üyesi olun.", "By {authors}": "Ekleyen {authors}", @@ -29,6 +33,7 @@ "Hey there,": "Merhaba,", "Hey there!": "Merhaba!", "Hi {firstName},": "Merhaba {firstName},", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Eğer böyle bir talepte bulunmadıysan bu e-postayı görmezden gelebilirsin.", "If you did not make this request, you can simply delete this message.": "Eğer böyle bir talepte bulunmadıysan bu mesajı silebilirsin.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "Yakın zamanda oturum açmayı denemediyseniz, erişimi engellemek için bu e-postayı güvenle yok sayabilirsiniz.", @@ -41,14 +46,13 @@ "Name": "İsim", "New comment on {postTitle}": "{postTitle} için yeni yorum", "New reply to your comment on {siteTitle}": "{siteTitle} sitesindeki yorumunuza yeni cevap", - "one-month": "bir ay", - "one-year": "bir yıl", "Open this link to redeem your gift.": "Hediyenizi almak için bu linki açın.", "Or use this link to securely sign in": "Ya da güvenli giriş yapmak için bu bağlantıyı kullan", "Or, skip the code and sign in directly": "Ya da kodu atlayıp doğrudan oturum aç", "paid": "ücretli", "Please confirm your email address with this link:": "Lütfen e-posta adresini bu bağlantıya tıklayarak onayla:", "Redeem your gift subscription": "Hediye aboneliğinizi alın", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "{siteTitle} için güvenli giriş bağlantısı", "See you soon!": "Yakında görüşmek üzere!", "Sent to {email}": "{email} adresine gönderildi", @@ -68,10 +72,15 @@ "Thank you for subscribing to {siteTitle}.": "{siteTitle} abone olduğunuz için teşekkür ederiz", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "{siteTitle} sitesine abone olduğun için teşekkürler. Otomatik olarak giriş yapmak için aşağıdaki bağlantıya tıkla:", "Thank you for supporting {siteTitle}.": "{siteTitle} sitesine desteğiniz için teşekkürler.", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "{siteTitle}’a desteğiniz için teşekkürler. Aşağıdaki bağlantıyı {cadenceLabel} {tierName} aboneliği hediye etmek istediğiniz kişiyle paylaşın.", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "{siteTitle} sitesini okuduğunuz için teşekkürler.", "The link expires on {expiresAt} and can only be redeemed once.": "Bağlantının süresi {expiresAt} tarihinde doluyor ve sadece bir kez kullanılabilir.", "This email address will not be used.": "Bu e-posta adresi kullanılmayacak.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "Bu mesaj {siteDomain} sitesinden {email} adresine gönderildi.", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "deneme", @@ -85,6 +94,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "{siteTitle} sitesine tekrar hoş geldin! Doğrulama kodun {otc}.", "Welcome back! Here's your code to sign in to {siteTitle}": "Tekrar hoş geldin! İşte {siteTitle} sitesinde oturum açmak için kodun", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Tekrar hoş geldin! {siteTitle} hesabına güvenli bir şekilde giriş yapmak için bu bağlantıyı kullan:", + "What's included": "", + "What's included:": "", "When:": "Ne zaman:", "Where:": "Nerede:", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "Bu mesajı, {site} sitesine %%{status}%% abonesi olduğunuz için alıyorsunuz.", @@ -97,7 +108,9 @@ "You're one tap away from subscribing to {siteTitle}!": "{siteTitle} abone olmanıza bir adım daha yakınsınız!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "{siteTitle} için {duration} aylık {tierName} aboneliği hediyeniz var", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "{siteTitle} için {duration} yıllık {tierName} aboneliği hediyeniz var", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Ücretsiz denemeniz {date} tarihinde sona eriyor, bu tarihten sonra düzenli fiyat uygulanacaktır. O zamana kadar her zaman iptal edebilirsiniz.", + "Your gift is on its way": "", "Your gift is ready": "Hediyeniz hazır", "Your gift subscription confirmation": "Hediye abonelik onayı", "Your gift subscription is ending soon": "Hediye aboneliğiniz yakında sona eriyor", diff --git a/packages/i18n/locales/uk/ghost.json b/packages/i18n/locales/uk/ghost.json index 8d79b0b6644..1b9a9f41f87 100644 --- a/packages/i18n/locales/uk/ghost.json +++ b/packages/i18n/locales/uk/ghost.json @@ -1,4 +1,7 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_few": "", "{count} month_many": "", @@ -9,6 +12,7 @@ "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "Всього найкращого!", "Become a paid member of {site} to get access to all premium content.": "", "By {authors}": "", @@ -33,6 +37,7 @@ "Hey there,": "Привіт,", "Hey there!": "Привіт!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Якщо цей запит не був зроблений тобою, можеш безпечно ігнорувати це повідомлення.", "If you did not make this request, you can simply delete this message.": "Якщо цей запит не був зроблений тобою, можеш просто видалити це повідомлення.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "", @@ -45,14 +50,13 @@ "Name": "", "New comment on {postTitle}": "", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "", "Please confirm your email address with this link:": "Будь ласка, підтвердь свою електронну пошту за допомогою цього посилання:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Безпечне посилання для входу до {siteTitle}", "See you soon!": "До зустрічі!", "Sent to {email}": "Відправлено на {email}", @@ -72,10 +76,15 @@ "Thank you for subscribing to {siteTitle}.": "Дякуємо за підписку на {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Дякуємо за підписку на {siteTitle}. Натисніть посилання нижче, щоб автоматично увійти:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Ця електронна пошта не буде використовуватися.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "", @@ -89,6 +98,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "З поверненням! Використовуйте це посилання, щоб безпечно увійти до свого облікового запису {siteTitle}:", + "What's included": "", + "What's included:": "", "When:": "", "Where:": "", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", @@ -101,7 +112,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Ви за один крок до підписки на {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/ur/ghost.json b/packages/i18n/locales/ur/ghost.json index c0307bbb059..7d9ecc48147 100644 --- a/packages/i18n/locales/ur/ghost.json +++ b/packages/i18n/locales/ur/ghost.json @@ -1,10 +1,14 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_other": "", "{count} year_one": "", "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "سب سے بہترین خواہشات!", "Become a paid member of {site} to get access to all premium content.": "", "By {authors}": "", @@ -29,6 +33,7 @@ "Hey there,": "ہی وہاں،", "Hey there!": "ہی وہاں!", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "اگر آپ نے یہ درخواست نہیں کی ہے تو، آپ یہ ای میل بے خوف نظر انداز کر سکتے ہیں۔", "If you did not make this request, you can simply delete this message.": "اگر آپ نے یہ درخواست نہیں کی ہے تو، بس یہ پیغام ہٹا دیں۔", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "", @@ -41,14 +46,13 @@ "Name": "", "New comment on {postTitle}": "", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "", "Please confirm your email address with this link:": "براہ کرم اس لنک کے ساتھ آپ کا ای میل پتہ تصدیق کریں:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "{siteTitle} کے لئے محفوظ سائن ان لنک", "See you soon!": "جلد ہی ملیں گے!", "Sent to {email}": "{email} کو بھیجا گیا", @@ -68,10 +72,15 @@ "Thank you for subscribing to {siteTitle}.": "{siteTitle} کو سبسکرائب کرنے کا شکریہ!", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "{siteTitle} کو سبسکرائب کرنے کا شکریہ. نیچے دیے گئے لنک پر ٹیپ کریں تاکہ آپ خود بخود سائن ان ہو جائیں:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "یہ ای میل پتہ استعمال نہیں ہو گا۔", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "", @@ -85,6 +94,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "خوش آمدید! اس لنک کا استعمال کریں تاکہ آپ {siteTitle} کے اکاؤنٹ میں محفوظ طریقے سے سائن ان کر سکیں:", + "What's included": "", + "What's included:": "", "When:": "", "Where:": "", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", @@ -97,7 +108,9 @@ "You're one tap away from subscribing to {siteTitle}!": "{siteTitle} کو سبسکرائب ہونے کے لئے آپ ایک ٹیپ کے فاصلے پر ہیں!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/uz/ghost.json b/packages/i18n/locales/uz/ghost.json index 522369c8a1d..4eec4f2e259 100644 --- a/packages/i18n/locales/uz/ghost.json +++ b/packages/i18n/locales/uz/ghost.json @@ -1,10 +1,14 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_one": "", "{count} month_other": "", "{count} year_one": "", "{count} year_other": "", "{date}": "{date}", "{tierName} membership": "", + "A gift, just for you": "", "All the best!": "Barcha ezgu tilaklar!", "Become a paid member of {site} to get access to all premium content.": "", "By {authors}": "", @@ -29,6 +33,7 @@ "Hey there,": "Salom,", "Hey there!": "", "Hi {firstName},": "", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Agar siz bu soʻrovni amalga oshirmagan boʻlsangiz, bu xatni eʼtiborsiz qoldirishingiz mumkin.", "If you did not make this request, you can simply delete this message.": "Agar siz ushbu so'rovni amalga oshirmagan bo'lsangiz, bu xabarni shunchaki o'chirib tashlashingiz mumkin.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "", @@ -41,14 +46,13 @@ "Name": "", "New comment on {postTitle}": "", "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", "Open this link to redeem your gift.": "", "Or use this link to securely sign in": "", "Or, skip the code and sign in directly": "", "paid": "", "Please confirm your email address with this link:": "Iltimos, elektron pochta manzilingizni ushbu havola bilan tasdiqlang:", "Redeem your gift subscription": "", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "{siteTitle} uchun xavfsiz kirish havolasi", "See you soon!": "Ko'rishguncha!", "Sent to {email}": "{email}ga yuborildi", @@ -68,10 +72,15 @@ "Thank you for subscribing to {siteTitle}.": "", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "{siteTitle} obunangiz uchun rahmat. Avtomatik tizimga kirish uchun quyidagi havolani bosing:", "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "", "The link expires on {expiresAt} and can only be redeemed once.": "", "This email address will not be used.": "Ushbu elektron pochta manzili ishlatilmaydi.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", "trialing": "", @@ -85,6 +94,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "", "Welcome back! Here's your code to sign in to {siteTitle}": "", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Qaytib kelganingizdan xursandmiz! {siteTitle} hisobingizga xavfsiz kirish uchun ushbu havoladan foydalaning:", + "What's included": "", + "What's included:": "", "When:": "", "Where:": "", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", @@ -97,7 +108,9 @@ "You're one tap away from subscribing to {siteTitle}!": "", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", + "Your gift is on its way": "", "Your gift is ready": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", diff --git a/packages/i18n/locales/vi/ghost.json b/packages/i18n/locales/vi/ghost.json index 2676955240a..12fe295e2d8 100644 --- a/packages/i18n/locales/vi/ghost.json +++ b/packages/i18n/locales/vi/ghost.json @@ -1,8 +1,12 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_other": "{count} tháng", "{count} year_other": "{count} năm", "{date}": "{date}", "{tierName} membership": "Thành viên {tierName}", + "A gift, just for you": "", "All the best!": "Chúc bạn mọi điều tốt đẹp nhất!", "Become a paid member of {site} to get access to all premium content.": "Trở thành thành viên trả phí của {site} để truy cập toàn bộ nội dung cao cấp.", "By {authors}": "Viết bởi {authors}", @@ -27,6 +31,7 @@ "Hey there,": "Chào bạn,", "Hey there!": "Chào bạn!", "Hi {firstName},": "Xin chào {firstName},", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "Nếu bạn không thực hiện yêu cầu này, vui lòng bỏ qua email này.", "If you did not make this request, you can simply delete this message.": "Nếu bạn không thực hiện yêu cầu này, hãy xóa email này.", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "Nếu không phải là bạn đăng nhập gần đây, bỏ qua email này để từ chối truy cập.", @@ -39,14 +44,13 @@ "Name": "Tên", "New comment on {postTitle}": "Bài viết {postTitle} có bình luận mới", "New reply to your comment on {siteTitle}": "Bình luận của bạn trên {siteTitle} có phản hồi mới", - "one-month": "một tháng", - "one-year": "một năm", "Open this link to redeem your gift.": "Mở liên kết này để mở quà của bạn.", "Or use this link to securely sign in": "Hoặc dùng liên kết này để đăng nhập an toàn", "Or, skip the code and sign in directly": "Hoặc, bỏ qua mã và đăng nhập trực tiếp", "paid": "trả phí", "Please confirm your email address with this link:": "Vui lòng xác nhận địa chỉ email của bạn bằng liên kết này:", "Redeem your gift subscription": "Đổi quà tặng gói thành viên", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "Liên kết đăng nhập an toàn {siteTitle}", "See you soon!": "Hẹn gặp lại bạn!", "Sent to {email}": "Đã gửi đến {email}", @@ -66,10 +70,15 @@ "Thank you for subscribing to {siteTitle}.": "Cám ơn bạn đã đăng ký thành viên {siteTitle}.", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "Cám ơn bạn đã đăng ký gói thành viên {siteTitle}. Nhấn vào liên kết bên dưới để đăng nhập tự động:", "Thank you for supporting {siteTitle}.": "Cảm ơn bạn đã ủng hộ {siteTitle}.", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "Cảm ơn bạn đã ủng hộ. Hãy chia sẻ liên kết bên dưới với người mà bạn muốn tặng thành viên {cadenceLabel} {tierName} trên {siteTitle}.", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "Cảm ơn bạn đã đọc {siteTitle}.", "The link expires on {expiresAt} and can only be redeemed once.": "Liên kết hết hạn vào {expiresAt} và chỉ có thể đổi một lần.", "This email address will not be used.": "Địa chỉ email này sẽ không còn sử dụng.", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "Tin nhắn này được {siteDomain} gửi tới {email}.", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "Để tiếp tục là thành viên {tierName}, hãy tiếp tục sử dụng gói trả phí ngay hôm nay và chúng tôi sẽ tự động gia hạn thời gian đọc thử còn lại của quà tặng dưới dạng bản đọc thử miễn phí.", "trialing": "đọc thử", @@ -83,6 +92,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "Chào mừng quay lại {siteTitle}! Mã xác minh của bạn là {otc}.", "Welcome back! Here's your code to sign in to {siteTitle}": "Chào mừng quay lại! Đây là mã để bạn đăng nhập {siteTitle}", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "Chào mừng quay lại! Sử dụng liên kết này để đăng nhập an toàn vào tài khoản {siteTitle} của bạn:", + "What's included": "", + "What's included:": "", "When:": "Thời gian:", "Where:": "Địa điểm:", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "Bạn nhận được email này vì bạn là thành viên %%{status}%% của {site}.", @@ -95,7 +106,9 @@ "You're one tap away from subscribing to {siteTitle}!": "Bạn còn một bước nữa để đăng ký {siteTitle}!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "Bạn vừa được tặng {duration}-tháng thành viên {tierName} trên {siteTitle}", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "Bạn vừa được tặng {duration}-năm {tierName} thành viên trên {siteTitle}", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "Gói đọc thử của bạn sẽ hết hạn vào {date} và sau đó bắt đầu tính phí. Bạn luôn có thể hủy gói trước thời điểm này.", + "Your gift is on its way": "", "Your gift is ready": "Món quà của bạn đã chuẩn bị xong", "Your gift subscription confirmation": "Xác nhận nhận quà gói thành viên", "Your gift subscription is ending soon": "Gói thành viên mà bạn được tặng sắp hết hạn", diff --git a/packages/i18n/locales/zh-Hant/ghost.json b/packages/i18n/locales/zh-Hant/ghost.json index fe3417704fe..8772a042647 100644 --- a/packages/i18n/locales/zh-Hant/ghost.json +++ b/packages/i18n/locales/zh-Hant/ghost.json @@ -1,8 +1,12 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_other": "{count} 個月", "{count} year_other": "{count} 年", "{date}": "{date}", "{tierName} membership": "{tierName} 會員資格", + "A gift, just for you": "", "All the best!": "一切順利", "Become a paid member of {site} to get access to all premium content.": "成為{site}的付費會員,即可暢享所有深度內容。", "By {authors}": "作者:{authors}", @@ -27,6 +31,7 @@ "Hey there,": "您好,", "Hey there!": "您好!", "Hi {firstName},": "{firstName} 您好,", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "如果您沒有發出此請求,您可以放心忽略此信件。", "If you did not make this request, you can simply delete this message.": "如果您沒有發出此請求,您可以直接刪除此信件。", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "若您近期未曾嘗試登入,可安全地忽略這封郵件以拒絕存取權限。", @@ -39,14 +44,13 @@ "Name": "名字", "New comment on {postTitle}": "{postTitle} 有新留言", "New reply to your comment on {siteTitle}": "對您在{siteTitle}上的評論的新回覆", - "one-month": "一個月", - "one-year": "一年", "Open this link to redeem your gift.": "開啟此連結以兌換您的禮物。", "Or use this link to securely sign in": "或使用此連結安全登入", "Or, skip the code and sign in directly": "或者,跳過驗證碼直接登入", "paid": "已付款", "Please confirm your email address with this link:": "請透過此連結確認您的電子郵件地址:", "Redeem your gift subscription": "兌換您的贈送訂閱", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "為{siteTitle}準備的安全登入連結", "See you soon!": "再見!", "Sent to {email}": "已發送到 {email}", @@ -66,10 +70,15 @@ "Thank you for subscribing to {siteTitle}.": "感謝您訂閱{siteTitle}。", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "感謝您訂閱{siteTitle}。點擊下面的連結即可自動登入:", "Thank you for supporting {siteTitle}.": "感謝您支持 {siteTitle}。", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "感謝您的支持。分享下方連結,即可將 {siteTitle} 的 {cadenceLabel} {tierName} 會員資格贈送給您想要的任何人。", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "感謝閱讀 {siteTitle}。", "The link expires on {expiresAt} and can only be redeemed once.": "此連結將於 {expiresAt} 失效,且只能兌換一次。", "This email address will not be used.": "此 email 將不會被使用。", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "此訊息由{siteDomain}寄送至{email}。", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "如需保留您的 {tierName} 會員資格,請於今天轉為付費訂閱,我們將自動把您剩餘的贈送時段轉換為免費試用期累加。", "trialing": "試用", @@ -83,6 +92,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "歡迎回到{siteTitle}!您的驗證碼為{otc}。", "Welcome back! Here's your code to sign in to {siteTitle}": "歡迎回來!以下是您登入{siteTitle}的驗證碼", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "歡迎回來!請使用此連結安全地登入您的{siteTitle}帳號:", + "What's included": "", + "What's included:": "", "When:": "時間:", "Where:": "地點:", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "您會收到這封電子報,因為您是 {site} 的%%{status}%% 訂閱者。", @@ -95,7 +106,9 @@ "You're one tap away from subscribing to {siteTitle}!": "點擊後即可完成{siteTitle}的訂閱!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "有人贈送了您 {siteTitle} 的 {duration} 個月 {tierName} 會員資格", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "有人贈送了您 {siteTitle} 的 {duration} 年 {tierName} 會員資格", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "您的免費試用將會在{date}結束,結束後您將會被收取標準價格。您可以在試用期結束前取消。", + "Your gift is on its way": "", "Your gift is ready": "您的禮物已準備就緒", "Your gift subscription confirmation": "您的贈送訂閱確認", "Your gift subscription is ending soon": "您的贈送訂閱即將到期", diff --git a/packages/i18n/locales/zh/ghost.json b/packages/i18n/locales/zh/ghost.json index 78803f34d3a..17afa9479e1 100644 --- a/packages/i18n/locales/zh/ghost.json +++ b/packages/i18n/locales/zh/ghost.json @@ -1,8 +1,12 @@ { + "{buyerName} has gifted you a {duration}-month {tierName} membership to {siteTitle}": "", + "{buyerName} has gifted you a {duration}-year {tierName} membership to {siteTitle}": "", + "{buyerName} sent you a gift": "", "{count} month_other": "{count} 个月", "{count} year_other": "{count} 年", "{date}": "{date}", "{tierName} membership": "{tierName} 会员资格", + "A gift, just for you": "", "All the best!": "一切顺利!", "Become a paid member of {site} to get access to all premium content.": "成为{site}的付费会员,即可畅享所有付费内容。", "By {authors}": "作者 {authors}", @@ -27,6 +31,7 @@ "Hey there,": "你好,", "Hey there!": "你好!", "Hi {firstName},": "{firstName} 您好,", + "Hi {recipientName},": "", "If you did not make this request, you can safely ignore this email.": "如果您没有发出此请求,您可以放心地忽略此电子邮件。", "If you did not make this request, you can simply delete this message.": "如果您没有发出此请求,您可以直接删除此消息。", "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "若您近期未尝试登录,可安全忽略此邮件以拒绝访问。", @@ -39,14 +44,13 @@ "Name": "姓名", "New comment on {postTitle}": "{postTitle}的新评论", "New reply to your comment on {siteTitle}": "您在{siteTitle}的评论收到新回复", - "one-month": "一个月", - "one-year": "一年", "Open this link to redeem your gift.": "打开此链接以兑换您的礼物。", "Or use this link to securely sign in": "或通过此链接安全登录", "Or, skip the code and sign in directly": "或跳过验证码直接登录", "paid": "付费", "Please confirm your email address with this link:": "请通过此链接确认您的电子邮件地址:", "Redeem your gift subscription": "兑换您的赠送订阅", + "Redeem your gift:": "", "Secure sign in link for {siteTitle}": "为 {siteTitle} 准备的安全登录链接", "See you soon!": "回见!", "Sent to {email}": "已发送至 {email}", @@ -66,10 +70,15 @@ "Thank you for subscribing to {siteTitle}.": "感谢订阅{siteTitle}。", "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "感谢您订阅 {siteTitle}。点击下面的链接即可自动登录:", "Thank you for supporting {siteTitle}.": "感谢您支持 {siteTitle}。", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "感谢您的支持。分享下方链接,即可将 {siteTitle} 的 {cadenceLabel} {tierName} 会员资格赠送给您想送的任何人。", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-month {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {duration}-year {tierName} membership to {siteTitle}.": "", + "Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — is on its way to {recipientEmail}. You can also share the link below yourself.": "", "Thanks for reading {siteTitle}.": "感谢阅读 {siteTitle}。", "The link expires on {expiresAt} and can only be redeemed once.": "该链接将于 {expiresAt} 失效,且只能兑换一次。", "This email address will not be used.": "此电子邮件地址将不会被使用。", + "This gift can only be redeemed once and expires on {expiresAt}.": "", + "This message was sent from {siteDomain} to {email} on behalf of {buyerName} ({buyerEmail}).": "", "This message was sent from {siteDomain} to {email}.": "此消息由 {siteDomain} 发送至 {email}。", "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "如需保留您的 {tierName} 会员资格,请于今天转换为付费订阅,我们将自动把您剩余的赠送时段作为免费试用期累加。", "trialing": "试用", @@ -83,6 +92,8 @@ "Welcome back to {siteTitle}! Your verification code is {otc}.": "欢迎回到{siteTitle}!您的验证码是{otc}。", "Welcome back! Here's your code to sign in to {siteTitle}": "欢迎回来!这是您登录{siteTitle}的验证码", "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "欢迎回来!请使用此链接安全登录您的 {siteTitle} 账户:", + "What's included": "", + "What's included:": "", "When:": "时间:", "Where:": "地点:", "You are receiving this because you are a %%{status}%% subscriber to {site}.": "您收到此邮件是因为您是{site}的%%{status}%% 订阅用户。", @@ -95,7 +106,9 @@ "You're one tap away from subscribing to {siteTitle}!": "仅需一次点击即可完成{siteTitle}的订阅!", "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "有人赠送了您 {siteTitle} 的 {duration} 个月 {tierName} 会员资格", "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "有人赠送了您 {siteTitle} 的 {duration} 年 {tierName} 会员资格", + "You've been gifted a membership to {siteTitle}": "", "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "您的免费试用将于{date}结束,届时将按标准价格收费。您可在到期前随时取消订阅。", + "Your gift is on its way": "", "Your gift is ready": "您的礼物已准备就绪", "Your gift subscription confirmation": "您的赠送订阅确认", "Your gift subscription is ending soon": "您的赠送订阅即将到期", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27751ee72b9..03eff79a3f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2653,6 +2653,9 @@ importers: stripe: specifier: 8.222.0 version: 8.222.0 + temporal-polyfill: + specifier: 'catalog:' + version: 0.3.2 terser: specifier: 5.48.0 version: 5.48.0 @@ -2717,6 +2720,9 @@ importers: '@types/lodash-es': specifier: 4.17.12 version: 4.17.12 + '@types/luxon': + specifier: 'catalog:' + version: 3.7.4 '@types/mime-types': specifier: 3.0.1 version: 3.0.1