diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 42465cee43d..1c9c3a0fcc0 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -55,9 +55,13 @@ reviews: code. Do not report formatting, naming, import ordering, type errors, or other findings already owned by configured static tools or failing GitHub checks. Do not request speculative abstractions, broad refactors, generic - documentation, or tests unrelated to changed behaviour. Treat nearby - AGENTS.md files and mapped codebase documentation as authoritative; do not - enforce proposals, plans, or historical guidance as current policy. + documentation, or tests unrelated to changed behaviour. For a move, + rename, TypeScript conversion, or explicitly behaviour-preserving refactor, + do not report pre-existing problems unless the diff introduces or worsens + them, makes them newly reachable, or prevents the stated transformation + from being correct. Treat nearby AGENTS.md files and mapped codebase + documentation as authoritative; do not enforce proposals, plans, or + historical guidance as current policy. - path: '**/*.{ts,tsx,mts,cts}' instructions: | Review lens: "where does this data become trusted?" diff --git a/apps/admin-x-framework/src/api/email-verification.ts b/apps/admin-x-framework/src/api/email-verification.ts index e7a0828e70f..98e91a39940 100644 --- a/apps/admin-x-framework/src/api/email-verification.ts +++ b/apps/admin-x-framework/src/api/email-verification.ts @@ -10,16 +10,18 @@ export interface EmailVerificationResponseType { } const dataType = 'SettingsResponseType'; -export const verifyEmailToken = createMutation({ - path: () => '/settings/verifications', - method: 'PUT', - body: ({ token }) => ({ token }), - updateQueries: { - dataType, - emberUpdateType: 'createOrUpdate', - update: (newData) => ({ - ...newData, - settings: newData.settings, - }), +export const useVerifyEmailToken = createMutation( + { + path: () => '/settings/verifications', + method: 'PUT', + body: ({ token }) => ({ token }), + updateQueries: { + dataType, + emberUpdateType: 'createOrUpdate', + update: (newData) => ({ + ...newData, + settings: newData.settings, + }), + }, }, -}); +); diff --git a/apps/admin-x-framework/src/api/featurebase.ts b/apps/admin-x-framework/src/api/featurebase.ts index cfb9c6da839..a1c0228a8ea 100644 --- a/apps/admin-x-framework/src/api/featurebase.ts +++ b/apps/admin-x-framework/src/api/featurebase.ts @@ -24,7 +24,7 @@ const baseFeaturebaseTokenQuery = createQuery({ path: '/featurebase/token/', }); -export const getFeaturebaseToken = (options: { enabled?: boolean } = {}) => { +export const useFeaturebaseToken = (options: { enabled?: boolean } = {}) => { return baseFeaturebaseTokenQuery({ ...FEATUREBASE_QUERY_OPTIONS, ...options, diff --git a/apps/admin-x-framework/src/api/feedback.ts b/apps/admin-x-framework/src/api/feedback.ts index 4133150a7fa..9c1decf2ec8 100644 --- a/apps/admin-x-framework/src/api/feedback.ts +++ b/apps/admin-x-framework/src/api/feedback.ts @@ -22,7 +22,7 @@ export interface FeedbackResponseType { const dataType = 'FeedbackResponseType'; -export const getPostFeedback = createQueryWithId({ +export const usePostFeedbackQuery = createQueryWithId({ dataType, path: (id) => `/feedback/${id}/`, }); diff --git a/apps/admin-x-framework/src/api/members.ts b/apps/admin-x-framework/src/api/members.ts index 0ec21968415..fccbf61a50a 100644 --- a/apps/admin-x-framework/src/api/members.ts +++ b/apps/admin-x-framework/src/api/members.ts @@ -290,7 +290,7 @@ export const useImportMembers = createMutation({ +export const useMember = createQueryWithId({ dataType, path: (id) => `/members/${id}/`, }); @@ -575,7 +575,7 @@ export interface MemberSigninUrlResponseType { // The Admin API wraps the controller payload in the `member_signin_urls` array // envelope (see `member-signin-urls.js` + framework serializer). Unwrap here so // consumers get the flat `{member_id, url}` object they actually want. -export const getMemberSigninUrl = createQueryWithId({ +export const useMemberSigninUrl = createQueryWithId({ dataType: 'MemberSigninUrlResponseType', path: (id) => `/members/${id}/signin_urls/`, returnData: (originalData) => { diff --git a/apps/admin-x-framework/src/api/posts.ts b/apps/admin-x-framework/src/api/posts.ts index 5aef65fdb8c..fef1609bc2d 100644 --- a/apps/admin-x-framework/src/api/posts.ts +++ b/apps/admin-x-framework/src/api/posts.ts @@ -76,7 +76,7 @@ export const useBrowsePostsInfinite = createInfiniteQuery({ +export const usePost = createQueryWithId({ dataType, path: (id) => `/posts/${id}/`, }); @@ -86,13 +86,21 @@ export const useDeletePost = createMutation({ path: (id) => `/posts/${id}/`, }); -export const useImportContentCSV = createMutation({ +export interface ImportContentCSVPayload { + file: File; + mapping: Record; +} + +export const useImportContentCSV = createMutation({ method: 'POST', retry: false, path: () => '/posts/upload/', - body: (file) => { + body: ({ file, mapping }) => { const formData = new FormData(); formData.append('postsfile', file); + for (const [header, field] of Object.entries(mapping)) { + formData.append(`mapping[${header}]`, field); + } return formData; }, }); diff --git a/apps/admin-x-framework/src/api/staff-token.ts b/apps/admin-x-framework/src/api/staff-token.ts index 5f1e5591ad3..c17a3a0f692 100644 --- a/apps/admin-x-framework/src/api/staff-token.ts +++ b/apps/admin-x-framework/src/api/staff-token.ts @@ -20,12 +20,12 @@ export interface StaffTokenResponseType { const dataType = 'StaffTokenResponseType'; -export const getStaffToken = createQuery({ +export const useStaffToken = createQuery({ dataType, path: '/users/me/token/', }); -export const genStaffToken = createMutation({ +export const useGenerateStaffToken = createMutation({ path: () => '/users/me/token/', method: 'PUT', }); diff --git a/apps/admin-x-framework/src/api/tags.ts b/apps/admin-x-framework/src/api/tags.ts index 25ddad8dee4..3541dce207c 100644 --- a/apps/admin-x-framework/src/api/tags.ts +++ b/apps/admin-x-framework/src/api/tags.ts @@ -85,7 +85,7 @@ export const useBrowseTags = ({ // Mirrors Ember's `queryRecord('tag', {slug})`, which the admin API serves at // the dedicated `/tags/slug/:slug/` route. -export const getTagBySlug = createQueryWithId({ +export const useTagBySlug = createQueryWithId({ dataType, path: (slug) => `/tags/slug/${slug}/`, }); diff --git a/apps/admin-x-framework/src/api/tinybird.ts b/apps/admin-x-framework/src/api/tinybird.ts index 704a14f395e..98313c8afe8 100644 --- a/apps/admin-x-framework/src/api/tinybird.ts +++ b/apps/admin-x-framework/src/api/tinybird.ts @@ -24,7 +24,7 @@ const baseTinybirdTokenQuery = createQuery({ path: '/tinybird/token/', }); -export const getTinybirdToken = (options: { enabled?: boolean } = {}) => { +export const useTinybirdTokenQuery = (options: { enabled?: boolean } = {}) => { return baseTinybirdTokenQuery({ ...TINYBIRD_QUERY_OPTIONS, ...options, diff --git a/apps/admin-x-framework/src/hooks/use-featurebase.ts b/apps/admin-x-framework/src/hooks/use-featurebase.ts index 319483b83a2..cda38498db2 100644 --- a/apps/admin-x-framework/src/hooks/use-featurebase.ts +++ b/apps/admin-x-framework/src/hooks/use-featurebase.ts @@ -1,5 +1,5 @@ import { type Deferred, deferred } from '../utils/deferred'; -import { getFeaturebaseToken } from '../api/featurebase'; +import { useFeaturebaseToken } from '../api/featurebase'; import { useBrowseConfig } from '../api/config'; import { useCallback, useEffect, useRef, useState } from 'react'; @@ -126,7 +126,7 @@ export function useFeaturebase(): Featurebase { const { organization, enabled } = config?.config.featurebase ?? {}; const isAvailable = !!enabled; - const { data: tokenData } = getFeaturebaseToken({ + const { data: tokenData } = useFeaturebaseToken({ enabled: isAvailable && shouldLoad, }); const token = tokenData?.featurebase?.token; diff --git a/apps/admin-x-framework/src/hooks/use-tinybird-token.ts b/apps/admin-x-framework/src/hooks/use-tinybird-token.ts index 8865300c694..fe5b3f83c7d 100644 --- a/apps/admin-x-framework/src/hooks/use-tinybird-token.ts +++ b/apps/admin-x-framework/src/hooks/use-tinybird-token.ts @@ -1,4 +1,4 @@ -import { getTinybirdToken } from '../api/tinybird'; +import { useTinybirdTokenQuery } from '../api/tinybird'; import { useWebAnalyticsEnabled } from '../providers/app-provider'; export interface UseTinybirdTokenResult { @@ -20,7 +20,7 @@ export const useTinybirdToken = (options: UseTinybirdTokenOptions = {}): UseTiny // Web analytics is a global kill-switch read from context, so no call site threads it. const webAnalyticsEnabled = useWebAnalyticsEnabled(); const effectiveEnabled = enabled && webAnalyticsEnabled; - const tinybirdQuery = getTinybirdToken({ enabled: effectiveEnabled }); + const tinybirdQuery = useTinybirdTokenQuery({ enabled: effectiveEnabled }); // A disabled React Query can keep cached data/errors, so return an idle // result — else direct consumers (the providers) leak a stale token. diff --git a/apps/admin-x-framework/src/index.ts b/apps/admin-x-framework/src/index.ts index 83cfa6acc3c..7c1c3d47bc2 100644 --- a/apps/admin-x-framework/src/index.ts +++ b/apps/admin-x-framework/src/index.ts @@ -1,6 +1,10 @@ // Framework export type { StatsConfig, TopLevelFrameworkProps } from './providers/framework-provider'; -export { FrameworkProvider, useFramework } from './providers/framework-provider'; +export { + FrameworkProvider, + defaultUnsplashConfig, + useFramework, +} from './providers/framework-provider'; // App Context export type { AppSettings, AppContextType } from './providers/app-provider'; diff --git a/apps/admin-x-framework/src/providers/framework-provider.tsx b/apps/admin-x-framework/src/providers/framework-provider.tsx index d504d184898..d266b8da996 100644 --- a/apps/admin-x-framework/src/providers/framework-provider.tsx +++ b/apps/admin-x-framework/src/providers/framework-provider.tsx @@ -56,6 +56,15 @@ export type TopLevelFrameworkProps = Omit; export type FrameworkContextType = Omit; +// Ghost's registered Unsplash application; the Client-ID is a public API key +export const defaultUnsplashConfig: FrameworkProviderProps['unsplashConfig'] = { + Authorization: 'Client-ID 8672af113b0a8573edae3aa3713886265d9bb741d707f6c01a486cde8c278980', + 'Accept-Version': 'v1', + 'Content-Type': 'application/json', + 'App-Pragma': 'no-cache', + 'X-Unsplash-Cache': true, +}; + const FrameworkContext = createContext({ ghostVersion: '', externalNavigate: () => {}, diff --git a/apps/admin-x-framework/test/unit/api/members.test.tsx b/apps/admin-x-framework/test/unit/api/members.test.tsx index b5df6ae62e1..826fd596586 100644 --- a/apps/admin-x-framework/test/unit/api/members.test.tsx +++ b/apps/admin-x-framework/test/unit/api/members.test.tsx @@ -5,7 +5,7 @@ import type { UserRoleType } from '../../../src/api/roles'; import { createTestQueryClient, renderHookWithProviders } from '../../../src/test/test-utils'; import { getMemberCountQueryKey, - getMemberSigninUrl, + useMemberSigninUrl, useAddMember, useBrowseMembersInfinite, useBulkDeleteMembers, @@ -502,7 +502,7 @@ describe('members api', () => { }, }, async (mock) => { - const { result } = renderHookWithProviders(() => getMemberSigninUrl('member-1'), { + const { result } = renderHookWithProviders(() => useMemberSigninUrl('member-1'), { queryClient, }); diff --git a/apps/admin-x-framework/test/unit/api/posts.test.tsx b/apps/admin-x-framework/test/unit/api/posts.test.tsx index 44643d2c0e9..daa03a4491e 100644 --- a/apps/admin-x-framework/test/unit/api/posts.test.tsx +++ b/apps/admin-x-framework/test/unit/api/posts.test.tsx @@ -12,13 +12,19 @@ describe('posts api', () => { const { result } = renderHookWithProviders(() => useImportContentCSV()); await act(async () => { - await result.current.mutateAsync(file); + await result.current.mutateAsync({ + file, + mapping: { Headline: 'title', Body: '', Published: 'published_at' }, + }); }); expect(mock.calls[0][0]).toBe('http://localhost:3000/ghost/api/admin/posts/upload/'); expect(mock.calls[0][1].method).toBe('POST'); expect(mock.calls[0][1].body).toBeInstanceOf(FormData); expect(mock.calls[0][1].body.get('postsfile')).toBe(file); + expect(mock.calls[0][1].body.get('mapping[Headline]')).toBe('title'); + expect(mock.calls[0][1].body.get('mapping[Body]')).toBe(''); + expect(mock.calls[0][1].body.get('mapping[Published]')).toBe('published_at'); expect(mock.calls[0][1].headers).not.toHaveProperty('content-type'); }); }); @@ -36,7 +42,9 @@ describe('posts api', () => { try { const { result } = renderHookWithProviders(() => useImportContentCSV(), { queryClient }); - const importPromise = result.current.mutateAsync(file).catch((error) => error); + const importPromise = result.current + .mutateAsync({ file, mapping: { title: 'title' } }) + .catch((error) => error); await vi.advanceTimersByTimeAsync(600); diff --git a/apps/admin-x-framework/test/unit/api/tinybird.test.tsx b/apps/admin-x-framework/test/unit/api/tinybird.test.tsx index d6a20eba3ee..ad83c15b0db 100644 --- a/apps/admin-x-framework/test/unit/api/tinybird.test.tsx +++ b/apps/admin-x-framework/test/unit/api/tinybird.test.tsx @@ -1,7 +1,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { renderHook, waitFor } from '@testing-library/react'; import React, { ReactNode } from 'react'; -import { getTinybirdToken } from '../../../src/api/tinybird'; +import { useTinybirdTokenQuery } from '../../../src/api/tinybird'; import { FrameworkProvider } from '../../../src/providers/framework-provider'; import { withMockFetch } from '../../utils/mock-fetch'; @@ -44,7 +44,7 @@ const wrapper: React.FC<{ children: ReactNode }> = ({ children }) => ( ); -describe('getTinybirdToken', () => { +describe('useTinybirdTokenQuery', () => { afterEach(() => { queryClient.clear(); vi.clearAllTimers(); @@ -57,7 +57,7 @@ describe('getTinybirdToken', () => { json: { tinybird: { token: 'test-token-123' } }, }, async (mock) => { - const { result } = renderHook(() => getTinybirdToken(), { wrapper }); + const { result } = renderHook(() => useTinybirdTokenQuery(), { wrapper }); await waitFor(() => expect(result.current.isLoading).toBe(false)); @@ -68,18 +68,18 @@ describe('getTinybirdToken', () => { ); }); - it('makes only one request for multiple getTinybirdToken calls (caching)', async () => { + it('makes only one request for multiple useTinybirdTokenQuery calls (caching)', async () => { await withMockFetch( { json: { tinybird: { token: 'cached-token' } }, }, async (mock) => { // First call - const { result: result1 } = renderHook(() => getTinybirdToken(), { wrapper }); + const { result: result1 } = renderHook(() => useTinybirdTokenQuery(), { wrapper }); await waitFor(() => expect(result1.current.isLoading).toBe(false)); // Second call should use cache - const { result: result2 } = renderHook(() => getTinybirdToken(), { wrapper }); + const { result: result2 } = renderHook(() => useTinybirdTokenQuery(), { wrapper }); await waitFor(() => expect(result2.current.isLoading).toBe(false)); // Both should have same data, but only 1 HTTP request @@ -97,7 +97,7 @@ describe('getTinybirdToken', () => { json: { tinybird: { token: 'initial-token' } }, }, async (mock) => { - const { result } = renderHook(() => getTinybirdToken(), { wrapper }); + const { result } = renderHook(() => useTinybirdTokenQuery(), { wrapper }); await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(mock.calls.length).toBe(1); @@ -121,13 +121,13 @@ describe('getTinybirdToken', () => { }, async (mock) => { // First call - const { result: result1 } = renderHook(() => getTinybirdToken(), { wrapper }); + const { result: result1 } = renderHook(() => useTinybirdTokenQuery(), { wrapper }); await waitFor(() => expect(result1.current.isLoading).toBe(false)); expect(mock.calls.length).toBe(1); // Second call immediately after should use cache - const { result: result2 } = renderHook(() => getTinybirdToken(), { wrapper }); + const { result: result2 } = renderHook(() => useTinybirdTokenQuery(), { wrapper }); await waitFor(() => expect(result2.current.isLoading).toBe(false)); // Should still be only 1 request due to built-in stale time @@ -144,7 +144,7 @@ describe('getTinybirdToken', () => { }, async () => { // Should work without any parameters - const { result } = renderHook(() => getTinybirdToken(), { wrapper }); + const { result } = renderHook(() => useTinybirdTokenQuery(), { wrapper }); await waitFor(() => expect(result.current.isLoading).toBe(false)); @@ -160,7 +160,7 @@ describe('getTinybirdToken', () => { json: { tinybird: { token: 'interface-token' } }, }, async () => { - const { result } = renderHook(() => getTinybirdToken(), { wrapper }); + const { result } = renderHook(() => useTinybirdTokenQuery(), { wrapper }); await waitFor(() => expect(result.current.isLoading).toBe(false)); @@ -182,7 +182,7 @@ describe('getTinybirdToken', () => { ok: false, }, async () => { - const { result } = renderHook(() => getTinybirdToken(), { wrapper }); + const { result } = renderHook(() => useTinybirdTokenQuery(), { wrapper }); await waitFor(() => expect(result.current.isLoading).toBe(false)); diff --git a/apps/admin-x-framework/test/unit/hooks/use-tinybird-token.test.tsx b/apps/admin-x-framework/test/unit/hooks/use-tinybird-token.test.tsx index 8f8f9246850..b09ba180e33 100644 --- a/apps/admin-x-framework/test/unit/hooks/use-tinybird-token.test.tsx +++ b/apps/admin-x-framework/test/unit/hooks/use-tinybird-token.test.tsx @@ -2,7 +2,7 @@ import { renderHook } from '@testing-library/react'; import { AppProvider, AppSettings } from '../../../src/providers/app-provider'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { useTinybirdToken } from '../../../src/hooks/use-tinybird-token'; -import { getTinybirdToken } from '../../../src/api/tinybird'; +import { useTinybirdTokenQuery } from '../../../src/api/tinybird'; import React from 'react'; const buildAppSettings = (webAnalytics: boolean): AppSettings => ({ @@ -17,12 +17,12 @@ const buildAppSettings = (webAnalytics: boolean): AppSettings => ({ }, }); -// Mock the getTinybirdToken API +// Mock the useTinybirdTokenQuery API vi.mock('../../../src/api/tinybird', () => ({ - getTinybirdToken: vi.fn(), + useTinybirdTokenQuery: vi.fn(), })); -const mockGetTinybirdToken = vi.mocked(getTinybirdToken); +const mockUseTinybirdTokenQuery = vi.mocked(useTinybirdTokenQuery); describe('useTinybirdToken', () => { let queryClient: QueryClient; @@ -46,7 +46,7 @@ describe('useTinybirdToken', () => { }); it('returns token when API returns valid token', () => { - mockGetTinybirdToken.mockReturnValue({ + mockUseTinybirdTokenQuery.mockReturnValue({ data: { tinybird: { token: 'valid-token-123' } }, isLoading: false, error: null, @@ -61,7 +61,7 @@ describe('useTinybirdToken', () => { }); it('returns undefined when API returns null token', () => { - mockGetTinybirdToken.mockReturnValue({ + mockUseTinybirdTokenQuery.mockReturnValue({ data: { tinybird: { token: null } }, isLoading: false, error: null, @@ -76,7 +76,7 @@ describe('useTinybirdToken', () => { it('uses built-in query options without requiring consumer configuration', () => { const mockRefetch = vi.fn(); - mockGetTinybirdToken.mockReturnValue({ + mockUseTinybirdTokenQuery.mockReturnValue({ data: { tinybird: { token: 'cached-token' } }, isLoading: false, error: null, @@ -89,16 +89,16 @@ describe('useTinybirdToken', () => { // Second render in same QueryClient context renderHook(() => useTinybirdToken(), { wrapper }); - // Verify that getTinybirdToken is called with default enabled: true - expect(mockGetTinybirdToken).toHaveBeenCalledWith({ enabled: true }); + // Verify that useTinybirdTokenQuery is called with default enabled: true + expect(mockUseTinybirdTokenQuery).toHaveBeenCalledWith({ enabled: true }); // Verify both calls used the default enabled option - expect(mockGetTinybirdToken.mock.calls[0]).toEqual([{ enabled: true }]); - expect(mockGetTinybirdToken.mock.calls[1]).toEqual([{ enabled: true }]); + expect(mockUseTinybirdTokenQuery.mock.calls[0]).toEqual([{ enabled: true }]); + expect(mockUseTinybirdTokenQuery.mock.calls[1]).toEqual([{ enabled: true }]); }); it('uses built-in query options for optimal token refresh behavior', () => { - mockGetTinybirdToken.mockReturnValue({ + mockUseTinybirdTokenQuery.mockReturnValue({ data: { tinybird: { token: 'test-token' } }, isLoading: false, error: null, @@ -108,12 +108,12 @@ describe('useTinybirdToken', () => { renderHook(() => useTinybirdToken(), { wrapper }); // Verify default enabled option is passed - expect(mockGetTinybirdToken).toHaveBeenCalledWith({ enabled: true }); - expect(mockGetTinybirdToken.mock.calls[0]).toEqual([{ enabled: true }]); + expect(mockUseTinybirdTokenQuery).toHaveBeenCalledWith({ enabled: true }); + expect(mockUseTinybirdTokenQuery.mock.calls[0]).toEqual([{ enabled: true }]); }); it('returns undefined for invalid token types without throwing error', () => { - mockGetTinybirdToken.mockReturnValue({ + mockUseTinybirdTokenQuery.mockReturnValue({ data: { tinybird: { token: 123 } }, // number instead of string isLoading: false, error: null, @@ -129,7 +129,7 @@ describe('useTinybirdToken', () => { it('passes through API errors', () => { const apiError = new Error('Network error'); - mockGetTinybirdToken.mockReturnValue({ + mockUseTinybirdTokenQuery.mockReturnValue({ data: null, isLoading: false, error: apiError, @@ -145,7 +145,7 @@ describe('useTinybirdToken', () => { it('exposes refetch function', () => { const mockRefetch = vi.fn(); - mockGetTinybirdToken.mockReturnValue({ + mockUseTinybirdTokenQuery.mockReturnValue({ data: { tinybird: { token: 'test-token' } }, isLoading: false, error: null, @@ -169,7 +169,7 @@ describe('useTinybirdToken', () => { refetch: vi.fn(), }; - mockGetTinybirdToken.mockImplementation(() => queryState as any); + mockUseTinybirdTokenQuery.mockImplementation(() => queryState as any); const { result, rerender } = renderHook(() => useTinybirdToken(), { wrapper }); @@ -211,7 +211,7 @@ describe('useTinybirdToken', () => { refetch: vi.fn(), }; - mockGetTinybirdToken.mockImplementation(() => { + mockUseTinybirdTokenQuery.mockImplementation(() => { fetchCount += 1; return { ...queryState, @@ -245,7 +245,7 @@ describe('useTinybirdToken', () => { }); it('respects enabled option when true', () => { - mockGetTinybirdToken.mockReturnValue({ + mockUseTinybirdTokenQuery.mockReturnValue({ data: { tinybird: { token: 'enabled-token' } }, isLoading: false, error: null, @@ -257,11 +257,11 @@ describe('useTinybirdToken', () => { expect(result.current.token).toBe('enabled-token'); expect(result.current.isLoading).toBe(false); expect(result.current.error).toBe(null); - expect(mockGetTinybirdToken).toHaveBeenCalledWith({ enabled: true }); + expect(mockUseTinybirdTokenQuery).toHaveBeenCalledWith({ enabled: true }); }); it('respects enabled option when false', () => { - mockGetTinybirdToken.mockReturnValue({ + mockUseTinybirdTokenQuery.mockReturnValue({ data: null, isLoading: false, error: null, @@ -273,11 +273,11 @@ describe('useTinybirdToken', () => { expect(result.current.token).toBeUndefined(); expect(result.current.isLoading).toBe(false); expect(result.current.error).toBe(null); - expect(mockGetTinybirdToken).toHaveBeenCalledWith({ enabled: false }); + expect(mockUseTinybirdTokenQuery).toHaveBeenCalledWith({ enabled: false }); }); it('defaults enabled to true when not specified', () => { - mockGetTinybirdToken.mockReturnValue({ + mockUseTinybirdTokenQuery.mockReturnValue({ data: { tinybird: { token: 'default-token' } }, isLoading: false, error: null, @@ -287,7 +287,7 @@ describe('useTinybirdToken', () => { const { result } = renderHook(() => useTinybirdToken(), { wrapper }); expect(result.current.token).toBe('default-token'); - expect(mockGetTinybirdToken).toHaveBeenCalledWith({ enabled: true }); + expect(mockUseTinybirdTokenQuery).toHaveBeenCalledWith({ enabled: true }); }); describe('web analytics gate', () => { @@ -304,7 +304,7 @@ describe('useTinybirdToken', () => { ); beforeEach(() => { - mockGetTinybirdToken.mockReturnValue({ + mockUseTinybirdTokenQuery.mockReturnValue({ data: { tinybird: { token: 'test-token' } }, isLoading: false, error: null, @@ -315,13 +315,13 @@ describe('useTinybirdToken', () => { it('never loads a token when web analytics is disabled, even if enabled is true', () => { renderHook(() => useTinybirdToken({ enabled: true }), { wrapper: withAppSettings(false) }); - expect(mockGetTinybirdToken).toHaveBeenCalledWith({ enabled: false }); + expect(mockUseTinybirdTokenQuery).toHaveBeenCalledWith({ enabled: false }); }); it('returns an idle result when disabled, even if the query reports loading/error', () => { // A disabled React Query still reports isLoading:true and may retain a // cached error — the hook must normalize this so providers don't hang. - mockGetTinybirdToken.mockReturnValue({ + mockUseTinybirdTokenQuery.mockReturnValue({ data: { tinybird: { token: 'stale-token' } }, isLoading: true, error: new Error('stale error'), @@ -340,19 +340,19 @@ describe('useTinybirdToken', () => { it('loads a token when web analytics is enabled', () => { renderHook(() => useTinybirdToken({ enabled: true }), { wrapper: withAppSettings(true) }); - expect(mockGetTinybirdToken).toHaveBeenCalledWith({ enabled: true }); + expect(mockUseTinybirdTokenQuery).toHaveBeenCalledWith({ enabled: true }); }); it('stays disabled when web analytics is on but the caller passes enabled false', () => { renderHook(() => useTinybirdToken({ enabled: false }), { wrapper: withAppSettings(true) }); - expect(mockGetTinybirdToken).toHaveBeenCalledWith({ enabled: false }); + expect(mockUseTinybirdTokenQuery).toHaveBeenCalledWith({ enabled: false }); }); it('defaults to enabled when no AppProvider is mounted (preserves standalone behavior)', () => { renderHook(() => useTinybirdToken({ enabled: true }), { wrapper }); - expect(mockGetTinybirdToken).toHaveBeenCalledWith({ enabled: true }); + expect(mockUseTinybirdTokenQuery).toHaveBeenCalledWith({ enabled: true }); }); it('stays disabled while settings are still loading (provider mounted, appSettings undefined)', () => { @@ -365,7 +365,7 @@ describe('useTinybirdToken', () => { renderHook(() => useTinybirdToken({ enabled: true }), { wrapper: loadingWrapper }); - expect(mockGetTinybirdToken).toHaveBeenCalledWith({ enabled: false }); + expect(mockUseTinybirdTokenQuery).toHaveBeenCalledWith({ enabled: false }); }); }); }); diff --git a/apps/admin/src/automations/automations.test.tsx b/apps/admin/src/automations/automations.test.tsx index 1c91bf6b5dc..a1e8c132287 100644 --- a/apps/admin/src/automations/automations.test.tsx +++ b/apps/admin/src/automations/automations.test.tsx @@ -69,7 +69,7 @@ vi.mock('@tryghost/admin-x-framework', async () => { ); return { ...actual, - getFeaturebaseToken: () => ({ data: undefined }), + useFeaturebaseToken: () => ({ data: undefined }), useFeaturebase: () => ({ isAvailable: false, openFeedbackWidget: () => {}, diff --git a/apps/admin/src/ember-bridge/ember-bridge.test.tsx b/apps/admin/src/ember-bridge/ember-bridge.test.tsx index a93c799ee14..9344ab1e7e1 100644 --- a/apps/admin/src/ember-bridge/ember-bridge.test.tsx +++ b/apps/admin/src/ember-bridge/ember-bridge.test.tsx @@ -234,42 +234,6 @@ describe('useEmberDataSync', () => { expect(invalidateSpy).not.toHaveBeenCalled(); }); - queryTest( - 'invalidates comment queries for mapped Ember comment events', - async ({ queryClient, wrapper }) => { - const mock = createMockStateBridge(); - window.EmberBridge = { state: mock.stateBridge }; - - queryClient.setQueryData(['MembersResponseType', '/members'], { members: [] }); - queryClient.setQueryData(['CommentsResponseType', '/comments'], { comments: [] }); - queryClient.setQueryData(['PostsResponseType', '/posts'], { posts: [] }); - - renderHook(() => useEmberDataSync(), { wrapper }); - - await waitFor(() => { - expect(mock.onSpy).toHaveBeenCalledWith('emberDataChange', expect.any(Function)); - }); - - act(() => { - mock.emit('emberDataChange', { - operation: 'update', - modelName: 'comment', - id: 'member-1', - data: null, - }); - }); - - await waitFor(() => { - const queries = queryClient.getQueryCache().getAll(); - const commentQueries = queries.filter((q) => q.queryKey[0] === 'CommentsResponseType'); - const nonCommentQueries = queries.filter((q) => q.queryKey[0] !== 'CommentsResponseType'); - - expect(commentQueries.every((q) => q.state.isInvalidated)).toBe(true); - expect(nonCommentQueries.every((q) => !q.state.isInvalidated)).toBe(true); - }); - }, - ); - queryTest( 'does not subscribe if unmounted before the bridge becomes available', async ({ wrapper }) => { @@ -519,3 +483,65 @@ describe('useEmberRouting', () => { }); }); }); + +describe('theme bridge helpers', () => { + test('isEmberThemeManaged reflects bridge presence', async () => { + const { isEmberThemeManaged } = await import('./ember-bridge'); + expect(isEmberThemeManaged()).toBe(false); + window.EmberBridge = { state: createMockStateBridge().stateBridge }; + expect(isEmberThemeManaged()).toBe(true); + }); + + test('applyEmberAdminThemePreference calls Ember when the method exists and reports it', async () => { + const { applyEmberAdminThemePreference } = await import('./ember-bridge'); + const mock = createMockStateBridge(); + const apply = vi.fn(); + mock.stateBridge.applyAdminThemePreference = apply; + window.EmberBridge = { state: mock.stateBridge }; + + expect(applyEmberAdminThemePreference('dark')).toBe(true); + expect(apply).toHaveBeenCalledWith('dark'); + }); + + test('applyEmberAdminThemePreference returns false without a bridge or method', async () => { + const { applyEmberAdminThemePreference } = await import('./ember-bridge'); + expect(applyEmberAdminThemePreference('dark')).toBe(false); + + // Bridge present but from an older Ember without the method + window.EmberBridge = { state: createMockStateBridge().stateBridge }; + expect(applyEmberAdminThemePreference('dark')).toBe(false); + }); + + test('preloadEmberAdminThemeStylesheet resolves with and without the bridge', async () => { + const { preloadEmberAdminThemeStylesheet } = await import('./ember-bridge'); + await expect(preloadEmberAdminThemeStylesheet()).resolves.toBeUndefined(); + + const mock = createMockStateBridge(); + const preload = vi.fn().mockResolvedValue(undefined); + mock.stateBridge.preloadAdminThemeStylesheet = preload; + window.EmberBridge = { state: mock.stateBridge }; + await preloadEmberAdminThemeStylesheet(); + expect(preload).toHaveBeenCalledTimes(1); + }); +}); + +describe('emberMutationHandlers', () => { + test('resolves the bridge at call time, not import time', async () => { + // Import first, install the bridge after: forwarding must still work. + const { emberMutationHandlers } = await import('./ember-bridge'); + expect(() => emberMutationHandlers.onUpdate('SettingsResponseType', {})).not.toThrow(); + + const mock = createMockStateBridge(); + window.EmberBridge = { state: mock.stateBridge }; + + emberMutationHandlers.onUpdate('SettingsResponseType', { settings: [] }); + emberMutationHandlers.onInvalidate('TagsResponseType'); + emberMutationHandlers.onDelete('UsersResponseType', 'user-1'); + + expect(mock.stateBridge.onUpdate).toHaveBeenCalledWith('SettingsResponseType', { + settings: [], + }); + expect(mock.stateBridge.onInvalidate).toHaveBeenCalledWith('TagsResponseType'); + expect(mock.stateBridge.onDelete).toHaveBeenCalledWith('UsersResponseType', 'user-1'); + }); +}); diff --git a/apps/admin/src/ember-bridge/ember-bridge.tsx b/apps/admin/src/ember-bridge/ember-bridge.tsx index ae3dcb2565e..57f44ef8769 100644 --- a/apps/admin/src/ember-bridge/ember-bridge.tsx +++ b/apps/admin/src/ember-bridge/ember-bridge.tsx @@ -16,13 +16,15 @@ export type StateBridgeEventMap = { featureFlagsChange: undefined; }; +export type AdminThemeMode = 'light' | 'dark' | 'system'; + export interface StateBridge { onUpdate: (dataType: string, response: unknown) => void; onInvalidate: (dataType: string) => void; onDelete: (dataType: string, id: string) => void; isFeatureEnabled?: (name: string) => boolean | undefined; preloadAdminThemeStylesheet?: () => Promise; - applyAdminThemePreference?: (mode: 'light' | 'dark' | 'system') => Promise | void; + applyAdminThemePreference?: (mode: AdminThemeMode) => Promise | void; on( event: K, callback: (event: StateBridgeEventMap[K]) => void, @@ -96,10 +98,8 @@ const EMBER_TO_REACT_TYPE_MAPPING: Record = { user: 'UsersResponseType', post: 'PostsResponseType', member: 'MembersResponseType', - comment: 'CommentsResponseType', tag: 'TagsResponseType', label: 'LabelsResponseType', - webhook: 'WebhooksResponseType', }; /** @@ -261,6 +261,59 @@ export function subscribeOpenGiftLinkModal( return onEmberStateBridgeEvent('openGiftLinkModal', handler); } +/** + * Whether Ember owns the DOM theme. In the embedded admin, Ember manages the + * `dark` class and the dark stylesheet, and installs its own + * prefers-color-scheme listener, so React must not apply the theme itself. + * + * Deliberately a synchronous snapshot (no waitForStateBridge): theme effects + * need the answer at effect time and fall back to applying the theme + * themselves while the bridge is absent. + */ +export function isEmberThemeManaged(): boolean { + return typeof window !== 'undefined' && Boolean(window.EmberBridge); +} + +/** + * Preloads Ember's dark stylesheet so a subsequent theme switch lands without + * a flash. Resolves immediately when no bridge (or an older Ember without the + * method) is present. + */ +export async function preloadEmberAdminThemeStylesheet(): Promise { + await window.EmberBridge?.state.preloadAdminThemeStylesheet?.(); +} + +/** + * Asks Ember to apply an admin theme preference. Returns false when no bridge + * (or an older Ember without the method) is present, so the caller can fall + * back to applying the theme itself. + */ +export function applyEmberAdminThemePreference(mode: AdminThemeMode): boolean { + const stateBridge = window.EmberBridge?.state; + if (!stateBridge?.applyAdminThemePreference) { + return false; + } + void stateBridge.applyAdminThemePreference(mode); + return true; +} + +/** + * React -> Ember mutation sync handlers for the FrameworkProvider. Each + * forwards a successful React mutation to Ember's store sync and no-ops when + * the bridge is absent (standalone React). + */ +export const emberMutationHandlers = { + onUpdate: (dataType: string, response: unknown): void => { + window.EmberBridge?.state.onUpdate(dataType, response); + }, + onInvalidate: (dataType: string): void => { + window.EmberBridge?.state.onInvalidate(dataType); + }, + onDelete: (dataType: string, id: string): void => { + window.EmberBridge?.state.onDelete(dataType, id); + }, +}; + // External store for sidebar visibility state function subscribeSidebarVisibility(callback: () => void): () => void { return onEmberStateBridgeEvent('sidebarVisibilityChange', callback); diff --git a/apps/admin/src/ember-bridge/index.ts b/apps/admin/src/ember-bridge/index.ts index 589630ab9fa..81d7466bd40 100644 --- a/apps/admin/src/ember-bridge/index.ts +++ b/apps/admin/src/ember-bridge/index.ts @@ -12,5 +12,14 @@ export { useEmberRouting, useForceUpgrade, subscribeOpenGiftLinkModal, + isEmberThemeManaged, + preloadEmberAdminThemeStylesheet, + applyEmberAdminThemePreference, + emberMutationHandlers, +} from './ember-bridge'; +export type { + AdminThemeMode, + EmberDataChangeEvent, + EmberRouting, + OpenGiftLinkModalEvent, } from './ember-bridge'; -export type { EmberDataChangeEvent, EmberRouting, OpenGiftLinkModalEvent } from './ember-bridge'; diff --git a/apps/admin/src/hooks/use-theme.ts b/apps/admin/src/hooks/use-theme.ts index d03256354e5..8a9dfc10d85 100644 --- a/apps/admin/src/hooks/use-theme.ts +++ b/apps/admin/src/hooks/use-theme.ts @@ -1,4 +1,9 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import { + applyEmberAdminThemePreference, + isEmberThemeManaged, + preloadEmberAdminThemeStylesheet, +} from '@/ember-bridge'; import { useEditUserPreferences, useUserPreferences } from '@/hooks/user-preferences'; export type ThemeMode = 'light' | 'dark' | 'system'; @@ -33,22 +38,11 @@ function applyThemeClass(resolvedTheme: ResolvedThemeMode) { }); } -// In the embedded admin, Ember owns the DOM theme: it manages both the `dark` -// class and the dark stylesheet, and installs its own prefers-color-scheme -// listener. The class-toggling and media-query effects below are only a fallback -// for running this hook standalone (no EmberBridge), so they must not fight Ember. -function isEmberManaged(): boolean { - return typeof window !== 'undefined' && Boolean(window.EmberBridge); -} - -async function preloadAdminThemeStylesheet() { - await window.EmberBridge?.state.preloadAdminThemeStylesheet?.(); -} - +// The class-toggling and media-query effects below are only a fallback for +// running this hook standalone (no EmberBridge), so they must not fight Ember +// when it manages the DOM theme — see isEmberThemeManaged. function applyAdminTheme(mode: ThemeMode, resolvedTheme: ResolvedThemeMode) { - if (window.EmberBridge?.state.applyAdminThemePreference) { - void window.EmberBridge.state.applyAdminThemePreference(mode); - } else { + if (!applyEmberAdminThemePreference(mode)) { applyThemeClass(resolvedTheme); } } @@ -68,7 +62,7 @@ export function useTheme() { useEffect(() => { if ( - isEmberManaged() || + isEmberThemeManaged() || typeof window === 'undefined' || typeof window.matchMedia !== 'function' ) { @@ -97,7 +91,7 @@ export function useTheme() { }, []); useEffect(() => { - if (isEmberManaged()) { + if (isEmberThemeManaged()) { return; } applyThemeClass(resolvedTheme); @@ -126,7 +120,7 @@ export function useTheme() { try { const nextResolvedTheme = mode === 'system' ? systemTheme : mode; - await preloadAdminThemeStylesheet().catch((error) => { + await preloadEmberAdminThemeStylesheet().catch((error) => { // eslint-disable-next-line no-console console.error('[Theme] Failed to preload admin theme stylesheet:', error); }); diff --git a/apps/admin/src/layout/app-sidebar/hooks/use-sidebar-banner-state.test.tsx b/apps/admin/src/layout/app-sidebar/hooks/use-sidebar-banner-state.test.tsx index 6e9c7d0c1cb..15185f73abb 100644 --- a/apps/admin/src/layout/app-sidebar/hooks/use-sidebar-banner-state.test.tsx +++ b/apps/admin/src/layout/app-sidebar/hooks/use-sidebar-banner-state.test.tsx @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import { useSidebarBannerState } from './use-sidebar-banner-state'; -const mockUseSidebarVisibility = vi.fn<() => boolean>(() => true); +const mockUseAdminSidebarVisibility = vi.fn<() => boolean>(() => true); const mockUseActiveThemeErrors = vi.fn<() => { hasErrors: boolean }>(() => ({ hasErrors: false })); const mockUseUpgradeStatus = vi.fn< () => { showUpgradeBanner: boolean; trialDaysRemaining: number } @@ -12,8 +12,8 @@ const mockUseWhatsNewStatus = vi.fn<() => { showWhatsNewBanner: boolean }>(() => showWhatsNewBanner: false, })); -vi.mock('@/ember-bridge/ember-bridge', () => ({ - useSidebarVisibility: () => mockUseSidebarVisibility(), +vi.mock('@/layout/sidebar-visibility', () => ({ + useAdminSidebarVisibility: () => mockUseAdminSidebarVisibility(), })); vi.mock('./use-theme-errors', () => ({ @@ -44,14 +44,14 @@ vi.mock('@/whats-new/components/whats-new-banner', () => ({ describe('useSidebarBannerState', () => { beforeEach(() => { - mockUseSidebarVisibility.mockReturnValue(true); + mockUseAdminSidebarVisibility.mockReturnValue(true); mockUseActiveThemeErrors.mockReturnValue({ hasErrors: false }); mockUseUpgradeStatus.mockReturnValue({ showUpgradeBanner: false, trialDaysRemaining: 0 }); mockUseWhatsNewStatus.mockReturnValue({ showWhatsNewBanner: false }); }); test('returns no banner when editor is open', () => { - mockUseSidebarVisibility.mockReturnValue(false); + mockUseAdminSidebarVisibility.mockReturnValue(false); mockUseActiveThemeErrors.mockReturnValue({ hasErrors: true }); mockUseUpgradeStatus.mockReturnValue({ showUpgradeBanner: true, trialDaysRemaining: 7 }); mockUseWhatsNewStatus.mockReturnValue({ showWhatsNewBanner: true }); diff --git a/apps/admin/src/layout/app-sidebar/hooks/use-sidebar-banner-state.tsx b/apps/admin/src/layout/app-sidebar/hooks/use-sidebar-banner-state.tsx index 98634b5b76c..0bb6f64bc61 100644 --- a/apps/admin/src/layout/app-sidebar/hooks/use-sidebar-banner-state.tsx +++ b/apps/admin/src/layout/app-sidebar/hooks/use-sidebar-banner-state.tsx @@ -1,8 +1,8 @@ import type { ReactNode } from 'react'; -import { useSidebarVisibility } from '@/ember-bridge/ember-bridge'; import ThemeErrorsBanner from '@/layout/app-sidebar/theme-errors-banner'; import UpgradeBanner from '@/layout/app-sidebar/upgrade-banner'; +import { useAdminSidebarVisibility } from '@/layout/sidebar-visibility'; import WhatsNewBanner from '@/whats-new/components/whats-new-banner'; import { useUpgradeStatus } from './use-upgrade-status'; @@ -19,7 +19,7 @@ export function useSidebarBannerState(): SidebarBannerState { const { hasErrors } = useActiveThemeErrors(); const { showUpgradeBanner, trialDaysRemaining } = useUpgradeStatus(); const { showWhatsNewBanner } = useWhatsNewStatus(); - const sidebarVisible = useSidebarVisibility(); + const sidebarVisible = useAdminSidebarVisibility(); if (!sidebarVisible) { return { diff --git a/apps/admin/src/layout/app-sidebar/hooks/use-upgrade-status.ts b/apps/admin/src/layout/app-sidebar/hooks/use-upgrade-status.ts index 2635df64d14..1f1036fa42b 100644 --- a/apps/admin/src/layout/app-sidebar/hooks/use-upgrade-status.ts +++ b/apps/admin/src/layout/app-sidebar/hooks/use-upgrade-status.ts @@ -1,4 +1,4 @@ -import { useSubscriptionStatus } from '@/ember-bridge/ember-bridge'; +import { useSubscriptionStatus } from '@/ember-bridge'; export interface UpgradeStatus { showUpgradeBanner: boolean; diff --git a/apps/admin/src/layout/sidebar-visibility.test.tsx b/apps/admin/src/layout/sidebar-visibility.test.tsx index 681b1e0d6c8..f62f0649ad0 100644 --- a/apps/admin/src/layout/sidebar-visibility.test.tsx +++ b/apps/admin/src/layout/sidebar-visibility.test.tsx @@ -12,7 +12,7 @@ vi.mock('@tryghost/admin-x-framework', () => ({ useMatches: () => useMatchesMock(), })); -vi.mock('@/ember-bridge/ember-bridge', () => ({ +vi.mock('@/ember-bridge', () => ({ useSidebarVisibility: () => useEmberSidebarVisibilityMock(), })); diff --git a/apps/admin/src/layout/sidebar-visibility.ts b/apps/admin/src/layout/sidebar-visibility.ts index fa4792bbf3c..d69a7541b9e 100644 --- a/apps/admin/src/layout/sidebar-visibility.ts +++ b/apps/admin/src/layout/sidebar-visibility.ts @@ -1,5 +1,5 @@ import { type AdminRouteHandle, useMatches } from '@tryghost/admin-x-framework'; -import { useSidebarVisibility as useEmberSidebarVisibility } from '@/ember-bridge/ember-bridge'; +import { useSidebarVisibility as useEmberSidebarVisibility } from '@/ember-bridge'; function hidesAdminSidebar(handle: unknown): handle is AdminRouteHandle { return ( diff --git a/apps/admin/src/main.tsx b/apps/admin/src/main.tsx index c96382ad81b..fe8ee5d1e0a 100644 --- a/apps/admin/src/main.tsx +++ b/apps/admin/src/main.tsx @@ -1,6 +1,8 @@ import { createRoot } from 'react-dom/client'; +import { defaultUnsplashConfig } from '@tryghost/admin-x-framework'; import './index.css'; import { AdminAppRoot } from './app-root.tsx'; +import { emberMutationHandlers } from './ember-bridge'; import { navigateTo } from './utils/navigation'; const framework = { @@ -8,23 +10,9 @@ const framework = { externalNavigate: (link: { route: string; isExternal: boolean; replace?: boolean }) => { navigateTo(link.route, { replace: link.replace }); }, - unsplashConfig: { - Authorization: 'Client-ID 8672af113b0a8573edae3aa3713886265d9bb741d707f6c01a486cde8c278980', - 'Accept-Version': 'v1', - 'Content-Type': 'application/json', - 'App-Pragma': 'no-cache', - 'X-Unsplash-Cache': true, - }, + unsplashConfig: defaultUnsplashConfig, sentryDSN: null, - onUpdate: (dataType: string, response: unknown) => { - window.EmberBridge?.state.onUpdate(dataType, response); - }, - onInvalidate: (dataType: string) => { - window.EmberBridge?.state.onInvalidate(dataType); - }, - onDelete: (dataType: string, id: string) => { - window.EmberBridge?.state.onDelete(dataType, id); - }, + ...emberMutationHandlers, }; createRoot(document.getElementById('root')!).render(); diff --git a/apps/admin/src/members/detail/member-detail.tsx b/apps/admin/src/members/detail/member-detail.tsx index 4317382e4ee..5f57aa6a483 100644 --- a/apps/admin/src/members/detail/member-detail.tsx +++ b/apps/admin/src/members/detail/member-detail.tsx @@ -37,7 +37,7 @@ import { import { dequal } from 'dequal'; import { deriveMemberDetailBackPath } from './member-detail-nav'; import { formatMemberName } from '@tryghost/shade/app'; -import { getMember, useAddMember, useEditMember } from '@tryghost/admin-x-framework/api/members'; +import { useMember, useAddMember, useEditMember } from '@tryghost/admin-x-framework/api/members'; import { getSettingValue, useBrowseSettings } from '@tryghost/admin-x-framework/api/settings'; import { toast } from 'sonner'; import { useBrowseNewsletters } from '@tryghost/admin-x-framework/api/newsletters'; @@ -74,7 +74,7 @@ const MemberDetailPage: React.FC = ({ const customFieldsEnabled = useFeatureFlag('membersCustomFields'); // `include=tiers` mirrors the Ember route so complimentary tiers arrive with the member. - const { data, isLoading, error, refetch } = getMember(memberId, { + const { data, isLoading, error, refetch } = useMember(memberId, { enabled: !!memberId && !isCreating, searchParams: { include: customFieldsEnabled ? 'tiers,custom_fields' : 'tiers' }, defaultErrorHandler: false, diff --git a/apps/admin/src/members/detail/member-impersonate-modal.tsx b/apps/admin/src/members/detail/member-impersonate-modal.tsx index 7186352a437..f940437d0c7 100644 --- a/apps/admin/src/members/detail/member-impersonate-modal.tsx +++ b/apps/admin/src/members/detail/member-impersonate-modal.tsx @@ -11,7 +11,7 @@ import { Label, LoadingIndicator, } from '@tryghost/shade/components'; -import { getMemberSigninUrl } from '@tryghost/admin-x-framework/api/members'; +import { useMemberSigninUrl } from '@tryghost/admin-x-framework/api/members'; import { toast } from 'sonner'; import { useQueryClient } from '@tanstack/react-query'; @@ -44,7 +44,7 @@ const MemberImpersonateModal: React.FC = ({ } }, [open, queryClient]); - const { data, isFetching, isError } = getMemberSigninUrl(memberId, { + const { data, isFetching, isError } = useMemberSigninUrl(memberId, { enabled: open, defaultErrorHandler: false, }); diff --git a/apps/admin/src/posts/analytics/hooks/use-post-feedback.ts b/apps/admin/src/posts/analytics/hooks/use-post-feedback.ts index 02d3424b190..043d8ac3014 100644 --- a/apps/admin/src/posts/analytics/hooks/use-post-feedback.ts +++ b/apps/admin/src/posts/analytics/hooks/use-post-feedback.ts @@ -1,4 +1,4 @@ -import { getPostFeedback } from '@tryghost/admin-x-framework/api/feedback'; +import { usePostFeedbackQuery } from '@tryghost/admin-x-framework/api/feedback'; import { useMemo } from 'react'; export const usePostFeedback = (postId: string, score?: number) => { @@ -6,7 +6,7 @@ export const usePostFeedback = (postId: string, score?: number) => { data: feedbackResponse, isLoading, error, - } = getPostFeedback(postId, { + } = usePostFeedbackQuery(postId, { searchParams: { limit: '50', // Get more data for pagination ...(score !== undefined ? { score: score.toString() } : {}), diff --git a/apps/admin/src/posts/analytics/hooks/use-post-newsletter-stats.ts b/apps/admin/src/posts/analytics/hooks/use-post-newsletter-stats.ts index 6393ca2c9cb..deab9f0d68b 100644 --- a/apps/admin/src/posts/analytics/hooks/use-post-newsletter-stats.ts +++ b/apps/admin/src/posts/analytics/hooks/use-post-newsletter-stats.ts @@ -4,7 +4,7 @@ import { useNewsletterBasicStats, useNewsletterClickStats, } from '@tryghost/admin-x-framework/api/stats'; -import { type Post, getPost } from '@tryghost/admin-x-framework/api/posts'; +import { type Post, usePost } from '@tryghost/admin-x-framework/api/posts'; import { processAndGroupTopLinks } from '@/posts/analytics/utils/link-helpers'; import { useMemo } from 'react'; import { useTopLinks } from '@tryghost/admin-x-framework/api/links'; @@ -18,10 +18,10 @@ type PostWithNewsletter = Post & { export const usePostNewsletterStats = (postId: string) => { // Fetch the post with main stats (email, clicks) - const { data: postResponse, isLoading: isPostLoading } = getPost(postId); + const { data: postResponse, isLoading: isPostLoading } = usePost(postId); // Fetch the post with feedback count relations - const { data: feedbackPostResponse, isLoading: isFeedbackPostLoading } = getPost(postId, { + const { data: feedbackPostResponse, isLoading: isFeedbackPostLoading } = usePost(postId, { searchParams: { include: 'count.positive_feedback,count.negative_feedback', }, diff --git a/apps/admin/src/settings/advanced/migration-tools/content-import/csv.test.ts b/apps/admin/src/settings/advanced/migration-tools/content-import/csv.test.ts new file mode 100644 index 00000000000..b7f133b6b88 --- /dev/null +++ b/apps/admin/src/settings/advanced/migration-tools/content-import/csv.test.ts @@ -0,0 +1,20 @@ +import { columnsOf, parseCSV } from './csv'; + +describe('content import CSV', () => { + it('parses CSV values and preserves columns missing from the first row', () => { + const rows = parseCSV('Title,Body,Date\nFirst,,\nSecond,

Body

,2025-01-01\n'); + + expect(rows).toEqual([ + { Title: 'First', Body: '', Date: '' }, + { Title: 'Second', Body: '

Body

', Date: '2025-01-01' }, + ]); + expect(columnsOf(rows)).toEqual(['Title', 'Body', 'Date']); + }); + + it('drops Papa Parse overflow cells from the mapping columns', () => { + const rows = parseCSV('Title\nFirst,overflow\n'); + + expect(rows).toEqual([{ Title: 'First' }]); + expect(columnsOf(rows)).toEqual(['Title']); + }); +}); diff --git a/apps/admin/src/settings/advanced/migration-tools/content-import/csv.ts b/apps/admin/src/settings/advanced/migration-tools/content-import/csv.ts new file mode 100644 index 00000000000..bc20f8a98a6 --- /dev/null +++ b/apps/admin/src/settings/advanced/migration-tools/content-import/csv.ts @@ -0,0 +1,50 @@ +import Papa from 'papaparse'; + +export function parseCSV(text: string): Record[] { + const parsed = Papa.parse>(text.replace(/^\ufeff/, ''), { + header: true, + skipEmptyLines: true, + }); + + return parsed.data + .map((row) => { + const normalized: Record = {}; + for (const [key, value] of Object.entries(row)) { + if (key === '__parsed_extra') { + continue; + } + normalized[key] = typeof value === 'string' ? value : ''; + } + return normalized; + }) + .filter((row) => Object.keys(row).length > 0); +} + +export function columnsOf(rows: Record[]): string[] { + const columns = new Set(); + for (const row of rows) { + for (const column of Object.keys(row)) { + columns.add(column); + } + } + return [...columns]; +} + +export function readCSV(file: File): Promise[]> { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + try { + if (typeof reader.result !== 'string') { + throw new Error('Failed to read CSV file as text'); + } + resolve(parseCSV(reader.result)); + } catch (error) { + reject(error instanceof Error ? error : new Error('Failed to parse CSV file')); + } + }; + reader.onerror = () => reject(reader.error ?? new Error('Failed to read CSV file')); + reader.onabort = () => reject(new Error('Reading the CSV file was interrupted')); + reader.readAsText(file); + }); +} diff --git a/apps/admin/src/settings/advanced/migration-tools/content-import/field-picker.tsx b/apps/admin/src/settings/advanced/migration-tools/content-import/field-picker.tsx new file mode 100644 index 00000000000..9123bef3f6a --- /dev/null +++ b/apps/admin/src/settings/advanced/migration-tools/content-import/field-picker.tsx @@ -0,0 +1,94 @@ +import { + Button, + Command, + CommandCheck, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + Popover, + PopoverContent, + PopoverTrigger, +} from '@tryghost/shade/components'; +import { LucideIcon, cn } from '@tryghost/shade/utils'; +import { useState } from 'react'; +import { CONTENT_FIELD_GROUPS, CONTENT_FIELD_MAPPINGS } from './mapping'; + +interface FieldPickerProps { + column: string; + value: string | null; + disabled: boolean; + onValueChange: (value: string | null) => void; +} + +export function FieldPicker({ column, value, disabled, onValueChange }: FieldPickerProps) { + const [open, setOpen] = useState(false); + const selected = CONTENT_FIELD_MAPPINGS.find((field) => field.value === value); + + const choose = (target: string | null) => { + onValueChange(target); + setOpen(false); + }; + + return ( + + + + + + + + + No post fields found. + + choose(null)}> + + Not imported + {!value && } + + + {CONTENT_FIELD_GROUPS.map((group) => ( + + {group.fields.map((field) => ( + choose(field.value)} + > + + {field.label} + {field.required ? ' (required)' : ''} + + {value === field.value && } + + ))} + + ))} + + + + + ); +} diff --git a/apps/admin/src/settings/advanced/migration-tools/content-import/mapping-step.tsx b/apps/admin/src/settings/advanced/migration-tools/content-import/mapping-step.tsx new file mode 100644 index 00000000000..c49346b6f7d --- /dev/null +++ b/apps/admin/src/settings/advanced/migration-tools/content-import/mapping-step.tsx @@ -0,0 +1,119 @@ +import { + Banner, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@tryghost/shade/components'; +import { Inline, Stack, Text } from '@tryghost/shade/primitives'; +import { LucideIcon, cn, formatNumber } from '@tryghost/shade/utils'; +import { FieldPicker } from './field-picker'; +import { ContentFieldMapping } from './mapping'; + +interface MappingStepProps { + rows: Record[]; + mapping: ContentFieldMapping; + sampleIndex: number; + disabled: boolean; + missingTitle: boolean; + onMappingChange: (column: string, target: string | null) => void; + onSampleIndexChange: (index: number) => void; +} + +export function MappingStep({ + rows, + mapping, + sampleIndex, + disabled, + missingTitle, + onMappingChange, + onSampleIndexChange, +}: MappingStepProps) { + const row = rows[sampleIndex] ?? {}; + const columns = Object.keys(mapping.toJSON()); + const hasPrevious = sampleIndex > 0; + const hasNext = sampleIndex < rows.length - 1; + + return ( + + + Choose which Ghost field each column in your CSV should import as. + + {missingTitle && ( + + + + + Required field missing: Title + + + + )} +
+
+ + + + CSV column + + + Sample #{formatNumber(sampleIndex + 1)} + + + + + + + Import as + + + + {columns.map((column) => ( + + {column} + + {row[column] || '\u00a0'} + + + onMappingChange(column, value)} + /> + + + ))} + +
+
+
+
+ ); +} diff --git a/apps/admin/src/settings/advanced/migration-tools/content-import/mapping.test.ts b/apps/admin/src/settings/advanced/migration-tools/content-import/mapping.test.ts new file mode 100644 index 00000000000..a3b2dcecccb --- /dev/null +++ b/apps/admin/src/settings/advanced/migration-tools/content-import/mapping.test.ts @@ -0,0 +1,103 @@ +import { CONTENT_FIELD_GROUPS, CONTENT_FIELD_MAPPINGS, ContentFieldMapping } from './mapping'; + +describe('ContentFieldMapping', () => { + it('keeps every CSV column in the upload contract', () => { + const mapping = ContentFieldMapping.empty(['Headline', 'Body']).update('Headline', 'title'); + + expect(mapping.toJSON()).toEqual({ Headline: 'title', Body: '' }); + }); + + it('allows only one CSV column to claim a Ghost field', () => { + const mapping = ContentFieldMapping.empty(['First title', 'Second title']) + .update('First title', 'title') + .update('Second title', 'title'); + + expect(mapping.toJSON()).toEqual({ 'First title': '', 'Second title': 'title' }); + }); + + it('detects exact field-name headers', () => { + const mapping = ContentFieldMapping.detect([ + 'title', + 'html', + 'markdown', + 'published_at', + 'Something else', + ]); + + expect(mapping.toJSON()).toEqual({ + title: 'title', + html: 'html', + markdown: 'markdown', + published_at: 'published_at', + 'Something else': '', + }); + }); + + it('does not case-fold or guess header mappings', () => { + const mapping = ContentFieldMapping.detect(['Title', 'Post HTML', 'Published At']); + + expect(mapping.toJSON()).toEqual({ Title: '', 'Post HTML': '', 'Published At': '' }); + }); + + it('reports whether the required title target is mapped', () => { + const missing = ContentFieldMapping.detect(['Headline']); + const complete = missing.update('Headline', 'title'); + + expect(missing.hasTarget('title')).toBe(false); + expect(complete.hasTarget('title')).toBe(true); + }); + + it('offers the full editorial field set grouped for search', () => { + expect(CONTENT_FIELD_GROUPS.map((group) => group.label)).toEqual([ + 'Content', + 'Publishing', + 'Images', + 'SEO', + 'Social', + 'Advanced', + ]); + expect(CONTENT_FIELD_MAPPINGS.map((field) => field.value)).toEqual([ + 'title', + 'html', + 'markdown', + 'slug', + 'custom_excerpt', + 'type', + 'status', + 'visibility', + 'featured', + 'created_at', + 'updated_at', + 'published_at', + 'feature_image', + 'feature_image_alt', + 'feature_image_caption', + 'show_title_and_feature_image', + 'meta_title', + 'meta_description', + 'canonical_url', + 'og_image', + 'og_title', + 'og_description', + 'twitter_image', + 'twitter_title', + 'twitter_description', + 'custom_template', + 'codeinjection_head', + 'codeinjection_foot', + 'frontmatter', + ]); + expect(CONTENT_FIELD_MAPPINGS.map((field) => field.value)).not.toEqual( + expect.arrayContaining([ + 'authors', + 'tags', + 'comment_id', + 'newsletter_id', + 'email', + 'tiers', + 'id', + 'lexical', + ]), + ); + }); +}); diff --git a/apps/admin/src/settings/advanced/migration-tools/content-import/mapping.ts b/apps/admin/src/settings/advanced/migration-tools/content-import/mapping.ts new file mode 100644 index 00000000000..2917bf05d93 --- /dev/null +++ b/apps/admin/src/settings/advanced/migration-tools/content-import/mapping.ts @@ -0,0 +1,124 @@ +export interface ContentField { + label: string; + value: string; + required: boolean; +} + +interface ContentFieldGroup { + label: string; + fields: readonly ContentField[]; +} + +export const CONTENT_FIELD_GROUPS: readonly ContentFieldGroup[] = [ + { + label: 'Content', + fields: [ + { label: 'Title', value: 'title', required: true }, + { label: 'HTML', value: 'html', required: false }, + { label: 'Markdown', value: 'markdown', required: false }, + { label: 'Slug', value: 'slug', required: false }, + { label: 'Custom excerpt', value: 'custom_excerpt', required: false }, + ], + }, + { + label: 'Publishing', + fields: [ + { label: 'Type', value: 'type', required: false }, + { label: 'Status', value: 'status', required: false }, + { label: 'Visibility', value: 'visibility', required: false }, + { label: 'Featured', value: 'featured', required: false }, + { label: 'Created at', value: 'created_at', required: false }, + { label: 'Updated at', value: 'updated_at', required: false }, + { label: 'Published at', value: 'published_at', required: false }, + ], + }, + { + label: 'Images', + fields: [ + { label: 'Feature image', value: 'feature_image', required: false }, + { label: 'Feature image alt', value: 'feature_image_alt', required: false }, + { label: 'Feature image caption', value: 'feature_image_caption', required: false }, + { + label: 'Show title and feature image', + value: 'show_title_and_feature_image', + required: false, + }, + ], + }, + { + label: 'SEO', + fields: [ + { label: 'Meta title', value: 'meta_title', required: false }, + { label: 'Meta description', value: 'meta_description', required: false }, + { label: 'Canonical URL', value: 'canonical_url', required: false }, + ], + }, + { + label: 'Social', + fields: [ + { label: 'Open Graph image', value: 'og_image', required: false }, + { label: 'Open Graph title', value: 'og_title', required: false }, + { label: 'Open Graph description', value: 'og_description', required: false }, + { label: 'Twitter image', value: 'twitter_image', required: false }, + { label: 'Twitter title', value: 'twitter_title', required: false }, + { label: 'Twitter description', value: 'twitter_description', required: false }, + ], + }, + { + label: 'Advanced', + fields: [ + { label: 'Custom template', value: 'custom_template', required: false }, + { label: 'Code injection head', value: 'codeinjection_head', required: false }, + { label: 'Code injection foot', value: 'codeinjection_foot', required: false }, + { label: 'Frontmatter', value: 'frontmatter', required: false }, + ], + }, +] as const; + +export const CONTENT_FIELD_MAPPINGS = CONTENT_FIELD_GROUPS.flatMap((group) => group.fields); + +export class ContentFieldMapping { + private readonly mapping: Record; + + constructor(mapping: Record) { + this.mapping = { ...mapping }; + } + + static empty(columns: string[]): ContentFieldMapping { + return new ContentFieldMapping(Object.fromEntries(columns.map((column) => [column, null]))); + } + + static detect(columns: string[]): ContentFieldMapping { + const targets = new Set(CONTENT_FIELD_MAPPINGS.map((field) => field.value)); + return new ContentFieldMapping( + Object.fromEntries(columns.map((column) => [column, targets.has(column) ? column : null])), + ); + } + + get(column: string): string | null { + return this.mapping[column] ?? null; + } + + hasTarget(target: string): boolean { + return Object.values(this.mapping).includes(target); + } + + update(column: string, target: string | null): ContentFieldMapping { + const next = { ...this.mapping }; + if (target) { + for (const mappedColumn of Object.keys(next)) { + if (next[mappedColumn] === target) { + next[mappedColumn] = null; + } + } + } + next[column] = target; + return new ContentFieldMapping(next); + } + + toJSON(): Record { + return Object.fromEntries( + Object.entries(this.mapping).map(([column, target]) => [column, target ?? '']), + ); + } +} diff --git a/apps/admin/src/settings/advanced/migration-tools/universal-import-modal.test.tsx b/apps/admin/src/settings/advanced/migration-tools/universal-import-modal.test.tsx index d076bbd5b22..6f2c6887150 100644 --- a/apps/admin/src/settings/advanced/migration-tools/universal-import-modal.test.tsx +++ b/apps/admin/src/settings/advanced/migration-tools/universal-import-modal.test.tsx @@ -38,6 +38,10 @@ vi.mock('@tryghost/admin-x-framework/hooks', async () => { }); describe('UniversalImportModal', () => { + beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn(); + }); + beforeEach(() => { vi.clearAllMocks(); mockImportContent.mockResolvedValue({}); @@ -109,7 +113,7 @@ describe('UniversalImportModal', () => { expect(screen.getByTestId('universal-import-modal')).toBeInTheDocument(); }); - it('sends CSV files to the posts upload endpoint when csvContentImporter is enabled', async () => { + it('maps CSV files before sending them to the posts upload endpoint', async () => { mockUseFeatureFlag.mockReturnValue(true); showModal(); @@ -121,11 +125,74 @@ describe('UniversalImportModal', () => { const file = new File(['title\nHello'], 'posts.csv', { type: 'text/csv' }); await dropFile(file); - await waitFor(() => expect(mockImportContentCSV).toHaveBeenCalledWith(file)); + expect(await screen.findByText('Map CSV fields')).toBeInTheDocument(); + expect(screen.getByText('title')).toBeInTheDocument(); + expect(screen.getByText('Hello')).toBeInTheDocument(); + expect(mockImportContentCSV).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'Import' })); + + await waitFor(() => + expect(mockImportContentCSV).toHaveBeenCalledWith({ + file, + mapping: { title: 'title' }, + }), + ); expect(mockImportContent).not.toHaveBeenCalled(); expect(await screen.findByTestId('confirmation-modal')).toHaveTextContent('Import in progress'); }); + it('can start over after selecting a CSV file', async () => { + mockUseFeatureFlag.mockReturnValue(true); + showModal(); + + await dropFile(new File(['title\nHello'], 'posts.csv', { type: 'text/csv' })); + expect(await screen.findByText('Map CSV fields')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Start over' })); + + expect(await fileInput()).toBeInTheDocument(); + expect(mockImportContentCSV).not.toHaveBeenCalled(); + }); + + it('blocks importing while the required title field is unmapped', async () => { + mockUseFeatureFlag.mockReturnValue(true); + showModal(); + + await dropFile(new File(['Headline\nHello'], 'posts.csv', { type: 'text/csv' })); + + expect(await screen.findByRole('alert')).toHaveTextContent('Required field missing: Title'); + expect(screen.getByRole('button', { name: 'Import' })).toBeDisabled(); + expect(mockImportContentCSV).not.toHaveBeenCalled(); + }); + + it('searches grouped editorial targets and submits the chosen field', async () => { + mockUseFeatureFlag.mockReturnValue(true); + showModal(); + + const file = new File(['title,Social summary\nHello,Shared copy'], 'posts.csv', { + type: 'text/csv', + }); + await dropFile(file); + + fireEvent.click(await screen.findByRole('combobox', { name: /Field for Social summary/ })); + expect(screen.getByText('Content')).toBeInTheDocument(); + expect(screen.getByText('Social')).toBeInTheDocument(); + + fireEvent.change(screen.getByPlaceholderText('Search post fields...'), { + target: { value: 'Twitter description' }, + }); + fireEvent.click(screen.getByText('Twitter description')); + fireEvent.click(screen.getByRole('button', { name: 'Import' })); + + await waitFor(() => + expect(mockImportContentCSV).toHaveBeenCalledWith({ + file, + mapping: { title: 'title', 'Social summary': 'twitter_description' }, + }), + ); + }); + it('still sends JSON files to the db import when csvContentImporter is enabled', async () => { mockUseFeatureFlag.mockReturnValue(true); showModal(); @@ -144,6 +211,8 @@ describe('UniversalImportModal', () => { showModal(); await dropFile(new File(['title\nHello'], 'posts.csv', { type: 'text/csv' })); + await screen.findByText('Map CSV fields'); + fireEvent.click(screen.getByRole('button', { name: 'Import' })); await waitFor(() => expect(mockHandleError).toHaveBeenCalledWith(error)); expect(screen.getByTestId('universal-import-modal')).toBeInTheDocument(); diff --git a/apps/admin/src/settings/advanced/migration-tools/universal-import-modal.tsx b/apps/admin/src/settings/advanced/migration-tools/universal-import-modal.tsx index dc35f83dd90..1453d5d4f1d 100644 --- a/apps/admin/src/settings/advanced/migration-tools/universal-import-modal.tsx +++ b/apps/admin/src/settings/advanced/migration-tools/universal-import-modal.tsx @@ -1,18 +1,30 @@ import React, { useState } from 'react'; import { Button, Dropzone } from '@tryghost/shade/components'; import { ExternalLink } from 'lucide-react'; -import { Inline } from '@tryghost/shade/primitives'; +import { Inline, Stack } from '@tryghost/shade/primitives'; import { SettingsModal } from '@tryghost/shade/patterns'; import { useConfirmation } from '@/settings/providers/confirmation-context'; import { useFeatureFlag, 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'; +import { ContentFieldMapping } from './content-import/mapping'; +import { MappingStep } from './content-import/mapping-step'; +import { columnsOf, readCSV } from './content-import/csv'; + +interface CSVImportState { + file: File; + rows: Record[]; + mapping: ContentFieldMapping; + sampleIndex: number; +} const UniversalImportModal: React.FC<{ onClose: () => void }> = ({ onClose }) => { const { mutateAsync: importContent } = useImportContent(); const { mutateAsync: importContentCSV } = useImportContentCSV(); const csvContentImporter = useFeatureFlag('csvContentImporter'); const [uploading, setUploading] = useState(false); + const [reading, setReading] = useState(false); + const [csvImport, setCSVImport] = useState(null); const handleError = useHandleError(); const { confirm } = useConfirmation(); @@ -20,27 +32,48 @@ const UniversalImportModal: React.FC<{ onClose: () => void }> = ({ onClose }) => ? { 'application/json': ['.json'], 'application/zip': ['.zip'], 'text/csv': ['.csv'] } : { 'application/json': ['.json'], 'application/zip': ['.zip'] }; + const finishImport = (isCSV: boolean) => { + 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, + }); + }; + const importFile = async (file: File) => { + const isCSV = csvContentImporter && file.name.toLowerCase().endsWith('.csv'); + if (isCSV) { + setReading(true); + try { + const rows = await readCSV(file); + if (rows.length === 0) { + throw new Error('File is empty, nothing to import. Please select a different file.'); + } + setCSVImport({ + file, + rows, + mapping: ContentFieldMapping.detect(columnsOf(rows)), + sampleIndex: 0, + }); + } catch (error) { + handleError(error); + } finally { + setReading(false); + } + return; + } + 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, - }); + await importContent(file); + finishImport(false); } catch (e) { handleError(e); } finally { @@ -48,51 +81,111 @@ const UniversalImportModal: React.FC<{ onClose: () => void }> = ({ onClose }) => } }; + const importCSV = async () => { + if (!csvImport) { + return; + } + setUploading(true); + try { + await importContentCSV({ file: csvImport.file, mapping: csvImport.mapping.toJSON() }); + finishImport(true); + } catch (error) { + handleError(error); + } finally { + setUploading(false); + } + }; + + const updateMapping = (column: string, target: string | null) => { + setCSVImport((current) => + current ? { ...current, mapping: current.mapping.update(column, target) } : current, + ); + }; + + const missingTitle = csvImport !== null && !csvImport.mapping.hasTarget('title'); + + const footer = csvImport ? ( + + + + + + + + ) : ( + + + Learn more + + + + ); + return ( - - Learn more - - - - } + footer={footer} okLabel="" - size="sm" + size={csvImport ? 'lg' : 'sm'} testId="universal-import-modal" - title="Universal import" + title={csvImport ? 'Map CSV fields' : 'Universal import'} onClose={onClose} > -
- void importFile(file)} - > -
- {uploading ? ( - 'Uploading...' - ) : ( - <> - Select any {csvContentImporter ? 'JSON, zip or CSV' : 'JSON or zip'} file that - contains
- posts and settings - - )} -
-
-
+ + {csvImport ? ( + + setCSVImport((current) => (current ? { ...current, sampleIndex } : current)) + } + /> + ) : ( + void importFile(file)} + > +
+ {uploading ? ( + 'Uploading...' + ) : reading ? ( + 'Reading CSV...' + ) : ( + <> + Select any {csvContentImporter ? 'JSON, zip or CSV' : 'JSON or zip'} file that + contains
+ posts and settings + + )} +
+
+ )} +
); }; diff --git a/apps/admin/src/settings/components/confirmation-modal.tsx b/apps/admin/src/settings/components/confirmation-modal.tsx index e0cc7222257..f68db9e2bb5 100644 --- a/apps/admin/src/settings/components/confirmation-modal.tsx +++ b/apps/admin/src/settings/components/confirmation-modal.tsx @@ -13,6 +13,8 @@ import { } from '@tryghost/shade/components'; import { cn } from '@tryghost/shade/utils'; +export const STICKY_FOOTER_TESTID = 'modal-sticky-footer'; + export interface ConfirmationModalProps { title?: React.ReactNode; prompt?: React.ReactNode; @@ -25,6 +27,8 @@ export interface ConfirmationModalProps { customFooter?: React.ReactNode; formSheet?: boolean; stickyFooter?: boolean; + /** Widens the dialog past its default `max-w-lg` for content that needs it. */ + contentClassName?: string; testId?: string; } @@ -47,6 +51,7 @@ export const ConfirmationModalContent: React.FC { const [taskState, setTaskState] = useState<'running' | ''>(''); @@ -82,7 +87,7 @@ export const ConfirmationModalContent: React.FC + {cancelLabel && ( - {expanded && ( -
- -
- )} + + + + + + ); +} + +/** A bare error string from the API: same row as a problem, minus anything to expand. */ +function ValidationMessageRow({ errorLabel, message }: { errorLabel: string; message: string }) { + return ( +
+
+ {errorLabel} +
+

{message}

); } -export function ThemeValidationDetailsDisclosure({ - defaultOpen, +export const THEME_PROBLEM_LIST_TESTID = 'theme-problem-list'; + +/** + * `errorLabel` names an error-severity row, so a dialog showing two lists can + * say which one blocked it: `Blocking` vs. plain `Error`. + */ +export function ValidationProblemList({ + errorLabel = 'Error', + expandedByDefault = false, + messages = [], problems, }: { - defaultOpen: boolean; + errorLabel?: string; + expandedByDefault?: boolean; + messages?: string[]; problems: ThemeProblem[]; }) { - const [open, setOpen] = useState(defaultOpen); - const count = problems.length; - - useEffect(() => { - setOpen(defaultOpen); - }, [defaultOpen]); - - const sortedProblems = useMemo(() => { - return [...problems].sort((a, b) => { - const severityOrder: Record = { - Error: 0, - Warning: 1, - Recommendation: 2, - }; - return severityOrder[getDisplaySeverity(a)] - severityOrder[getDisplaySeverity(b)]; - }); - }, [problems]); - - if (!count) { + const sortedProblems = useMemo(() => sortBySeverity(problems), [problems]); + const values = useMemo(() => sortedProblems.map(problemValue), [sortedProblems]); + + if (!sortedProblems.length && !messages.length) { return null; } return ( -
- - {open && ( -
- {sortedProblems.map((problem) => ( - +
+ {messages.map((message) => ( + + ))} + {sortedProblems.length > 0 && ( + + {sortedProblems.map((problem, index) => ( + ))} -
+ )}
); } -export function ErrorTextCard({ message }: { message: string }) { - return ( -
-
- -

{message}

-
-
- ); -} +export function ThemeValidationIssueList({ problems }: { problems: ThemeProblem[] }) { + if (!problems.length) { + return null; + } -export function OutcomeBanner({ - children, - title, - variant, -}: { - children: React.ReactNode; - title: string; - variant: 'success' | 'destructive'; -}) { - const Icon = variant === 'success' ? LucideIcon.CheckCircle2 : LucideIcon.AlertTriangle; - const iconClassName = variant === 'success' ? 'text-state-success' : 'text-destructive'; + const counts = countBySeverity(problems); + // The heading reads severity, not count, so the icon has to match it: amber + // is only right when the set is warnings and recommendations alone. + const hasError = hasErrorProblem(problems); return ( - -
-
- -
-
-

- {title} -

-
{children}
-
+
+
+

+ + {formatIssueSummary(counts)} +

+ {hasError && ( +

+ Highly recommended to fix, functionality could be restricted +

+ )}
- + +
); } diff --git a/apps/admin/src/settings/site/theme/theme-validation-issues.test.ts b/apps/admin/src/settings/site/theme/theme-validation-issues.test.ts index 7721b2a1221..d98ca6c9d06 100644 --- a/apps/admin/src/settings/site/theme/theme-validation-issues.test.ts +++ b/apps/admin/src/settings/site/theme/theme-validation-issues.test.ts @@ -1,8 +1,12 @@ import { + describeThemeOutcome, fatalErrorsSchema, getIssuesFromFatalErrors, + getIssuesFromInstalledTheme, parseFatalErrors, + sortBySeverity, } from './theme-validation-issues'; +import { type InstalledTheme, type ThemeProblem } from '@tryghost/admin-x-framework/api/themes'; const problem = { code: 'GS030-NO-DEFAULT-TEMPLATE', @@ -13,6 +17,18 @@ const problem = { rule: 'A default template is required.', }; +/** A problem at an arbitrary severity, for the display-level helpers. */ +function themeProblem(level: string, code = 'GS000'): ThemeProblem { + return { + code, + level, + rule: 'rule', + details: 'details', + failures: [], + fatal: false, + } as unknown as ThemeProblem; +} + describe('fatalErrorsSchema', () => { it('parses string and structured validation details', () => { const result = fatalErrorsSchema.safeParse([ @@ -54,3 +70,52 @@ describe('fatalErrorsSchema', () => { expect(parseFatalErrors([{ details: ' ' }])).toBeNull(); }); }); + +describe('describeThemeOutcome', () => { + it('reports a clean install', () => { + expect(describeThemeOutcome('uploaded', [])).toBe('uploaded successfully'); + }); + + it('calls a warnings-only set warnings', () => { + expect( + describeThemeOutcome('uploaded', [themeProblem('warning'), themeProblem('recommendation')]), + ).toBe('uploaded, but it has some warnings'); + }); + + it('calls a set containing an error issues, so it cannot contradict the issue list', () => { + expect(describeThemeOutcome('uploaded', [themeProblem('error'), themeProblem('warning')])).toBe( + 'uploaded, but it has some issues', + ); + }); + + it("carries the caller's verb", () => { + expect(describeThemeOutcome('saved', [])).toBe('saved successfully'); + expect(describeThemeOutcome('installed', [themeProblem('warning')])).toBe( + 'installed, but it has some warnings', + ); + }); + + it("treats a theme's merged errors and warnings as one set", () => { + const theme = { + name: 'mytheme', + errors: [themeProblem('error')], + warnings: [themeProblem('warning')], + } as unknown as InstalledTheme; + + expect(describeThemeOutcome('uploaded', getIssuesFromInstalledTheme(theme))).toBe( + 'uploaded, but it has some issues', + ); + }); +}); + +describe('sortBySeverity', () => { + it('orders errors before warnings before recommendations', () => { + const sorted = sortBySeverity([ + themeProblem('recommendation', 'REC'), + themeProblem('warning', 'WARN'), + themeProblem('error', 'ERR'), + ]); + + expect(sorted.map((item) => item.code)).toEqual(['ERR', 'WARN', 'REC']); + }); +}); diff --git a/apps/admin/src/settings/site/theme/theme-validation-issues.ts b/apps/admin/src/settings/site/theme/theme-validation-issues.ts index f9a47a9ab80..09e55fedb60 100644 --- a/apps/admin/src/settings/site/theme/theme-validation-issues.ts +++ b/apps/admin/src/settings/site/theme/theme-validation-issues.ts @@ -84,3 +84,60 @@ export function parseFatalErrors(data: unknown): FatalErrors | null { export function getIssuesFromInstalledTheme(installedTheme: InstalledTheme): ThemeProblem[] { return [...(installedTheme.errors || []), ...(installedTheme.warnings || [])]; } + +export type DisplaySeverity = 'Error' | 'Warning' | 'Recommendation'; + +/** The Shade `Badge` variants a severity is allowed to take. */ +export type DisplayVariant = 'destructive' | 'warning' | 'secondary'; + +/** Most to least severe — drives both the list order and the summary order. */ +export const SEVERITY_ORDER: DisplaySeverity[] = ['Error', 'Warning', 'Recommendation']; + +/** Name and colour in one row per level, so a badge can never mix the two. */ +const SEVERITY_DISPLAY: Record< + ThemeProblem['level'], + { severity: DisplaySeverity; variant: DisplayVariant } +> = { + error: { severity: 'Error', variant: 'destructive' }, + warning: { severity: 'Warning', variant: 'warning' }, + recommendation: { severity: 'Recommendation', variant: 'secondary' }, +}; + +function displayFor(problem: ThemeProblem) { + return SEVERITY_DISPLAY[problem.level] ?? SEVERITY_DISPLAY.error; +} + +export function getDisplaySeverity(problem: ThemeProblem): DisplaySeverity { + return displayFor(problem).severity; +} + +export function getDisplayVariant(problem: ThemeProblem): DisplayVariant { + return displayFor(problem).variant; +} + +export function sortBySeverity(problems: ThemeProblem[]): ThemeProblem[] { + return [...problems].sort( + (a, b) => + SEVERITY_ORDER.indexOf(getDisplaySeverity(a)) - SEVERITY_ORDER.indexOf(getDisplaySeverity(b)), + ); +} + +export function hasErrorProblem(problems: ThemeProblem[]): boolean { + return problems.some((problem) => getDisplaySeverity(problem) === 'Error'); +} + +/** Past tense of what Ghost did, or refused to do, with a theme. */ +export type ThemeAction = 'uploaded' | 'installed' | 'activated' | 'saved'; + +/** + * Completes " was ...". `errors` and `warnings` arrive merged, so the + * noun reads the severities present rather than the count — a set containing + * errors called "warnings" contradicts the list rendered beside it. + */ +export function describeThemeOutcome(action: ThemeAction, problems: ThemeProblem[]): string { + if (!problems.length) { + return `${action} successfully`; + } + + return `${action}, but it has some ${hasErrorProblem(problems) ? 'issues' : 'warnings'}`; +} diff --git a/apps/admin/src/tags/detail/tag-detail.tsx b/apps/admin/src/tags/detail/tag-detail.tsx index 736866f2575..15d04e7ab06 100644 --- a/apps/admin/src/tags/detail/tag-detail.tsx +++ b/apps/admin/src/tags/detail/tag-detail.tsx @@ -34,7 +34,7 @@ import { validateTagDraft, } from './tag-detail-edit'; import { dequal } from 'dequal'; -import { getTagBySlug, useAddTag, useEditTag } from '@tryghost/admin-x-framework/api/tags'; +import { useTagBySlug, useAddTag, useEditTag } from '@tryghost/admin-x-framework/api/tags'; import { toast } from 'sonner'; import { useBrowseSite } from '@tryghost/admin-x-framework/api/site'; import { useUnsavedChangesGuard } from '@/hooks/use-unsaved-changes-guard'; @@ -58,7 +58,7 @@ const TagDetail: React.FC = () => { // `include=count.posts` mirrors the Ember route so the delete modal can // report how many posts the tag will be removed from. - const { data, isLoading, error, refetch } = getTagBySlug(tagSlug, { + const { data, isLoading, error, refetch } = useTagBySlug(tagSlug, { enabled: !!tagSlug && !isCreating, searchParams: { include: 'count.posts' }, defaultErrorHandler: false, diff --git a/apps/admin/test-utils/acceptance/render-admin-app.tsx b/apps/admin/test-utils/acceptance/render-admin-app.tsx index e156ba2265c..ec80dbe5e63 100644 --- a/apps/admin/test-utils/acceptance/render-admin-app.tsx +++ b/apps/admin/test-utils/acceptance/render-admin-app.tsx @@ -1,7 +1,7 @@ import { QueryClient } from '@tanstack/react-query'; import { render } from 'vitest-browser-react'; import { configResponse, settingsResponse } from '@tryghost/test-data'; -import type { TopLevelFrameworkProps } from '@tryghost/admin-x-framework'; +import { defaultUnsplashConfig, type TopLevelFrameworkProps } from '@tryghost/admin-x-framework'; import '@/index.css'; import { AdminAppRoot } from '@/app-root'; @@ -91,13 +91,8 @@ export async function renderAdminApp( externalNavigate: (link) => { document.body.dataset.externalNavigate = JSON.stringify(link); }, - unsplashConfig: { - Authorization: '', - 'Accept-Version': 'v1', - 'Content-Type': 'application/json', - 'App-Pragma': 'no-cache', - 'X-Unsplash-Cache': true, - }, + // Production shape, but without the real API key so tests never hit Unsplash + unsplashConfig: { ...defaultUnsplashConfig, Authorization: '' }, sentryDSN: null, onUpdate: () => {}, onInvalidate: () => {}, diff --git a/apps/ember-admin/app/adapters/offer.js b/apps/ember-admin/app/adapters/offer.js deleted file mode 100644 index 5fa0daf8920..00000000000 --- a/apps/ember-admin/app/adapters/offer.js +++ /dev/null @@ -1,21 +0,0 @@ -import ApplicationAdapter from 'ghost-admin/adapters/application'; - -export default class Offer extends ApplicationAdapter { - queryRecord(store, type, query) { - if (query && query.id) { - let {id} = query; - delete query.id; - let url = this.buildURL(type.modelName, id, query, 'findRecord'); - return this.ajax(url, 'GET', {data: query}); - } - - return super.queryRecord(...arguments); - } - - urlForDeleteRecord() { - let url = super.urlForDeleteRecord(...arguments); - let parsedUrl = new URL(url); - - return parsedUrl.toString(); - } -} diff --git a/apps/ember-admin/app/helpers/parse-history-event.js b/apps/ember-admin/app/helpers/parse-history-event.js deleted file mode 100644 index 3b8626c035e..00000000000 --- a/apps/ember-admin/app/helpers/parse-history-event.js +++ /dev/null @@ -1,206 +0,0 @@ -import Helper from '@ember/component/helper'; -import {prefixAssetUrl} from 'ghost-admin/utils/asset-base'; - -export default class ParseHistoryEvent extends Helper { - compute([ev]) { - const action = getAction(ev); - const actionIcon = getActionIcon(ev); - const contextResource = getContextResource(ev); - const linkTarget = getLinkTarget(ev); - - const actor = getActor(ev); - const actorLinkTarget = getActorLinkTarget(ev); - - const actorIcon = getActorIcon(ev); - - return { - contextResource, - linkTarget, - actionIcon, - action, - actor, - actorIcon, - actorLinkTarget, - original: ev, - isBulkAction: !!ev.context.count - }; - } -} - -function getActor(ev) { - if (!ev.actor.id) { - return null; - } - - return ev.actor; -} - -function getActorIcon(ev) { - if (!ev.actor.id || !ev.actor.image) { - // keep path separate so asset rewriting correctly picks it up - const defaultImage = '/img/user-image.png'; - return prefixAssetUrl(`assets${defaultImage}`); - } - - return ev.actor.image; -} - -function getActorLinkTarget(ev) { - const actor = getActor(ev); - if (!actor) { - return null; - } - - switch (ev.actor_type) { - case 'integration': - if (!actor.id) { - return null; - } - - return { - route: 'settings.integration', - models: [actor.id] - }; - case 'user': - if (!actor.slug) { - return null; - } - - return { - route: 'settings.staff.user', - models: [actor.slug] - }; - } - - return null; -} - -function getLinkTarget(ev) { - let resourceType = ev.resource_type; - - if (ev.event !== 'deleted') { - switch (ev.resource_type) { - case 'page': - case 'post': - if (!ev.resource || !ev.resource.id) { - return null; - } - - if (resourceType === 'post') { - if (ev.context?.type) { - resourceType = ev.context?.type; - } - } - - return { - route: 'lexical-editor.edit', - models: [resourceType, ev.resource.id] - }; - case 'integration': - if (!ev.resource || !ev.resource.id) { - return null; - } - - return { - route: 'settings.integration', - models: [ev.resource.id] - }; - case 'offer': - if (!ev.resource || !ev.resource.id) { - return null; - } - - return { - route: 'offer', - models: [ev.resource.id] - }; - case 'tag': - if (!ev.resource || !ev.resource.slug) { - return null; - } - - return { - route: 'tag', - models: [ev.resource.slug] - }; - case 'product': - return { - route: 'settings.membership', - models: null - }; - case 'user': - if (!ev.resource || !ev.resource.slug) { - return null; - } - - return { - route: 'settings.staff.user', - models: [ev.resource.slug] - }; - } - } - - return null; -} - -function getActionIcon(ev) { - switch (ev.event) { - case 'added': - return 'plus-large'; - case 'edited': - return 'pen'; - case 'deleted': - return 'trash'; - } - - return 'info'; -} - -function getAction(ev) { - let resourceType = ev.resource_type; - - if (resourceType === 'api_key') { - resourceType = 'API key'; - } else if (resourceType === 'setting') { - resourceType = 'settings'; - } else if (resourceType === 'product') { - resourceType = 'tier'; - } else if (resourceType === 'gift_link') { - resourceType = 'gift link'; - } - - // Because a `page` and `post` both use the same model, we store the - // actual type in the context, so let's check if that exists - if (resourceType === 'post') { - if (ev.context?.type) { - resourceType = ev.context?.type; - } - } - - let action = ev.event; - - if (ev.event === 'edited') { - if (ev.context.action_name) { - action = ev.context.action_name; - } - } - - if (ev.context.count && ev.context.count > 1) { - return `${ev.context.count} ${resourceType}s ${action}`; - } - - return `${resourceType} ${action}`; -} - -function getContextResource(ev) { - if (ev.resource_type === 'setting') { - if (ev.context?.group && ev.context?.key) { - return { - first: ev.context.group, - second: ev.context.key - }; - } - } - - return null; -} diff --git a/apps/ember-admin/app/mixins/validation-engine.js b/apps/ember-admin/app/mixins/validation-engine.js index e27a73073bd..1ba33f81362 100644 --- a/apps/ember-admin/app/mixins/validation-engine.js +++ b/apps/ember-admin/app/mixins/validation-engine.js @@ -9,7 +9,6 @@ import Mixin from '@ember/object/mixin'; import Model from '@ember-data/model'; import NavItemValidator from 'ghost-admin/validators/nav-item'; import NewsletterValidator from 'ghost-admin/validators/newsletter'; -import OfferValidator from 'ghost-admin/validators/offer'; import PostValidator from 'ghost-admin/validators/post'; import RSVP from 'rsvp'; import ResetValidator from 'ghost-admin/validators/reset'; @@ -73,7 +72,6 @@ export default Mixin.create({ label: LabelValidator, snippet: SnippetValidator, tier: TierValidator, - offer: OfferValidator, newsletter: NewsletterValidator }; }, diff --git a/apps/ember-admin/app/models/offer.js b/apps/ember-admin/app/models/offer.js index 90ab91df977..94f9872bd85 100644 --- a/apps/ember-admin/app/models/offer.js +++ b/apps/ember-admin/app/models/offer.js @@ -1,9 +1,6 @@ import Model, {attr} from '@ember-data/model'; -import ValidationEngine from 'ghost-admin/mixins/validation-engine'; - -export default Model.extend(ValidationEngine, { - validationType: 'offer', +export default Model.extend({ name: attr('string'), code: attr('string'), cadence: attr('string'), diff --git a/apps/ember-admin/app/routes/application.js b/apps/ember-admin/app/routes/application.js index b57873d0e0f..68d59a2de1a 100644 --- a/apps/ember-admin/app/routes/application.js +++ b/apps/ember-admin/app/routes/application.js @@ -68,7 +68,10 @@ function setupAutomationsSessionReplay(replay, shouldStartRecording) { recordingStarted = true; clearTimeout(initialRouteCheck); - replay.stop().then(() => replay.start()).catch((error) => { + replay.stop().then(() => { + replay.start(); + Sentry.setTag('replay_area', 'automations'); + }).catch((error) => { try { replay.startBuffering(); } catch (e) { diff --git a/apps/ember-admin/app/services/state-bridge.js b/apps/ember-admin/app/services/state-bridge.js index 3f4eff148bf..c7c8e94749c 100644 --- a/apps/ember-admin/app/services/state-bridge.js +++ b/apps/ember-admin/app/services/state-bridge.js @@ -6,7 +6,6 @@ import {inject} from 'ghost-admin/decorators/inject'; import {run} from '@ember/runloop'; const emberDataTypeMapping = { - AutomatedEmailDesignResponseType: null, // automated email design settings only exist in React admin AutomatedEmailsResponseType: null, // automated emails only exist in React admin AutomationsResponseType: null, // automations only exist in React admin CommentsResponseType: null, // comments only exist in React admin @@ -24,7 +23,7 @@ const emberDataTypeMapping = { ThemesResponseType: {type: 'theme'}, TiersResponseType: {type: 'tier'}, UsersResponseType: {type: 'user'}, - CustomThemeSettingsResponseType: null // custom theme settings no longer exist in Admin + CustomThemeSettingsResponseType: null // invalidated by React theme activation; nothing to sync in Ember }; export default class StateBridgeService extends Service.extend(Evented) { @@ -81,23 +80,22 @@ export default class StateBridgeService extends Service.extend(Evented) { /* React -> Ember ------------------------------------------------------- - The React admin shell app or a component that extends AdminXComponent will - call these methods any time they update their own data. + The React admin shell calls these methods any time it updates its own + data. These methods take the React data and push it into the Ember store to trigger reactivity, then trigger any other side effects needed to keep non-derived state in sync. + Data types without a model mapping — explicitly null or absent entirely — + are React-only and no-op; emberDataTypeMapping lists the Ember-relevant + types. + */ @action onUpdate(dataType, response) { - if (!(dataType in emberDataTypeMapping)) { - throw new Error(`A mutation updating ${dataType} succeeded in React Admin but there is no mapping to an Ember type. Add one to emberDataTypeMapping`); - } - - // Skip processing if mapping is explicitly set to null - if (emberDataTypeMapping[dataType] === null) { + if (!emberDataTypeMapping[dataType]) { return; } @@ -165,12 +163,7 @@ export default class StateBridgeService extends Service.extend(Evented) { @action onInvalidate(dataType) { - if (!(dataType in emberDataTypeMapping)) { - throw new Error(`A mutation invalidating ${dataType} succeeded in React Admin but there is no mapping to an Ember type. Add one to emberDataTypeMapping`); - } - - // Skip processing if mapping is explicitly set to null - if (emberDataTypeMapping[dataType] === null) { + if (!emberDataTypeMapping[dataType]) { return; } @@ -197,12 +190,7 @@ export default class StateBridgeService extends Service.extend(Evented) { @action onDelete(dataType, id) { - if (!(dataType in emberDataTypeMapping)) { - throw new Error(`A mutation deleting ${dataType} succeeded in React Admin but there is no mapping to an Ember type. Add one to emberDataTypeMapping`); - } - - // Skip processing if mapping is explicitly set to null - if (emberDataTypeMapping[dataType] === null) { + if (!emberDataTypeMapping[dataType]) { return; } @@ -264,7 +252,7 @@ export default class StateBridgeService extends Service.extend(Evented) { // The gift-link modal lives in React. Ember surfaces (the posts/pages // context menu) ask React to open it for a given post/page rather than - // duplicating the modal — see useOpenGiftLinkModal on the React side. + // duplicating the modal — see subscribeOpenGiftLinkModal on the React side. @action triggerOpenGiftLinkModal({id, resource}) { this.trigger('openGiftLinkModal', {id, resource}); diff --git a/apps/ember-admin/app/styles/layouts/members.css b/apps/ember-admin/app/styles/layouts/members.css index e8fb9d615e8..b6e6cd3ad4e 100644 --- a/apps/ember-admin/app/styles/layouts/members.css +++ b/apps/ember-admin/app/styles/layouts/members.css @@ -1891,17 +1891,6 @@ a.gh-members-emailpreview-subscription-link { color: var(--darkgrey); } -.gh-membertier-offer-cancellation, -.gh-membertier-offers { - margin-top: 16px; - padding-top: 16px; - border-top:1px solid #ECEEF0; -} - -.gh-membertier-offer-cancellation div + div { - margin-top: 16px; -} - .gh-cp-membertier-attribution .gh-cp-membertier-renewal { color: var(--middarkgrey); font-size: 1.3rem; diff --git a/apps/ember-admin/app/validators/offer.js b/apps/ember-admin/app/validators/offer.js deleted file mode 100644 index b6df2695f50..00000000000 --- a/apps/ember-admin/app/validators/offer.js +++ /dev/null @@ -1,77 +0,0 @@ -import BaseValidator from './base'; -import validator from 'validator'; - -export default BaseValidator.create({ - properties: ['name', 'amount', 'displayTitle', 'displayDescription', 'code', 'durationInMonths'], - - name(model) { - if (!model.name) { - model.errors.add('name', 'Please enter a name.'); - this.invalidate(); - } - if (!validator.isLength(model.name || '', {max: 40})) { - model.errors.add('name', 'Name cannot be longer than 40 characters.'); - this.invalidate(); - } - }, - - amount(model) { - if (model.amount === '' || model.amount === undefined) { - model.errors.add('amount', 'Please enter the amount.'); - - return this.invalidate(); - } - - if (model.type === 'trial') { - if (model.amount < 1) { - model.errors.add('amount', 'Free trial must be at least 1 day.'); - return this.invalidate(); - } - - if (!model.amount.toString().match(/^\d+$/)) { - model.errors.add('amount', 'Trial days must be a whole number.'); - return this.invalidate(); - } - } - - if (model.type === 'percent') { - if (model.amount < 0 || model.amount > 100) { - model.errors.add('amount', 'Amount must be between 0 and 100%.'); - return this.invalidate(); - } - - if (!model.amount.toString().match(/^\d+$/)) { - model.errors.add('amount', 'Amount must be a whole number.'); - return this.invalidate(); - } - } - - if (model.type === 'fixed') { - if (model.amount < 0) { - model.errors.add('amount', 'Amount must be greater than 0.'); - return this.invalidate(); - } - } - }, - - displayDescription(model) { - if (!validator.isLength(model.displayDescription || '', {max: 191})) { - model.errors.add('displayDescription', 'Display description cannot be longer than 191 characters.'); - this.invalidate(); - } - }, - - durationInMonths(model) { - if (model.duration === 'repeating' && !model.durationInMonths) { - model.errors.add('durationInMonths', 'Please enter the duration in months.'); - this.invalidate(); - } - }, - - code(model) { - if (!model.code) { - model.errors.add('code', 'Please enter an offer code.'); - this.invalidate(); - } - } -}); diff --git a/apps/ember-admin/mirage/config/offers.js b/apps/ember-admin/mirage/config/offers.js index b84ae463855..01f652b5205 100644 --- a/apps/ember-admin/mirage/config/offers.js +++ b/apps/ember-admin/mirage/config/offers.js @@ -1,21 +1,5 @@ import {paginatedResponse} from '../utils'; export default function mockOffers(server) { - server.post('/offers/'); - server.get('/offers/', paginatedResponse('offers')); - - server.get('/offers/:id/', function ({offers}, {params}) { - let {id} = params; - let tier = offers.find(id); - - return tier || new Response(404, {}, { - errors: [{ - type: 'NotFoundError', - message: 'Offer not found.' - }] - }); - }); - - server.put('/offers/:id/'); } diff --git a/apps/ember-admin/mirage/factories/offer.js b/apps/ember-admin/mirage/factories/offer.js deleted file mode 100644 index acf194b2448..00000000000 --- a/apps/ember-admin/mirage/factories/offer.js +++ /dev/null @@ -1,20 +0,0 @@ -import moment from 'moment-timezone'; -import {Factory} from 'miragejs'; -import {faker} from '@faker-js/faker'; - -let randomDate = function randomDate(start = moment().subtract(30, 'days').toDate(), end = new Date()) { - return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime())); -}; - -export default Factory.extend({ - name() { return `${faker.person.firstName()} ${faker.person.lastName()}`; }, - amount() { return faker.number.int({min: 1, max: 10}); }, - displayTitle() { return faker.lorem.word(); }, - code() { return faker.lorem.slug(); }, - createdAt() { return randomDate(); }, - tier() { - return { - id: '1' - }; - } -}); diff --git a/apps/ember-admin/public/assets/icons/discount-bubble.svg b/apps/ember-admin/public/assets/icons/discount-bubble.svg deleted file mode 100644 index 48ad661296c..00000000000 --- a/apps/ember-admin/public/assets/icons/discount-bubble.svg +++ /dev/null @@ -1,11 +0,0 @@ - - discount-bubble - - - - - - - \ No newline at end of file diff --git a/apps/ember-admin/public/assets/icons/offer.svg b/apps/ember-admin/public/assets/icons/offer.svg deleted file mode 100644 index 97df42714ae..00000000000 --- a/apps/ember-admin/public/assets/icons/offer.svg +++ /dev/null @@ -1,10 +0,0 @@ - - offer - - - - - - \ No newline at end of file diff --git a/apps/ember-admin/public/assets/img/marketing/offers-1.jpg b/apps/ember-admin/public/assets/img/marketing/offers-1.jpg deleted file mode 100644 index 0eb36bc22b1..00000000000 Binary files a/apps/ember-admin/public/assets/img/marketing/offers-1.jpg and /dev/null differ diff --git a/apps/ember-admin/public/assets/img/marketing/offers-2.jpg b/apps/ember-admin/public/assets/img/marketing/offers-2.jpg deleted file mode 100644 index 0f00af91c07..00000000000 Binary files a/apps/ember-admin/public/assets/img/marketing/offers-2.jpg and /dev/null differ diff --git a/apps/ember-admin/public/assets/img/marketing/offers-3.jpg b/apps/ember-admin/public/assets/img/marketing/offers-3.jpg deleted file mode 100644 index 827771c88f1..00000000000 Binary files a/apps/ember-admin/public/assets/img/marketing/offers-3.jpg and /dev/null differ diff --git a/apps/ember-admin/tests/unit/services/state-bridge-test.js b/apps/ember-admin/tests/unit/services/state-bridge-test.js index 1e2502f219e..a74065d8ba0 100644 --- a/apps/ember-admin/tests/unit/services/state-bridge-test.js +++ b/apps/ember-admin/tests/unit/services/state-bridge-test.js @@ -74,27 +74,20 @@ describe('Unit: Service: state-bridge', function () { }); describe('#onUpdate', function () { - it('throws error for unknown data type', function () { - expect(() => { - service.onUpdate('UnknownType', {}); - }).to.throw('A mutation updating UnknownType succeeded in React Admin but there is no mapping to an Ember type'); - }); - - it('skips processing for null-mapped data types', function () { - const response = {customThemeSettings: [{key: 'test', value: 'value'}]}; - + it('ignores unknown data types', function () { run(() => { - service.onUpdate('CustomThemeSettingsResponseType', response); + service.onUpdate('UnknownType', {}); }); expect(store.pushPayload.called).to.be.false; + expect(store.push.called).to.be.false; }); - it('skips processing for automated email design data type', function () { - const response = {automated_email_design: [{id: '1'}]}; + it('skips processing for null-mapped data types', function () { + const response = {customThemeSettings: [{key: 'test', value: 'value'}]}; run(() => { - service.onUpdate('AutomatedEmailDesignResponseType', response); + service.onUpdate('CustomThemeSettingsResponseType', response); }); expect(store.pushPayload.called).to.be.false; @@ -276,23 +269,17 @@ describe('Unit: Service: state-bridge', function () { }); describe('#onInvalidate', function () { - it('throws error for unknown data type', function () { - expect(() => { - service.onInvalidate('UnknownType'); - }).to.throw('A mutation invalidating UnknownType succeeded in React Admin but there is no mapping to an Ember type'); - }); - - it('skips processing for null-mapped data types', function () { + it('ignores unknown data types', function () { run(() => { - service.onInvalidate('CustomThemeSettingsResponseType'); + service.onInvalidate('UnknownType'); }); expect(store.unloadAll.called).to.be.false; }); - it('skips processing for automated email design data type', function () { + it('skips processing for null-mapped data types', function () { run(() => { - service.onInvalidate('AutomatedEmailDesignResponseType'); + service.onInvalidate('CustomThemeSettingsResponseType'); }); expect(store.unloadAll.called).to.be.false; @@ -340,10 +327,14 @@ describe('Unit: Service: state-bridge', function () { }); describe('#onDelete', function () { - it('throws error for unknown data type', function () { - expect(() => { + it('ignores unknown data types', function () { + sinon.spy(store, 'peekRecord'); + + run(() => { service.onDelete('UnknownType', '123'); - }).to.throw('A mutation deleting UnknownType succeeded in React Admin but there is no mapping to an Ember type'); + }); + + expect(store.peekRecord.called).to.be.false; }); it('skips processing for null-mapped data types', function () { diff --git a/apps/portal/package.json b/apps/portal/package.json index 9ad84ac78fd..ac75d6e7fda 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/portal", - "version": "2.70.0", + "version": "2.71.0", "description": "Drop-in membership interface for Ghost sites", "license": "MIT", "author": "Ghost Foundation", @@ -48,6 +48,7 @@ "eslint": "catalog:", "jsdom": "catalog:", "react": "catalog:react17", + "react-day-picker": "catalog:", "react-dom": "catalog:react17", "typescript": "catalog:", "vite": "catalog:", diff --git a/apps/portal/src/actions.js b/apps/portal/src/actions.js index 4f05608be9c..e89d87f6c1e 100644 --- a/apps/portal/src/actions.js +++ b/apps/portal/src/actions.js @@ -431,6 +431,7 @@ async function checkoutGift({ data, state, api }) { recipientName, buyerName, personalMessage, + deliveryDate, } = data; await api.member.checkoutGift({ tierId, @@ -441,6 +442,7 @@ async function checkoutGift({ data, state, api }) { ...(recipientName ? { recipientName } : {}), ...(buyerName ? { buyerName } : {}), ...(personalMessage ? { personalMessage } : {}), + ...(deliveryDate ? { deliveryDate } : {}), }); return { action: 'checkoutGift:success', diff --git a/apps/portal/src/app.jsx b/apps/portal/src/app.jsx index 992f95a4501..144559ea8bd 100644 --- a/apps/portal/src/app.jsx +++ b/apps/portal/src/app.jsx @@ -639,6 +639,15 @@ export default class App extends React.Component { const cadence = qParams.get('gift_cadence'); const duration = Number(qParams.get('gift_duration')); const deliveryMethod = qParams.get('gift_delivery'); + const deliveryDateParam = qParams.get('gift_delivery_date'); + const deliveryDate = /^\d{4}-\d{2}-\d{2}$/.test(deliveryDateParam || '') + ? deliveryDateParam + : null; + // Exact send instant in epoch ms; a delivery date without it means + // the send already happened. + const scheduledAtParam = Number(qParams.get('gift_scheduled_at')); + const scheduledAt = + Number.isFinite(scheduledAtParam) && scheduledAtParam > 0 ? scheduledAtParam : null; clearURLParams([ 'stripe', 'gift_token', @@ -646,6 +655,8 @@ export default class App extends React.Component { 'gift_cadence', 'gift_duration', 'gift_delivery', + 'gift_delivery_date', + 'gift_scheduled_at', ]); if (token) { return { @@ -657,6 +668,8 @@ export default class App extends React.Component { cadence, duration: GIFT_DURATION_CATALOGUE.includes(duration) ? duration : null, deliveryMethod: deliveryMethod === 'email' ? 'email' : 'link', + deliveryDate, + scheduledAt, }, }; } diff --git a/apps/portal/src/components/common/date-picker.jsx b/apps/portal/src/components/common/date-picker.jsx new file mode 100644 index 00000000000..7306b8fead2 --- /dev/null +++ b/apps/portal/src/components/common/date-picker.jsx @@ -0,0 +1,498 @@ +import AppContext from '../../app-context'; +import CalendarIcon from '../../images/icons/calendar.svg?react'; +import { DayPicker } from 'react-day-picker'; +import { createPortal } from 'react-dom'; +import { + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { parseDateValue, toDateValue } from '../../utils/date-time'; + +export const DatePickerStyles = ` + .gh-portal-datepicker { + position: relative; + } + + .gh-portal-datepicker-field { + position: relative; + } + + .gh-portal-datepicker-field .gh-portal-input { + width: 100%; + margin-bottom: 0; + box-sizing: border-box; + } + + /* Focusing the field reveals the real segments so the date stays typable. */ + .gh-portal-datepicker-field .gh-portal-input.has-min-label:not(:focus), + .gh-portal-datepicker-field .gh-portal-input.has-min-label:not(:focus)::-webkit-datetime-edit { + color: transparent; + } + + .gh-portal-datepicker-min-label { + position: absolute; + top: 50%; + inset-inline-start: 13px; + transform: translateY(-50%); + font-size: 1.5rem; + pointer-events: none; + } + + .gh-portal-datepicker-field .gh-portal-input:focus ~ .gh-portal-datepicker-min-label { + opacity: 0; + } + + /* Hidden unless the browser gives us a handle on its own picker button — + see the @supports below. Without that we can't suppress the native + calendar, and two of them in one field is worse than the stock one. */ + .gh-portal-datepicker-toggle { + display: none; + } + + @supports selector(::-webkit-calendar-picker-indicator) { + .gh-portal-datepicker-field .gh-portal-input::-webkit-calendar-picker-indicator { + display: none; + } + + .gh-portal-datepicker-toggle { + display: flex; + align-items: center; + justify-content: center; + position: absolute; + top: 50%; + inset-inline-end: 12px; + width: 18px; + height: 18px; + padding: 0; + transform: translateY(-50%); + background: none; + border: 0; + color: var(--grey7); + cursor: pointer; + } + + .gh-portal-datepicker-toggle svg { + width: 18px; + height: 18px; + } + } + + /* Portalled outside the animated, overflow-hidden gift reveal to avoid + clipping and layout shifts; placed from the field's measured rect. */ + .gh-portal-datepicker-popover { + position: absolute; + z-index: 100; + padding: 10px; + background: var(--white); + border: 1px solid var(--grey90); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + } + + .gh-portal-datepicker-months { + position: relative; + } + + .gh-portal-datepicker-month-caption { + display: flex; + align-items: center; + justify-content: center; + height: 28px; + margin-bottom: 2px; + font-size: 1.4rem; + font-weight: 600; + color: var(--grey0); + } + + .gh-portal-datepicker-nav { + position: absolute; + top: 0; + inset-inline: 0; + display: flex; + justify-content: space-between; + pointer-events: none; + } + + .gh-portal-datepicker-nav button { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + background: none; + border: 0; + border-radius: 6px; + color: var(--grey3); + cursor: pointer; + pointer-events: auto; + } + + .gh-portal-datepicker-nav button:hover:not([aria-disabled='true']) { + background: var(--grey96); + color: var(--grey0); + } + + .gh-portal-datepicker-nav button[aria-disabled='true'] { + opacity: 0.3; + cursor: default; + } + + .gh-portal-datepicker-nav svg { + width: 16px; + height: 16px; + fill: currentColor; + } + + /* RTL gets the chevrons mirrored, not just repositioned — react-day-picker + swaps which button means previous, but the glyph itself still points the + way it was drawn. */ + html[dir="rtl"] .gh-portal-datepicker-nav svg { + transform: scale(-1, 1); + } + + .gh-portal-datepicker-grid { + border-collapse: collapse; + } + + .gh-portal-datepicker-weekday { + width: 34px; + padding-bottom: 2px; + font-size: 1.1rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.3px; + color: var(--grey7); + } + + .gh-portal-datepicker-day { + padding: 0; + } + + .gh-portal-datepicker-day-button { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 34px; + height: 30px; + padding: 0; + background: none; + border: 0; + border-radius: 6px; + font-size: 1.35rem; + color: var(--grey0); + cursor: pointer; + } + + .gh-portal-datepicker-day-button:hover:not(:disabled) { + background: var(--grey96); + } + + .gh-portal-datepicker-today .gh-portal-datepicker-day-button::after { + content: ''; + position: absolute; + bottom: 3px; + left: 50%; + width: 3px; + height: 3px; + transform: translateX(-50%); + border-radius: 50%; + background: var(--brandcolor); + } + + .gh-portal-datepicker-selected .gh-portal-datepicker-day-button::after { + background: var(--white); + } + + .gh-portal-datepicker-selected .gh-portal-datepicker-day-button { + background: var(--brandcolor); + color: var(--white); + } + + /* Dim the brand fill on hover to preserve the selected day's white text. */ + .gh-portal-datepicker-selected .gh-portal-datepicker-day-button:hover:not(:disabled) { + background: var(--brandcolor); + opacity: 0.92; + } + + .gh-portal-datepicker-disabled .gh-portal-datepicker-day-button, + .gh-portal-datepicker-outside .gh-portal-datepicker-day-button { + color: var(--grey8); + cursor: default; + } + + .gh-portal-datepicker-disabled .gh-portal-datepicker-day-button:hover { + background: none; + } +`; + +// Map only the classes Portal styles so react-day-picker's stylesheet doesn't +// have to be shipped. +const classNames = { + months: 'gh-portal-datepicker-months', + month_caption: 'gh-portal-datepicker-month-caption', + nav: 'gh-portal-datepicker-nav', + month_grid: 'gh-portal-datepicker-grid', + weekday: 'gh-portal-datepicker-weekday', + day: 'gh-portal-datepicker-day', + day_button: 'gh-portal-datepicker-day-button', + today: 'gh-portal-datepicker-today', + selected: 'gh-portal-datepicker-selected', + disabled: 'gh-portal-datepicker-disabled', + outside: 'gh-portal-datepicker-outside', +}; + +const POPOVER_GAP = 6; + +// Render outside the clipped gift reveal, falling back to the owning +// document's body. +function getPopoverHost(node) { + return node?.closest('.gh-portal-popup-container') || node?.ownerDocument?.body || null; +} + +// The nearest scrolling ancestor is the real viewport: Portal's popup is a +// scrolling box inside a full-height iframe. +function getVisibleBox(node) { + const view = node?.ownerDocument?.defaultView; + for (let el = node?.parentElement; el && view; el = el.parentElement) { + const { overflowY } = view.getComputedStyle(el); + if (overflowY === 'auto' || overflowY === 'scroll') { + return el.getBoundingClientRect(); + } + } + const height = view?.innerHeight || node?.ownerDocument?.documentElement?.clientHeight || 0; + return height ? { top: 0, bottom: height } : null; +} + +// Intl reports the week start as 1–7 (Monday–Sunday); react-day-picker counts +// 0–6 from Sunday. Older browsers expose weekInfo as a property instead of a +// method, or not at all — fall back to Sunday. +function getWeekStart(locale) { + try { + const info = new Intl.Locale(locale).getWeekInfo?.() ?? new Intl.Locale(locale).weekInfo; + const firstDay = info?.firstDay; + return firstDay ? firstDay % 7 : 0; + } catch (e) { + return 0; + } +} + +/** + * A native date input with a custom calendar popover. Values are `YYYY-MM-DD` + * strings in and out, matching an ``. + */ +const DatePicker = ({ + id, + value, + onChange, + min, + max, + hasError = false, + // Shown in place of the date while the value sits on `min` — the gift flow + // reads today as "Now" rather than a date. + minLabel = null, + ariaLabel, +}) => { + const { locale: siteLocale = 'en', dir = 'ltr' } = useContext(AppContext); + const [isOpen, setIsOpen] = useState(false); + const [popoverStyle, setPopoverStyle] = useState(null); + const containerRef = useRef(null); + const fieldRef = useRef(null); + const toggleRef = useRef(null); + const popoverRef = useRef(null); + + const selected = parseDateValue(value); + const minDate = parseDateValue(min); + const maxDate = parseDateValue(max); + + // The publication locale is stored unvalidated ('en_US' passes the + // settings check but Intl rejects it), so fall back to English rather + // than crash the field mid-render. + const locale = useMemo(() => { + try { + new Intl.DateTimeFormat(siteLocale); + return siteLocale; + } catch (e) { + return 'en'; + } + }, [siteLocale]); + + // Rebuilt only when the locale changes: constructing a DateTimeFormat is + // the expensive part, and these run for every cell on every render. + const formats = useMemo( + () => ({ + monthCaption: new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }), + weekday: new Intl.DateTimeFormat(locale, { weekday: 'short' }), + day: new Intl.DateTimeFormat(locale, { day: 'numeric' }), + }), + [locale], + ); + + const weekStartsOn = useMemo(() => getWeekStart(locale), [locale]); + + // Measures after render in the portal host's coordinate space — the + // calendar's height varies by month. + const position = useCallback(() => { + const field = fieldRef.current; + const target = getPopoverHost(field); + const popover = popoverRef.current; + if (!field || !target || !popover) { + return; + } + const rect = field.getBoundingClientRect(); + const host = target.getBoundingClientRect(); + const visible = getVisibleBox(field); + const height = popover.offsetHeight; + + const spaceBelow = visible ? visible.bottom - rect.bottom : Infinity; + const spaceAbove = visible ? rect.top - visible.top : 0; + const flip = spaceBelow < height + POPOVER_GAP && spaceAbove > spaceBelow; + + setPopoverStyle({ + top: flip ? rect.top - host.top - height - POPOVER_GAP : rect.bottom - host.top + POPOVER_GAP, + right: host.right - rect.right, + }); + }, []); + + // Layout effect, not a plain one: the popover is measured and placed before + // the browser paints, so it never shows up in the wrong spot first. + useLayoutEffect(() => { + if (isOpen) { + position(); + } + }, [isOpen, position]); + + // Portal renders inside an iframe, so the global `document` here is the + // parent page's — listeners have to go on the element's own document or + // they never fire. Scroll is captured because it's the popup + // wrapper that scrolls, not the document. + useEffect(() => { + if (!isOpen) { + return; + } + const node = containerRef.current; + const doc = node?.ownerDocument; + if (!doc) { + return; + } + const onPointerDown = (event) => { + if ( + !node.contains(event.target) && + !event.target.closest?.('.gh-portal-datepicker-popover') + ) { + setIsOpen(false); + } + }; + const onKeyDown = (event) => { + if (event.key === 'Escape') { + setIsOpen(false); + toggleRef.current?.focus(); + } + }; + doc.addEventListener('pointerdown', onPointerDown); + doc.addEventListener('keydown', onKeyDown); + doc.addEventListener('scroll', position, true); + doc.defaultView?.addEventListener('resize', position); + return () => { + doc.removeEventListener('pointerdown', onPointerDown); + doc.removeEventListener('keydown', onKeyDown); + doc.removeEventListener('scroll', position, true); + doc.defaultView?.removeEventListener('resize', position); + }; + }, [isOpen, position]); + + const toggle = () => setIsOpen((currentlyOpen) => !currentlyOpen); + + const handleSelect = (date) => { + if (!date) { + return; + } + onChange(toDateValue(date)); + setIsOpen(false); + toggleRef.current?.focus(); + }; + + const showMinLabel = !!minLabel && !!value && value === min; + + return ( +
+
+ {/* Keeps a native date input for locale-aware keyboard + editing; only the browser's calendar is replaced. */} + !event.target.value && onChange(min)} + onChange={(event) => onChange(event.target.value)} + /> + {showMinLabel && ( + + )} + +
+ {isOpen && + createPortal( +
+ formats.monthCaption.format(date), + formatWeekdayName: (date) => formats.weekday.format(date), + formatDay: (date) => formats.day.format(date), + }} + mode="single" + selected={selected} + startMonth={minDate} + weekStartsOn={weekStartsOn} + onMonthChange={position} + onSelect={handleSelect} + /> +
, + getPopoverHost(fieldRef.current), + )} +
+ ); +}; + +export default DatePicker; diff --git a/apps/portal/src/components/common/gift-email-preview.jsx b/apps/portal/src/components/common/gift-email-preview.jsx index 2e4acc5fc9c..f67917ab780 100644 --- a/apps/portal/src/components/common/gift-email-preview.jsx +++ b/apps/portal/src/components/common/gift-email-preview.jsx @@ -1,7 +1,8 @@ +import { useEffect, useState } from 'react'; import Interpolate from '@doist/react-interpolate'; import CheckmarkIcon from '../../images/icons/checkmark.svg?react'; import QuoteIcon from '../../images/icons/quote.svg?react'; -import { getDateString } from '../../utils/date-time'; +import { getDateString, parseDateValue } from '../../utils/date-time'; import { getGiftIntroduction } from '../../utils/gift-redemption-notification'; import { t } from '../../utils/i18n'; @@ -16,6 +17,10 @@ const GiftEmailPreview = ({ recipientEmail, buyerName, giftMessage, + // The effective delivery date, always set — isScheduled says whether it + // is a future pick or just "today". + deliveryDate, + isScheduled = false, cadence, duration, tierName, @@ -29,6 +34,17 @@ const GiftEmailPreview = ({ const message = giftMessage.trim(); const todayDate = getDateString(new Date()); + const scheduledDate = getDateString(parseDateValue(deliveryDate)) || todayDate; + // The last future date is kept so the scheduled label stays legible while + // its side of the date stack cross-fades out after switching back to + // "now" — otherwise it would hard-swap to today's date mid-fade. + const [lastScheduledDate, setLastScheduledDate] = useState(''); + useEffect(() => { + if (isScheduled) { + setLastScheduledDate(scheduledDate); + } + }, [isScheduled, scheduledDate]); + const scheduledLabel = isScheduled ? scheduledDate : lastScheduledDate || todayDate; // "Name
" is how a mail client identifies a recipient; fall back to // whichever half the buyer has filled in so far. @@ -75,7 +91,22 @@ const GiftEmailPreview = ({
-
{todayDate}
+
+
+ {todayDate} +
+
+ {scheduledLabel} +
+
diff --git a/apps/portal/src/components/frame.styles.js b/apps/portal/src/components/frame.styles.js index 1ffffc3ac13..f393edda0c2 100644 --- a/apps/portal/src/components/frame.styles.js +++ b/apps/portal/src/components/frame.styles.js @@ -10,6 +10,7 @@ import { SwitchStyles } from './common/switch'; import AccountHomePageStyles from './pages/AccountHomePage/account-home-page.css?inline'; import { AccountPlanPageStyles } from './pages/account-plan-page'; import { InputFieldStyles } from './common/input-field'; +import { DatePickerStyles } from './common/date-picker'; import { SignupPageStyles } from './pages/signup-page'; import { ProductsSectionStyles } from './common/products-section'; import { AvatarStyles } from './common/member-gravatar'; @@ -1357,6 +1358,7 @@ export function getFrameStyles({ site }) { AccountHomePageStyles + AccountPlanPageStyles + InputFieldStyles + + DatePickerStyles + ProductsSectionStyles({ site }) + SwitchStyles + ActionButtonStyles + diff --git a/apps/portal/src/components/pages/beta-gift-page.jsx b/apps/portal/src/components/pages/beta-gift-page.jsx index 77676751977..3bad3e92fd8 100644 --- a/apps/portal/src/components/pages/beta-gift-page.jsx +++ b/apps/portal/src/components/pages/beta-gift-page.jsx @@ -1,6 +1,7 @@ import { useContext, useEffect, useRef, useState } from 'react'; import AppContext from '../../app-context'; import CloseButton from '../common/close-button'; +import DatePicker from '../common/date-picker'; import SiteTitleBackButton from '../common/site-title-back-button'; import ActionButton from '../common/action-button'; import GiftCard from '../common/gift-card'; @@ -11,6 +12,7 @@ import CheckmarkIcon from '../../images/icons/checkmark.svg?react'; import giftCardNoiseUrl from '../../images/gift-card-noise.webp'; import giftCardOrbUrl from '../../images/gift-card-orb.webp'; import { isCookiesDisabled } from '../../utils/helpers'; +import { addCalendarDays, getDateInputValue } from '../../utils/date-time'; import { getActiveGiftDuration, getAvailableGiftDurations, @@ -514,6 +516,23 @@ html[dir="rtl"] .gh-portal-content.gift .gh-portal-btn-site-title-back { letter-spacing: 0.02em; } +.gh-portal-gift-checkout-delivery-date { + margin-top: 16px; +} + +.gh-portal-gift-checkout-delivery-date .gh-portal-input { + margin-bottom: 0; + box-sizing: border-box; +} + +.gh-portal-gift-checkout-delivery-error { + margin: 8px 0 0; + color: var(--red); + font-size: 1.3rem; + letter-spacing: 0.35px; + line-height: 1.6em; +} + .gh-portal-gift-checkout .gh-portal-btn-primary { border-radius: 999px; } @@ -1322,6 +1341,9 @@ function GiftDurationSwitch({ offeredDurations, activeDuration, setSelectedDurat const GIFT_EMAIL_MAX_LENGTH = 191; const GIFT_NAME_MAX_LENGTH = 191; const GIFT_MESSAGE_MAX_LENGTH = 250; +// Mirrors GIFT_MAX_SCHEDULE_DAYS in ghost/core's gifts constants — change +// them together. +const GIFT_MAX_SCHEDULE_DAYS = 365; function getTierPriceLabel(product, months) { return formatGiftValue(getGiftPrice(product, months)); @@ -1338,6 +1360,10 @@ const BetaGiftPage = () => { const [buyerName, setBuyerName] = useState(member?.name || ''); const [giftMessage, setGiftMessage] = useState(''); const [deliveryMethod, setDeliveryMethod] = useState('email'); + // null means untouched: the effective date then tracks "today" in the + // site's timezone on every render, so an untouched form still means + // "send now" after the page sits open across site-midnight. + const [deliveryDate, setDeliveryDate] = useState(null); const [errors, setErrors] = useState({}); const { cardRef, containerProps: cardTiltProps } = useCardTilt(); @@ -1431,7 +1457,9 @@ const BetaGiftPage = () => { const activeDurationLabel = getGiftDurationAttributiveLabel(emailDuration); const isPurchasing = action === 'checkoutGift:running'; const hasErrors = - step === 'plan' ? !!(errors.email || errors.buyerName) : !!errors.recipientEmail; + step === 'plan' + ? !!(errors.email || errors.buyerName) + : !!(errors.recipientEmail || errors.deliveryDate); const isDisabled = isCookiesDisabled() || isPurchasing || hasErrors; const isLoggedIn = !!member; const showBuyerName = !(member?.name || '').trim(); @@ -1441,6 +1469,9 @@ const BetaGiftPage = () => { // stays for the plan step and for "I'll share it myself", where no email is // sent and the card is what the buyer passes on. const showEmailPreview = step === 'delivery' && deliveryMethod === 'email'; + const minDeliveryDate = getDateInputValue(new Date(), site.timezone); + const maxDeliveryDate = addCalendarDays(minDeliveryDate, GIFT_MAX_SCHEDULE_DAYS); + const effectiveDeliveryDate = deliveryDate ?? minDeliveryDate; const emailField = { type: 'email', @@ -1498,6 +1529,7 @@ const BetaGiftPage = () => { setErrors((currentErrors) => ({ ...currentErrors, recipientEmail: '', + deliveryDate: '', })); setRecipientEmail(event.target.value); }; @@ -1506,10 +1538,21 @@ const BetaGiftPage = () => { setErrors((currentErrors) => ({ ...currentErrors, recipientEmail: '', + deliveryDate: '', })); setDeliveryMethod(method); }; + const handleDeliveryDateChange = (nextDate) => { + setErrors((currentErrors) => ({ + ...currentErrors, + deliveryDate: '', + })); + // Store today as null so "send now" keeps tracking the site day across + // midnight; a typed past date stays put for validation to call out. + setDeliveryDate(nextDate === minDeliveryDate ? null : nextDate); + }; + const handleContinueToDelivery = (e) => { e.preventDefault(); if (!isLoggedIn) { @@ -1543,6 +1586,7 @@ const BetaGiftPage = () => { const trimmedBuyerName = buyerName.trim(); const trimmedGiftMessage = giftMessage.trim(); const isEmailDelivery = deliveryMethod === 'email'; + const isScheduled = isEmailDelivery && effectiveDeliveryDate > minDeliveryDate; const fieldsToValidate = []; if (!isLoggedIn) { @@ -1564,6 +1608,16 @@ const BetaGiftPage = () => { formErrors.recipientEmail = t("Enter the recipient's email address"); } + if (isEmailDelivery) { + if (!effectiveDeliveryDate) { + formErrors.deliveryDate = t('Choose a delivery date'); + } else if (effectiveDeliveryDate < minDeliveryDate) { + formErrors.deliveryDate = t('Choose a date from today onwards'); + } else if (effectiveDeliveryDate > maxDeliveryDate) { + formErrors.deliveryDate = t('Choose a date within the next year'); + } + } + const formHasErrors = Object.values(formErrors).some((errorMessage) => !!errorMessage); setErrors(formErrors); @@ -1584,6 +1638,7 @@ const BetaGiftPage = () => { ...(isEmailDelivery && trimmedRecipientName ? { recipientName: trimmedRecipientName } : {}), ...(trimmedBuyerName ? { buyerName: trimmedBuyerName } : {}), ...(isEmailDelivery && trimmedGiftMessage ? { personalMessage: trimmedGiftMessage } : {}), + ...(isScheduled ? { deliveryDate: effectiveDeliveryDate } : {}), }); }; @@ -1755,7 +1810,7 @@ const BetaGiftPage = () => { className={'gh-portal-btn' + (deliveryMethod === 'email' ? ' active' : '')} onClick={() => handleDeliveryMethodChange('email')} > - {t('Email it to them now')} + {t('Email it to them')}
+
+
+ +
+ + {errors.deliveryDate && ( +

+ {errors.deliveryDate} +

+ )} +
@@ -1886,6 +1963,8 @@ const BetaGiftPage = () => { buyerName={buyerName} cadence={emailDuration.cadence} duration={emailDuration.duration} + deliveryDate={effectiveDeliveryDate} + isScheduled={effectiveDeliveryDate > minDeliveryDate} giftMessage={giftMessage} recipientEmail={recipientEmail} recipientName={recipientName} diff --git a/apps/portal/src/components/pages/beta-gift-success-page.jsx b/apps/portal/src/components/pages/beta-gift-success-page.jsx index ccb7847f97c..0186383a5d5 100644 --- a/apps/portal/src/components/pages/beta-gift-success-page.jsx +++ b/apps/portal/src/components/pages/beta-gift-success-page.jsx @@ -7,6 +7,7 @@ import copyTextToClipboard from '../../utils/copy-to-clipboard'; import { getAvailableProducts } from '../../utils/helpers'; import { getGiftDurationLabel } from '../../utils/gift-redemption-notification'; import { getGiftPrice } from '../../utils/gift-subscriptions'; +import { getDateString, parseDateValue } from '../../utils/date-time'; import { t } from '../../utils/i18n'; import useCardTilt from '../../utils/use-card-tilt'; import { formatGiftValue } from './gift-page'; @@ -146,6 +147,8 @@ const BetaGiftSuccessPage = () => { const cadence = pageData?.cadence; const duration = pageData?.duration || 1; const deliveryMethod = pageData?.deliveryMethod; + const deliveryDate = pageData?.deliveryDate; + const scheduledAt = pageData?.scheduledAt; const siteUrl = site?.url || ''; const siteIcon = site?.icon; const siteTitle = site?.title || ''; @@ -162,9 +165,20 @@ const BetaGiftSuccessPage = () => { const isEmailed = deliveryMethod === 'email'; + // A delivery date without a future gift_scheduled_at means the server + // already sent the gift. + const isStillScheduled = isEmailed && deliveryDate && scheduledAt && scheduledAt > Date.now(); + let titleText = t('Your gift is ready'); let subtitleText = t("Send the link below to share it with whoever you'd like."); - if (isEmailed) { + if (isStillScheduled) { + const formattedDate = getDateString(parseDateValue(deliveryDate)); + titleText = t('Your gift is scheduled'); + subtitleText = t( + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.", + { deliveryDate: formattedDate }, + ); + } else if (isEmailed) { titleText = t('Your gift is on its way'); subtitleText = t("We'll email it to the recipient. A copy will be in your inbox too."); } diff --git a/apps/portal/src/images/icons/calendar.svg b/apps/portal/src/images/icons/calendar.svg new file mode 100644 index 00000000000..2be4995cb08 --- /dev/null +++ b/apps/portal/src/images/icons/calendar.svg @@ -0,0 +1 @@ + diff --git a/apps/portal/src/utils/api.js b/apps/portal/src/utils/api.js index 62456bb5f11..444e1634b53 100644 --- a/apps/portal/src/utils/api.js +++ b/apps/portal/src/utils/api.js @@ -713,6 +713,7 @@ function setupGhostApi({ siteUrl = window.location.origin, apiUrl, apiKey }) { recipientName, buyerName, personalMessage, + deliveryDate, } = {}) { const siteUrlObj = new URL(siteUrl); const url = endpointFor({ type: 'members', resource: 'create-stripe-checkout-session' }); @@ -742,6 +743,7 @@ function setupGhostApi({ siteUrl = window.location.origin, apiUrl, apiKey }) { ...(recipientName ? { recipientName } : {}), ...(buyerName ? { buyerName } : {}), ...(personalMessage ? { personalMessage } : {}), + ...(deliveryDate ? { deliveryDate } : {}), cancelUrl: cancelUrlObj.href, }; diff --git a/apps/portal/src/utils/date-time.js b/apps/portal/src/utils/date-time.js index 06a248ee034..5d7266ab3a8 100644 --- a/apps/portal/src/utils/date-time.js +++ b/apps/portal/src/utils/date-time.js @@ -19,6 +19,56 @@ const dateFormatterFor = (locale, timeZone) => { } }; +// "2026-08-03" as a local date. `new Date(string)` would read it as UTC and +// land on the previous day for anyone west of Greenwich. +export const parseDateValue = (value) => { + const [year, month, day] = (value || '').split('-').map(Number); + if (!year || !month || !day) { + return null; + } + return new Date(year, month - 1, day); +}; + +export const toDateValue = (date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +}; + +// Calendar-day arithmetic on a `YYYY-MM-DD` value. Runs through UTC so the +// result never depends on the viewer's DST transitions. +export const addCalendarDays = (value, days) => { + const [year, month, day] = value.split('-').map(Number); + const date = new Date(Date.UTC(year, month - 1, day + days)); + return date.toISOString().slice(0, 10); +}; + +// Cached by timezone: construction is the expensive part. The browser's ICU +// may reject a server-validated zone, so fall back to UTC rather than throw +// mid-render. +const dateInputFormatters = new Map(); + +const dateInputFormatterFor = (timeZone) => { + let formatter = dateInputFormatters.get(timeZone); + if (!formatter) { + const options = { year: 'numeric', month: '2-digit', day: '2-digit' }; + try { + formatter = new Intl.DateTimeFormat('en-CA', { ...options, timeZone }); + } catch (err) { + formatter = new Intl.DateTimeFormat('en-CA', { ...options, timeZone: DEFAULT_TIMEZONE }); + } + dateInputFormatters.set(timeZone, formatter); + } + return formatter; +}; + +export const getDateInputValue = (date, timeZone = DEFAULT_TIMEZONE) => { + const parts = dateInputFormatterFor(timeZone).formatToParts(date); + const part = (type) => parts.find((item) => item.type === type)?.value; + return `${part('year')}-${part('month')}-${part('day')}`; +}; + export const getSiteDateString = (isoDate, { locale, timezone } = {}) => { if (!isoDate) { return ''; diff --git a/apps/portal/src/utils/errors.js b/apps/portal/src/utils/errors.js index 65f22f41407..29fe0c12aab 100644 --- a/apps/portal/src/utils/errors.js +++ b/apps/portal/src/utils/errors.js @@ -15,15 +15,7 @@ export class HumanReadableError extends Error { // Bad request + Too many requests if (res.status === 400 || res.status === 429) { try { - const json = await res.json(); - if ( - json.errors && - Array.isArray(json.errors) && - json.errors.length > 0 && - json.errors[0].message - ) { - return new HumanReadableError(json.errors[0].message, { code: json.errors[0].code }); - } + return fromErrorsJSON(await res.json()); } catch (e) { // Failed to decode: ignore return undefined; @@ -37,15 +29,7 @@ export class HumanReadableError extends Error { } try { - const json = await res.json(); - if ( - json.errors && - Array.isArray(json.errors) && - json.errors.length > 0 && - json.errors[0].message - ) { - return new HumanReadableError(json.errors[0].message, { code: json.errors[0].code }); - } + return fromErrorsJSON(await res.json()); } catch (e) { // Failed to decode: ignore return undefined; @@ -59,6 +43,17 @@ export class HumanReadableError extends Error { } } +function fromErrorsJSON(json) { + const error = Array.isArray(json.errors) ? json.errors[0] : null; + if (!error?.message) { + return undefined; + } + + return new HumanReadableError(error.message, { + code: error.code, + }); +} + export const specialMessages = []; /** diff --git a/apps/portal/test/actions.test.ts b/apps/portal/test/actions.test.ts index 0aa74010f3b..8b87f9a67d6 100644 --- a/apps/portal/test/actions.test.ts +++ b/apps/portal/test/actions.test.ts @@ -773,6 +773,33 @@ describe('checkoutGift action', () => { }); }); + test('passes a scheduled delivery date through to api.member.checkoutGift', async () => { + const mockApi = { + member: { + checkoutGift: vi.fn(() => Promise.resolve()), + }, + }; + + await ActionHandler({ + action: 'checkoutGift', + data: { + tierId: 'tier_123', + duration: 3, + deliveryMethod: 'email', + recipientEmail: 'recipient@example.com', + deliveryDate: '2026-12-25', + }, + state: {}, + api: mockApi, + }); + + expect(mockApi.member.checkoutGift).toHaveBeenCalledWith( + expect.objectContaining({ + deliveryDate: '2026-12-25', + }), + ); + }); + test('returns failed action with notification on error', async () => { const mockApi = { member: { diff --git a/apps/portal/test/api.test.js b/apps/portal/test/api.test.js index a4738228722..0c11091522e 100644 --- a/apps/portal/test/api.test.js +++ b/apps/portal/test/api.test.js @@ -70,6 +70,35 @@ describe('Portal API gift redemption', () => { ); }); + test('preserves the gift error code from the members api', async () => { + const ghostApi = setupGhostApi({ siteUrl: 'https://example.com' }); + + vi.spyOn(window, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + errors: [ + { + message: 'This gift has expired.', + code: 'GIFT_EXPIRED', + }, + ], + }), + { + status: 400, + headers: { + 'Content-Type': 'application/json', + }, + }, + ), + ); + + await expect(ghostApi.gift.fetchRedemptionData({ token: 'gift-token-123' })).rejects.toEqual( + new HumanReadableError('This gift has expired.', { + code: 'GIFT_EXPIRED', + }), + ); + }); + test('preserves the api error message for 404 members api gift responses', async () => { const ghostApi = setupGhostApi({ siteUrl: 'https://example.com' }); @@ -308,6 +337,35 @@ describe('Portal API gift checkout', () => { personalMessage: 'Enjoy!', }); }); + + test('sends a scheduled gift delivery date', async () => { + const ghostApi = setupGhostApi({ siteUrl: 'https://example.com' }); + const requestSpy = vi.spyOn(window, 'fetch').mockImplementation((url) => { + if (url.includes('/members/api/session/')) { + return Promise.resolve(new Response('identity-token', { status: 200 })); + } + + return Promise.resolve( + new Response( + JSON.stringify({ + url: 'https://checkout.stripe.com/gift-session', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + }); + + await ghostApi.member.checkoutGift({ + tierId: 'tier_123', + duration: 3, + deliveryMethod: 'email', + recipientEmail: 'recipient@example.com', + deliveryDate: '2026-12-25', + }); + + const [, request] = requestSpy.mock.calls.at(-1); + expect(JSON.parse(request.body)).toMatchObject({ deliveryDate: '2026-12-25' }); + }); }); describe('Portal API member checkout', () => { diff --git a/apps/portal/test/portal-links.test.jsx b/apps/portal/test/portal-links.test.jsx index 4a07a62a3c8..782c4a1c1da 100644 --- a/apps/portal/test/portal-links.test.jsx +++ b/apps/portal/test/portal-links.test.jsx @@ -6,6 +6,7 @@ import { } from './utils/test-fixtures'; import { appRender, fireEvent, waitFor, within } from './utils/test-utils'; import setupGhostApi from '../src/utils/api'; +import { toDateValue } from '../src/utils/date-time'; const defaultGiftResponse = { gifts: [ @@ -597,7 +598,7 @@ describe('Portal Data links:', () => { }); describe('?stripe=gift-purchase-success', () => { - test('opens gift success page', async () => { + test('opens gift success page for immediate email delivery', async () => { const site = { ...FixtureSite.singleTier.basic, labs: { giftSubCustomization: true }, @@ -628,6 +629,51 @@ describe('Portal Data links:', () => { expect(duration).toBeInTheDocument(); }); + test('opens gift success page with scheduled delivery wording for a future date', async () => { + const site = { + ...FixtureSite.singleTier.basic, + labs: { giftSubCustomization: true }, + }; + const tierId = site.products.find((product) => product.type === 'paid').id; + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 60); + const deliveryDateValue = toDateValue(futureDate); + const scheduledAtValue = futureDate.getTime(); + const formattedDeliveryDate = futureDate.toLocaleDateString('en-GB', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + window.location.href = `https://portal.localhost/?stripe=gift-purchase-success&gift_token=abc123&gift_tier=${tierId}&gift_cadence=year&gift_duration=12&gift_delivery=email&gift_delivery_date=${deliveryDateValue}&gift_scheduled_at=${scheduledAtValue}`; + window.location.search = `?stripe=gift-purchase-success&gift_token=abc123&gift_tier=${tierId}&gift_cadence=year&gift_duration=12&gift_delivery=email&gift_delivery_date=${deliveryDateValue}&gift_scheduled_at=${scheduledAtValue}`; + window.location.hash = ''; + window.location.pathname = '/'; + + let { popupFrame, triggerButtonFrame, ...utils } = await setup({ + site, + showPopup: false, + }); + + expect(triggerButtonFrame).toBeInTheDocument(); + + popupFrame = await utils.findByTitle(/portal-popup/i); + expect(popupFrame).toBeInTheDocument(); + + const giftTitle = within(popupFrame.contentDocument).queryByText(/your gift is scheduled/i); + expect(giftTitle).toBeInTheDocument(); + + const deliveryDate = within(popupFrame.contentDocument).queryByText( + new RegExp(formattedDeliveryDate, 'i'), + ); + expect(deliveryDate).toBeInTheDocument(); + + const redeemUrl = within(popupFrame.contentDocument).queryByText(/\/gift\/abc123$/); + expect(redeemUrl).toBeInTheDocument(); + + const duration = within(popupFrame.contentDocument).queryByText('12 months'); + expect(duration).toBeInTheDocument(); + }); + test('does not open gift success page when gift_token is missing', async () => { window.location.href = 'https://portal.localhost/?stripe=gift-purchase-success'; window.location.search = '?stripe=gift-purchase-success'; diff --git a/apps/portal/test/unit/components/common/date-picker.test.tsx b/apps/portal/test/unit/components/common/date-picker.test.tsx new file mode 100644 index 00000000000..b98aafc45b5 --- /dev/null +++ b/apps/portal/test/unit/components/common/date-picker.test.tsx @@ -0,0 +1,178 @@ +import { fireEvent, render } from '../../../utils/test-utils'; +import DatePicker from '../../../../src/components/common/date-picker'; + +const MIN = '2026-08-03'; +const MAX = '2027-08-03'; + +type RenderPickerUtils = ReturnType & { onChange: ReturnType }; + +const renderPicker = ( + props: Record = {}, + contextOverrides: Record = {}, +): RenderPickerUtils => { + const onChange = vi.fn(); + const utils = render( + , + { overrideContext: contextOverrides }, + ); + return { ...utils, onChange }; +}; + +const openPicker = ({ getByTestId }: RenderPickerUtils) => { + fireEvent.click(getByTestId('datepicker-toggle')); +}; + +const field = ({ container }: RenderPickerUtils): HTMLInputElement => { + const input = container.querySelector('.gh-portal-datepicker-field input'); + if (!input) { + throw new Error('Expected date picker input'); + } + return input; +}; + +// Day buttons are named for assistive tech with the full date ("Wednesday, +// August 12th, 2026"), so they're reached by the number they actually show. +const dayButton = ({ getByText }: RenderPickerUtils, day: number) => + getByText(String(day), { selector: 'button.gh-portal-datepicker-day-button' }); + +describe('DatePicker', () => { + it('shows the minLabel while the field sits on the minimum', () => { + const utils = renderPicker({ minLabel: 'Now' }); + + expect(utils.getByText('Now')).toBeInTheDocument(); + expect(field(utils)).toHaveClass('has-min-label'); + }); + + it('shows the real date once a later day is chosen', () => { + const utils = renderPicker({ minLabel: 'Now', value: '2026-08-12' }); + + expect(utils.queryByText('Now')).not.toBeInTheDocument(); + expect(field(utils)).not.toHaveClass('has-min-label'); + }); + + it('keeps the date typable', () => { + const utils = renderPicker(); + + expect(field(utils)).toHaveAttribute('type', 'date'); + expect(field(utils)).toHaveAttribute('min', MIN); + expect(field(utils)).toHaveAttribute('max', MAX); + }); + + it('reports what was typed', () => { + const utils = renderPicker(); + + fireEvent.change(field(utils), { target: { value: '2026-09-01' } }); + + expect(utils.onChange).toHaveBeenCalledWith('2026-09-01'); + }); + + // Mid-edit a date input reports '' while its segments are incomplete, so + // this only settles on blur — restoring a value while typing would fight it. + it('snaps a cleared field back to the minimum on blur', () => { + const utils = renderPicker(); + + fireEvent.blur(field(utils), { target: { value: '' } }); + + expect(utils.onChange).toHaveBeenCalledWith(MIN); + }); + + it('opens from the calendar icon', () => { + const utils = renderPicker(); + + expect(utils.queryByTestId('datepicker-popover')).not.toBeInTheDocument(); + openPicker(utils); + expect(utils.getByTestId('datepicker-popover')).toBeInTheDocument(); + }); + + it('does not open when the field itself is clicked', () => { + const utils = renderPicker(); + + fireEvent.click(field(utils)); + fireEvent.focus(field(utils)); + + expect(utils.queryByTestId('datepicker-popover')).not.toBeInTheDocument(); + }); + + it('reports the chosen day as a YYYY-MM-DD string', () => { + const utils = renderPicker(); + openPicker(utils); + + fireEvent.click(dayButton(utils, 12)); + + expect(utils.onChange).toHaveBeenCalledWith('2026-08-12'); + }); + + it('closes once a day is chosen', () => { + const utils = renderPicker(); + openPicker(utils); + + fireEvent.click(dayButton(utils, 12)); + + expect(utils.queryByTestId('datepicker-popover')).not.toBeInTheDocument(); + }); + + // Parsed as a local date rather than UTC — `new Date('2026-08-03')` would + // land on the 2nd anywhere west of Greenwich. + it('does not shift the selected day across timezones', () => { + const utils = renderPicker({ value: '2026-08-12' }); + openPicker(utils); + + expect(dayButton(utils, 12).closest('td')).toHaveClass('gh-portal-datepicker-selected'); + }); + + it('refuses days before the minimum', () => { + const utils = renderPicker(); + openPicker(utils); + + fireEvent.click(dayButton(utils, 2)); + + expect(utils.onChange).not.toHaveBeenCalled(); + }); + + it('renders the calendar outside the field, not within it', () => { + const utils = renderPicker(); + openPicker(utils); + + const popover = utils.getByTestId('datepicker-popover'); + expect(popover).toBeInTheDocument(); + expect(utils.container.querySelector('.gh-portal-datepicker')).not.toContainElement(popover); + }); + + // Placement is measured against the nearest scrolling ancestor, not the + // window: Portal's popup is a scrolling box inside a full-height iframe, so + // the window would report far more room than the reader can actually see. + it('hangs below the field when there is room', () => { + const utils = renderPicker(); + openPicker(utils); + + const popover = utils.getByTestId('datepicker-popover'); + // jsdom reports every rect as zero, so the only honest assertion here + // is that a placement was computed at all rather than left hidden. + expect(popover.style.top).not.toBe(''); + expect(popover.style.right).not.toBe(''); + expect(popover.style.visibility).not.toBe('hidden'); + }); + + it('marks the field as invalid when asked', () => { + const utils = renderPicker({ hasError: true }); + + expect(field(utils)).toHaveClass('error'); + }); + + it('closes on Escape', () => { + const utils = renderPicker(); + openPicker(utils); + + fireEvent.keyDown(document, { key: 'Escape' }); + + expect(utils.queryByTestId('datepicker-popover')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/portal/test/unit/components/pages/beta-gift-page.test.tsx b/apps/portal/test/unit/components/pages/beta-gift-page.test.tsx index 954173a0a77..803ae3751a7 100644 --- a/apps/portal/test/unit/components/pages/beta-gift-page.test.tsx +++ b/apps/portal/test/unit/components/pages/beta-gift-page.test.tsx @@ -226,7 +226,7 @@ describe('BetaGiftPage', () => { fireEvent.change(getByLabelText('Optional message'), { target: { value: 'Enjoy!' } }); fireEvent.click(getByRole('radio', { name: "I'll share it myself" })); - fireEvent.click(getByRole('radio', { name: 'Email it to them now' })); + fireEvent.click(getByRole('radio', { name: 'Email it to them' })); expect(getByLabelText("Recipient's name")).toHaveValue('Taylor'); expect(getByLabelText("Recipient's email")).toHaveValue('recipient@example.com'); @@ -241,4 +241,63 @@ describe('BetaGiftPage', () => { deliveryMethod: 'link', }); }); + + test('schedules email delivery from the always-visible delivery date field', () => { + const site = buildSite({ labs: { giftSubCustomization: true } }); + const { container, getByLabelText, getByRole, mockDoActionFn } = setup(site); + + fireEvent.change(getByLabelText('Your name'), { target: { value: 'Jamie' } }); + fireEvent.click(getByRole('button', { name: 'Continue to delivery details' })); + fireEvent.change(getByLabelText("Recipient's email"), { + target: { value: 'recipient@example.com' }, + }); + const deliveryDate = container.querySelector('#gift-delivery-date'); + if (!deliveryDate) { + throw new Error('Expected delivery date input'); + } + const scheduledDate = deliveryDate.getAttribute('max'); + fireEvent.change(deliveryDate, { target: { value: scheduledDate } }); + fireEvent.click(getByRole('button', { name: 'Continue to payment' })); + + expect(mockDoActionFn).toHaveBeenCalledWith( + 'checkoutGift', + expect.objectContaining({ + deliveryMethod: 'email', + deliveryDate: scheduledDate, + }), + ); + }); + + test('disables purchase until an out-of-range delivery date is corrected', () => { + const site = buildSite({ labs: { giftSubCustomization: true } }); + const { container, getByLabelText, getByRole, getByText, mockDoActionFn } = setup(site); + + fireEvent.change(getByLabelText('Your name'), { target: { value: 'Jamie' } }); + fireEvent.click(getByRole('button', { name: 'Continue to delivery details' })); + fireEvent.change(getByLabelText("Recipient's email"), { + target: { value: 'recipient@example.com' }, + }); + const deliveryDate = container.querySelector('#gift-delivery-date'); + if (!deliveryDate) { + throw new Error('Expected delivery date input'); + } + // min/max don't stop typed values, so an out-of-range date is reachable + fireEvent.change(deliveryDate, { target: { value: '2020-01-01' } }); + fireEvent.click(getByRole('button', { name: 'Continue to payment' })); + + expect(getByText('Choose a date from today onwards')).toBeInTheDocument(); + expect(getByRole('button', { name: 'Continue to payment' })).toBeDisabled(); + expect(mockDoActionFn).not.toHaveBeenCalled(); + + fireEvent.change(deliveryDate, { target: { value: '2999-01-01' } }); + fireEvent.click(getByRole('button', { name: 'Continue to payment' })); + + expect(getByText('Choose a date within the next year')).toBeInTheDocument(); + expect(getByRole('button', { name: 'Continue to payment' })).toBeDisabled(); + expect(mockDoActionFn).not.toHaveBeenCalled(); + + fireEvent.change(deliveryDate, { target: { value: deliveryDate.getAttribute('max') } }); + + expect(getByRole('button', { name: 'Continue to payment' })).not.toBeDisabled(); + }); }); diff --git a/apps/portal/test/unit/components/pages/gift-success-page.test.tsx b/apps/portal/test/unit/components/pages/gift-success-page.test.tsx index 5205d6c0ec5..7663c806151 100644 --- a/apps/portal/test/unit/components/pages/gift-success-page.test.tsx +++ b/apps/portal/test/unit/components/pages/gift-success-page.test.tsx @@ -6,15 +6,20 @@ import { getSiteData, } from '../../../../src/utils/fixtures-generator'; import { render } from '../../../utils/test-utils'; +import { toDateValue } from '../../../../src/utils/date-time'; function setup({ Page, monthlyPrice, deliveryMethod = 'link', + deliveryDate, + scheduledAt, }: { Page: typeof GiftSuccessPage; monthlyPrice: ReturnType | null; deliveryMethod?: 'email' | 'link'; + deliveryDate?: string; + scheduledAt?: number; }) { const product = { ...getProductData({ @@ -38,6 +43,8 @@ function setup({ cadence: 'month', duration: 3, deliveryMethod, + deliveryDate, + scheduledAt, }, }, }); @@ -82,4 +89,63 @@ describe('BetaGiftSuccessPage', () => { expect(getByText('Share it yourself')).toBeInTheDocument(); expect(getByTestId('gift-redeem-link')).toHaveTextContent('/gift/abc123'); }); + + test('uses scheduled delivery wording and preserves the site-calendar date', () => { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 60); + const deliveryDate = toDateValue(futureDate); + const formattedDate = futureDate.toLocaleDateString('en-GB', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + + const { getByText, getByTestId } = setup({ + Page: BetaGiftSuccessPage, + monthlyPrice: getPriceData({ amount: 500, interval: 'month' }), + deliveryMethod: 'email', + deliveryDate, + scheduledAt: futureDate.getTime(), + }); + + expect(getByText('Your gift is scheduled')).toBeInTheDocument(); + expect( + getByText( + `We'll email it to the recipient on ${formattedDate}. A copy is in your inbox too.`, + ), + ).toBeInTheDocument(); + expect(getByTestId('gift-redeem-link')).toHaveTextContent('/gift/abc123'); + }); + + // The schedule is baked into the success URL at checkout-session + // creation; a payment completed after the send instant delivers + // immediately, so a passed instant must not promise a scheduled send. + test('uses immediate delivery wording when the send instant has passed', () => { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 30); + + const { getByText } = setup({ + Page: BetaGiftSuccessPage, + monthlyPrice: getPriceData({ amount: 500, interval: 'month' }), + deliveryMethod: 'email', + deliveryDate: toDateValue(futureDate), + scheduledAt: Date.now() - 60_000, + }); + + expect(getByText('Your gift is on its way')).toBeInTheDocument(); + expect( + getByText("We'll email it to the recipient. A copy will be in your inbox too."), + ).toBeInTheDocument(); + }); + + test('uses immediate delivery wording when no send instant accompanies the date', () => { + const { getByText } = setup({ + Page: BetaGiftSuccessPage, + monthlyPrice: getPriceData({ amount: 500, interval: 'month' }), + deliveryMethod: 'email', + deliveryDate: '2020-01-01', + }); + + expect(getByText('Your gift is on its way')).toBeInTheDocument(); + }); }); diff --git a/docs/README.md b/docs/README.md index 40544951774..a8d71668096 100644 --- a/docs/README.md +++ b/docs/README.md @@ -121,9 +121,9 @@ For more detail, see the [contribution workflow](contributing/workflow.md). ### Testing -Use `pnpm check` as the default one-stop command for linting and testing. Add -tests at the closest layer to the behavior you changed. Browser end-to-end tests -and Ember Admin tests run separately from `pnpm check`. +Use `pnpm check` as the default one-stop command for formatting checks, linting, +and testing. Add tests at the closest layer to the behavior you changed. Browser +end-to-end tests and Ember Admin tests run separately from `pnpm check`. For more detail, see the [testing guide](contributing/testing.md) including how to choose a test suite, run focused tests, and use the separate browser and diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md index 60a0db1dec0..3cdaceab4bd 100644 --- a/docs/contributing/testing.md +++ b/docs/contributing/testing.md @@ -12,8 +12,9 @@ From the repository root, run: pnpm check ``` -This is the default one-stop command for linting and testing. It runs -`pnpm lint` followed by `pnpm test` across the monorepo. +This is the default one-stop command for formatting checks, linting, and +testing. It runs `pnpm format:check`, `pnpm lint`, and then `pnpm test` across +the monorepo. `pnpm check` does not run the Playwright browser end-to-end suite or Ember Admin's test suite. Run those separately when your change affects those areas. diff --git a/docs/contributing/workflow.md b/docs/contributing/workflow.md index d98c2bafed8..1fa8a37faff 100644 --- a/docs/contributing/workflow.md +++ b/docs/contributing/workflow.md @@ -37,17 +37,18 @@ first; ordinary pull requests target `main`. Add or update automated tests when behavior changes. Run the most focused checks for the code you touched, following the README beside that workspace. Before -handing off a change, use the repository's one-stop lint and test command: +handing off a change, use the repository's one-stop formatting, lint, and test +command: ```bash pnpm check ``` -`pnpm check` runs `pnpm lint` followed by `pnpm test`. It does not include the -browser E2E suite or Ember Admin tests, so run those separately when the affected -area requires them. CI uses the Nx affected graph and path filters to select the -relevant lint, unit, integration, acceptance, build, and browser-test jobs for a -pull request. +`pnpm check` runs `pnpm format:check`, `pnpm lint`, and then `pnpm test`. It does +not include the browser E2E suite or Ember Admin tests, so run those separately +when the affected area requires them. CI uses the Nx affected graph and path +filters to select the relevant lint, unit, integration, acceptance, build, and +browser-test jobs for a pull request. ## Formatting diff --git a/ghost/core/core/frontend/services/llms/markdown.js b/ghost/core/core/frontend/services/llms/markdown.js index 987029e96e9..cf65ba47b41 100644 --- a/ghost/core/core/frontend/services/llms/markdown.js +++ b/ghost/core/core/frontend/services/llms/markdown.js @@ -127,7 +127,46 @@ function renderEntryMarkdownBody(entry) { return collapseWhitespace(htmlToPlaintext.excerpt(entry.html || '')); } -function renderEntryMarkdown(entry, { llmsIndexUrl }) { +/** + * Visibility-appropriate notice matching content-cta.hbs wording. + * For gated entries the notice carries the meaning — never emit + * "_No content available._" when this is used. + * + * `resourceKind` must be passed by callers that know it: the content API + * does not serialize `type`, so `entry.type` is undefined for both posts + * and pages. + */ +function getGatedNotice(entry, resourceKind) { + const resource = (resourceKind || entry.type) === 'page' ? 'page' : 'post'; + const visibility = entry.visibility; + + if (visibility === 'paid') { + return `This ${resource} is for paying subscribers only.`; + } + + if (visibility === 'tiers') { + const tierNames = (Array.isArray(entry.tiers) ? entry.tiers : []) + .map((tier) => tier?.name) + .filter(Boolean); + + if (tierNames.length === 1) { + return `This ${resource} is for subscribers on the ${tierNames[0]} tier only.`; + } + + if (tierNames.length > 1) { + const firsts = tierNames.slice(0, -1).join(', '); + const last = tierNames[tierNames.length - 1]; + return `This ${resource} is for subscribers on the ${firsts} and ${last} tiers only.`; + } + + return `This ${resource} is for paying subscribers only.`; + } + + return `This ${resource} is for subscribers only.`; +} + +function renderEntryMarkdown(entry, options = {}) { + const { llmsIndexUrl, notice, cta } = options; const tags = getTagNames(entry); const metadata = [ entry.url ? `- URL: ${entry.url}` : null, @@ -141,7 +180,14 @@ function renderEntryMarkdown(entry, { llmsIndexUrl }) { tags.length ? `- Tags: ${tags.join(', ')}` : null, ].filter(Boolean); - const body = renderEntryMarkdownBody(entry) || '_No content available._'; + // Gated entries render only the paywall preview. custom_excerpt is already + // carried by the `- Description:` metadata line above, so falling back to it + // here would just repeat it. + const isGated = Boolean(notice); + const body = isGated + ? renderEntryMarkdownBody(entry) || null + : renderEntryMarkdownBody(entry) || '_No content available._'; + const lines = [ '> ## Content Index', `> Fetch the complete content index at: ${llmsIndexUrl}`, @@ -156,7 +202,23 @@ function renderEntryMarkdown(entry, { llmsIndexUrl }) { lines.push(''); } - lines.push(body); + if (body) { + lines.push(body); + } + + if (notice) { + if (body) { + lines.push('', '---', ''); + } + lines.push(`_${notice}_`); + + const ctaLines = Array.isArray(cta) ? cta : cta ? [cta] : []; + for (const line of ctaLines) { + if (line) { + lines.push('', line); + } + } + } return lines.join('\n'); } @@ -166,6 +228,7 @@ module.exports = { collapseWhitespace, formatIsoDate, getAcceptedMarkdownContentType, + getGatedNotice, getMarkdownPath, getMarkdownUrl, getPrimaryAuthorName, diff --git a/ghost/core/core/frontend/services/llms/service.js b/ghost/core/core/frontend/services/llms/service.js index 4626e0fbc4c..4258d4dd5d7 100644 --- a/ghost/core/core/frontend/services/llms/service.js +++ b/ghost/core/core/frontend/services/llms/service.js @@ -1,4 +1,9 @@ -const { getMarkdownUrl, renderEntryMarkdownBody, truncateDescription } = require('./markdown'); +const { + getGatedNotice, + getMarkdownUrl, + renderEntryMarkdownBody, + truncateDescription, +} = require('./markdown'); const LLMS_TXT_BUDGET = 50 * 1024; const LLMS_FULL_TXT_BUDGET = 5 * 1024 * 1024; @@ -12,8 +17,6 @@ const LLMS_FULL_TXT_INTRO = 'Public Ghost content for AI and LLM tooling. This file includes a bounded export of public pages first, then recent public posts.'; const MARKDOWN_DISCOVERABILITY = 'Append `.md` to any post or page URL to get the content in Markdown (for example, `/example-post.md`).'; -const MEMBERS_ONLY_NOTICE = 'This post is for subscribers only.'; -const PAID_MEMBERS_ONLY_NOTICE = 'This post is for paying subscribers only.'; const EMPTY_SECTION = '_No public content available._'; const LLMS_FULL_TXT_TRUNCATION_FOOTER = '\n_Truncated after 5 MiB. Use `/sitemap.xml` for the complete archive of public content._\n'; @@ -27,15 +30,7 @@ const LLMS_TXT_FIELDS = 'id,title,slug,custom_excerpt,featured,published_at,url, const LLMS_FULL_TXT_FIELDS = 'id,title,slug,featured,published_at,updated_at,created_at,url,visibility,custom_excerpt'; -function createLlmsService({ - settingsCache, - config, - urlUtils, - routing, - api, - machinePaymentsService, - fullTxtBudget, -}) { +function createLlmsService({ settingsCache, config, urlUtils, routing, api, fullTxtBudget }) { const footerBudget = Math.max( Buffer.byteLength(LLMS_FULL_TXT_TRUNCATION_FOOTER, 'utf8'), Buffer.byteLength(LLMS_FULL_TXT_RECENT_POSTS_FOOTER, 'utf8'), @@ -47,22 +42,9 @@ function createLlmsService({ } function isDiscoverable(entry) { - const { isPurchasableEntry } = require('../../../shared/machine-payments'); - - if (entry.visibility === 'public' || !entry.visibility) { - return true; - } - - // Free-members-only stays undiscoverable for agents. - if (entry.visibility === 'members') { - return false; - } - - if (!isPurchasableEntry(entry)) { - return false; - } - - return machinePaymentsService?.isEnabled?.() === true; + // Gated posts now expose a free preview on `.md`, so list them alongside + // public content. Only exclude the 404 placeholder page. + return Boolean(entry.url) && !entry.url.endsWith('/404/'); } async function fetchPublicEntry(resourceType, id, member = null) { @@ -184,7 +166,7 @@ function createLlmsService({ break; } - const formattedEntry = buildFullEntry(entry); + const formattedEntry = buildFullEntry(entry, type); const entryBlock = `${formattedEntry}\n`; const entryBytes = Buffer.byteLength(entryBlock, 'utf8'); @@ -287,24 +269,25 @@ function createLlmsService({ return optionalLinks.join('\n'); } - function buildMembersOnlyBody(entry) { - const notice = - entry.visibility === 'paid' || entry.visibility === 'tiers' - ? PAID_MEMBERS_ONLY_NOTICE - : MEMBERS_ONLY_NOTICE; + function buildMembersOnlyBody(entry, resourceKind) { + const notice = getGatedNotice(entry, resourceKind); + // No `- Description:` line in this format, so the excerpt fallback is the + // only way the teaser reaches the reader. + const preview = renderEntryMarkdownBody(entry); const excerpt = truncateDescription(entry.custom_excerpt); + const body = preview || excerpt; - return excerpt ? `${excerpt}\n\n_${notice}_` : `_${notice}_`; + return body ? `${body}\n\n_${notice}_` : `_${notice}_`; } - function buildFullEntry(entry) { + function buildFullEntry(entry, resourceKind) { const lastUpdated = entry.updated_at || entry.published_at || entry.created_at; const lastUpdatedLine = lastUpdated ? `Last updated: ${new Date(lastUpdated).toISOString()}` : null; const isMembersOnly = entry.visibility && entry.visibility !== 'public'; const markdownBody = isMembersOnly - ? buildMembersOnlyBody(entry) + ? buildMembersOnlyBody(entry, resourceKind) : renderEntryMarkdownBody(entry) || '_No content available._'; return [`### ${entry.title}`, `URL: ${entry.url}`, lastUpdatedLine, '', markdownBody] @@ -324,9 +307,7 @@ function createLlmsService({ ...(type === 'page' ? { order: 'id asc' } : { order: 'published_at desc' }), }); - const entries = (response?.[responseKey] || []) - .filter((entry) => entry.url && !entry.url.endsWith('/404/')) - .filter(isDiscoverable); + const entries = (response?.[responseKey] || []).filter(isDiscoverable); return { entries, pagination: response?.meta?.pagination }; } diff --git a/ghost/core/core/frontend/services/routing/controllers/entry.ts b/ghost/core/core/frontend/services/routing/controllers/entry.ts index 30d2e16ddb3..380b20686d2 100644 --- a/ghost/core/core/frontend/services/routing/controllers/entry.ts +++ b/ghost/core/core/frontend/services/routing/controllers/entry.ts @@ -25,6 +25,7 @@ export interface Entry { id: string; url: string; visibility: string; + access?: boolean; [key: string]: unknown; } diff --git a/ghost/core/core/frontend/services/routing/controllers/entry/markdown.ts b/ghost/core/core/frontend/services/routing/controllers/entry/markdown.ts index d510fbfec7f..a5f2cad507b 100644 --- a/ghost/core/core/frontend/services/routing/controllers/entry/markdown.ts +++ b/ghost/core/core/frontend/services/routing/controllers/entry/markdown.ts @@ -2,10 +2,10 @@ import type { Request, Response } from 'express'; import type { Entry, EntryResponse } from '../entry'; import buildCanonicalUrl from './canonical-url'; -const config = require('../../../../../shared/config'); const urlUtils = require('../../../../../shared/url-utils').default; const { getAcceptedMarkdownContentType, + getGatedNotice, getMarkdownPath, renderEntryMarkdown, } = require('../../../llms/markdown'); @@ -52,7 +52,7 @@ async function copyFetchResponse(fetchResponse: globalThis.Response, res: Respon /** * Only public entries ever render as markdown without payment; gated entries - * stay html (or 403 / 402 on an explicit `.md` URL). + * stay html (or preview / 402 on an explicit `.md` URL). */ export function isPublic(entry: Entry): boolean { return entry.visibility === 'public'; @@ -60,31 +60,74 @@ export function isPublic(entry: Entry): boolean { function serveMarkdown(res: Response, entry: Entry) { const llmsIndexUrl = urlUtils.urlFor({ relativeUrl: '/llms.txt' }, true); - res.set('Cache-Control', `public, max-age=${config.get('caching:llms:maxAge')}`); res.set('Content-Location', getMarkdownPath(new URL(entry.url).pathname)); res.type('text/markdown'); return res.send(renderEntryMarkdown(entry, { llmsIndexUrl })); } +/** + * The content API does not serialize `type`, so the notice wording has to come + * from the route rather than the entry. + */ +function getResourceKind(res: EntryResponse): 'page' | 'post' { + return res.routerOptions.resourceType === 'pages' || res.routerOptions.context?.includes('page') + ? 'page' + : 'post'; +} + +/** + * Public free-preview for gated entries (access === false). Same truncated + * html the web renderer shows, plus a subscription notice and Portal CTA. + */ +function servePreviewMarkdown(res: EntryResponse, entry: Entry) { + const llmsIndexUrl = urlUtils.urlFor({ relativeUrl: '/llms.txt' }, true); + const subscribeUrl = urlUtils.urlFor({ relativeUrl: '/#/portal/signup' }, true); + + res.set('Content-Location', getMarkdownPath(new URL(entry.url).pathname)); + res.type('text/markdown'); + return res.send( + renderEntryMarkdown(entry, { + llmsIndexUrl, + notice: getGatedNotice(entry, getResourceKind(res)), + cta: `Subscribe: ${subscribeUrl}`, + }), + ); +} + function refuseMembersOnlyMarkdown(res: Response) { return res.status(403).type('text/markdown').send(MEMBERS_ONLY_MARKDOWN); } +function formatPaymentAmount(amount: number, currency: string): string { + try { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currency.toUpperCase(), + }).format(amount / 100); + } catch { + return `${(amount / 100).toFixed(2)} ${currency.toUpperCase()}`; + } +} + /** * Challenge-before-render paid markdown path. Full HTML is loaded only after - * the machine-payments orchestrator verifies payment. + * the machine-payments orchestrator verifies payment. Unpaid challenges carry + * the free preview + an agent upsell CTA in a text/markdown body. */ async function servePaidMarkdown(req: Request, res: EntryResponse, entry: Entry) { const machinePaymentsService = getMachinePaymentsService(req); if (!machinePaymentsService?.isPurchasable(entry)) { - return refuseMembersOnlyMarkdown(res); + // access===true means entry.html is the full post — never cache that + // publicly. Fall back to the 403 stub for members who already have access. + if (entry.access === true) { + return refuseMembersOnlyMarkdown(res); + } + return servePreviewMarkdown(res, entry); } - const resourceType = - res.routerOptions.resourceType === 'pages' || res.routerOptions.context?.includes('page') - ? 'pages' - : 'posts'; + const resourceKind = getResourceKind(res); + const resourceType = resourceKind === 'page' ? 'pages' : 'posts'; const llmsIndexUrl = urlUtils.urlFor({ relativeUrl: '/llms.txt' }, true); const contentLocation = getMarkdownPath(new URL(entry.url).pathname); const fetchRequest = toFetchRequest(req); @@ -95,6 +138,23 @@ async function servePaidMarkdown(req: Request, res: EntryResponse, entry: Entry) description: typeof entry.title === 'string' ? entry.title : undefined, contentLocation, renderMarkdown: (paidEntry: Entry) => renderEntryMarkdown(paidEntry, { llmsIndexUrl }), + // Only attach a preview when the entry is already gated (access !== true). + // When access===true, entry.html is the full post and must not appear on 402. + ...(entry.access !== true + ? { + renderPreviewMarkdown: (terms: { amount: number; currency: string }) => { + const price = formatPaymentAmount(terms.amount, terms.currency); + return renderEntryMarkdown(entry, { + llmsIndexUrl, + notice: getGatedNotice(entry, resourceKind), + cta: [ + `Agents can purchase one-shot access to the full markdown for ${price} per request via MPP or x402 — see the WWW-Authenticate response header for payment terms.`, + `Subscribe: ${urlUtils.urlFor({ relativeUrl: '/#/portal/signup' }, true)}`, + ], + }); + }, + } + : {}), }); return await copyFetchResponse(response, res); @@ -109,8 +169,8 @@ export function isMdRequest(res: EntryResponse): boolean { /** * Serve a `.md` URL as markdown for LLM consumption. When the feature is - * disabled we redirect to the canonical (html) url; members-only content is - * refused or challenged via machine payments when enabled. + * disabled we redirect to the canonical (html) url; gated content shows the + * free preview (or a machine-payments challenge when enabled). */ export async function serveMdRequest(req: Request, res: EntryResponse, entry: Entry) { if (!llmsEnabled(req)) { @@ -118,11 +178,22 @@ export async function serveMdRequest(req: Request, res: EntryResponse, entry: En } if (!isPublic(entry)) { + // Member with access still holds full html on the entry. Serving it at + // 200 with public cache headers would leak paid content to the CDN. + // Member `.md` unlock is out of scope — keep today's 403 / payment path. + if (entry.access === true) { + if (entry.visibility === 'paid' || entry.visibility === 'tiers') { + return await servePaidMarkdown(req, res, entry); + } + + return refuseMembersOnlyMarkdown(res); + } + if (entry.visibility === 'paid' || entry.visibility === 'tiers') { return await servePaidMarkdown(req, res, entry); } - return refuseMembersOnlyMarkdown(res); + return servePreviewMarkdown(res, entry); } return serveMarkdown(res, entry); diff --git a/ghost/core/core/frontend/web/site.js b/ghost/core/core/frontend/web/site.js index ff107b7e9b8..b9fe15e8cd4 100644 --- a/ghost/core/core/frontend/web/site.js +++ b/ghost/core/core/frontend/web/site.js @@ -82,7 +82,6 @@ module.exports = function setupSiteApp(routerConfig) { urlUtils, routing, api, - machinePaymentsService, }); siteApp.set('llmsService', llmsService); diff --git a/ghost/core/core/server/api/endpoints/gifts.js b/ghost/core/core/server/api/endpoints/gifts.js index cdfc218839f..5e02c0e09f3 100644 --- a/ghost/core/core/server/api/endpoints/gifts.js +++ b/ghost/core/core/server/api/endpoints/gifts.js @@ -1,5 +1,8 @@ const domainEvents = require('@tryghost/domain-events'); const StartGiftReminderFlushEvent = require('../../services/gifts/events/start-gift-reminder-flush-event'); +const { + StartGiftDeliveryFlushEvent, +} = require('../../services/gifts/events/start-gift-delivery-flush-event'); /** @type {import('@tryghost/api-framework').Controller} */ const controller = { @@ -18,6 +21,20 @@ const controller = { domainEvents.dispatch(StartGiftReminderFlushEvent.create()); }, }, + + flushDeliveries: { + statusCode: 204, + headers: { + cacheInvalidate: false, + }, + permissions: { + docName: 'gifts', + method: 'flushDeliveries', + }, + query() { + domainEvents.dispatch(StartGiftDeliveryFlushEvent.create()); + }, + }, }; module.exports = controller; diff --git a/ghost/core/core/server/api/endpoints/posts.js b/ghost/core/core/server/api/endpoints/posts.js index 2bd5d0487b5..de51daf5999 100644 --- a/ghost/core/core/server/api/endpoints/posts.js +++ b/ghost/core/core/server/api/endpoints/posts.js @@ -134,6 +134,7 @@ const controller = { // the 202. const { importId, total } = await contentImportService.importCSV({ filePath: frame.file.path, + mapping: frame.data.mapping, }); return { meta: { import_id: importId, total } }; }, diff --git a/ghost/core/core/server/data/migrations/versions/6.60/2026-08-20-13-51-00-add-gifts-flush-deliveries-permission-to-scheduler-integration.js b/ghost/core/core/server/data/migrations/versions/6.60/2026-08-20-13-51-00-add-gifts-flush-deliveries-permission-to-scheduler-integration.js new file mode 100644 index 00000000000..a1118d22aca --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/6.60/2026-08-20-13-51-00-add-gifts-flush-deliveries-permission-to-scheduler-integration.js @@ -0,0 +1,10 @@ +const { addPermissionWithRoles } = require('../../utils'); + +module.exports = addPermissionWithRoles( + { + name: 'Flush gift deliveries', + action: 'flushDeliveries', + object: 'gift', + }, + ['Scheduler Integration'], +); diff --git a/ghost/core/core/server/data/migrations/versions/6.60/2026-08-24-13-49-32-add-scheduled-at-to-gift-deliveries.js b/ghost/core/core/server/data/migrations/versions/6.60/2026-08-24-13-49-32-add-scheduled-at-to-gift-deliveries.js new file mode 100644 index 00000000000..3a68a9c3af6 --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/6.60/2026-08-24-13-49-32-add-scheduled-at-to-gift-deliveries.js @@ -0,0 +1,18 @@ +const { + combineNonTransactionalMigrations, + createAddColumnMigration, + createAddIndexMigration, +} = require('../../utils'); + +module.exports = combineNonTransactionalMigrations( + createAddColumnMigration( + 'gift_deliveries', + 'scheduled_at', + { + type: 'dateTime', + nullable: true, + }, + { algorithm: 'auto' }, + ), + createAddIndexMigration('gift_deliveries', ['status', 'scheduled_at']), +); diff --git a/ghost/core/core/server/data/schema/fixtures/fixtures.json b/ghost/core/core/server/data/schema/fixtures/fixtures.json index 68e515fbf20..738359aae73 100644 --- a/ghost/core/core/server/data/schema/fixtures/fixtures.json +++ b/ghost/core/core/server/data/schema/fixtures/fixtures.json @@ -820,6 +820,11 @@ "action_type": "flushReminders", "object_type": "gift" }, + { + "name": "Flush gift deliveries", + "action_type": "flushDeliveries", + "object_type": "gift" + }, { "name": "Manage gift links", "action_type": "manage", @@ -1003,7 +1008,7 @@ "Scheduler Integration": { "post": "publish", "automation": "poll", - "gift": "flushReminders" + "gift": ["flushReminders", "flushDeliveries"] }, "Self-Serve Migration Integration": { "db": "importContent", diff --git a/ghost/core/core/server/data/schema/schema.js b/ghost/core/core/server/data/schema/schema.js index df16107b2a1..75079092425 100644 --- a/ghost/core/core/server/data/schema/schema.js +++ b/ghost/core/core/server/data/schema/schema.js @@ -2454,6 +2454,7 @@ module.exports = { isIn: [['pending', 'sending', 'sent', 'failed', 'cancelled']], }, }, + scheduled_at: { type: 'dateTime', nullable: true }, started_at: { type: 'dateTime', nullable: true }, email_sent_at: { type: 'dateTime', nullable: true }, email_provider_message_id: { type: 'string', maxlength: 1000, nullable: true }, @@ -2470,6 +2471,7 @@ module.exports = { outcome_error: { type: 'text', maxlength: 65535, nullable: true }, '@@INDEXES@@': [ ['status', 'started_at'], + ['status', 'scheduled_at'], { columns: ['email_provider_message_id'], length: 31 }, ], }, diff --git a/ghost/core/core/server/services/content-import/import/importer.ts b/ghost/core/core/server/services/content-import/import/importer.ts index 583ae20efe0..8dca5d2d0cf 100644 --- a/ghost/core/core/server/services/content-import/import/importer.ts +++ b/ghost/core/core/server/services/content-import/import/importer.ts @@ -1,5 +1,11 @@ import moment from 'moment-timezone'; -import buildPostData, { RowSkipped, type HtmlToLexical, type PostData } from './post-data'; +import buildPostData, { + RowSkipped, + type CleanHTML, + type HtmlToLexical, + type MarkdownToHtml, + type PostData, +} from './post-data'; import type { PostImportRow } from './row'; import type { Clock, ImportRunStore, RowOutcome } from './store'; @@ -13,6 +19,7 @@ const tpl = require('@tryghost/tpl'); export interface ImportRequest { filePath: string; + mapping?: Record; } // The id is what a completion report will be looked up by. @@ -33,7 +40,7 @@ export interface PostsRepository { // 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; +type ReadRows = (path: string, mapping?: Record) => Promise; const messages = { unreadableFile: 'The file could not be parsed as a CSV file.', @@ -58,6 +65,8 @@ interface ImporterDeps { posts: PostsRepository; // A getter so the heavy html->lexical require resolves once per run getHtmlToLexical: () => HtmlToLexical; + getMarkdownToHtml: () => MarkdownToHtml; + getCleanHTML: () => CleanHTML; addJob: (job: { job: () => Promise; offloaded: boolean; name: string }) => void; report: FailureReporter; store: ImportRunStore; @@ -78,6 +87,8 @@ class ContentCSVImporter { private _readRows: ReadRows; private _posts: PostsRepository; private _getHtmlToLexical: () => HtmlToLexical; + private _getMarkdownToHtml: () => MarkdownToHtml; + private _getCleanHTML: () => CleanHTML; private _addJob: ImporterDeps['addJob']; private _report: FailureReporter; private _store: ImportRunStore; @@ -90,6 +101,8 @@ class ContentCSVImporter { readRows, posts, getHtmlToLexical, + getMarkdownToHtml, + getCleanHTML, addJob, report, store, @@ -101,6 +114,8 @@ class ContentCSVImporter { this._readRows = readRows; this._posts = posts; this._getHtmlToLexical = getHtmlToLexical; + this._getMarkdownToHtml = getMarkdownToHtml; + this._getCleanHTML = getCleanHTML; this._addJob = addJob; this._report = report; this._store = store; @@ -113,7 +128,7 @@ class ContentCSVImporter { async importCSV(request: ImportRequest): Promise { let rows: PostImportRow[]; try { - rows = await this._readRows(request.filePath); + rows = await this._readRows(request.filePath, request.mapping); } catch (error) { throw new errors.ValidationError({ message: tpl(messages.unreadableFile), @@ -158,6 +173,8 @@ class ContentCSVImporter { try { const htmlToLexical = this._getHtmlToLexical(); + const markdownToHtml = this._getMarkdownToHtml(); + const cleanHTML = this._getCleanHTML(); let attemptedWrites = 0; let successfulWrites = 0; let failedWrites = 0; @@ -168,7 +185,7 @@ class ContentCSVImporter { let data: PostData; try { - data = buildPostData(row, htmlToLexical, importTagNames); + data = buildPostData(row, htmlToLexical, importTagNames, markdownToHtml, cleanHTML); } catch (error) { if (error instanceof RowSkipped) { this._store.record(runId, { 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 index a4424ef2743..4792f0db7c7 100644 --- a/ghost/core/core/server/services/content-import/import/post-data.ts +++ b/ghost/core/core/server/services/content-import/import/post-data.ts @@ -3,6 +3,8 @@ import { importableRowSchema, type PostImportRow } from './row'; const { slugify } = require('@tryghost/string'); export type HtmlToLexical = (html: string) => unknown; +export type MarkdownToHtml = (markdown: string) => string; +export type CleanHTML = (args: { html: string; opinionated: boolean }) => string; // A malformed row, refused before any write was attempted; distinct from a write // that failed, which the importer records separately. @@ -13,6 +15,20 @@ export class RowSkipped extends Error { } } +export interface PostsMetaData { + feature_image_alt?: string; + feature_image_caption?: string; + meta_title?: string; + meta_description?: string; + og_image?: string; + og_title?: string; + og_description?: string; + twitter_image?: string; + twitter_title?: string; + twitter_description?: string; + frontmatter?: string; +} + // 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. @@ -21,19 +37,53 @@ export interface PostData { title: string; slug: string; lexical?: string; + custom_excerpt?: string; + feature_image?: string; + canonical_url?: string; + custom_template?: string; + codeinjection_head?: string; + codeinjection_foot?: string; + featured?: boolean; + show_title_and_feature_image?: boolean; published_at?: string; created_at?: string; updated_at?: string; - status: 'published'; - type: 'post'; - visibility: 'public'; + status: 'draft' | 'published'; + type: 'post' | 'page'; + visibility: 'public' | 'members' | 'paid'; tags: Array<{ name: string }>; + posts_meta?: PostsMetaData; } +const DIRECT_OPTIONAL_FIELDS = [ + 'custom_excerpt', + 'feature_image', + 'canonical_url', + 'custom_template', + 'codeinjection_head', + 'codeinjection_foot', +] as const; + +const META_FIELDS = [ + 'feature_image_alt', + 'feature_image_caption', + 'meta_title', + 'meta_description', + 'og_image', + 'og_title', + 'og_description', + 'twitter_image', + 'twitter_title', + 'twitter_description', + 'frontmatter', +] as const; + export default function buildPostData( row: PostImportRow, htmlToLexical: HtmlToLexical, importTagNames: string[], + markdownToHtml?: MarkdownToHtml, + cleanHTML?: CleanHTML, ): PostData { const check = importableRowSchema.safeParse(row); if (!check.success) { @@ -45,32 +95,90 @@ export default function buildPostData( // 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', + slug: slugify(row.slug ?? row.title), + // Explicit rather than left to the model, which would default visibility + // from the site setting. Never 'scheduled': the post scheduler has no + // importing check. + status: (row.status as PostData['status'] | undefined) ?? 'published', + type: (row.type as PostData['type'] | undefined) ?? 'post', + visibility: (row.visibility as PostData['visibility'] | undefined) ?? 'public', // The # prefix gives the batch tags internal visibility. tags: importTagNames.map((name) => ({ name })), }; - if (row.html) { + if (row.html && row.markdown) { + throw new RowSkipped('html and markdown cannot both be provided'); + } + + let sourceHTML = row.html; + let sourceKind: 'html' | 'markdown' = 'html'; + if (row.markdown) { + if (!markdownToHtml) { + throw new RowSkipped('markdown could not be converted'); + } + try { + sourceHTML = markdownToHtml(row.markdown); + sourceKind = 'markdown'; + } catch { + throw new RowSkipped('markdown could not be converted'); + } + } + + if (sourceHTML) { + let cleanedHTML = sourceHTML; + if (cleanHTML) { + try { + cleanedHTML = cleanHTML({ html: sourceHTML, opinionated: true }); + } catch { + throw new RowSkipped('html could not be cleaned'); + } + } + try { - data.lexical = JSON.stringify(htmlToLexical(row.html)); + data.lexical = JSON.stringify(htmlToLexical(cleanedHTML)); } catch { - throw new RowSkipped('html could not be converted'); + throw new RowSkipped(`${sourceKind} 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. + for (const field of DIRECT_OPTIONAL_FIELDS) { + if (row[field] !== undefined) { + data[field] = row[field]; + } + } + + if (row.featured !== undefined) { + data.featured = toBoolean(row.featured); + } + if (row.show_title_and_feature_image !== undefined) { + data.show_title_and_feature_image = toBoolean(row.show_title_and_feature_image); + } + + // published_at remains the fallback timestamp for the whole post, while an + // explicit created_at or updated_at wins for that individual field. if (row.published_at) { data.published_at = row.published_at; - data.created_at = row.published_at; - data.updated_at = row.published_at; + } + if (row.created_at ?? row.published_at) { + data.created_at = row.created_at ?? row.published_at; + } + if (row.updated_at ?? row.published_at) { + data.updated_at = row.updated_at ?? row.published_at; + } + + const postsMeta: PostsMetaData = {}; + for (const field of META_FIELDS) { + if (row[field] !== undefined) { + postsMeta[field] = row[field]; + } + } + if (Object.keys(postsMeta).length > 0) { + data.posts_meta = postsMeta; } return data; } + +function toBoolean(value: string): boolean { + return value === '1' || value.toLowerCase() === 'true'; +} diff --git a/ghost/core/core/server/services/content-import/import/reader.ts b/ghost/core/core/server/services/content-import/import/reader.ts index aad0d020300..d1b9100f2af 100644 --- a/ghost/core/core/server/services/content-import/import/reader.ts +++ b/ghost/core/core/server/services/content-import/import/reader.ts @@ -1,14 +1,14 @@ import { parse as parseCSV } from '../csv'; -import { postImportRowSchema, type PostImportRow } from './row'; +import { EDITORIAL_POST_FIELDS, 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', -}; +const FIELD_BY_HEADER: Record = Object.fromEntries( + EDITORIAL_POST_FIELDS.map((field) => [field, field]), +); -export default async function readPostRows(path: string): Promise { - const rows = await parseCSV(path, FIELD_BY_HEADER); +export default async function readPostRows( + path: string, + mapping?: Record, +): Promise { + const rows = await parseCSV(path, mapping ?? 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 index 6421e0032f8..d5dbe4f8ea7 100644 --- a/ghost/core/core/server/services/content-import/import/row.ts +++ b/ghost/core/core/server/services/content-import/import/row.ts @@ -1,6 +1,38 @@ import { z } from 'zod'; import { Temporal } from 'temporal-polyfill'; +export const EDITORIAL_POST_FIELDS = [ + 'title', + 'html', + 'markdown', + 'slug', + 'custom_excerpt', + 'type', + 'status', + 'visibility', + 'featured', + 'created_at', + 'updated_at', + 'published_at', + 'feature_image', + 'feature_image_alt', + 'feature_image_caption', + 'show_title_and_feature_image', + 'meta_title', + 'meta_description', + 'canonical_url', + 'og_image', + 'og_title', + 'og_description', + 'twitter_image', + 'twitter_title', + 'twitter_description', + 'custom_template', + 'codeinjection_head', + 'codeinjection_foot', + 'frontmatter', +] as const; + // An empty cell (or the literal 'undefined') reads as absent, not as a value. const optionalCell = z .string() @@ -16,31 +48,78 @@ export const postImportRowSchema = z .default('') .transform((cell) => cell.trim()), html: z.string().default(''), + markdown: z.string().default(''), + slug: optionalCell, + custom_excerpt: optionalCell, + type: optionalCell, + status: optionalCell, + visibility: optionalCell, + featured: optionalCell, + created_at: optionalCell, + updated_at: optionalCell, published_at: optionalCell, + feature_image: optionalCell, + feature_image_alt: optionalCell, + feature_image_caption: optionalCell, + show_title_and_feature_image: optionalCell, + meta_title: optionalCell, + meta_description: optionalCell, + canonical_url: optionalCell, + og_image: optionalCell, + og_title: optionalCell, + og_description: optionalCell, + twitter_image: optionalCell, + twitter_title: optionalCell, + twitter_description: optionalCell, + custom_template: optionalCell, + codeinjection_head: optionalCell, + codeinjection_foot: optionalCell, + frontmatter: optionalCell, }) .loose(); export type PostImportRow = z.infer; +const ENUM_FIELDS = { + type: ['post', 'page'], + status: ['draft', 'published'], + visibility: ['public', 'members', 'paid'], +} as const; +const BOOLEAN_FIELDS = ['featured', 'show_title_and_feature_image'] as const; +const DATE_FIELDS = ['created_at', 'updated_at', 'published_at'] as const; + // 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}"`, - }); +// skipped under options.importing. Invalid source values are refused per row +// rather than silently replaced with defaults. +export const importableRowSchema = postImportRowSchema.superRefine((row, ctx) => { + if (!row.title) { + ctx.addIssue({ code: 'custom', message: 'title is required' }); + } else if (row.title.length > 255) { + ctx.addIssue({ code: 'custom', message: 'title must be 255 characters or fewer' }); + } + + for (const field of DATE_FIELDS) { + const value = row[field]; + if (value && !isValidDate(value)) { + ctx.addIssue({ code: 'custom', message: `${field} is not a valid date: "${value}"` }); } - }); + } + + for (const [field, allowed] of Object.entries(ENUM_FIELDS)) { + const value = row[field]; + if (typeof value === 'string' && !allowed.includes(value as never)) { + ctx.addIssue({ code: 'custom', message: `${field} must be one of: ${allowed.join(', ')}` }); + } + } + + for (const field of BOOLEAN_FIELDS) { + const value = row[field]; + if (value && !['true', 'false', '1', '0'].includes(value.toLowerCase())) { + ctx.addIssue({ code: 'custom', message: `${field} must be true, false, 1, or 0` }); + } + } +}); // 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 diff --git a/ghost/core/core/server/services/content-import/index.ts b/ghost/core/core/server/services/content-import/index.ts index 684da704604..00f45c11721 100644 --- a/ghost/core/core/server/services/content-import/index.ts +++ b/ghost/core/core/server/services/content-import/index.ts @@ -5,11 +5,34 @@ import ContentCSVImporter, { type FailureReporter, } from './import/importer'; import readPostRows from './import/reader'; +import { EDITORIAL_POST_FIELDS } from './import/row'; 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) }); +const importableFields = new Set(EDITORIAL_POST_FIELDS); +const mappingSchema = z.record(z.string(), z.string()).superRefine((mapping, ctx) => { + const targets = new Set(); + for (const [header, target] of Object.entries(mapping)) { + if (!header || header in Object.prototype) { + ctx.addIssue({ code: 'custom', message: `Invalid CSV header mapping: "${header}"` }); + } + if (target && !importableFields.has(target)) { + ctx.addIssue({ code: 'custom', message: `Unknown post field mapping: "${target}"` }); + } + if (target && targets.has(target)) { + ctx.addIssue({ code: 'custom', message: `Post field is mapped more than once: "${target}"` }); + } + targets.add(target); + } + if (!targets.has('title')) { + ctx.addIssue({ code: 'custom', message: 'Post field mapping must include "title"' }); + } +}); +const importRequestSchema = z.object({ + filePath: z.string().min(1), + mapping: mappingSchema.optional(), +}); // A junk timezone setting falls back to UTC rather than mis-stamping the batch tag. const timezoneSchema = z.string().min(1).catch('Etc/UTC'); @@ -49,6 +72,8 @@ function makeImporter(): ContentCSVImporter { create: (data, options) => models.Post.add(data, options), }, getHtmlToLexical: () => lexicalLib.htmlToLexicalConverter, + getMarkdownToHtml: () => require('@tryghost/kg-markdown-html-renderer').render, + getCleanHTML: () => require('@tryghost/mg-clean-html').cleanHTML, addJob: jobsService.addJob.bind(jobsService), report, store: new ImportRunStore(), @@ -71,5 +96,15 @@ 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)); + + const parsedRequest = importRequestSchema.safeParse(request); + + if (!parsedRequest.success) { + throw new errors.ValidationError({ + message: parsedRequest.error.issues[0]?.message ?? 'Invalid content import request', + err: parsedRequest.error, + }); + } + + return importer.importCSV(parsedRequest.data); } diff --git a/ghost/core/core/server/services/gifts/CONTEXT.md b/ghost/core/core/server/services/gifts/CONTEXT.md index 0665d7662df..75511d2e9cc 100644 --- a/ghost/core/core/server/services/gifts/CONTEXT.md +++ b/ghost/core/core/server/services/gifts/CONTEXT.md @@ -44,6 +44,14 @@ _Avoid_: Gift activation 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 +**Scheduled delivery**: +A gift delivery whose recipient email is planned for a future delivery date. Scheduling delivery does not delay redemption availability. +_Avoid_: Scheduled gift, scheduled redemption + +**Delivery date**: +The publication-local calendar date selected for a scheduled delivery. At 09:00 in the publication timezone it makes the recipient email due; the current site-calendar date means immediate delivery. It does not delay redemption availability. +_Avoid_: Delivery time, deliver-at date + **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 @@ -62,6 +70,18 @@ _Avoid_: Gift sent, delivery state ### Time and lifecycle +**Redemption availability**: +The instant from which a gift subscription may be claimed. It begins at purchase for every gift, including one whose recipient email is scheduled for later. +_Avoid_: Gift activation, delivery time + +**Claim window**: +The period from gift purchase until gift expiration in which an unredeemed gift subscription may be claimed. It may exceed 365 days when delivery is scheduled for a future date. +_Avoid_: Gift duration, access period + +**Expiry anchor**: +The publication-local calendar date from which gift expiration is calculated. It is the selected delivery date for a scheduled delivery and the gift purchase date otherwise. +_Avoid_: Claim-window start, redemption availability + **Gift duration**: The total length of gifted access, measured from redemption. _Avoid_: Gift cadence, billing period @@ -71,13 +91,17 @@ 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 that begins when a gift subscription is purchased and was never redeemed. +The end of an unredeemed gift subscription's claim window. It falls 365 site-calendar days after the expiry anchor; later email handling does not move it. _Avoid_: Gift consumption **Gift consumption**: The end of a redeemed gift subscription's role in providing access, either when its duration finishes or a paid subscription takes over. _Avoid_: Gift expiration, cancellation +**Consumption reminder**: +The email sent to a redeemer, at most once per gift and up to seven days before gift consumption, so gift continuation can be arranged before gifted access ends. +_Avoid_: Expiry reminder, expiration reminder + **Gift continuation**: Starting a paid subscription from gifted access while carrying the remaining gifted time forward before billing begins. _Avoid_: Gift renewal diff --git a/ghost/core/core/server/services/gifts/README.md b/ghost/core/core/server/services/gifts/README.md index 9158aa04f54..a283959ae29 100644 --- a/ghost/core/core/server/services/gifts/README.md +++ b/ghost/core/core/server/services/gifts/README.md @@ -23,9 +23,12 @@ transactions, or Stripe objects. - `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. + interface. Immediate email delivery starts after purchase through an + in-process event; scheduled email delivery becomes eligible at the + GiftDelivery's `scheduledAt` and uses the same processing path. Gift links + remain redeemable from purchase. Delivery claims are atomic; + stale in-progress claims are retried after a crash, so mail-transport + acceptance is at least once. - `GiftDeliveryService.recordOutcome(...)` retains only the newest Mailgun delivery outcome; mail transport acceptance remains the authoritative sent fact. A newly recorded permanent provider failure sends the buyer a diff --git a/ghost/core/core/server/services/gifts/constants.ts b/ghost/core/core/server/services/gifts/constants.ts index 6d1d5a7eadb..2029b6c7c66 100644 --- a/ghost/core/core/server/services/gifts/constants.ts +++ b/ghost/core/core/server/services/gifts/constants.ts @@ -1,4 +1,12 @@ export const GIFT_EXPIRY_DAYS = 365; +// How far ahead a delivery may be scheduled. Portal's picker carries its own +// copy (GIFT_MAX_SCHEDULE_DAYS in beta-gift-page.jsx) since Portal ships +// separately — change them together. +export const GIFT_MAX_SCHEDULE_DAYS = 365; +// Site-local hour a scheduled delivery is sent at. Portal never derives +// this: the exact send instant travels in the success URL as +// gift_scheduled_at. +export const GIFT_SEND_HOUR = 9; 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-buyer-notice-layout.hbs b/ghost/core/core/server/services/gifts/email-templates/gift-buyer-notice-layout.hbs new file mode 100644 index 00000000000..802987ff6c8 --- /dev/null +++ b/ghost/core/core/server/services/gifts/email-templates/gift-buyer-notice-layout.hbs @@ -0,0 +1,66 @@ + + + + + + {{> noticeTitle}} + + + + + + + + + + +
  +
+ + + + +
+ + {{#if siteIconUrl}} + + + + {{/if}} + + + + + + +
{{siteTitle}}
+

{{> noticeTitle}}

+

{{> noticeIntro}}

+ + + + + + +
+

{{gift.link}}

+
+

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

+
+

{{t 'This message was sent from {siteDomain} to {email}.' siteDomain=(linkTag siteUrl siteDomain class="small" style="text-decoration: underline; color: #738A94; font-size: 11px;") email=(linkTag (mailto toEmail) toEmail class="small" style="text-decoration: underline; color: #738A94; font-size: 11px;")}}

+
+
+
+
 
+ + diff --git a/ghost/core/core/server/services/gifts/email-templates/gift-buyer-notice.ts b/ghost/core/core/server/services/gifts/email-templates/gift-buyer-notice.ts new file mode 100644 index 00000000000..c4187e7fba7 --- /dev/null +++ b/ghost/core/core/server/services/gifts/email-templates/gift-buyer-notice.ts @@ -0,0 +1,44 @@ +import type { Translate } from '../gift-email-renderer'; + +export interface GiftBuyerNoticeData { + siteTitle: string; + siteUrl: string; + siteIconUrl: string | null; + siteDomain: string; + toEmail: string; + gift: { + link: string; + expiresAt: string; + // Not read by the shared body; the notices interpolate it into their + // own intro sentences. + recipientEmail?: string | null; + }; +} + +export type GiftRecipientNoticeData = GiftBuyerNoticeData & { + gift: GiftBuyerNoticeData['gift'] & { recipientEmail: string }; +}; + +export function renderBuyerNoticeText( + data: GiftBuyerNoticeData, + t: Translate, + { heading, intro }: { heading: string; intro: string }, +): string { + return `${heading} + +${intro} + +${data.gift.link} + +${t('The link expires on {expiresAt} and can only be redeemed once.', { + expiresAt: data.gift.expiresAt, + interpolation: { escapeValue: false }, +})} + +--- +${t('This message was sent from {siteDomain} to {email}.', { + siteDomain: data.siteDomain, + email: data.toEmail, + interpolation: { escapeValue: false }, +})}`; +} diff --git a/ghost/core/core/server/services/gifts/email-templates/gift-delivery-failure.hbs b/ghost/core/core/server/services/gifts/email-templates/gift-delivery-failure.hbs index 16b4cbcb40e..861124db4b1 100644 --- a/ghost/core/core/server/services/gifts/email-templates/gift-delivery-failure.hbs +++ b/ghost/core/core/server/services/gifts/email-templates/gift-delivery-failure.hbs @@ -1,66 +1,4 @@ - - - - - - {{t 'We couldn\'t deliver your gift'}} - - - - - - - - - - -
  -
- - - - -
- - {{#if siteIconUrl}} - - - - {{/if}} - - - - - - -
{{siteTitle}}
-

{{t 'We couldn\'t deliver your gift'}}

-

{{t 'We couldn\'t deliver your gift to {recipientEmail}. Send them the gift link below so they can redeem it.' recipientEmail=(strong gift.recipientEmail)}}

- - - - - - -
-

{{gift.link}}

-
-

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

-
-

{{t 'This message was sent from {siteDomain} to {email}.' siteDomain=(linkTag siteUrl siteDomain class="small" style="text-decoration: underline; color: #738A94; font-size: 11px;") email=(linkTag (mailto toEmail) toEmail class="small" style="text-decoration: underline; color: #738A94; font-size: 11px;")}}

-
-
-
-
 
- - +{{#*inline "noticeTitle"}}{{t 'We couldn\'t deliver your gift'}}{{/inline}} +{{#*inline "noticePreheader"}}{{t 'Send the gift link manually so it can still be redeemed.'}}{{/inline}} +{{#*inline "noticeIntro"}}{{t 'We couldn\'t deliver your gift to {recipientEmail}. Send them the gift link below so they can redeem it.' recipientEmail=(strong gift.recipientEmail)}}{{/inline}} +{{> giftBuyerNoticeLayout}} diff --git a/ghost/core/core/server/services/gifts/email-templates/gift-delivery-failure.ts b/ghost/core/core/server/services/gifts/email-templates/gift-delivery-failure.ts index 049f25b4a98..00bb23b05f8 100644 --- a/ghost/core/core/server/services/gifts/email-templates/gift-delivery-failure.ts +++ b/ghost/core/core/server/services/gifts/email-templates/gift-delivery-failure.ts @@ -1,40 +1,17 @@ import type { Translate } from '../gift-email-renderer'; +import { renderBuyerNoticeText, type GiftRecipientNoticeData } from './gift-buyer-notice'; -export interface GiftDeliveryFailureData { - siteTitle: string; - siteUrl: string; - siteIconUrl: string | null; - siteDomain: string; - toEmail: string; - gift: { - link: string; - expiresAt: string; - recipientEmail: string; - }; -} +export type GiftDeliveryFailureData = GiftRecipientNoticeData; export function renderText(data: GiftDeliveryFailureData, t: Translate): string { - return `${t("We couldn't deliver your gift")} - -${t( - "We couldn't deliver your gift to {recipientEmail}. Send them the gift link below so they can redeem it.", - { - recipientEmail: data.gift.recipientEmail, - interpolation: { escapeValue: false }, - }, -)} - -${data.gift.link} - -${t('The link expires on {expiresAt} and can only be redeemed once.', { - expiresAt: data.gift.expiresAt, - interpolation: { escapeValue: false }, -})} - ---- -${t('This message was sent from {siteDomain} to {email}.', { - siteDomain: data.siteDomain, - email: data.toEmail, - interpolation: { escapeValue: false }, -})}`; + return renderBuyerNoticeText(data, t, { + heading: t("We couldn't deliver your gift"), + intro: t( + "We couldn't deliver your gift to {recipientEmail}. Send them the gift link below so they can redeem it.", + { + recipientEmail: data.gift.recipientEmail, + interpolation: { escapeValue: false }, + }, + ), + }); } 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 b0a27926c12..5582a1dbfb2 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}} -

{{#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}}

+

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

+

{{#if gift.deliveryDate}}{{#if gift.isMonthly}}{{t 'Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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) deliveryDate=(strong gift.deliveryDate)}}{{else}}{{t 'Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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) deliveryDate=(strong gift.deliveryDate)}}{{/if}}{{else 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 b0b92b5b4f4..402dc08ef0e 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 @@ -1,4 +1,5 @@ import type { Translate } from '../gift-email-renderer'; +import { renderBuyerNoticeText } from './gift-buyer-notice'; export interface GiftPurchaseConfirmationData { siteTitle: string; @@ -14,59 +15,75 @@ export interface GiftPurchaseConfirmationData { link: string; expiresAt: string; recipientEmail: string | null; + // Only ever set alongside recipientEmail: a schedule exists only for + // emailed gifts. + deliveryDate: string | null; }; } -export function renderText(data: GiftPurchaseConfirmationData, t: Translate): string { +// Each translated sentence must stay intact; only the selection between them +// varies. +function renderIntro(data: GiftPurchaseConfirmationData, t: Translate): string { + const gift = data.gift; const giftDescription = { - duration: data.gift.duration, - count: data.gift.duration, - tierName: data.gift.tierName, + duration: gift.duration, + count: gift.duration, + tierName: gift.tierName, siteTitle: data.siteTitle, interpolation: { escapeValue: false }, }; - const intro = data.gift.recipientEmail - ? data.gift.isMonthly + + if (gift.recipientEmail && gift.deliveryDate) { + const scheduled = { + ...giftDescription, + recipientEmail: gift.recipientEmail, + deliveryDate: gift.deliveryDate, + }; + return 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, - }, + 'Thank you for your support. Your gift — a {duration}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. You can also share the link below yourself.', + scheduled, ) : 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 + 'Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. You can also share the link below yourself.', + scheduled, + ); + } + + if (gift.recipientEmail) { + const emailed = { + ...giftDescription, + recipientEmail: gift.recipientEmail, + }; + return 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, + '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.', + emailed, ) : 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, + '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.', + emailed, ); - const heading = data.gift.recipientEmail ? t('Your gift is on its way') : t('Your gift is ready'); - - return `${heading} + } -${intro} - -${data.gift.link} + return 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, + ); +} -${t('The link expires on {expiresAt} and can only be redeemed once.', { - expiresAt: data.gift.expiresAt, - interpolation: { escapeValue: false }, -})} +export function renderText(data: GiftPurchaseConfirmationData, t: Translate): string { + const heading = + data.gift.recipientEmail && data.gift.deliveryDate + ? t('Your gift is scheduled') + : data.gift.recipientEmail + ? t('Your gift is on its way') + : t('Your gift is ready'); ---- -${t('This message was sent from {siteDomain} to {email}.', { - siteDomain: data.siteDomain, - email: data.toEmail, - interpolation: { escapeValue: false }, -})}`; + return renderBuyerNoticeText(data, t, { heading, intro: renderIntro(data, t) }); } diff --git a/ghost/core/core/server/services/gifts/email-templates/gift-sent-confirmation.hbs b/ghost/core/core/server/services/gifts/email-templates/gift-sent-confirmation.hbs new file mode 100644 index 00000000000..4ec8ad09b53 --- /dev/null +++ b/ghost/core/core/server/services/gifts/email-templates/gift-sent-confirmation.hbs @@ -0,0 +1,4 @@ +{{#*inline "noticeTitle"}}{{t 'Your gift has been sent'}}{{/inline}} +{{#*inline "noticePreheader"}}{{t 'Your gift has been sent'}}{{/inline}} +{{#*inline "noticeIntro"}}{{t 'Your gift was sent to {recipientEmail}. You can also share the link below yourself.' recipientEmail=(strong gift.recipientEmail)}}{{/inline}} +{{> giftBuyerNoticeLayout}} diff --git a/ghost/core/core/server/services/gifts/email-templates/gift-sent-confirmation.ts b/ghost/core/core/server/services/gifts/email-templates/gift-sent-confirmation.ts new file mode 100644 index 00000000000..8cdac813843 --- /dev/null +++ b/ghost/core/core/server/services/gifts/email-templates/gift-sent-confirmation.ts @@ -0,0 +1,17 @@ +import type { Translate } from '../gift-email-renderer'; +import { renderBuyerNoticeText, type GiftRecipientNoticeData } from './gift-buyer-notice'; + +export type GiftSentConfirmationData = GiftRecipientNoticeData; + +export function renderText(data: GiftSentConfirmationData, t: Translate): string { + return renderBuyerNoticeText(data, t, { + heading: t('Your gift has been sent'), + intro: t( + 'Your gift was sent to {recipientEmail}. You can also share the link below yourself.', + { + recipientEmail: data.gift.recipientEmail, + interpolation: { escapeValue: false }, + }, + ), + }); +} diff --git a/ghost/core/core/server/services/gifts/events/start-gift-delivery-flush-event.ts b/ghost/core/core/server/services/gifts/events/start-gift-delivery-flush-event.ts new file mode 100644 index 00000000000..16bc99b5d42 --- /dev/null +++ b/ghost/core/core/server/services/gifts/events/start-gift-delivery-flush-event.ts @@ -0,0 +1,11 @@ +export class StartGiftDeliveryFlushEvent { + readonly timestamp: Date; + + constructor(timestamp: Date) { + this.timestamp = timestamp; + } + + static create(timestamp = new Date()) { + return new StartGiftDeliveryFlushEvent(timestamp); + } +} diff --git a/ghost/core/core/server/services/gifts/gift-date.ts b/ghost/core/core/server/services/gifts/gift-date.ts new file mode 100644 index 00000000000..da71d531965 --- /dev/null +++ b/ghost/core/core/server/services/gifts/gift-date.ts @@ -0,0 +1,33 @@ +const DEFAULT_DATE_LOCALE = 'en-gb'; +const DEFAULT_TIMEZONE = 'Etc/UTC'; + +function dateFormatterFor(locale: string, timeZone: string): Intl.DateTimeFormat | null { + try { + return new Intl.DateTimeFormat(locale, { + day: 'numeric', + month: 'short', + year: 'numeric', + timeZone, + }); + } catch { + return null; + } +} + +export function formatGiftDate( + date: Date, + { locale, timeZone }: { locale?: string | null; timeZone?: string | null }, +): string { + const publicationLocale = locale || DEFAULT_DATE_LOCALE; + const publicationTimeZone = timeZone || DEFAULT_TIMEZONE; + + // Publication settings are stored unvalidated. Drop them one at a time so + // a bad locale keeps the publication timezone, while a bad timezone still + // falls back to a stable UTC date. + const formatter = + dateFormatterFor(publicationLocale, publicationTimeZone) || + dateFormatterFor(DEFAULT_DATE_LOCALE, publicationTimeZone) || + dateFormatterFor(DEFAULT_DATE_LOCALE, DEFAULT_TIMEZONE)!; + + return formatter.format(date); +} 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 index 7284ba95961..0b569901d42 100644 --- a/ghost/core/core/server/services/gifts/gift-delivery-bookshelf-repository.ts +++ b/ghost/core/core/server/services/gifts/gift-delivery-bookshelf-repository.ts @@ -1,6 +1,9 @@ +import errors from '@tryghost/errors'; import type { Knex } from 'knex'; -import { toDatabaseDate } from '../../lib/db-date'; +import { fromDatabaseDate, toDatabaseDate } from '../../lib/db-date'; +import { decodeGiftRow } from './gift-codec'; import { decodeGiftDeliveryRow, encodeGiftDelivery } from './gift-delivery-codec'; +import type { Gift } from './gift'; import type { GiftDeliveryData, GiftDeliveryOutcome, @@ -10,6 +13,11 @@ import type { RepositoryTransactionOptions } from './gift-bookshelf-repository'; export type GiftDeliveryOutcomeRecordResult = 'recorded' | 'stale' | 'not_found'; +export interface RecoverableGiftDelivery { + delivery: GiftDeliveryData; + gift: Gift; +} + export interface GiftDeliveryRepository { getById(id: string, options?: RepositoryTransactionOptions): Promise; getByGiftId( @@ -17,7 +25,12 @@ export interface GiftDeliveryRepository { options?: RepositoryTransactionOptions, ): Promise; getByProviderMessageId(providerMessageId: string): Promise; - findRecoverableForPurchasedGifts(staleBefore: Date, limit: number): Promise; + findRecoverableForPurchasedGifts( + now: Date, + staleBefore: Date, + limit: number, + ): Promise; + findScheduledTimesForPurchasedGifts(now: Date): Promise; tryStartDelivery(id: string, now: Date, staleBefore: Date): Promise; markSent(id: string, sentAt: Date, providerMessageId: string | null): Promise; recordCancelledAcceptance( @@ -56,6 +69,16 @@ type GiftDeliveryBookshelfModel = { ): Promise | null>; }; +// A null scheduled_at means the delivery was due at purchase time, so it is +// always due. Kept sargable against the (status, scheduled_at) index. +function dueDeliveries(query: Knex.QueryBuilder, now: Date): void { + query.andWhere((due) => { + due + .whereNull('gift_deliveries.scheduled_at') + .orWhere('gift_deliveries.scheduled_at', '<=', toDatabaseDate(now)); + }); +} + // 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 { @@ -111,17 +134,58 @@ export class GiftDeliveryBookshelfRepository implements GiftDeliveryRepository { } async findRecoverableForPurchasedGifts( + now: Date, staleBefore: Date, limit: number, - ): Promise { + ): Promise { const rows = await this.knex('gift_deliveries') .select('gift_deliveries.*') .join('gifts', 'gifts.id', 'gift_deliveries.gift_id') .where('gifts.status', 'purchased') + .modify(dueDeliveries, now) .modify(recoverableDeliveries, staleBefore) + .orderByRaw('COALESCE(gift_deliveries.scheduled_at, gifts.purchased_at) ASC') .limit(limit); - return rows.map(decodeGiftDeliveryRow); + const deliveries: GiftDeliveryData[] = rows.map((row: unknown) => decodeGiftDeliveryRow(row)); + if (deliveries.length === 0) { + return []; + } + + const giftRows = await this.knex('gifts') + .select('*') + .whereIn( + 'id', + deliveries.map((delivery) => delivery.giftId), + ); + const giftsById = new Map( + giftRows.map((row: Record) => [String(row.id), decodeGiftRow(row)]), + ); + + return deliveries.map((delivery) => { + const gift = giftsById.get(delivery.giftId); + if (!gift) { + throw new errors.InternalServerError({ + message: `Gift not found for recoverable delivery ${delivery.id}`, + }); + } + + return { delivery, gift }; + }); + } + + // Distinct times only: the scheduler arms one flush job per scheduled + // time, so scheduled gifts clustering on a popular date collapse to a + // single row instead of one per delivery. + async findScheduledTimesForPurchasedGifts(now: Date): Promise { + const rows = await this.knex('gift_deliveries') + .distinct('gift_deliveries.scheduled_at') + .join('gifts', 'gifts.id', 'gift_deliveries.gift_id') + .where('gift_deliveries.status', 'pending') + .where('gifts.status', 'purchased') + .where('gift_deliveries.scheduled_at', '>', toDatabaseDate(now)); + + return rows.map((row) => fromDatabaseDate(row.scheduled_at)); } async tryStartDelivery( @@ -131,6 +195,7 @@ export class GiftDeliveryBookshelfRepository implements GiftDeliveryRepository { ): Promise { const claimed = await this.knex('gift_deliveries') .where({ id }) + .modify(dueDeliveries, now) .whereExists((query) => { query .select('gifts.id') diff --git a/ghost/core/core/server/services/gifts/gift-delivery-schema.ts b/ghost/core/core/server/services/gifts/gift-delivery-schema.ts index b1a86372c1c..abe3d034f08 100644 --- a/ghost/core/core/server/services/gifts/gift-delivery-schema.ts +++ b/ghost/core/core/server/services/gifts/gift-delivery-schema.ts @@ -24,6 +24,7 @@ export const DbGiftDelivery = z.object({ gift_id: z.string(), recipient_email: z.string().email(), status: GiftDeliveryStatusSchema.default('pending'), + scheduled_at: DbDate.nullable().default(null), started_at: DbDate.nullable().default(null), email_sent_at: DbDate.nullable().default(null), email_provider_message_id: z.string().nullable().default(null), diff --git a/ghost/core/core/server/services/gifts/gift-delivery-service.ts b/ghost/core/core/server/services/gifts/gift-delivery-service.ts index 0e4d17e87ef..1e06ff6875f 100644 --- a/ghost/core/core/server/services/gifts/gift-delivery-service.ts +++ b/ghost/core/core/server/services/gifts/gift-delivery-service.ts @@ -7,11 +7,27 @@ import type { } from './gift-delivery-bookshelf-repository'; import type { GiftDeliveryData } from './gift-delivery-schema'; import type { GiftCadence } from './gift-schema'; +import type { GiftFlushScheduler } from './gift-flush-scheduler'; +import type { Gift } from './gift'; import { SendGiftDeliveryEvent } from './events/send-gift-delivery-event'; import { GIFT_DELIVERY_STALE_AFTER_MS } from './constants'; const DomainEvents = require('@tryghost/domain-events'); +const RECOVERY_CONCURRENCY = 10; + +// A delivery counts as scheduled only when its send time was still in the +// future at purchase: immediate deliveries have no scheduledAt, and a checkout +// that completes after its scheduled date sends straight away, so the buyer's +// purchase confirmation already covers the send. +function wasScheduledDelivery(delivery: GiftDeliveryData, gift: Gift): boolean { + return Boolean( + delivery.scheduledAt && + gift.purchasedAt && + delivery.scheduledAt.getTime() > gift.purchasedAt.getTime(), + ); +} + interface Tier { name: string; toJSON(): { @@ -45,6 +61,12 @@ interface GiftEmailService { token: string; expiresAt: Date; }): Promise; + sendGiftSentConfirmation(data: { + buyerEmail: string; + recipientEmail: string; + token: string; + expiresAt: Date; + }): Promise; } interface GiftDeliveryServiceDeps { @@ -55,6 +77,7 @@ interface GiftDeliveryServiceDeps { giftEmailAnalytics: { schedule(): Promise; }; + giftDeliveryScheduler: Pick; } export interface GiftDeliveryRecoveryResult { @@ -63,6 +86,11 @@ export interface GiftDeliveryRecoveryResult { failedCount: number; } +export interface GiftDeliveryDispatchResult { + recipientEmail: string; + scheduledAt: Date | null; +} + export class GiftDeliveryService { private readonly deps: GiftDeliveryServiceDeps; @@ -71,7 +99,11 @@ export class GiftDeliveryService { } async createForCheckout( - { giftId, recipientEmail }: { giftId: string; recipientEmail: string }, + { + giftId, + recipientEmail, + scheduledAt, + }: { giftId: string; recipientEmail: string; scheduledAt: Date | null }, options: RepositoryTransactionOptions = {}, ): Promise { const delivery: GiftDeliveryData = { @@ -79,6 +111,7 @@ export class GiftDeliveryService { giftId, recipientEmail, status: 'pending', + scheduledAt, startedAt: null, emailSentAt: null, emailProviderMessageId: null, @@ -91,51 +124,92 @@ export class GiftDeliveryService { return delivery.id; } - async dispatchForGift(giftId: string): Promise { + async dispatchForGift({ + giftId, + }: { + giftId: string; + }): Promise { const delivery = await this.deps.giftDeliveryRepository.getByGiftId(giftId); if (!delivery || delivery.status !== 'pending') { - return delivery?.recipientEmail ?? null; + return delivery + ? { recipientEmail: delivery.recipientEmail, scheduledAt: delivery.scheduledAt } + : null; + } + + if (delivery.scheduledAt && delivery.scheduledAt.getTime() > Date.now()) { + await this.deps.giftDeliveryScheduler.scheduleAt(delivery.scheduledAt.getTime(), { + deliveryId: delivery.id, + }); + // The scheduled time can pass while the flush is being armed, in which + // case the armed check skipped the job — fall through and send + // now rather than leaving the delivery for the daily backstop. + if (delivery.scheduledAt.getTime() > Date.now()) { + return { recipientEmail: delivery.recipientEmail, scheduledAt: delivery.scheduledAt }; + } } DomainEvents.dispatch(SendGiftDeliveryEvent.create({ deliveryId: delivery.id })); - return delivery.recipientEmail; + return { recipientEmail: delivery.recipientEmail, scheduledAt: delivery.scheduledAt }; } - 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, - ); + async recoverPending(batchSize = 1000): Promise { 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', - ); + // A scheduled flush fires once, so drain until nothing recoverable + // remains; the batch cap only bounds a pathological send that leaves + // rows in the recoverable set. + const maxBatches = 100; + for (let batch = 0; batch < maxBatches; batch++) { + const now = new Date(); + const staleBefore = new Date(now.getTime() - GIFT_DELIVERY_STALE_AFTER_MS); + const deliveries = await this.deps.giftDeliveryRepository.findRecoverableForPurchasedGifts( + now, + staleBefore, + batchSize, + ); + + let nextIndex = 0; + const recoverNext = async () => { + while (nextIndex < deliveries.length) { + const { delivery, gift } = deliveries[nextIndex]; + nextIndex += 1; + try { + const deliveryResult = await this.send(delivery.id, gift); + 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', + ); + } + } + }; + + await Promise.all( + Array.from({ length: Math.min(RECOVERY_CONCURRENCY, deliveries.length) }, recoverNext), + ); + + if (deliveries.length < batchSize) { + break; } } return result; } + async reschedulePending(): Promise { + await this.deps.giftDeliveryScheduler.rescheduleAll(); + } + async cancelPendingForGift( token: string, options: RepositoryTransactionOptions = {}, @@ -143,7 +217,7 @@ export class GiftDeliveryService { return this.deps.giftDeliveryRepository.cancelPendingForGift(token, options); } - async send(id: string): Promise<'sent' | 'skipped' | 'failed'> { + async send(id: string, giftSnapshot?: Gift): 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); @@ -152,7 +226,7 @@ export class GiftDeliveryService { return 'skipped'; } - const gift = await this.deps.giftRepository.getById(delivery.giftId); + const gift = giftSnapshot ?? (await this.deps.giftRepository.getById(delivery.giftId)); if (!gift) { logging.error( { @@ -317,6 +391,29 @@ export class GiftDeliveryService { } } + if (persisted && wasScheduledDelivery(delivery, gift)) { + // Best effort: buyer confirmations have no durable retry state; + // recipient delivery is unaffected. + try { + await this.deps.giftEmailService.sendGiftSentConfirmation({ + buyerEmail: gift.buyerEmail, + recipientEmail: delivery.recipientEmail, + token: gift.token, + expiresAt: gift.expiresAt!, + }); + } catch (err) { + logging.error( + { + event: { name: 'gift_delivery.sent_confirmation.failed' }, + err, + deliveryId: delivery.id, + giftId: delivery.giftId, + }, + 'Failed to send gift sent confirmation to buyer', + ); + } + } + if (persisted) { return 'sent'; } 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 2efd2bf149c..5ba348ff430 100644 --- a/ghost/core/core/server/services/gifts/gift-email-renderer.ts +++ b/ghost/core/core/server/services/gifts/gift-email-renderer.ts @@ -9,17 +9,16 @@ 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'; +import type { GiftSentConfirmationData } from './email-templates/gift-sent-confirmation'; +import { renderText as renderSentConfirmationText } from './email-templates/gift-sent-confirmation'; export type Translate = (key: string, options?: Record) => string; export class GiftEmailRenderer { private readonly handlebars: typeof Handlebars; private readonly t: Translate; - - private purchaseConfirmationTemplate: HandlebarsTemplateDelegate | null = null; - private deliveryFailureTemplate: HandlebarsTemplateDelegate | null = null; - private reminderTemplate: HandlebarsTemplateDelegate | null = null; - private deliveryTemplate: HandlebarsTemplateDelegate | null = null; + private readonly templates = new Map>(); + private readonly partials = new Map>(); constructor({ t }: { t: Translate }) { this.t = t; @@ -30,69 +29,83 @@ export class GiftEmailRenderer { async renderPurchaseConfirmation( data: GiftPurchaseConfirmationData, ): Promise<{ html: string; text: string }> { - if (!this.purchaseConfirmationTemplate) { - const source = await fs.readFile( - path.join(__dirname, './email-templates/gift-purchase-confirmation.hbs'), - 'utf8', - ); - - this.purchaseConfirmationTemplate = this.handlebars.compile(source); - } + const template = await this.getTemplate('gift-purchase-confirmation.hbs'); return { - html: this.purchaseConfirmationTemplate(data), + html: template(data), text: renderPurchaseConfirmationText(data, this.t), }; } + private async getTemplate(filename: string): Promise { + let template = this.templates.get(filename); + if (!template) { + template = (async () => { + await this.ensurePartial('giftBuyerNoticeLayout', 'gift-buyer-notice-layout.hbs'); + const source = await fs.readFile( + path.join(__dirname, './email-templates', filename), + 'utf8', + ); + return this.handlebars.compile(source); + })(); + this.templates.set(filename, template); + } + + return template; + } + + private async ensurePartial(name: string, filename: string): Promise { + let registration = this.partials.get(name); + if (!registration) { + registration = fs + .readFile(path.join(__dirname, './email-templates', filename), 'utf8') + .then((source) => this.handlebars.registerPartial(name, source)); + this.partials.set(name, registration); + } + + await registration; + } + async renderDeliveryFailure( data: GiftDeliveryFailureData, ): Promise<{ html: string; text: string }> { - if (!this.deliveryFailureTemplate) { - const source = await fs.readFile( - path.join(__dirname, './email-templates/gift-delivery-failure.hbs'), - 'utf8', - ); - this.deliveryFailureTemplate = this.handlebars.compile(source); - } + const template = await this.getTemplate('gift-delivery-failure.hbs'); return { - html: this.deliveryFailureTemplate(data), + html: template(data), text: renderDeliveryFailureText(data, this.t), }; } async renderReminder(data: GiftReminderData): Promise<{ html: string; text: string }> { - if (!this.reminderTemplate) { - const source = await fs.readFile( - path.join(__dirname, './email-templates/gift-reminder.hbs'), - 'utf8', - ); - - this.reminderTemplate = this.handlebars.compile(source); - } + const template = await this.getTemplate('gift-reminder.hbs'); return { - html: this.reminderTemplate(data), + html: template(data), text: renderReminderText(data, this.t), }; } 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); - } + const template = await this.getTemplate('gift-delivery.hbs'); return { - html: this.deliveryTemplate(data), + html: template(data), text: renderDeliveryText(data, this.t), }; } + async renderSentConfirmation( + data: GiftSentConfirmationData, + ): Promise<{ html: string; text: string }> { + const template = await this.getTemplate('gift-sent-confirmation.hbs'); + + return { + html: template(data), + text: renderSentConfirmationText(data, this.t), + }; + } + private registerTemplateHelpers(): void { this.handlebars.registerHelper('t', (key: string, options?: Handlebars.HelperOptions) => { const hash = options?.hash || {}; 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 3dc12eb5354..1238fdf87a6 100644 --- a/ghost/core/core/server/services/gifts/gift-email-service.ts +++ b/ghost/core/core/server/services/gifts/gift-email-service.ts @@ -1,12 +1,12 @@ import { GiftEmailRenderer, Translate } from './gift-email-renderer'; +import type { GiftRecipientNoticeData } from './email-templates/gift-buyer-notice'; import type { GiftCadence } from './gift-schema'; import { Color } from '@tryghost/color-utils'; import errors from '@tryghost/errors'; import { getMailgunMessageId } from '../lib/mailgun-message-id'; import { GIFT_DELIVERY_EMAIL_TAG } from './constants'; +import { formatGiftDate } from './gift-date'; -const DEFAULT_DATE_LOCALE = 'en-gb'; -const DEFAULT_TIMEZONE = 'Etc/UTC'; const DEFAULT_ACCENT_COLOR = '#15212A'; interface TransactionalMailer { @@ -58,6 +58,7 @@ interface PurchaseConfirmationData { cadence: GiftCadence; duration: number; expiresAt: Date; + scheduledAt: Date | null; recipientEmail?: string | null; } @@ -82,13 +83,18 @@ interface GiftDeliverySendData { expiresAt: Date; } -interface GiftDeliveryFailureNotificationData { +// Buyer notices about a delivery share one payload today; the neutral name +// keeps either email free to grow fields without silently widening the other. +interface GiftDeliveryNoticeData { buyerEmail: string; recipientEmail: string; token: string; expiresAt: Date; } +type GiftDeliveryFailureNotificationData = GiftDeliveryNoticeData; +type GiftSentConfirmationData = GiftDeliveryNoticeData; + export class GiftEmailService { private readonly transactionalMailer: TransactionalMailer; private readonly bulkMailer: BulkMailer; @@ -155,38 +161,11 @@ export class GiftEmailService { return this.mixAccentColor('#15212A', 0.72, '#738A94'); } - private dateFormatterFor(locale: string, timeZone: string): Intl.DateTimeFormat | null { - try { - return new Intl.DateTimeFormat(locale, { - day: 'numeric', - month: 'short', - year: 'numeric', - timeZone, - }); - } catch (err) { - return null; - } - } - private formatDate(date: Date): string { - const locale = this.settingsCache.get('locale') || DEFAULT_DATE_LOCALE; - const timeZone = this.settingsCache.get('timezone') || DEFAULT_TIMEZONE; - - // A publication's locale is stored unvalidated, so a tag Intl rejects - // ("en_US") would otherwise throw a RangeError that the mail callers - // swallow, silently dropping every gift email. Drop the publication's - // settings one at a time instead. - const formatter = - this.dateFormatterFor(locale, timeZone) || - this.dateFormatterFor(DEFAULT_DATE_LOCALE, timeZone) || - new Intl.DateTimeFormat(DEFAULT_DATE_LOCALE, { - day: 'numeric', - month: 'short', - year: 'numeric', - timeZone: DEFAULT_TIMEZONE, - }); - - return formatter.format(date); + return formatGiftDate(date, { + locale: this.settingsCache.get('locale'), + timeZone: this.settingsCache.get('timezone'), + }); } async sendPurchaseConfirmation({ @@ -196,6 +175,7 @@ export class GiftEmailService { cadence, duration, expiresAt, + scheduledAt, recipientEmail = null, }: PurchaseConfirmationData): Promise { const siteDomain = this.siteDomain; @@ -203,6 +183,8 @@ export class GiftEmailService { const siteTitle = this.settingsCache.get('title') ?? siteDomain; const giftLink = `${siteUrl.replace(/\/$/, '')}/gift/${token}`; + const scheduled = Boolean(recipientEmail) && scheduledAt && scheduledAt.getTime() > Date.now(); + const deliveryDate = scheduled ? this.formatDate(scheduledAt) : null; const { html, text } = await this.renderer.renderPurchaseConfirmation({ siteTitle, siteUrl, @@ -217,12 +199,20 @@ export class GiftEmailService { link: giftLink, expiresAt: this.formatDate(expiresAt), recipientEmail, + deliveryDate, }, }); await this.transactionalMailer.send({ to: buyerEmail, - subject: recipientEmail ? this.t('Your gift is on its way') : this.t('Your gift is ready'), + subject: scheduled + ? this.t('Your gift will be sent on {deliveryDate}', { + deliveryDate, + interpolation: { escapeValue: false }, + }) + : recipientEmail + ? this.t('Your gift is on its way') + : this.t('Your gift is ready'), html, text, from: this.getFromAddress(), @@ -231,17 +221,35 @@ export class GiftEmailService { }); } - async sendDeliveryFailureNotification({ - buyerEmail, - recipientEmail, - token, - expiresAt, - }: GiftDeliveryFailureNotificationData): Promise { + async sendGiftSentConfirmation(data: GiftSentConfirmationData): Promise { + await this.sendBuyerNotice(data, { + subject: this.t('Your gift has been sent'), + render: (payload) => this.renderer.renderSentConfirmation(payload), + }); + } + + async sendDeliveryFailureNotification(data: GiftDeliveryFailureNotificationData): Promise { + await this.sendBuyerNotice(data, { + subject: this.t("We couldn't deliver your gift"), + render: (payload) => this.renderer.renderDeliveryFailure(payload), + }); + } + + private async sendBuyerNotice( + { buyerEmail, recipientEmail, token, expiresAt }: GiftDeliveryNoticeData, + { + subject, + render, + }: { + subject: string; + render: (_payload: GiftRecipientNoticeData) => Promise<{ html: string; text: string }>; + }, + ): Promise { 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.renderDeliveryFailure({ + const { html, text } = await render({ siteTitle, siteUrl, siteIconUrl: this.blogIcon.getIconUrl({ absolute: true, fallbackToDefault: false }), @@ -256,7 +264,7 @@ export class GiftEmailService { await this.transactionalMailer.send({ to: buyerEmail, - subject: this.t("We couldn't deliver your gift"), + subject, html, text, from: this.getFromAddress(), diff --git a/ghost/core/core/server/services/gifts/gift-flush-scheduler.ts b/ghost/core/core/server/services/gifts/gift-flush-scheduler.ts new file mode 100644 index 00000000000..19c024cd280 --- /dev/null +++ b/ghost/core/core/server/services/gifts/gift-flush-scheduler.ts @@ -0,0 +1,163 @@ +import logging from '@tryghost/logging'; +import type { SchedulerAdapter, SchedulerJob } from '@tryghost/adapter-base-scheduling'; +import type { InternalApiKey, InternalKeys } from '../internal-keys'; + +const urlUtils = require('../../../shared/url-utils').default; +const { getSignedAdminToken } = require('../../adapters/scheduling/utils'); + +// The default scheduling adapter can ping up to 50ms before the scheduled +// time, and the flush queries truncate "now" to whole seconds — an exactly +// on-time job could find nothing due and be consumed without sending +// anything. Arming each job just past its second avoids that. +const FLUSH_DELAY_MS = 1000; + +function normalizeTime(time: number): number { + return Math.floor(time / 1000) * 1000; +} + +interface GiftFlushSchedulerOptions { + apiUrl: string; + adapter: SchedulerAdapter; + internalKeys: InternalKeys; + endpoint: 'flush_reminders' | 'flush_deliveries'; + // snake_case identifier used to derive log event names and messages. + name: string; + // Times (ms since epoch) still needing a flush job, used to rebuild the + // adapter queue at boot and on key rotation. + findScheduledTimes(): Promise; +} + +/** + * Arms one-shot Admin API flush callbacks through the scheduling adapter, + * deduplicated by fire time. + */ +export class GiftFlushScheduler { + readonly #apiUrl: string; + readonly #adapter: SchedulerAdapter; + readonly #internalKeys: InternalKeys; + readonly #endpoint: GiftFlushSchedulerOptions['endpoint']; + readonly #name: string; + readonly #findScheduledTimes: GiftFlushSchedulerOptions['findScheduledTimes']; + readonly #scheduledTimes = new Set(); + + constructor({ + apiUrl, + adapter, + internalKeys, + endpoint, + name, + findScheduledTimes, + }: GiftFlushSchedulerOptions) { + this.#apiUrl = apiUrl; + this.#adapter = adapter; + this.#internalKeys = internalKeys; + this.#endpoint = endpoint; + this.#name = name; + this.#findScheduledTimes = findScheduledTimes; + this.#adapter.register(this); + } + + /** + * Arm a flush for the given fire time. Already-due work is left to the + * daily recovery jobs. + */ + async scheduleAt(time: number, logContext: Record = {}): Promise { + time = normalizeTime(time); + const now = Date.now(); + if (time <= now) { + return; + } + + // Prune elapsed one-shot times so the set can't grow for the process + // lifetime. + for (const armed of this.#scheduledTimes) { + if (armed <= now) { + this.#scheduledTimes.delete(armed); + } + } + + if (this.#scheduledTimes.has(time)) { + return; + } + + try { + const key = await this.#internalKeys.get('ghost-scheduler'); + + // A concurrent call may have armed this time during the key fetch. + if (this.#scheduledTimes.has(time)) { + return; + } + + const job = this.#buildJob(time, key); + this.#scheduledTimes.add(time); + + // schedule() captures asynchronous failures instead of throwing, so a + // silent failure leaves the time marked armed; the daily recovery pass + // sends the work at most a day late. + this.#adapter.schedule(job); + } catch (err) { + // A synchronous key, job-construction, or adapter failure must not escape + // into the completed gift flow. Remove any marker so a later call can + // retry; daily recovery handles the work if it does not. + this.#scheduledTimes.delete(time); + logging.error( + { + event: { name: `${this.#name}_scheduler.schedule.failed` }, + err, + ...logContext, + }, + `Failed to schedule ${this.#name.replaceAll('_', ' ')}`, + ); + } + } + + /** + * Rebuilds pending flushes under the current key, unscheduling + * previous-key jobs during rotation; recovery handles elapsed times. + */ + async rescheduleAll({ previousKey }: { previousKey?: InternalApiKey } = {}): Promise { + const currentKey = await this.#internalKeys.get('ghost-scheduler'); + const pending = await this.#findScheduledTimes(); + + // On boot, unscheduling an identical URL and time would tombstone the + // rebuilt job; only key rotation has a distinct stale URL to + // unschedule. + const bootstrap = !previousKey; + const scheduledTimes = new Set(pending.map(normalizeTime)); + + this.#scheduledTimes.clear(); + + for (const time of scheduledTimes) { + if (time <= Date.now()) { + continue; + } + const job = this.#buildJob(time, currentKey); + // Reuse the boot job so correctness doesn't depend on token + // signing being deterministic. + const staleJob = previousKey ? this.#buildJob(time, previousKey) : job; + this.#adapter.unschedule(staleJob, { bootstrap }); + + // Before GiftFlushScheduler, reminder jobs were armed without the + // one-second flush delay. During the first scheduler-key rotation after + // an upgrade, tombstone that legacy URL+time as well. + if (previousKey && this.#endpoint === 'flush_reminders') { + this.#adapter.unschedule(this.#buildJob(time, previousKey, 0), { bootstrap }); + } + + this.#adapter.schedule(job); + this.#scheduledTimes.add(time); + } + } + + #buildJob(time: number, key: InternalApiKey, delayMs = FLUSH_DELAY_MS): SchedulerJob { + const jobTime = time + delayMs; + const signedAdminToken = getSignedAdminToken({ + publishedAt: new Date(jobTime).toISOString(), + apiUrl: this.#apiUrl, + key, + }); + const url = new URL(urlUtils.urlJoin(this.#apiUrl, 'gifts', this.#endpoint)); + url.searchParams.set('token', signedAdminToken); + return { time: jobTime, url: url.toString(), extra: { httpMethod: 'PUT' } }; + } +} diff --git a/ghost/core/core/server/services/gifts/gift-reminder-scheduler.ts b/ghost/core/core/server/services/gifts/gift-reminder-scheduler.ts deleted file mode 100644 index 6a8fe8312dc..00000000000 --- a/ghost/core/core/server/services/gifts/gift-reminder-scheduler.ts +++ /dev/null @@ -1,106 +0,0 @@ -import logging from '@tryghost/logging'; -import { Gift } from './gift'; -import type { SchedulerAdapter, SchedulerJob } from '@tryghost/adapter-base-scheduling'; -import type { InternalApiKey, InternalKeys } from '../internal-keys'; -import { GIFT_REMINDER_LEAD_DAYS } from './constants'; -// Same-domain (scheduling) primitives, used unconditionally. -const urlUtils = require('../../../shared/url-utils').default; -const { getSignedAdminToken } = require('../../adapters/scheduling/utils'); - -const MS_PER_DAY = 24 * 60 * 60 * 1000; -const GIFT_REMINDER_LEAD_MS = GIFT_REMINDER_LEAD_DAYS * MS_PER_DAY; - -interface GiftReminderSchedulerDeps { - apiUrl: string; - adapter: SchedulerAdapter; - internalKeys: InternalKeys; - findUnsentReminders(): Promise; -} - -export class GiftReminderScheduler { - readonly #apiUrl: string; - readonly #adapter: SchedulerAdapter; - readonly #internalKeys: InternalKeys; - readonly #findUnsentReminders: () => Promise; - - constructor({ apiUrl, adapter, internalKeys, findUnsentReminders }: GiftReminderSchedulerDeps) { - this.#apiUrl = apiUrl; - this.#adapter = adapter; - this.#internalKeys = internalKeys; - this.#findUnsentReminders = findUnsentReminders; - this.#adapter.register(this); - } - - /** - * Queue a single reminder callback for a freshly-redeemed gift. The - * callback fires at consumesAt - GIFT_REMINDER_LEAD_DAYS. Already-due - * reminders are skipped — the daily cron picks them up. - */ - async scheduleFor(gift: Gift): Promise { - if (!gift.consumesAt) { - return; - } - const time = gift.consumesAt.getTime() - GIFT_REMINDER_LEAD_MS; - if (time <= Date.now()) { - return; - } - - try { - const key = await this.#internalKeys.get('ghost-scheduler'); - this.#adapter.schedule(this.#buildJob(time, key)); - } catch (err) { - logging.error( - { - event: { name: 'gift_reminder_scheduler.schedule.failed' }, - err, - giftToken: gift.token, - }, - 'Failed to schedule gift reminder', - ); - } - } - - /** - * Re-issue every queued reminder under the current scheduler key. Pass - * the pre-rotation secret as `previousKey` so each adapter-queued URL - * can be reconstructed for unschedule before resigning with the new - * key. Reminders whose fire time has already passed are skipped — the - * daily cron picks them up. - */ - async rescheduleAll({ previousKey }: { previousKey?: InternalApiKey } = {}): Promise { - const currentKey = await this.#internalKeys.get('ghost-scheduler'); - const unscheduleKey = previousKey ?? currentKey; - const pending = await this.#findUnsentReminders(); - - // Same-key rebuild (no previousKey, boot path) → URL signature is - // identical to the about-to-be-scheduled job. The default adapter - // implements unschedule via tombstones keyed by URL+time, so a same-URL - // unschedule poisons the scheduled job. Bootstrap mode skips the - // tombstone write. Rotation (previousKey provided) → URLs differ, so - // the tombstone correctly targets the old queued entry. - const bootstrap = !previousKey; - - for (const gift of pending) { - if (!gift.consumesAt) { - continue; - } - const time = gift.consumesAt.getTime() - GIFT_REMINDER_LEAD_MS; - if (time <= Date.now()) { - continue; - } - this.#adapter.unschedule(this.#buildJob(time, unscheduleKey), { bootstrap }); - this.#adapter.schedule(this.#buildJob(time, currentKey)); - } - } - - #buildJob(time: number, key: InternalApiKey): SchedulerJob { - const signedAdminToken = getSignedAdminToken({ - publishedAt: new Date(time).toISOString(), - apiUrl: this.#apiUrl, - key, - }); - const url = new URL(urlUtils.urlJoin(this.#apiUrl, 'gifts', 'flush_reminders')); - url.searchParams.set('token', signedAdminToken); - return { time, url: url.toString(), extra: { httpMethod: 'PUT' } }; - } -} diff --git a/ghost/core/core/server/services/gifts/gift-service.ts b/ghost/core/core/server/services/gifts/gift-service.ts index 297a0a94317..fe81ff15873 100644 --- a/ghost/core/core/server/services/gifts/gift-service.ts +++ b/ghost/core/core/server/services/gifts/gift-service.ts @@ -10,11 +10,16 @@ import type { GiftEventPage, GiftRepository, } from './gift-bookshelf-repository'; -import type { GiftDeliveryService } from './gift-delivery-service'; -import type { GiftReminderScheduler } from './gift-reminder-scheduler'; +import type { GiftDeliveryDispatchResult, GiftDeliveryService } from './gift-delivery-service'; +import type { GiftFlushScheduler } from './gift-flush-scheduler'; import { GiftCadenceSchema, type GiftCadence } from './gift-schema'; import tpl from '@tryghost/tpl'; -import { GIFT_EXPIRY_DAYS, GIFT_REMINDER_FLOOR_DAYS, GIFT_REMINDER_LEAD_DAYS } from './constants'; +import { + GIFT_MAX_SCHEDULE_DAYS, + GIFT_REMINDER_FLOOR_DAYS, + GIFT_REMINDER_LEAD_DAYS, + GIFT_SEND_HOUR, +} from './constants'; import { resolveGiftDuration, validateGiftCheckoutOffer, @@ -99,6 +104,7 @@ interface GiftEmailService { cadence: GiftCadence; duration: number; expiresAt: Date; + scheduledAt: Date | null; recipientEmail?: string | null; }): Promise; sendReminder(data: { @@ -175,7 +181,7 @@ interface GiftServiceDeps { tiersService: TiersService; giftEmailService: GiftEmailService; staffServiceEmails: StaffServiceEmails; - giftReminderScheduler: Pick; + giftReminderScheduler: Pick; checkoutAdapter: { getCustomerId(buyer: GiftCheckoutBuyer): Promise; createSession(data: GiftCheckoutSession): Promise<{ id: string; url: string }>; @@ -211,6 +217,7 @@ export interface StartGiftCheckoutInput { recipientName?: unknown; buyerName?: unknown; personalMessage?: unknown; + deliveryDate?: unknown; successUrl: string; cancelUrl?: string; buyer: GiftCheckoutBuyer; @@ -236,6 +243,7 @@ const GiftCheckoutDeliverySchema = z.discriminatedUnion('deliveryMethod', [ recipientEmail: EmptyCheckoutStringSchema, recipientName: EmptyCheckoutStringSchema, personalMessage: EmptyCheckoutStringSchema, + deliveryDate: EmptyCheckoutStringSchema, buyerName: NullableCheckoutStringSchema(GIFT_NAME_MAX_LENGTH), }), z.object({ @@ -243,6 +251,13 @@ const GiftCheckoutDeliverySchema = z.discriminatedUnion('deliveryMethod', [ recipientEmail: z.string().trim().email().max(GIFT_EMAIL_MAX_LENGTH), recipientName: NullableCheckoutStringSchema(GIFT_NAME_MAX_LENGTH), personalMessage: NullableCheckoutStringSchema(GIFT_CHECKOUT_MESSAGE_MAX_LENGTH), + deliveryDate: z + .string() + .trim() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .nullable() + .optional() + .default(null), buyerName: RequiredCheckoutStringSchema(GIFT_NAME_MAX_LENGTH), }), ]); @@ -317,6 +332,7 @@ export class GiftService { input.recipientName, input.buyerName, input.personalMessage, + input.deliveryDate, ].some((value) => value !== undefined && value !== null && value !== ''); if (!customizationEnabled && (input.deliveryMethod === 'email' || populatedDeliveryFields)) { @@ -343,6 +359,7 @@ export class GiftService { recipientName: input.recipientName, buyerName: input.buyerName, personalMessage: input.personalMessage, + deliveryDate: input.deliveryDate, } : { deliveryMethod: 'link', @@ -358,6 +375,7 @@ export class GiftService { } const delivery: GiftCheckoutDelivery = parsedDelivery.data; + const scheduledDelivery = this.resolveDeliveryDate(delivery.deliveryDate); if (input.offerId) { throw new errors.BadRequestError({ @@ -439,6 +457,16 @@ export class GiftService { successUrl.searchParams.set('gift_tier', tierId); successUrl.searchParams.set('gift_cadence', cadence); successUrl.searchParams.set('gift_delivery', delivery.deliveryMethod); + if (scheduledDelivery.date && scheduledDelivery.scheduledAt) { + successUrl.searchParams.set('gift_delivery_date', scheduledDelivery.date); + // The exact send instant, so the success page can tell "still + // scheduled" from "already sent" without re-deriving the send + // hour client-side. + successUrl.searchParams.set( + 'gift_scheduled_at', + String(scheduledDelivery.scheduledAt.getTime()), + ); + } if (totalMonths !== undefined) { successUrl.searchParams.set('gift_duration', String(totalMonths)); } @@ -454,6 +482,8 @@ export class GiftService { buyerName: delivery.buyerName, recipientName: delivery.recipientName, personalMessage: delivery.personalMessage, + expiryAnchor: scheduledDelivery.scheduledAt, + expiryTimeZone: this.siteZone(), tierId, cadence, duration, @@ -467,6 +497,7 @@ export class GiftService { { giftId: id, recipientEmail: delivery.recipientEmail, + scheduledAt: scheduledDelivery.scheduledAt, }, { transacting }, ); @@ -535,22 +566,56 @@ export class GiftService { return this.completeLegacyPurchase(input); } - private getClaimDeadline(purchasedAt: Date): Date { + // The publication timezone setting, falling back to UTC when unset or + // when the bundled tz data doesn't know the zone — an unknown name would + // otherwise resolve to the host's zone and date gift lifecycle values + // against the wrong calendar. + private siteZone(): string { const timezoneSetting = this.deps.settingsCache.get('timezone'); const zone = typeof timezoneSetting === 'string' && timezoneSetting ? timezoneSetting : DEFAULT_TIMEZONE; - let purchase = DateTime.fromJSDate(purchasedAt, { zone }); - // A zone name the bundled tz data doesn't know would otherwise resolve - // to the host's zone and date the deadline against the wrong calendar. - if (!purchase.isValid) { + if (zone !== DEFAULT_TIMEZONE && !DateTime.now().setZone(zone).isValid) { logging.warn( - `Unknown publication timezone "${zone}", dating the gift claim deadline in ${DEFAULT_TIMEZONE}`, + `Unknown publication timezone "${zone}", dating gift lifecycle values in ${DEFAULT_TIMEZONE}`, ); - purchase = DateTime.fromJSDate(purchasedAt, { zone: DEFAULT_TIMEZONE }); + return DEFAULT_TIMEZONE; } - return purchase.plus({ days: GIFT_EXPIRY_DAYS }).endOf('day').toJSDate(); + return zone; + } + + private resolveDeliveryDate(deliveryDate: string | null): { + date: string | null; + scheduledAt: Date | null; + } { + if (!deliveryDate) { + return { date: null, scheduledAt: null }; + } + + const zone = this.siteZone(); + const today = DateTime.now().setZone(zone).startOf('day'); + const selected = DateTime.fromFormat(deliveryDate, 'yyyy-MM-dd', { zone }).startOf('day'); + + if ( + !selected.isValid || + selected.toMillis() < today.toMillis() || + selected.toMillis() > today.plus({ days: GIFT_MAX_SCHEDULE_DAYS }).toMillis() + ) { + throw new errors.BadRequestError({ + message: 'Bad Request.', + context: `Gift delivery date must be today or within the next ${GIFT_MAX_SCHEDULE_DAYS} days`, + }); + } + + if (selected.hasSame(today, 'day')) { + return { date: null, scheduledAt: null }; + } + + return { + date: deliveryDate, + scheduledAt: selected.set({ hour: GIFT_SEND_HOUR }).toJSDate(), + }; } private async completePendingPurchase(input: GiftPaymentCompletionData): Promise { @@ -565,7 +630,6 @@ export class GiftService { } const data = parsed.data; const purchasedAt = new Date(); - const expiresAt = this.getClaimDeadline(purchasedAt); const member = data.stripeCustomerId ? await this.deps.memberRepository.get({ customer_id: data.stripeCustomerId }) : null; @@ -612,7 +676,7 @@ export class GiftService { stripeCheckoutSessionId: data.stripeCheckoutSessionId, stripePaymentIntentId: data.stripePaymentIntentId, purchasedAt, - expiresAt, + expiryTimeZone: this.siteZone(), }); if (!purchased) { return null; @@ -626,9 +690,9 @@ export class GiftService { return false; } - let recipientEmail: string | null = null; + let delivery: GiftDeliveryDispatchResult | null = null; try { - recipientEmail = await this.deps.giftDeliveryService.dispatchForGift(data.giftId); + delivery = await this.deps.giftDeliveryService.dispatchForGift({ giftId: data.giftId }); } catch (err) { logging.error( { @@ -639,7 +703,7 @@ export class GiftService { 'Failed to dispatch purchased gift delivery', ); } - await this.sendPurchaseNotifications(completed, member, recipientEmail); + await this.sendPurchaseNotifications(completed, member, delivery); return true; } @@ -655,7 +719,6 @@ export class GiftService { } const data = parsed.data; const purchasedAt = new Date(); - const expiresAt = this.getClaimDeadline(purchasedAt); if (await this.deps.giftRepository.existsByCheckoutSessionId(data.stripeCheckoutSessionId)) { return false; @@ -677,7 +740,7 @@ export class GiftService { stripeCheckoutSessionId: data.stripeCheckoutSessionId, stripePaymentIntentId: data.stripePaymentIntentId, purchasedAt, - expiresAt, + expiryTimeZone: this.siteZone(), }); await this.deps.giftRepository.create(gift); @@ -688,7 +751,7 @@ export class GiftService { private async sendPurchaseNotifications( gift: Gift, member: MemberModel | null, - recipientEmail: string | null, + delivery: GiftDeliveryDispatchResult | null, ): Promise { let tier: Tier | null; try { @@ -745,7 +808,8 @@ export class GiftService { cadence: gift.cadence, duration: gift.duration, expiresAt: gift.expiresAt!, - recipientEmail, + scheduledAt: delivery?.scheduledAt ?? null, + recipientEmail: delivery?.recipientEmail ?? null, }); } catch (err) { logging.error('Failed to send gift purchase confirmation email', err); @@ -859,7 +923,12 @@ export class GiftService { logging.error('Failed to notify staff of gift redemption', err); } - await this.deps.giftReminderScheduler.scheduleFor(redeemed); + const reminderDueAt = redeemed.reminderDueAt(); + if (reminderDueAt) { + await this.deps.giftReminderScheduler.scheduleAt(reminderDueAt.getTime(), { + giftToken: redeemed.token, + }); + } }; if (input.transacting) { diff --git a/ghost/core/core/server/services/gifts/gift.ts b/ghost/core/core/server/services/gifts/gift.ts index 09bc7c6df87..a33c431525a 100644 --- a/ghost/core/core/server/services/gifts/gift.ts +++ b/ghost/core/core/server/services/gifts/gift.ts @@ -1,7 +1,11 @@ import type { GiftCadence, GiftData, GiftDataInput, GiftStatus } from './gift-schema'; +import { DateTime } from 'luxon'; +import { GIFT_EXPIRY_DAYS, GIFT_REMINDER_LEAD_DAYS } from './constants'; export type { GiftCadence, GiftStatus } from './gift-schema'; +const MS_PER_DAY = 24 * 60 * 60 * 1000; + export type RedeemableCheckFailureReason = | 'payment-pending' | 'redeemed' @@ -38,7 +42,7 @@ export type GiftFromPurchaseData = Pick< | 'stripePaymentIntentId' > & { purchasedAt: Date; - expiresAt: Date; + expiryTimeZone: string; }; export type GiftFromCheckoutData = Pick< @@ -54,7 +58,10 @@ export type GiftFromCheckoutData = Pick< | 'buyerName' | 'recipientName' | 'personalMessage' ->; +> & { + expiryAnchor: Date | null; + expiryTimeZone: string; +}; export interface CompleteGiftPurchaseData { buyerEmail: string | null; @@ -62,7 +69,14 @@ export interface CompleteGiftPurchaseData { stripeCheckoutSessionId: string; stripePaymentIntentId: string; purchasedAt: Date; - expiresAt: Date; + expiryTimeZone: string; +} + +function calculateGiftExpiration(anchor: Date, timeZone: string): Date { + return DateTime.fromJSDate(anchor, { zone: timeZone }) + .plus({ days: GIFT_EXPIRY_DAYS }) + .endOf('day') + .toJSDate(); } export class Gift implements GiftData { @@ -119,12 +133,14 @@ export class Gift implements GiftData { } static fromPurchase(data: GiftFromPurchaseData) { + const { expiryTimeZone, ...giftData } = data; + return new Gift({ - ...data, + ...giftData, redeemerMemberId: null, consumesAt: null, checkoutStartedAt: data.purchasedAt, - expiresAt: data.expiresAt, + expiresAt: calculateGiftExpiration(data.purchasedAt, expiryTimeZone), status: 'purchased', purchasedAt: data.purchasedAt, redeemedAt: null, @@ -136,14 +152,16 @@ export class Gift implements GiftData { } static fromCheckout(data: GiftFromCheckoutData) { + const { expiryAnchor, expiryTimeZone, ...giftData } = data; + return new Gift({ - ...data, + ...giftData, redeemerMemberId: null, stripeCheckoutSessionId: null, stripePaymentIntentId: null, checkoutStartedAt: new Date(), consumesAt: null, - expiresAt: null, + expiresAt: expiryAnchor ? calculateGiftExpiration(expiryAnchor, expiryTimeZone) : null, status: 'payment_pending', purchasedAt: null, redeemedAt: null, @@ -159,9 +177,12 @@ export class Gift implements GiftData { return null; } + const { expiryTimeZone, ...purchaseData } = data; + return new Gift({ ...this, - ...data, + ...purchaseData, + expiresAt: this.expiresAt ?? calculateGiftExpiration(data.purchasedAt, expiryTimeZone), status: 'purchased', }); } @@ -317,6 +338,14 @@ export class Gift implements GiftData { }); } + /** Returns the reminder time, or null when the gift has no consumption date. */ + reminderDueAt(): Date | null { + if (!this.consumesAt) { + return null; + } + return new Date(this.consumesAt.getTime() - GIFT_REMINDER_LEAD_DAYS * MS_PER_DAY); + } + remind(): Gift | null { if (this.consumesSoonReminderSentAt !== null) { return null; diff --git a/ghost/core/core/server/services/gifts/index.ts b/ghost/core/core/server/services/gifts/index.ts index e452c39813e..432d520e70f 100644 --- a/ghost/core/core/server/services/gifts/index.ts +++ b/ghost/core/core/server/services/gifts/index.ts @@ -4,14 +4,14 @@ 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 { GiftFlushScheduler } from './gift-flush-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; - schedulerAdapter: SchedulerAdapter; + schedulerAdapter: SchedulerAdapter & { rescheduleOnBoot?: boolean }; internalKeys: InternalKeys; } @@ -24,6 +24,10 @@ export let service: GiftService | undefined; export let deliveryService: GiftDeliveryService | undefined; +// Persistent scheduling adapters keep their queue across restarts and opt out +// of boot-time rebuilds (same contract post scheduling honours in boot.js). +let rescheduleDeliveriesOnBoot = true; + export async function init(options: GiftServiceInitOptions): Promise { if (service) { return; @@ -43,6 +47,7 @@ export async function init(options: GiftServiceInitOptions): Promise { const logging = require('@tryghost/logging'); const { SubscriptionActivatedEvent } = require('../../../shared/events'); const StartGiftReminderFlushEvent = require('./events/start-gift-reminder-flush-event'); + const { StartGiftDeliveryFlushEvent } = require('./events/start-gift-delivery-flush-event'); const StartGiftCleanupEvent = require('./events/start-gift-cleanup-event'); const jobs = require('./jobs'); const emailAnalyticsJobs = require('../email-analytics/jobs'); @@ -80,6 +85,17 @@ export async function init(options: GiftServiceInitOptions): Promise { blogIcon, t, }); + const giftDeliveryScheduler = new GiftFlushScheduler({ + apiUrl: options.apiUrl, + adapter: options.schedulerAdapter, + internalKeys: options.internalKeys, + endpoint: 'flush_deliveries', + name: 'gift_delivery', + findScheduledTimes: async () => { + const scheduled = await deliveryRepository.findScheduledTimesForPurchasedGifts(new Date()); + return scheduled.map((scheduledAt) => scheduledAt.getTime()); + }, + }); const giftDeliveryService = new GiftDeliveryService({ giftRepository: repository, giftDeliveryRepository: deliveryRepository, @@ -88,13 +104,21 @@ export async function init(options: GiftServiceInitOptions): Promise { giftEmailAnalytics: { schedule: () => emailAnalyticsJobs.scheduleRecurringGiftDeliveriesJob(true), }, + giftDeliveryScheduler, }); - const giftReminderScheduler = new GiftReminderScheduler({ + const giftReminderScheduler = new GiftFlushScheduler({ apiUrl: options.apiUrl, adapter: options.schedulerAdapter, internalKeys: options.internalKeys, - findUnsentReminders: () => repository.findUnsentReminders(), + endpoint: 'flush_reminders', + name: 'gift_reminder', + findScheduledTimes: async () => { + const pending = await repository.findUnsentReminders(); + return pending + .map((gift) => gift.reminderDueAt()?.getTime()) + .filter((time): time is number => time !== undefined); + }, }); const giftService = new GiftService({ giftRepository: repository, @@ -115,6 +139,7 @@ export async function init(options: GiftServiceInitOptions): Promise { service = giftService; deliveryService = giftDeliveryService; + rescheduleDeliveriesOnBoot = Boolean(options.schedulerAdapter.rescheduleOnBoot); controller = new GiftController({ service: giftService }); DomainEvents.subscribe( @@ -145,6 +170,18 @@ export async function init(options: GiftServiceInitOptions): Promise { } }); + DomainEvents.subscribe(StartGiftDeliveryFlushEvent, async () => { + const start = Date.now(); + try { + const { sentCount, skippedCount, failedCount } = await giftDeliveryService.recoverPending(); + logging.info( + `Processed ${sentCount} due gift deliveries, skipped ${skippedCount}, failed ${failedCount} in ${Date.now() - start}ms`, + ); + } catch (err) { + logging.error(err, 'Failed to process due gift deliveries'); + } + }); + DomainEvents.subscribe(SendGiftDeliveryEvent, async (event: { data: { deliveryId: string } }) => { const start = Date.now(); try { @@ -210,9 +247,9 @@ export async function init(options: GiftServiceInitOptions): Promise { 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(). +// Re-arms future deliveries and retries sends interrupted by a previous +// shutdown. Runs after all services initialise because recovery sends need +// tiers and members. export async function recoverPendingDeliveries(): Promise { if (!deliveryService) { return; @@ -220,6 +257,14 @@ export async function recoverPendingDeliveries(): Promise { const logging = require('@tryghost/logging'); + if (rescheduleDeliveriesOnBoot) { + try { + await deliveryService.reschedulePending(); + } catch (err) { + logging.error(err, 'Failed to reschedule pending gift deliveries'); + } + } + try { const { sentCount, skippedCount, failedCount } = await deliveryService.recoverPending(); if (sentCount + skippedCount + failedCount > 0) { diff --git a/ghost/core/core/server/services/machine-payments/service.ts b/ghost/core/core/server/services/machine-payments/service.ts index 8ff158eb3da..a8305e4ad39 100644 --- a/ghost/core/core/server/services/machine-payments/service.ts +++ b/ghost/core/core/server/services/machine-payments/service.ts @@ -47,6 +47,8 @@ type ChallengeOrFulfillOptions = { resourceType: 'posts' | 'pages'; description?: string; renderMarkdown: (entry: Record) => string; + /** When set, unpaid 402 responses carry this markdown preview instead of problem+json. */ + renderPreviewMarkdown?: (terms: PaymentTerms) => string; contentLocation: string; }; @@ -195,7 +197,7 @@ export class MachinePaymentsService { return await this.#handleFulfill(credentialed, request, terms, options); } - return await this.#paymentRequiredResponse(request, terms); + return await this.#paymentRequiredResponse(request, terms, options); } async #handleFulfill( @@ -289,7 +291,11 @@ export class MachinePaymentsService { return new Response(body, { status: 200, headers }); } - async #paymentRequiredResponse(request: Request, terms: PaymentTerms): Promise { + async #paymentRequiredResponse( + request: Request, + terms: PaymentTerms, + options: ChallengeOrFulfillOptions, + ): Promise { const results = await Promise.allSettled( this.adapters.map((adapter) => adapter.challenge(request, terms)), ); @@ -310,18 +316,26 @@ export class MachinePaymentsService { }); } + const previewBody = options.renderPreviewMarkdown?.(terms) || null; const headers = new Headers({ 'Cache-Control': 'no-store', - 'Content-Type': 'application/problem+json', + 'Content-Type': previewBody ? 'text/markdown; charset=utf-8' : 'application/problem+json', }); + if (previewBody && options.contentLocation) { + headers.set('Content-Location', options.contentLocation); + } + for (const challenge of challenges) { if (challenge.headers) { challenge.headers.forEach((value, key) => { // Preserve every WWW-Authenticate (mpp compose can emit several) - // and keep distinct payment challenge headers. + // and keep distinct payment challenge headers. Do not let adapter + // Content-Type overwrite the preview body type. if (key.toLowerCase() === 'www-authenticate') { headers.append(key, value); + } else if (key.toLowerCase() === 'content-type') { + // Keep our chosen body content type. } else if (!headers.has(key)) { headers.set(key, value); } @@ -329,18 +343,19 @@ export class MachinePaymentsService { } } - return new Response( + const body = + previewBody || JSON.stringify({ type: 'https://paymentauth.org/problems/payment-required', title: 'Payment Required', status: 402, detail: 'Payment is required to access this markdown content.', - }), - { - status: 402, - headers, - }, - ); + }); + + return new Response(body, { + status: 402, + headers, + }); } #paymentCredentialErrorResponse(err: unknown): Response { 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 2d7c468da07..59c0f663736 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 @@ -709,6 +709,7 @@ module.exports = class RouterController { * @param {string} [options.recipientName] * @param {string} [options.buyerName] * @param {string} [options.personalMessage] + * @param {string} [options.deliveryDate] * @param {string} [options.email] * @param {string} options.successUrl * @param {string} options.cancelUrl @@ -734,6 +735,7 @@ module.exports = class RouterController { recipientName: options.recipientName, buyerName: options.buyerName, personalMessage: options.personalMessage, + deliveryDate: options.deliveryDate, successUrl: options.successUrl, cancelUrl: options.cancelUrl, buyer: { @@ -898,6 +900,7 @@ module.exports = class RouterController { recipientName: req.body.recipientName, buyerName: req.body.buyerName, personalMessage: req.body.personalMessage, + deliveryDate: req.body.deliveryDate, successUrl: siteUrl, cancelUrl: options.cancelUrl || siteUrl, }); diff --git a/ghost/core/core/server/web/api/endpoints/admin/routes.js b/ghost/core/core/server/web/api/endpoints/admin/routes.js index 788f45672b8..4de38994727 100644 --- a/ghost/core/core/server/web/api/endpoints/admin/routes.js +++ b/ghost/core/core/server/web/api/endpoints/admin/routes.js @@ -95,6 +95,7 @@ module.exports = function apiRoutes() { // ## Gifts router.put('/gifts/flush_reminders', mw.authAdminApiWithUrl, http(api.gifts.flushReminders)); + router.put('/gifts/flush_deliveries', mw.authAdminApiWithUrl, http(api.gifts.flushDeliveries)); // ## Settings router.get('/settings/routes/yaml', mw.authAdminApi, http(api.settings.download)); diff --git a/ghost/core/core/server/web/gift-preview/controller.js b/ghost/core/core/server/web/gift-preview/controller.js index f4d23faa478..24ef1c48ac8 100644 --- a/ghost/core/core/server/web/gift-preview/controller.js +++ b/ghost/core/core/server/web/gift-preview/controller.js @@ -72,6 +72,11 @@ async function giftPreview(req, res) { const ogUrl = `${siteUrl}/gift/${encodeURIComponent(token)}`; const redirectUrl = `${siteUrl}/#/portal/gift/redeem/${encodeURIComponent(token)}`; + const imageMeta = ` + + `; + const twitterImageMeta = `\n `; + const html = ` @@ -85,15 +90,12 @@ async function giftPreview(req, res) { - - - + ${imageMeta} - - + ${twitterImageMeta} diff --git a/ghost/core/core/shared/config/defaults.json b/ghost/core/core/shared/config/defaults.json index 983beee05fd..22ac15930bb 100644 --- a/ghost/core/core/shared/config/defaults.json +++ b/ghost/core/core/shared/config/defaults.json @@ -311,7 +311,7 @@ }, "portal": { "url": "https://cdn.jsdelivr.net/ghost/portal@~{version}/umd/portal.min.js", - "version": "2.70" + "version": "2.71" }, "sodoSearch": { "url": "https://cdn.jsdelivr.net/ghost/sodo-search@~{version}/umd/sodo-search.min.js", diff --git a/ghost/core/package.json b/ghost/core/package.json index 9ebedd30e9e..08c4c42bf2b 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -113,9 +113,11 @@ "@tryghost/kg-default-nodes": "workspace:*", "@tryghost/kg-html-to-lexical": "workspace:*", "@tryghost/kg-lexical-html-renderer": "workspace:*", + "@tryghost/kg-markdown-html-renderer": "workspace:*", "@tryghost/limit-service": "catalog:", "@tryghost/logging": "catalog:", "@tryghost/metrics": "catalog:", + "@tryghost/mg-clean-html": "catalog:", "@tryghost/mongo-knex": "catalog:", "@tryghost/mongo-utils": "0.6.5", "@tryghost/mw-error-handler": "1.0.13", diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/gift-deliveries.test.ts.snap b/ghost/core/test/e2e-api/admin/__snapshots__/gift-deliveries.test.ts.snap new file mode 100644 index 00000000000..237bef53523 --- /dev/null +++ b/ghost/core/test/e2e-api/admin/__snapshots__/gift-deliveries.test.ts.snap @@ -0,0 +1,74 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`Gift Deliveries API flushDeliveries dispatches a flush event with a valid scheduler integration token 1: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin", + "x-powered-by": "Express", +} +`; + +exports[`Gift Deliveries API flushDeliveries does not flush when request lacks a token 1: [body] 1`] = ` +Object { + "errors": Array [ + Object { + "code": "INVALID_JWT", + "context": null, + "details": null, + "ghostErrorCode": null, + "help": null, + "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "message": "Invalid token: No token found in URL", + "property": null, + "type": "UnauthorizedError", + }, + ], +} +`; + +exports[`Gift Deliveries API flushDeliveries does not flush when request lacks a token 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "235", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Gift Deliveries API flushDeliveries does not flush when request token is invalid 1: [body] 1`] = ` +Object { + "errors": Array [ + Object { + "code": "INVALID_JWT", + "context": null, + "details": null, + "ghostErrorCode": null, + "help": null, + "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "message": "Invalid token: jwt audience invalid. expected: /\\\\/?admin\\\\/?$/", + "property": null, + "type": "UnauthorizedError", + }, + ], +} +`; + +exports[`Gift Deliveries API flushDeliveries does not flush when request token is invalid 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "262", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; diff --git a/ghost/core/test/e2e-api/admin/gift-deliveries.test.ts b/ghost/core/test/e2e-api/admin/gift-deliveries.test.ts new file mode 100644 index 00000000000..c3363057ee8 --- /dev/null +++ b/ghost/core/test/e2e-api/admin/gift-deliveries.test.ts @@ -0,0 +1,112 @@ +import sinon from 'sinon'; + +const domainEvents = require('@tryghost/domain-events'); +const models = require('../../../core/server/models'); +const { getSignedAdminToken } = require('../../../core/server/adapters/scheduling/utils'); +const { + agentProvider, + fixtureManager, + matchers, + assertions, +} = require('../../utils/e2e-framework'); +const { + StartGiftDeliveryFlushEvent, +} = require('../../../core/server/services/gifts/events/start-gift-delivery-flush-event'); + +const { anyContentVersion, anyEtag, anyErrorId } = matchers; +const { cacheInvalidateHeaderNotSet } = assertions; + +describe('Gift Deliveries API', function () { + let agent: { put: (_url: string) => any }; + let schedulerKey: { id: string; secret: string }; + let schedulerToken: string; + + beforeAll(async function () { + agent = await agentProvider.getAdminAPIAgent(); + await fixtureManager.init('integrations', 'api_keys'); + + schedulerKey = await models.Integration.getApiKeyBySlug('ghost-scheduler', 'admin'); + + schedulerToken = getSignedAdminToken({ + publishedAt: new Date().toISOString(), + apiUrl: '/admin/', + key: schedulerKey, + }); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('flushDeliveries', function () { + let dispatchStub: sinon.SinonStub; + + beforeEach(function () { + dispatchStub = sinon.stub(domainEvents, 'dispatch'); + }); + + it('does not flush when request lacks a token', async function () { + await agent + .put('gifts/flush_deliveries/') + .expectStatus(401) + .expect(cacheInvalidateHeaderNotSet()) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag, + }) + .matchBodySnapshot({ + errors: [ + { + id: anyErrorId, + message: 'Invalid token: No token found in URL', + }, + ], + }); + + sinon.assert.notCalled(dispatchStub); + }); + + it('does not flush when request token is invalid', async function () { + const invalidSchedulerToken = getSignedAdminToken({ + publishedAt: new Date().toISOString(), + apiUrl: '/members/', + key: schedulerKey, + }); + + await agent + .put(`gifts/flush_deliveries/?token=${invalidSchedulerToken}`) + .expectStatus(401) + .expect(cacheInvalidateHeaderNotSet()) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag, + }) + .matchBodySnapshot({ + errors: [ + { + id: anyErrorId, + }, + ], + }); + + sinon.assert.notCalled(dispatchStub); + }); + + it('dispatches a flush event with a valid scheduler integration token', async function () { + await agent + .put(`gifts/flush_deliveries/?token=${schedulerToken}`) + .expectStatus(204) + .expectEmptyBody() + .expect(cacheInvalidateHeaderNotSet()) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag, + }); + + sinon.assert.calledOnceWithExactly( + dispatchStub, + sinon.match.instanceOf(StartGiftDeliveryFlushEvent), + ); + }); + }); +}); 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 ac43a4d94b4..5a7ac37819a 100644 --- a/ghost/core/test/e2e-api/admin/posts-importer.test.js +++ b/ghost/core/test/e2e-api/admin/posts-importer.test.js @@ -1,5 +1,6 @@ const assert = require('node:assert/strict'); const fs = require('node:fs/promises'); +const FormData = require('form-data'); const os = require('node:os'); const { agentProvider, @@ -104,6 +105,62 @@ describe('Posts Importer API', function () { await agent.post('posts/upload/').attach('postsfile', csvPath).expectStatus(403); }); + it('Rejects an explicit mapping without a title target', async function () { + await agent.loginAsOwner(); + + const bodyOnlyCsvPath = await csvFile( + 'posts-import-body-only-mapping.csv', + 'Body\n

This mapping has no title target

\n', + ); + const form = new FormData(); + form.append('mapping[Body]', 'html'); + form.append('postsfile', await fs.readFile(bodyOnlyCsvPath), { + filename: path.basename(bodyOnlyCsvPath), + contentType: 'text/csv', + }); + + const { body } = await agent.post('posts/upload/').body(form).expectStatus(422); + + assert.match(body.errors[0].message, /mapping must include "title"/); + }); + + it('Rejects unsafe, unknown, and duplicate field mappings', async function () { + await agent.loginAsOwner(); + + const mappedCsvPath = await csvFile( + 'posts-import-invalid-mappings.csv', + 'First,Second\nOne,Two\n', + ); + const cases = [ + { + mapping: { constructor: 'title' }, + reason: /Invalid CSV header mapping: "constructor"/, + }, + { + mapping: { First: 'title', Second: 'authors' }, + reason: /Unknown post field mapping: "authors"/, + }, + { + mapping: { First: 'title', Second: 'title' }, + reason: /Post field is mapped more than once: "title"/, + }, + ]; + + for (const { mapping, reason } of cases) { + const form = new FormData(); + for (const [header, field] of Object.entries(mapping)) { + form.append(`mapping[${header}]`, field); + } + form.append('postsfile', await fs.readFile(mappedCsvPath), { + filename: path.basename(mappedCsvPath), + contentType: 'text/csv', + }); + + const { body } = await agent.post('posts/upload/').body(form).expectStatus(422); + assert.match(body.errors[0].message, reason); + } + }); + it('Imports each CSV row as a post with its content and publish date', async function () { await agent.loginAsOwner(); @@ -205,16 +262,121 @@ describe('Posts Importer API', function () { assert.equal(post.get('visibility'), 'public'); }); + it('Imports arbitrarily headed CSV fields across every editorial category', async function () { + await agent.loginAsOwner(); + + const fullCsvPath = await csvFile( + 'posts-import-full-fields.csv', + 'Headline,Body,Address,Kind,State,Audience,Hero,Show title,Search title,Social copy,Template,Created,Published\n' + + 'Mapped field post,"

Mapped body

",Custom Address,page,draft,members,1,0,Mapped SEO title,Mapped social description,wide,2024-01-02T00:00:00.000Z,2024-02-03T00:00:00.000Z\n', + ); + + const form = new FormData(); + for (const [header, field] of Object.entries({ + Headline: 'title', + Body: 'html', + Address: 'slug', + Kind: 'type', + State: 'status', + Audience: 'visibility', + Hero: 'featured', + 'Show title': 'show_title_and_feature_image', + 'Search title': 'meta_title', + 'Social copy': 'twitter_description', + Template: 'custom_template', + Created: 'created_at', + Published: 'published_at', + })) { + form.append(`mapping[${header}]`, field); + } + form.append('postsfile', await fs.readFile(fullCsvPath), { + filename: path.basename(fullCsvPath), + contentType: 'text/csv', + }); + + await agent.post('posts/upload/').body(form).expectStatus(202); + await jobsService.allSettled(); + + const post = await models.Post.findOne( + { title: 'Mapped field post', status: 'all' }, + { withRelated: ['posts_meta'] }, + ); + assert.ok(post); + assert.equal(post.get('slug'), 'custom-address'); + assert.equal(post.get('type'), 'page'); + assert.equal(post.get('status'), 'draft'); + assert.equal(post.get('visibility'), 'members'); + assert.equal(post.get('featured'), true); + assert.equal(post.get('show_title_and_feature_image'), false); + assert.equal(post.get('custom_template'), 'wide'); + assert.equal(post.get('created_at').toISOString(), '2024-01-02T00:00:00.000Z'); + assert.equal(post.get('published_at').toISOString(), '2024-02-03T00:00:00.000Z'); + assert.equal(post.get('updated_at').toISOString(), '2024-02-03T00:00:00.000Z'); + assert.equal(post.related('posts_meta').get('meta_title'), 'Mapped SEO title'); + assert.equal( + post.related('posts_meta').get('twitter_description'), + 'Mapped social description', + ); + assert.match(post.get('html'), /Mapped body/); + }); + + it('Renders a mapped Markdown column through the post content converter', async function () { + await agent.loginAsOwner(); + + const markdownCsvPath = await csvFile( + 'posts-import-markdown.csv', + 'Headline,Source\n' + 'Markdown field post,"## Imported heading with **strong text**"\n', + ); + const form = new FormData(); + form.append('mapping[Headline]', 'title'); + form.append('mapping[Source]', 'markdown'); + form.append('postsfile', await fs.readFile(markdownCsvPath), { + filename: path.basename(markdownCsvPath), + contentType: 'text/csv', + }); + + await agent.post('posts/upload/').body(form).expectStatus(202); + await jobsService.allSettled(); + + const post = await models.Post.findOne({ title: 'Markdown field post', status: 'all' }); + assert.ok(post); + assert.match(post.get('html'), /]*>Imported heading with strong text<\/h2>/); + }); + + it('Cleans supplied HTML before converting it to post content', async function () { + await agent.loginAsOwner(); + + const cleanupCsvPath = await csvFile( + 'posts-import-clean-html.csv', + 'title,html\n' + + 'Clean HTML post,"

Clean me

"\n', + ); + + await agent.post('posts/upload/').attach('postsfile', cleanupCsvPath).expectStatus(202); + await jobsService.allSettled(); + + const post = await models.Post.findOne({ title: 'Clean HTML post', status: 'all' }); + assert.ok(post); + assert.match(post.get('html'), /

Clean me<\/strong><\/p>/); + assert.doesNotMatch(post.get('html'), /style=/); + }); + 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', + 'title,html,markdown,published_at,status,featured\n' + + 'Bad rows check one,

Before the bad row

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

This row has no title

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

This row has a bad date

,,not-a-date,published,false\n' + + 'Bad rows check calendar,

This row has a rolled-over date

,,2025-02-30T00:00:00.000Z,published,false\n' + + `${'x'.repeat(256)},

This title is too long

,,2024-03-02T00:00:00.000Z,published,false\n` + + 'Bad rows check status,

This row has a bad status

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

This row has a bad featured value

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

This row has HTML

,This row also has Markdown,2024-03-02T00:00:00.000Z,published,false\n' + + 'Bad rows check two,

After the bad rows

,,2024-03-04T00:00:00.000Z,published,false\n' + + 'Bad rows check three,

A loose date format

,,01 May 2024 00:00:00 GMT,published,false\n', ); await agent.post('posts/upload/').attach('postsfile', badRowsCsvPath).expectStatus(202); @@ -229,7 +391,7 @@ describe('Posts Importer API', function () { assert.deepEqual( posts.map((post) => post.get('title')).sort(), - ['Bad rows check one', 'Bad rows check two'], + ['Bad rows check one', 'Bad rows check three', 'Bad rows check two'], 'the good rows imported; the malformed ones did not', ); }); diff --git a/ghost/core/test/e2e-frontend/llms-routes.test.js b/ghost/core/test/e2e-frontend/llms-routes.test.js index 41fbbe2572d..64daf740c5f 100644 --- a/ghost/core/test/e2e-frontend/llms-routes.test.js +++ b/ghost/core/test/e2e-frontend/llms-routes.test.js @@ -114,8 +114,8 @@ describe('llms.txt routing', function () { lexical: testUtils.DataGenerator.markdownToLexical('secret'), }); const membersOnly = testUtils.DataGenerator.forKnex.createPost({ - slug: 'llms-members-hidden', - title: 'Members Hidden Post', + slug: 'llms-members-preview', + title: 'Members Preview Post', visibility: 'members', status: 'published', custom_excerpt: 'Members teaser', @@ -128,7 +128,7 @@ describe('llms.txt routing', function () { sinon.restore(); }); - it('includes purchasable paid posts in llms.txt', async function () { + it('includes paid and members posts in llms.txt', async function () { const res = await request .get('/llms.txt') .expect('Content-Type', /text\/plain/) @@ -136,13 +136,17 @@ describe('llms.txt routing', function () { assert.ok( res.text.includes(`[Paid Discoverable Post](${siteUrl}/llms-paid-discoverable.md)`), - 'expected paid post .md link when machine payments are enabled', + 'expected paid post .md link', ); assert.match(res.text, /Agent teaser/); - assert.doesNotMatch(res.text, /Members Hidden Post/); + assert.ok( + res.text.includes(`[Members Preview Post](${siteUrl}/llms-members-preview.md)`), + 'expected members post .md link with free preview discoverability', + ); + assert.match(res.text, /Members teaser/); }); - it('lists paid posts with notices in llms-full.txt', async function () { + it('lists gated posts with notices in llms-full.txt', async function () { const res = await request .get('/llms-full.txt') .expect('Content-Type', /text\/plain/) @@ -150,7 +154,8 @@ describe('llms.txt routing', function () { assert.match(res.text, /### Paid Discoverable Post/); assert.match(res.text, /paying subscribers only/i); - assert.doesNotMatch(res.text, /### Members Hidden Post/); + assert.match(res.text, /### Members Preview Post/); + assert.match(res.text, /This post is for subscribers only\./); }); }); }); diff --git a/ghost/core/test/e2e-frontend/machine-payments-markdown.test.js b/ghost/core/test/e2e-frontend/machine-payments-markdown.test.js index d1eb27f0c2f..ba9b7cf110c 100644 --- a/ghost/core/test/e2e-frontend/machine-payments-markdown.test.js +++ b/ghost/core/test/e2e-frontend/machine-payments-markdown.test.js @@ -79,13 +79,15 @@ describe('Machine payments markdown routing', function () { sinon.restore(); }); - it('refuses free-members-only posts with 403 markdown', async function () { + it('serves free-preview markdown for members-only posts', async function () { const res = await request .get(`/${membersSlug}.md`) - .expect(403) + .expect(200) .expect('Content-Type', /text\/markdown/); - assert.match(res.text, /Members-only content/); + assert.match(res.text, /# /); + assert.match(res.text, /This post is for subscribers only\./); + assert.match(res.text, /Subscribe:/); assert.doesNotMatch(res.text, /Secret paid body/); }); @@ -107,6 +109,7 @@ describe('Machine payments markdown routing', function () { const [, options] = machinePayments.challengeOrFulfill.firstCall.args; assert.equal(options.resourceType, 'posts'); assert.equal(options.contentLocation, `/${paidSlug}.md`); + assert.equal(typeof options.renderPreviewMarkdown, 'function'); }); it('serves paid markdown after the orchestrator fulfills payment', async function () { @@ -139,15 +142,72 @@ describe('Machine payments markdown routing', function () { assert.match(res.text, /Secret paid body/); }); - it('refuses paid posts when they are not purchasable', async function () { + it('serves free-preview markdown for paid posts when they are not purchasable', async function () { sinon.stub(machinePayments, 'isPurchasable').returns(false); const res = await request .get(`/${paidSlug}.md`) - .expect(403) + .expect(200) + .expect('Content-Type', /text\/markdown/); + + assert.match(res.text, /This post is for paying subscribers only\./); + assert.match(res.text, /Subscribe:/); + assert.doesNotMatch(res.text, /Secret paid body/); + }); + + it('serves paywall preview content on gated .md when a paywall card is present', async function () { + const previewSlug = 'mp-paid-preview-markdown'; + await testUtils.fixtures.insertPosts([ + testUtils.DataGenerator.forKnex.createPost({ + slug: previewSlug, + title: 'Paywall Preview Post', + custom_excerpt: 'Custom teaser excerpt', + visibility: 'paid', + status: 'published', + published_at: moment().toDate(), + lexical: testUtils.DataGenerator.markdownToLexical( + 'Free preview above the wall\n\n\n\nSecret paid body', + ), + }), + ]); + sinon.stub(machinePayments, 'isPurchasable').returns(false); + + const res = await request + .get(`/${previewSlug}.md`) + .expect(200) + .expect('Content-Type', /text\/markdown/); + + assert.match(res.text, /# Paywall Preview Post/); + assert.match(res.text, /Free preview above the wall/); + assert.match(res.text, /Custom teaser excerpt/); + assert.match(res.text, /This post is for paying subscribers only\./); + assert.doesNotMatch(res.text, /Secret paid body/); + }); + + it('serves title and excerpt without body when gated post has no paywall card', async function () { + const noPreviewSlug = 'mp-paid-no-preview-markdown'; + await testUtils.fixtures.insertPosts([ + testUtils.DataGenerator.forKnex.createPost({ + slug: noPreviewSlug, + title: 'No Preview Paid Post', + custom_excerpt: 'Only the excerpt is public', + visibility: 'paid', + status: 'published', + published_at: moment().toDate(), + lexical: testUtils.DataGenerator.markdownToLexical('Secret paid body with no paywall'), + }), + ]); + sinon.stub(machinePayments, 'isPurchasable').returns(false); + + const res = await request + .get(`/${noPreviewSlug}.md`) + .expect(200) .expect('Content-Type', /text\/markdown/); - assert.match(res.text, /Members-only content/); + assert.match(res.text, /# No Preview Paid Post/); + assert.match(res.text, /Only the excerpt is public/); + assert.match(res.text, /This post is for paying subscribers only\./); + assert.doesNotMatch(res.text, /Secret paid body with no paywall/); }); it('ignores Payment credentials on HTML permalinks for gated posts', async function () { diff --git a/ghost/core/test/integration/migrations/migration.test.js b/ghost/core/test/integration/migrations/migration.test.js index 4691b555157..0fbf4ad45af 100644 --- a/ghost/core/test/integration/migrations/migration.test.js +++ b/ghost/core/test/integration/migrations/migration.test.js @@ -91,7 +91,7 @@ describe('Migrations', function () { // Custom assertion to wrap all permissions function assertCompletePermissions(permissions) { // If you have to change this number, please add the relevant `assertHavePermission` checks below - assert.equal(permissions.length, 142); + assert.equal(permissions.length, 143); assertHavePermission(permissions, 'Export database', [ 'Administrator', @@ -183,6 +183,7 @@ describe('Migrations', function () { 'Super Editor', ]); assertHavePermission(permissions, 'Flush gift reminders', ['Scheduler Integration']); + assertHavePermission(permissions, 'Flush gift deliveries', ['Scheduler Integration']); assertHavePermission(permissions, 'Manage gift links', [ 'Administrator', 'Editor', 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 index 65db8f71b70..c68445dea3d 100644 --- 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 @@ -36,12 +36,14 @@ describe('GiftDeliveryBookshelfRepository (integration)', function () { startedAt = null, deliveryStatus = 'pending', giftStatus = 'purchased', - purchasedAt = new Date(), + purchasedAt = new Date('2026-01-01T00:00:00.000Z'), + scheduledAt = null, }: { startedAt?: Date | null; deliveryStatus?: string; giftStatus?: string; purchasedAt?: Date; + scheduledAt?: Date | null; } = {}) { giftSequence += 1; const gift = await models.Gift.add({ @@ -74,6 +76,7 @@ describe('GiftDeliveryBookshelfRepository (integration)', function () { gift_id: gift.id, recipient_email: `recipient-${giftSequence}@example.com`, status: deliveryStatus, + scheduled_at: scheduledAt, started_at: startedAt, email_sent_at: null, email_provider_message_id: null, @@ -207,12 +210,45 @@ describe('GiftDeliveryBookshelfRepository (integration)', function () { startedAt: now, }); - const deliveries = await deliveryRepository.findRecoverableForPurchasedGifts(staleBefore, 100); + const deliveries = await deliveryRepository.findRecoverableForPurchasedGifts( + now, + staleBefore, + 100, + ); assert.deepEqual( - new Set(deliveries.map((delivery) => delivery.id)), + new Set(deliveries.map(({ delivery }) => delivery.id)), new Set([purchased.delivery.id, stale.delivery.id]), ); + assert.ok(deliveries.every(({ gift }) => gift.status === 'purchased')); + }); + + it('keeps future deliveries pending until their scheduled time', async function () { + const now = new Date('2026-08-18T12:00:00.000Z'); + const future = await createPendingEmailGift({ + purchasedAt: new Date('2026-08-18T10:00:00.000Z'), + scheduledAt: new Date('2026-12-25T09:00:00.000Z'), + }); + + assert.equal( + await deliveryRepository.tryStartDelivery( + future.delivery.id, + now, + new Date('2026-08-18T11:00:00.000Z'), + ), + null, + ); + assert.deepEqual( + await deliveryRepository.findRecoverableForPurchasedGifts( + now, + new Date('2026-08-18T11:00:00.000Z'), + 100, + ), + [], + ); + + const scheduled = await deliveryRepository.findScheduledTimesForPurchasedGifts(now); + assert.deepEqual(scheduled, [new Date('2026-12-25T09:00:00.000Z')]); }); it('allows exactly one concurrent caller to reclaim a stale sending delivery', async function () { 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 index 2048e7dbc5a..8b053a3910a 100644 --- a/ghost/core/test/integration/services/gifts/gift-delivery-processing.test.ts +++ b/ghost/core/test/integration/services/gifts/gift-delivery-processing.test.ts @@ -5,7 +5,7 @@ import { SendGiftDeliveryEvent } from '../../../../core/server/services/gifts/ev 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'); +const { agentProvider, fixtureManager, mockManager } = require('../../../utils/e2e-framework'); describe('Gift delivery processing', function () { let paidTierId: string; @@ -29,10 +29,17 @@ describe('Gift delivery processing', function () { await DomainEvents.allSettled(); await models.GiftDelivery.query().del(); await models.Gift.query().del(); + mockManager.restore(); sinon.restore(); }); - async function createPendingEmailGift() { + async function createPendingEmailGift({ + purchasedAt, + scheduledAt = null, + }: { + purchasedAt?: Date; + scheduledAt?: Date | null; + } = {}) { giftSequence += 1; const now = new Date(); @@ -54,7 +61,7 @@ describe('Gift delivery processing', function () { consumes_at: null, expires_at: new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000), status: 'purchased', - purchased_at: now, + purchased_at: purchasedAt ?? now, redeemed_at: null, consumed_at: null, expired_at: null, @@ -65,6 +72,7 @@ describe('Gift delivery processing', function () { gift_id: gift.id, recipient_email: `recipient-${giftSequence}@example.com`, status: 'pending', + scheduled_at: scheduledAt, started_at: null, email_sent_at: null, email_provider_message_id: null, @@ -123,4 +131,20 @@ describe('Gift delivery processing', function () { assert.equal(reloaded.get('email_provider_message_id'), 'provider-123'); assert.ok(reloaded.get('email_sent_at')); }); + + it('confirms to the buyer after a scheduled gift is sent', async function () { + const emailMockReceiver = mockManager.mockMail(); + const now = Date.now(); + const { delivery } = await createPendingEmailGift({ + purchasedAt: new Date(now - 2 * 60 * 60 * 1000), + scheduledAt: new Date(now - 60 * 60 * 1000), + }); + + DomainEvents.dispatch(SendGiftDeliveryEvent.create({ deliveryId: delivery.id })); + await DomainEvents.allSettled(); + + sinon.assert.calledOnce(deliverySend); + emailMockReceiver.assertSentEmailCount(1); + assert.equal(emailMockReceiver.getSentEmail(0).subject, 'Your gift has been sent'); + }); }); diff --git a/ghost/core/test/integration/services/machine-payments/orchestration.test.js b/ghost/core/test/integration/services/machine-payments/orchestration.test.js index 6265f116605..3de25999867 100644 --- a/ghost/core/test/integration/services/machine-payments/orchestration.test.js +++ b/ghost/core/test/integration/services/machine-payments/orchestration.test.js @@ -2,6 +2,21 @@ const assert = require('node:assert/strict'); const sinon = require('sinon'); const logging = require('@tryghost/logging'); const { createLlmsService } = require('../../../../core/frontend/services/llms/service'); +const { + getGatedNotice, + renderEntryMarkdown, + renderEntryMarkdownBody, + truncateDescription, + formatIsoDate, + collapseWhitespace, + getMarkdownPath, + getMarkdownUrl, + getResourcePathFromMarkdownPath, + getAcceptedMarkdownContentType, + markdownFromHtml, + getPrimaryAuthorName, + getTagNames, +} = require('../../../../core/frontend/services/llms/markdown'); const { MachinePaymentsService, } = require('../../../../core/server/services/machine-payments/service'); @@ -33,6 +48,136 @@ describe('Integration: machine-payments orchestration coverage', function () { sinon.restore(); }); + describe('gated markdown preview helpers', function () { + it('formats notices and renders preview markdown for gated entries', function () { + assert.equal( + getGatedNotice({ visibility: 'members' }, 'post'), + 'This post is for subscribers only.', + ); + assert.equal( + getGatedNotice({ visibility: 'paid' }, 'page'), + 'This page is for paying subscribers only.', + ); + assert.equal( + getGatedNotice({ visibility: 'paid', type: 'page' }), + 'This page is for paying subscribers only.', + ); + assert.equal( + getGatedNotice({ visibility: 'tiers', tiers: [{ name: 'Gold' }] }, 'post'), + 'This post is for subscribers on the Gold tier only.', + ); + assert.equal( + getGatedNotice({ visibility: 'tiers', tiers: [{ name: 'Gold' }, { name: 'Silver' }] }), + 'This post is for subscribers on the Gold and Silver tiers only.', + ); + assert.equal( + getGatedNotice({ visibility: 'tiers', tiers: [] }, 'post'), + 'This post is for paying subscribers only.', + ); + assert.equal( + getGatedNotice({ visibility: 'tiers', tiers: [{ name: null }, {}] }, 'page'), + 'This page is for paying subscribers only.', + ); + + const withPreview = renderEntryMarkdown( + { + title: 'Gated', + url: 'https://example.com/gated/', + type: 'post', + visibility: 'paid', + html: '

Free preview

', + custom_excerpt: 'Teaser', + published_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-02T00:00:00.000Z', + authors: [{ name: 'Alice' }], + tags: [{ name: 'Tech' }], + }, + { + llmsIndexUrl: 'https://example.com/llms.txt', + notice: getGatedNotice({ visibility: 'paid' }, 'post'), + cta: ['Agents can pay', 'Subscribe: https://example.com/#/portal/signup'], + }, + ); + assert.match(withPreview, /Free preview/); + assert.match(withPreview, /_This post is for paying subscribers only\._/); + assert.match(withPreview, /Agents can pay/); + assert.equal(withPreview.match(/Teaser/g).length, 1); + + // String CTA (not array) and no metadata fields beyond title. + const stringCta = renderEntryMarkdown( + { title: 'Bare', html: '

Lead

', visibility: 'members' }, + { + llmsIndexUrl: 'https://example.com/llms.txt', + notice: getGatedNotice({ visibility: 'members' }, 'post'), + cta: 'Subscribe: https://example.com/#/portal/signup', + }, + ); + assert.match(stringCta, /Lead/); + assert.match(stringCta, /Subscribe:/); + + const noPreview = renderEntryMarkdown( + { + title: 'Gated', + url: 'https://example.com/gated/', + visibility: 'members', + html: '', + }, + { + llmsIndexUrl: 'https://example.com/llms.txt', + notice: getGatedNotice({ visibility: 'members' }, 'post'), + }, + ); + assert.match(noPreview, /_This post is for subscribers only\._/); + assert.doesNotMatch(noPreview, /_No content available\._/); + + // Non-gated empty body falls back to the placeholder. + const emptyPublic = renderEntryMarkdown( + { title: 'Empty' }, + { llmsIndexUrl: 'https://example.com/llms.txt' }, + ); + assert.match(emptyPublic, /_No content available\._/); + }); + + it('covers markdown helper edge branches used by gated previews', function () { + assert.equal(collapseWhitespace(' a \n\n b '), 'a b'); + assert.ok(truncateDescription('x'.repeat(400)).endsWith('…')); + assert.equal(formatIsoDate('2026-01-01T00:00:00.000Z'), '2026-01-01T00:00:00.000Z'); + assert.equal(formatIsoDate(null), null); + assert.equal(formatIsoDate('not-a-date'), null); + assert.equal(getMarkdownPath('/'), '/index.md'); + assert.equal(getMarkdownPath('/about/'), '/about.md'); + assert.equal(getMarkdownPath('/about'), '/about.md'); + assert.equal(getMarkdownUrl('https://example.com/about/'), 'https://example.com/about.md'); + assert.equal(getResourcePathFromMarkdownPath('/about.md'), '/about/'); + assert.equal(getResourcePathFromMarkdownPath('/index.md'), '/'); + assert.equal(getResourcePathFromMarkdownPath('/about/'), null); + assert.match(markdownFromHtml('

Hi there

'), /Hi \*\*there\*\*/); + assert.equal(markdownFromHtml(''), null); + assert.equal(renderEntryMarkdownBody({ html: '

Body

' }), 'Body'); + assert.equal(renderEntryMarkdownBody({ html: '' }), ''); + assert.equal(getPrimaryAuthorName({ primary_author: { name: 'Pat' } }), 'Pat'); + assert.equal(getPrimaryAuthorName({ authors: [{ name: 'Sam' }] }), 'Sam'); + assert.equal(getPrimaryAuthorName({}), null); + assert.deepEqual(getTagNames({ tags: [{ name: 'A' }, { name: 'B' }] }), ['A', 'B']); + assert.deepEqual(getTagNames({ primary_tag: { name: 'Solo' } }), ['Solo']); + assert.deepEqual(getTagNames({}), []); + assert.equal( + getAcceptedMarkdownContentType({ + get: () => 'text/markdown', + accepts: () => 'text/markdown', + }), + 'text/markdown', + ); + assert.equal( + getAcceptedMarkdownContentType({ + get: () => 'text/html', + accepts: () => 'text/html', + }), + null, + ); + }); + }); + describe('llms discoverability', function () { function createService({ posts, machinePaymentsService } = {}) { const urlMap = Object.fromEntries( @@ -81,7 +226,7 @@ describe('Integration: machine-payments orchestration coverage', function () { }); } - it('excludes members-only and non-purchasable tiers posts from llms.txt', async function () { + it('lists members-only and tiers posts in llms.txt once free previews are public', async function () { const service = createService({ posts: [ { id: '1', title: 'Public', slug: 'public', visibility: 'public', type: 'post' }, @@ -95,13 +240,76 @@ describe('Integration: machine-payments orchestration coverage', function () { type: 'post', }, ], - machinePaymentsService: { isEnabled: () => true }, }); const llmsTxt = await service.getLlmsTxt(); assert.match(llmsTxt, /Public/); - assert.doesNotMatch(llmsTxt, /Members/); - assert.doesNotMatch(llmsTxt, /Mixed Tiers/); + assert.match(llmsTxt, /Members/); + assert.match(llmsTxt, /Mixed Tiers/); + }); + + it('renders free previews and notices for gated posts in llms-full.txt', async function () { + const service = createService({ + posts: [ + { + id: '1', + title: 'Members Preview', + slug: 'members-preview', + visibility: 'members', + html: '

Above the wall

', + custom_excerpt: 'Members teaser', + type: 'post', + }, + { + id: '2', + title: 'Paid Excerpt Only', + slug: 'paid-excerpt', + visibility: 'paid', + html: '', + custom_excerpt: 'Paid teaser', + type: 'post', + }, + { + id: '3', + title: 'Tiered Post', + slug: 'tiered', + visibility: 'tiers', + html: '', + tiers: [{ name: 'Gold' }, { name: 'Silver' }], + type: 'post', + }, + { + id: '4', + title: 'Notice Only', + slug: 'notice-only', + visibility: 'members', + html: '', + type: 'post', + }, + ], + }); + + const llmsFullTxt = await service.getLlmsFullTxt(); + assert.match( + llmsFullTxt, + /### Members Preview[\s\S]*Above the wall[\s\S]*This post is for subscribers only\./, + ); + assert.match( + llmsFullTxt, + /### Paid Excerpt Only[\s\S]*Paid teaser[\s\S]*This post is for paying subscribers only\./, + ); + assert.match( + llmsFullTxt, + /### Tiered Post[\s\S]*This post is for subscribers on the Gold and Silver tiers only\./, + ); + assert.match(llmsFullTxt, /### Notice Only[\s\S]*This post is for subscribers only\./); + }); + + it('returns the empty section when there are no published pages', async function () { + const service = createService({ posts: [] }); + const llmsTxt = await service.getLlmsTxt(); + assert.match(llmsTxt, /## Pages\n_No public content available\./); + assert.match(llmsTxt, /## Posts\n_No public content available\./); }); it('reads posts and pages through fetchPublicEntry with tiers included', async function () { @@ -199,6 +407,7 @@ describe('Integration: machine-payments orchestration coverage', function () { }, ); assert.equal(challenge.status, 402); + assert.equal(challenge.headers.get('Content-Type'), 'application/problem+json'); sinon.assert.notCalled(contentLoader.loadFullEntry); mppAdapter.canHandle.returns(true); @@ -231,6 +440,60 @@ describe('Integration: machine-payments orchestration coverage', function () { ); }); + it('returns a markdown preview body on 402 when renderPreviewMarkdown is provided', async function () { + const service = createService(); + const renderPreviewMarkdown = sinon + .stub() + .returns('# Preview\n\n_This post is for paying subscribers only._'); + + // Adapter Content-Type must not overwrite the preview body type. + mppAdapter.challenge.resolves( + new Response('', { + status: 402, + headers: { + 'WWW-Authenticate': 'Payment realm="mpp"', + 'Content-Type': 'text/plain', + 'PAYMENT-REQUIRED': 'secondary', + }, + }), + ); + + const response = await service.challengeOrFulfill(new Request('http://example.com/paid.md'), { + entryId: 'post1', + resourceType: 'posts', + description: 'Paid', + contentLocation: '/paid.md', + renderMarkdown: () => '# full', + renderPreviewMarkdown, + }); + + assert.equal(response.status, 402); + assert.equal(response.headers.get('WWW-Authenticate'), 'Payment realm="mpp"'); + assert.equal(response.headers.get('Content-Type'), 'text/markdown; charset=utf-8'); + assert.equal(response.headers.get('Content-Location'), '/paid.md'); + assert.equal(response.headers.get('PAYMENT-REQUIRED'), 'secondary'); + assert.equal(response.headers.get('Cache-Control'), 'no-store'); + assert.match(await response.text(), /# Preview/); + sinon.assert.calledOnce(renderPreviewMarkdown); + sinon.assert.notCalled(contentLoader.loadFullEntry); + }); + + it('returns 503 when every adapter challenge is unavailable', async function () { + mppAdapter.challenge.resolves(null); + const service = createService(); + + const response = await service.challengeOrFulfill(new Request('http://example.com/paid.md'), { + entryId: 'post1', + resourceType: 'posts', + contentLocation: '/paid.md', + renderMarkdown: () => '# body', + renderPreviewMarkdown: () => '# preview', + }); + + assert.equal(response.status, 503); + assert.match(await response.text(), /challenges are temporarily unavailable/); + }); + it('is purchasable only when enabled and the entry is paid', function () { const service = createService(); assert.equal(service.isPurchasable({ visibility: 'paid' }), true); diff --git a/ghost/core/test/unit/api/endpoints/gifts.test.js b/ghost/core/test/unit/api/endpoints/gifts.test.js index b7b612708a4..d1488791c0b 100644 --- a/ghost/core/test/unit/api/endpoints/gifts.test.js +++ b/ghost/core/test/unit/api/endpoints/gifts.test.js @@ -22,4 +22,16 @@ describe('Gifts controller', function () { assert.equal(result, undefined); }); }); + + describe('flushDeliveries', function () { + it('dispatches a delivery flush event', function () { + const dispatch = sinon.stub(domainEvents, 'dispatch'); + + const result = giftsController.flushDeliveries.query({}); + + assert.equal(result, undefined); + sinon.assert.calledOnce(dispatch); + assert.equal(dispatch.firstCall.firstArg.constructor.name, 'StartGiftDeliveryFlushEvent'); + }); + }); }); diff --git a/ghost/core/test/unit/frontend/services/llms/markdown.test.js b/ghost/core/test/unit/frontend/services/llms/markdown.test.js index fd4214f5e66..34c7907434e 100644 --- a/ghost/core/test/unit/frontend/services/llms/markdown.test.js +++ b/ghost/core/test/unit/frontend/services/llms/markdown.test.js @@ -7,6 +7,7 @@ const { getMarkdownUrl, getResourcePathFromMarkdownPath, getAcceptedMarkdownContentType, + getGatedNotice, markdownFromHtml, renderEntryMarkdown, renderEntryMarkdownBody, @@ -197,5 +198,118 @@ describe('Unit: frontend/services/llms/markdown', function () { assert.match(result, /- Type: page/); assert.doesNotMatch(result, /- Tags:/); }); + + it('appends notice and CTA for gated previews', function () { + const entry = { + title: 'Paid Post', + url: 'https://example.com/paid/', + type: 'post', + visibility: 'paid', + html: '

Free preview

', + custom_excerpt: 'Teaser', + tags: [], + }; + + const result = renderEntryMarkdown(entry, { + llmsIndexUrl: 'https://example.com/llms.txt', + notice: getGatedNotice(entry), + cta: 'Subscribe: https://example.com/#/portal/signup', + }); + + assert.match(result, /Free preview/); + assert.match(result, /---/); + assert.match(result, /_This post is for paying subscribers only\._/); + assert.match(result, /Subscribe: https:\/\/example\.com\/#\/portal\/signup/); + assert.doesNotMatch(result, /_No content available\._/); + }); + + it('keeps the excerpt on the Description line only when gated html is empty', function () { + const entry = { + title: 'Paid Post', + url: 'https://example.com/paid/', + type: 'post', + visibility: 'paid', + html: '', + custom_excerpt: 'Only the excerpt', + tags: [], + }; + + const result = renderEntryMarkdown(entry, { + llmsIndexUrl: 'https://example.com/llms.txt', + notice: getGatedNotice(entry), + }); + + assert.match(result, /- Description: Only the excerpt/); + assert.equal(result.match(/Only the excerpt/g).length, 1); + assert.match(result, /_This post is for paying subscribers only\._/); + assert.doesNotMatch(result, /_No content available\._/); + }); + + it('omits body for gated entries with no html and no excerpt', function () { + const entry = { + title: 'Paid Post', + url: 'https://example.com/paid/', + type: 'post', + visibility: 'members', + html: '', + tags: [], + }; + + const result = renderEntryMarkdown(entry, { + llmsIndexUrl: 'https://example.com/llms.txt', + notice: getGatedNotice(entry), + }); + + assert.match(result, /^# Paid Post$/m); + assert.match(result, /_This post is for subscribers only\._/); + assert.doesNotMatch(result, /_No content available\._/); + }); + }); + + describe('getGatedNotice', function () { + it('returns members notice for posts', function () { + assert.equal( + getGatedNotice({ visibility: 'members', type: 'post' }), + 'This post is for subscribers only.', + ); + }); + + it('returns paid notice for pages', function () { + assert.equal( + getGatedNotice({ visibility: 'paid', type: 'page' }), + 'This page is for paying subscribers only.', + ); + }); + + it('uses the explicit resourceKind when entry.type is absent', function () { + // The content API does not serialize `type`, so callers pass it in. + assert.equal( + getGatedNotice({ visibility: 'paid' }, 'page'), + 'This page is for paying subscribers only.', + ); + assert.equal( + getGatedNotice({ visibility: 'members' }, 'post'), + 'This post is for subscribers only.', + ); + }); + + it('formats single and multiple tiers', function () { + assert.equal( + getGatedNotice({ + visibility: 'tiers', + type: 'post', + tiers: [{ name: 'Gold' }], + }), + 'This post is for subscribers on the Gold tier only.', + ); + assert.equal( + getGatedNotice({ + visibility: 'tiers', + type: 'post', + tiers: [{ name: 'Gold' }, { name: 'Silver' }], + }), + 'This post is for subscribers on the Gold and Silver tiers only.', + ); + }); }); }); diff --git a/ghost/core/test/unit/frontend/services/llms/service.test.js b/ghost/core/test/unit/frontend/services/llms/service.test.js index 5b299ce071f..edc1a8b53e9 100644 --- a/ghost/core/test/unit/frontend/services/llms/service.test.js +++ b/ghost/core/test/unit/frontend/services/llms/service.test.js @@ -517,7 +517,7 @@ describe('Unit: frontend/services/llms/service', function () { assert.match(llmsFullTxt, /Append `\.md` to any post or page URL/); }); - it('excludes free-members-only posts from the llms.txt index', async function () { + it('lists free-members-only posts in the llms.txt index with their teaser', async function () { const service = createService({ pages: [], posts: [ @@ -536,11 +536,13 @@ describe('Unit: frontend/services/llms/service', function () { const llmsTxt = await service.getLlmsTxt(); - assert.doesNotMatch(llmsTxt, /Members Post/); - assert.match(llmsTxt, /## Posts\n_No public content available\./); + assert.match( + llmsTxt, + /\[Members Post\]\(https:\/\/example\.com\/members-post\.md\) - Public teaser/, + ); }); - it('lists paid posts in llms.txt only when machine payments are enabled', async function () { + it('lists paid posts in llms.txt regardless of machine payments', async function () { const posts = [ { id: 'post-p', @@ -553,22 +555,14 @@ describe('Unit: frontend/services/llms/service', function () { ]; const urlMap = { 'post-p': 'https://example.com/paid-post/' }; - const disabled = createService({ pages: [], posts, urlMap }); - assert.doesNotMatch(await disabled.getLlmsTxt(), /Paid Post/); - - const enabled = createService({ - pages: [], - posts, - urlMap, - machinePaymentsService: { isEnabled: () => true }, - }); + const service = createService({ pages: [], posts, urlMap }); assert.match( - await enabled.getLlmsTxt(), + await service.getLlmsTxt(), /\[Paid Post\]\(https:\/\/example\.com\/paid-post\.md\) - Paywalled teaser/, ); }); - it('keeps gated bodies behind notices in llms-full.txt for purchasable paid posts', async function () { + it('includes free preview and notice for gated posts in llms-full.txt', async function () { const service = createService({ pages: [], posts: [ @@ -577,8 +571,8 @@ describe('Unit: frontend/services/llms/service', function () { title: 'Members Post', slug: 'members-post', custom_excerpt: 'Public teaser', - html: '', - plaintext: '', + html: '

Free preview above the wall

', + plaintext: 'Free preview above the wall', visibility: 'members', type: 'post', }, @@ -597,12 +591,14 @@ describe('Unit: frontend/services/llms/service', function () { 'post-m': 'https://example.com/members-post/', 'post-p': 'https://example.com/paid-post/', }, - machinePaymentsService: { isEnabled: () => true }, }); const llmsFullTxt = await service.getLlmsFullTxt(); - assert.doesNotMatch(llmsFullTxt, /### Members Post/); + assert.match( + llmsFullTxt, + /### Members Post[\s\S]*Free preview above the wall[\s\S]*This post is for subscribers only\./, + ); assert.match( llmsFullTxt, /### Paid Post[\s\S]*Paywalled teaser[\s\S]*This post is for paying subscribers only\./, diff --git a/ghost/core/test/unit/frontend/services/routing/controllers/entry.test.ts b/ghost/core/test/unit/frontend/services/routing/controllers/entry.test.ts index dd99726567b..dae2faad032 100644 --- a/ghost/core/test/unit/frontend/services/routing/controllers/entry.test.ts +++ b/ghost/core/test/unit/frontend/services/routing/controllers/entry.test.ts @@ -290,8 +290,37 @@ describe('Unit - services/routing/controllers/entry', function () { sinon.assert.calledWith(res.redirect, 302, '/does-exist/'); }); - it('returns 403 for non-public posts when the llms feature is enabled', async function () { + it('serves free-preview markdown for gated posts when access is false', async function () { + post.url = 'http://127.0.0.1:2369/does-exist/'; + post.visibility = 'members'; + post.access = false; + post.title = 'Members Post'; + post.html = '

Free preview

'; + post.custom_excerpt = 'A teaser'; + llmsService.isEnabled.returns(true); + + entryLookUpStub.withArgs(req.path, res.routerOptions).resolves({ entry: post }); + + await controllers.entry(req, res, sinon.stub()); + + sinon.assert.calledWith(res.type, 'text/markdown'); + sinon.assert.calledOnce(res.send); + sinon.assert.notCalled(res.status); + const body = res.send.firstCall.args[0]; + assert.match(body, /# Members Post/); + assert.match(body, /Free preview/); + assert.match(body, /This post is for subscribers only\./); + assert.match(body, /Subscribe:/); + sinon.assert.neverCalledWith(res.set, 'Cache-Control', sinon.match.any); + sinon.assert.notCalled(res.redirect); + }); + + it('returns 403 for gated posts when the member already has access', async function () { + // access===true means entry.html is the full post; serving it at 200 + // with public cache headers would leak paid content to the CDN. post.visibility = 'members'; + post.access = true; + post.html = '

Full members body

'; llmsService.isEnabled.returns(true); entryLookUpStub.withArgs(req.path, res.routerOptions).resolves({ entry: post }); @@ -301,9 +330,28 @@ describe('Unit - services/routing/controllers/entry', function () { sinon.assert.calledWith(res.status, 403); sinon.assert.calledWith(res.type, 'text/markdown'); sinon.assert.calledOnce(res.send); + assert.doesNotMatch(res.send.firstCall.args[0], /Full members body/); sinon.assert.notCalled(res.redirect); }); + it('returns 403 for paid posts with access when machine payments are not purchasable', async function () { + post.url = 'http://127.0.0.1:2369/does-exist/'; + post.visibility = 'paid'; + post.access = true; + post.html = '

Full paid body

'; + llmsService.isEnabled.returns(true); + req.app.get.withArgs('machinePaymentsService').returns({ + isPurchasable: sinon.stub().returns(false), + }); + + entryLookUpStub.withArgs(req.path, res.routerOptions).resolves({ entry: post }); + + await controllers.entry(req, res, sinon.stub()); + + sinon.assert.calledWith(res.status, 403); + assert.doesNotMatch(res.send.firstCall.args[0], /Full paid body/); + }); + it('serves markdown for public posts when the llms feature is enabled', async function () { post.url = 'http://127.0.0.1:2369/does-exist/'; post.visibility = 'public'; @@ -315,6 +363,7 @@ describe('Unit - services/routing/controllers/entry', function () { sinon.assert.calledWith(res.type, 'text/markdown'); sinon.assert.calledOnce(res.send); + sinon.assert.neverCalledWith(res.set, 'Cache-Control', sinon.match.any); sinon.assert.notCalled(res.redirect); }); }); 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 84321f379fe..d60a3de915f 100644 --- a/ghost/core/test/unit/server/data/schema/integrity.test.js +++ b/ghost/core/test/unit/server/data/schema/integrity.test.js @@ -37,8 +37,8 @@ const parseYaml = require('../../../../../core/server/services/route-settings/ya */ describe('DB version integrity', function () { // Only these variables should need updating - const currentSchemaHash = '091d0105868c689120d75dcc553282e7'; - const currentFixturesHash = '84c2ea9677201ec4e59ca893f3a9f2c1'; + const currentSchemaHash = 'c4c1ef4ab373e100452e6e478a0a5758'; + const currentFixturesHash = '1727a789194847da33d68dd95301b416'; const currentSettingsHash = 'f57245b22af55ea5fc1e5e20913de9bb'; const currentRoutesHash = 'd8c25fa01bf6d22a2bcb05ba0de70dc1'; 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 index 3d27f71cbdd..16b4a5720e7 100644 --- 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 @@ -9,6 +9,7 @@ import type { PostData } from '../../../../../../core/server/services/content-im const row = (title: string, html = `

${title}

`): PostImportRow => ({ title, html, + markdown: '', published_at: '2025-01-01T00:00:00.000Z', }); @@ -21,11 +22,21 @@ function harness(rows: PostImportRow[] = [row('First'), row('Second')]) { const urlFailures = new Map(); const store = new ImportRunStore(); let converterResolutions = 0; + let markdownRendererResolutions = 0; + let cleanerResolutions = 0; // Late-bound: the importer captures deps at construction. let htmlToLexicalFactory: () => (html: string) => unknown = () => { converterResolutions += 1; return (html: string) => ({ converted: html }); }; + let markdownToHtmlFactory: () => (markdown: string) => string = () => { + markdownRendererResolutions += 1; + return (markdown: string) => `

${markdown}

`; + }; + let cleanHTMLFactory: () => (args: { html: string; opinionated: boolean }) => string = () => { + cleanerResolutions += 1; + return ({ html }) => html; + }; const deps = { readRows: async () => rows, @@ -41,6 +52,8 @@ function harness(rows: PostImportRow[] = [row('First'), row('Second')]) { }, }, getHtmlToLexical: () => htmlToLexicalFactory(), + getMarkdownToHtml: () => markdownToHtmlFactory(), + getCleanHTML: () => cleanHTMLFactory(), addJob: (job: { name: string; offloaded: boolean; job: () => Promise }) => { jobs.push(job); }, @@ -74,6 +87,14 @@ function harness(rows: PostImportRow[] = [row('First'), row('Second')]) { const setHtmlToLexicalFactory = (factory: () => (html: string) => unknown) => { htmlToLexicalFactory = factory; }; + const setMarkdownToHtmlFactory = (factory: () => (markdown: string) => string) => { + markdownToHtmlFactory = factory; + }; + const setCleanHTMLFactory = ( + factory: () => (args: { html: string; opinionated: boolean }) => string, + ) => { + cleanHTMLFactory = factory; + }; return { importer, @@ -86,7 +107,11 @@ function harness(rows: PostImportRow[] = [row('First'), row('Second')]) { urlFailures, store, setHtmlToLexicalFactory, + setMarkdownToHtmlFactory, + setCleanHTMLFactory, converterResolutions: () => converterResolutions, + markdownRendererResolutions: () => markdownRendererResolutions, + cleanerResolutions: () => cleanerResolutions, }; } @@ -144,6 +169,22 @@ describe('ContentCSVImporter', function () { assert.equal(h.created.length, 2); }); + it('passes a caller-supplied mapping to the CSV reader', async function () { + const h = harness(); + let receivedMapping: Record | undefined; + const importer = new ContentCSVImporter({ + ...h.deps, + readRows: async (_path, mapping) => { + receivedMapping = mapping; + return [row('Mapped')]; + }, + }); + + await importer.importCSV({ filePath: '/tmp/posts.csv', mapping: { Headline: 'title' } }); + + assert.deepEqual(receivedMapping, { Headline: 'title' }); + }); + it('writes one post per row, in order, under the importing options', async function () { const h = harness(); @@ -201,6 +242,22 @@ describe('ContentCSVImporter', function () { assert.equal(h.converterResolutions(), 1); }); + it('resolves the markdown renderer once per run, not per row', async function () { + const h = harness(); + + await h.run(); + + assert.equal(h.markdownRendererResolutions(), 1); + }); + + it('resolves the HTML cleaner once per run, not per row', async function () { + const h = harness(); + + await h.run(); + + assert.equal(h.cleanerResolutions(), 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}`))); @@ -279,7 +336,7 @@ describe('ContentCSVImporter', function () { 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 }, + { title: '', html: '

No title

', markdown: '', published_at: undefined }, row('Third'), ]); @@ -304,10 +361,57 @@ describe('ContentCSVImporter', function () { ); }); + it('isolates a markdown conversion failure to its row', async function () { + const badMarkdown = { ...row('Bad markdown', ''), markdown: 'bad' }; + const h = harness([row('First'), badMarkdown, row('Third')]); + h.setMarkdownToHtmlFactory(() => (markdown: string) => { + if (markdown === 'bad') { + throw new Error('bad markdown'); + } + return `

${markdown}

`; + }); + + await h.run(); + + assert.deepEqual( + h.created.map((call) => call.data.title), + ['First', 'Third'], + ); + assert.deepEqual(h.store.get('run_test')?.rows[1], { + line: 3, + title: 'Bad markdown', + status: 'skipped', + reason: 'markdown could not be converted', + }); + }); + + it('isolates an HTML cleaning failure to its row', async function () { + const h = harness([row('First'), row('Bad clean'), row('Third')]); + h.setCleanHTMLFactory(() => ({ html }) => { + if (html.includes('Bad clean')) { + throw new Error('cleaner failed'); + } + return html; + }); + + await h.run(); + + assert.deepEqual( + h.created.map((call) => call.data.title), + ['First', 'Third'], + ); + assert.deepEqual(h.store.get('run_test')?.rows[1], { + line: 3, + title: 'Bad clean', + status: 'skipped', + reason: 'html could not be cleaned', + }); + }); + 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 }, + { title: '', html: '

No title

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

Still no title

', markdown: '', published_at: undefined }, ]); await h.run(); 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 index 23db2754f71..a47f7b18a88 100644 --- 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 @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { cleanHTML as officialCleanHTML } from '@tryghost/mg-clean-html'; import buildPostData, { RowSkipped, } from '../../../../../../core/server/services/content-import/import/post-data'; @@ -7,6 +8,7 @@ import { postImportRowSchema } from '../../../../../../core/server/services/cont // 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 markdownToHtml = (markdown: string) => `

${markdown.slice(2)}

`; const row = (cells: Record) => postImportRowSchema.parse(cells); @@ -21,6 +23,34 @@ describe('buildPostData', function () { assert.equal('html' in data, false); }); + it('runs opinionated HTML cleanup before lexical conversion', function () { + const source = + '

Hello


Header

'; + const data = buildPostData( + row({ title: 'T', html: source }), + htmlToLexical, + TAGS, + markdownToHtml, + officialCleanHTML, + ); + + assert.equal(data.lexical, JSON.stringify({ converted: '

Hello

Header

' })); + }); + + it('preserves protected Instagram embed markup during cleanup', function () { + const source = + '

Protected

'; + const data = buildPostData( + row({ title: 'T', html: source }), + htmlToLexical, + TAGS, + markdownToHtml, + officialCleanHTML, + ); + + assert.equal(data.lexical, JSON.stringify({ converted: source })); + }); + 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' }), @@ -31,12 +61,79 @@ describe('buildPostData', function () { assert.equal(data.slug, 'a-post-with-a-comma-in-its-title'); }); + it('sanitizes an explicit slug instead of deriving it from the title', function () { + const data = buildPostData( + row({ title: 'Different title', slug: ' Custom Slug, Here ' }), + htmlToLexical, + TAGS, + ); + + assert.equal(data.slug, 'custom-slug-here'); + }); + 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('renders markdown to html before converting it to lexical', function () { + const data = buildPostData( + row({ title: 'T', markdown: '# Hello' }), + htmlToLexical, + TAGS, + markdownToHtml, + ); + + assert.equal(data.lexical, JSON.stringify({ converted: '

Hello

' })); + }); + + it('cleans markdown-rendered HTML before converting it to lexical', function () { + const styledMarkdownRenderer = () => '

Hello

'; + const data = buildPostData( + row({ title: 'T', markdown: 'Hello' }), + htmlToLexical, + TAGS, + styledMarkdownRenderer, + officialCleanHTML, + ); + + assert.equal(data.lexical, JSON.stringify({ converted: '

Hello

' })); + }); + + it('skips a row that supplies both html and markdown', function () { + assert.throws( + () => + buildPostData( + row({ title: 'T', html: '

HTML

', markdown: 'Markdown' }), + htmlToLexical, + TAGS, + markdownToHtml, + ), + (error: unknown) => { + assert.ok(error instanceof RowSkipped); + assert.equal(error.message, 'html and markdown cannot both be provided'); + return true; + }, + ); + }); + + it('skips a row when markdown rendering fails', function () { + const throwingRenderer = () => { + throw new Error('renderer exploded'); + }; + + assert.throws( + () => + buildPostData(row({ title: 'T', markdown: 'bad' }), htmlToLexical, TAGS, throwingRenderer), + (error: unknown) => { + assert.ok(error instanceof RowSkipped); + assert.equal(error.message, 'markdown could not be converted'); + return true; + }, + ); + }); + 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' }), @@ -66,6 +163,74 @@ describe('buildPostData', function () { assert.equal('authors' in data, false); }); + it('imports publishing, image, and advanced post fields', function () { + const data = buildPostData( + row({ + title: 'Full post', + type: 'page', + status: 'draft', + visibility: 'members', + featured: '1', + show_title_and_feature_image: 'false', + custom_excerpt: 'Summary', + feature_image: 'https://example.com/image.jpg', + canonical_url: 'https://example.com/original', + custom_template: 'wide', + codeinjection_head: '', + codeinjection_foot: '', + }), + htmlToLexical, + TAGS, + ); + + assert.equal(data.type, 'page'); + assert.equal(data.status, 'draft'); + assert.equal(data.visibility, 'members'); + assert.equal(data.featured, true); + assert.equal(data.show_title_and_feature_image, false); + assert.equal(data.custom_excerpt, 'Summary'); + assert.equal(data.feature_image, 'https://example.com/image.jpg'); + assert.equal(data.canonical_url, 'https://example.com/original'); + assert.equal(data.custom_template, 'wide'); + assert.equal(data.codeinjection_head, ''); + assert.equal(data.codeinjection_foot, ''); + }); + + it('puts feature metadata, SEO, social fields, and frontmatter in posts_meta', function () { + const data = buildPostData( + row({ + title: 'Metadata post', + feature_image_alt: 'Alt text', + feature_image_caption: 'Caption', + meta_title: 'Meta title', + meta_description: 'Meta description', + og_image: 'https://example.com/og.jpg', + og_title: 'OG title', + og_description: 'OG description', + twitter_image: 'https://example.com/twitter.jpg', + twitter_title: 'Twitter title', + twitter_description: 'Twitter description', + frontmatter: 'key: value', + }), + htmlToLexical, + TAGS, + ); + + assert.deepEqual(data.posts_meta, { + feature_image_alt: 'Alt text', + feature_image_caption: 'Caption', + meta_title: 'Meta title', + meta_description: 'Meta description', + og_image: 'https://example.com/og.jpg', + og_title: 'OG title', + og_description: 'OG description', + twitter_image: 'https://example.com/twitter.jpg', + twitter_title: 'Twitter title', + twitter_description: 'Twitter description', + frontmatter: 'key: value', + }); + }); + it('omits every date when the cell is absent, leaving the model to stamp now', function () { const data = buildPostData(row({ title: 'T' }), htmlToLexical, TAGS); @@ -74,6 +239,23 @@ describe('buildPostData', function () { assert.equal('updated_at' in data, false); }); + it('lets explicit created and updated dates override the published date fallback', function () { + const data = buildPostData( + row({ + title: 'T', + published_at: '2025-01-01', + created_at: '2024-01-01', + updated_at: '2025-02-01', + }), + htmlToLexical, + TAGS, + ); + + assert.equal(data.published_at, '2025-01-01'); + assert.equal(data.created_at, '2024-01-01'); + assert.equal(data.updated_at, '2025-02-01'); + }); + const skipsWith = (cells: Record, reason: string | RegExp) => { assert.throws( () => buildPostData(row(cells), htmlToLexical, TAGS), @@ -132,6 +314,22 @@ describe('buildPostData', function () { assert.equal(data.published_at, 'May 1, 2024'); }); + for (const [field, value, reason] of [ + ['type', 'article', 'type must be one of: post, page'], + ['status', 'scheduled', 'status must be one of: draft, published'], + ['visibility', 'private', 'visibility must be one of: public, members, paid'], + ['featured', 'yes', 'featured must be true, false, 1, or 0'], + [ + 'show_title_and_feature_image', + 'sometimes', + 'show_title_and_feature_image must be true, false, 1, or 0', + ], + ] as const) { + it(`skips a row with an invalid ${field}`, function () { + skipsWith({ title: 'T', [field]: value }, reason); + }); + } + it('skips a row whose html cannot be converted', function () { const throwingConverter = () => { throw new Error('parser exploded'); @@ -146,4 +344,26 @@ describe('buildPostData', function () { }, ); }); + + it('skips a row whose html cannot be cleaned', function () { + const throwingCleaner = () => { + throw new Error('cleaner exploded'); + }; + + assert.throws( + () => + buildPostData( + row({ title: 'T', html: '

bad

' }), + htmlToLexical, + TAGS, + markdownToHtml, + throwingCleaner, + ), + (error: unknown) => { + assert.ok(error instanceof RowSkipped); + assert.equal(error.message, 'html could not be cleaned'); + return true; + }, + ); + }); }); diff --git a/ghost/core/test/unit/server/services/content-import/import/reader.test.ts b/ghost/core/test/unit/server/services/content-import/import/reader.test.ts new file mode 100644 index 00000000000..bd481ae7edc --- /dev/null +++ b/ghost/core/test/unit/server/services/content-import/import/reader.test.ts @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import readPostRows from '../../../../../../core/server/services/content-import/import/reader'; + +describe('content import reader', function () { + let directory: string; + + beforeEach(async function () { + directory = await fs.mkdtemp(path.join(os.tmpdir(), 'content-import-reader-')); + }); + + afterEach(async function () { + await fs.rm(directory, { recursive: true, force: true }); + }); + + it('maps arbitrary headers and drops explicitly ignored columns', async function () { + const file = path.join(directory, 'posts.csv'); + await fs.writeFile(file, 'Headline,Body,Ignore me\nHello,

World

,unused\n'); + + const rows = await readPostRows(file, { + Headline: 'title', + Body: 'html', + 'Ignore me': '', + }); + + assert.deepEqual(rows, [{ title: 'Hello', html: '

World

', markdown: '' }]); + }); + + it('keeps the existing identity headers when no mapping is supplied', async function () { + const file = path.join(directory, 'posts.csv'); + await fs.writeFile(file, 'title,html,published_at\nHello,

World

,2025-01-01\n'); + + const rows = await readPostRows(file); + + assert.deepEqual(rows, [ + { title: 'Hello', html: '

World

', markdown: '', published_at: '2025-01-01' }, + ]); + }); + + it('keeps full editorial identity headers for direct API clients', async function () { + const file = path.join(directory, 'full-post.csv'); + await fs.writeFile(file, 'title,slug,featured,meta_title\nHello,custom,1,Search title\n'); + + const rows = await readPostRows(file); + + assert.deepEqual(rows, [ + { + title: 'Hello', + slug: 'custom', + featured: '1', + meta_title: 'Search title', + html: '', + markdown: '', + }, + ]); + }); +}); 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 index 8d178b1979c..9c9f9b367b6 100644 --- 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 @@ -19,6 +19,11 @@ describe('post import row schema', function () { assert.equal(postImportRowSchema.parse({ html: '

Hi

' }).html, '

Hi

'); }); + it('defaults a missing markdown cell to the empty string', function () { + assert.equal(postImportRowSchema.parse({}).markdown, ''); + assert.equal(postImportRowSchema.parse({ markdown: '# Hi' }).markdown, '# 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); @@ -28,6 +33,20 @@ describe('post import row schema', function () { ); }); + it('reads empty optional editorial cells as absent', function () { + const parsed = postImportRowSchema.parse({ + slug: '', + feature_image: 'undefined', + meta_title: '', + frontmatter: '', + }); + + assert.equal(parsed.slug, undefined); + assert.equal(parsed.feature_image, undefined); + assert.equal(parsed.meta_title, undefined); + assert.equal(parsed.frontmatter, undefined); + }); + 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/gifts/gift-bookshelf-repository.test.ts b/ghost/core/test/unit/server/services/gifts/gift-bookshelf-repository.test.ts index e7fdfe8068c..c0a151027b4 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 @@ -200,7 +200,7 @@ describe('GiftBookshelfRepository', function () { stripeCheckoutSessionId: 'cs_123', stripePaymentIntentId: 'pi_456', purchasedAt: new Date('2026-08-18T23:30:00.000Z'), - expiresAt: new Date('2027-08-19T06:59:59.999Z'), + expiryTimeZone: 'America/Los_Angeles', }); const id = await repository.create(gift, { transacting }); @@ -232,7 +232,7 @@ describe('GiftBookshelfRepository', function () { stripeCheckoutSessionId: 'cs_123', stripePaymentIntentId: 'pi_456', purchasedAt: new Date('2026-08-18T23:30:00.000Z'), - expiresAt: new Date('2027-08-19T06:59:59.999Z'), + expiryTimeZone: 'America/Los_Angeles', }); await assert.rejects(() => repository.create(gift), { 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 index e19da23482c..929f9227ef8 100644 --- 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 @@ -21,10 +21,15 @@ describe('GiftDeliveryService', function () { let giftEmailService: { sendGiftDelivery: sinon.SinonStub; sendDeliveryFailureNotification: sinon.SinonStub; + sendGiftSentConfirmation: sinon.SinonStub; }; let giftEmailAnalytics: { schedule: sinon.SinonStub; }; + let giftDeliveryScheduler: { + scheduleAt: sinon.SinonStub; + rescheduleAll: sinon.SinonStub; + }; let dispatchDelivery: sinon.SinonStub; let tiersService: { api: { @@ -47,6 +52,7 @@ describe('GiftDeliveryService', function () { getByGiftId: sinon.stub().resolves(null), getByProviderMessageId: sinon.stub().resolves(null), findRecoverableForPurchasedGifts: sinon.stub().resolves([]), + findScheduledTimesForPurchasedGifts: sinon.stub().resolves([]), tryStartDelivery: sinon.stub().resolves(buildGiftDelivery({ status: 'sending' })), markSent: sinon.stub().resolves(true), recordCancelledAcceptance: sinon.stub().resolves(false), @@ -59,10 +65,15 @@ describe('GiftDeliveryService', function () { giftEmailService = { sendGiftDelivery: sinon.stub().resolves({ providerMessageId: 'provider-123' }), sendDeliveryFailureNotification: sinon.stub().resolves(undefined), + sendGiftSentConfirmation: sinon.stub().resolves(undefined), }; giftEmailAnalytics = { schedule: sinon.stub().resolves(undefined), }; + giftDeliveryScheduler = { + scheduleAt: sinon.stub().resolves(undefined), + rescheduleAll: sinon.stub().resolves(undefined), + }; dispatchDelivery = sinon.stub(DomainEvents, 'dispatch'); tiersService = { api: { @@ -81,9 +92,21 @@ describe('GiftDeliveryService', function () { tiersService, giftEmailService, giftEmailAnalytics, + giftDeliveryScheduler, }); } + function buildRecoverableDelivery(id: string) { + return { + delivery: buildGiftDelivery({ id }), + gift: buildGift({ + buyerName: 'Buyer', + recipientName: 'Recipient', + personalMessage: 'Enjoy this gift', + }), + }; + } + afterEach(function () { sinon.restore(); }); @@ -94,15 +117,54 @@ describe('GiftDeliveryService', function () { ); const service = createService(); - assert.equal(await service.dispatchForGift('gift_1'), 'recipient@example.com'); + assert.deepEqual(await service.dispatchForGift({ giftId: 'gift_1' }), { + recipientEmail: 'recipient@example.com', + scheduledAt: null, + }); assert.deepEqual(dispatchDelivery.firstCall.firstArg.data, { deliveryId: 'delivery_1' }); }); - it('recovers pending deliveries for purchased gifts one at a time', async function () { + it('schedules a future delivery instead of dispatching it immediately', async function () { + const scheduledAt = new Date(Date.now() + 60_000); + giftDeliveryRepository.getByGiftId.resolves( + buildGiftDelivery({ id: 'delivery_1', scheduledAt }), + ); + const service = createService(); + + assert.deepEqual(await service.dispatchForGift({ giftId: 'gift_1' }), { + recipientEmail: 'recipient@example.com', + scheduledAt, + }); + sinon.assert.calledOnceWithExactly(giftDeliveryScheduler.scheduleAt, scheduledAt.getTime(), { + deliveryId: 'delivery_1', + }); + sinon.assert.notCalled(dispatchDelivery); + }); + + it('sends immediately when the scheduled time passes while the flush is being armed', async function () { + const clock = sinon.useFakeTimers(new Date('2026-12-25T08:59:59.900Z')); + const scheduledAt = new Date('2026-12-25T09:00:00.000Z'); + giftDeliveryRepository.getByGiftId.resolves( + buildGiftDelivery({ id: 'delivery_1', scheduledAt }), + ); + giftDeliveryScheduler.scheduleAt.callsFake(async () => { + clock.tick(200); + }); + const service = createService(); + + assert.deepEqual(await service.dispatchForGift({ giftId: 'gift_1' }), { + recipientEmail: 'recipient@example.com', + scheduledAt, + }); + assert.deepEqual(dispatchDelivery.firstCall.firstArg.data, { deliveryId: 'delivery_1' }); + clock.restore(); + }); + + it('recovers pending deliveries for purchased gifts concurrently', async function () { giftDeliveryRepository.findRecoverableForPurchasedGifts.resolves([ - buildGiftDelivery({ id: 'delivery_1' }), - buildGiftDelivery({ id: 'delivery_2' }), - buildGiftDelivery({ id: 'delivery_3' }), + buildRecoverableDelivery('delivery_1'), + buildRecoverableDelivery('delivery_2'), + buildRecoverableDelivery('delivery_3'), ]); giftDeliveryRepository.tryStartDelivery .withArgs('delivery_1') @@ -132,20 +194,88 @@ describe('GiftDeliveryService', function () { sinon.assert.calledOnceWithExactly( giftDeliveryRepository.findRecoverableForPurchasedGifts, sinon.match.date, + sinon.match.date, 1000, ); sinon.assert.notCalled(dispatchDelivery); sinon.assert.calledTwice(giftEmailService.sendGiftDelivery); - assert.equal(maxInFlight, 1); + assert.equal(maxInFlight, 2); + sinon.assert.notCalled(giftRepository.getById); sinon.assert.calledWith(giftDeliveryRepository.markSent, 'delivery_1'); sinon.assert.calledWith(giftDeliveryRepository.markSent, 'delivery_2'); }); + it('bounds recovery concurrency at ten deliveries', async function () { + const deliveries = Array.from({ length: 12 }, (_, index) => + buildRecoverableDelivery(`delivery_${index + 1}`), + ); + giftDeliveryRepository.findRecoverableForPurchasedGifts.resolves(deliveries); + giftDeliveryRepository.tryStartDelivery.callsFake(async (id) => + buildGiftDelivery({ id, status: 'sending' }), + ); + 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: 12, + skippedCount: 0, + failedCount: 0, + }); + assert.equal(maxInFlight, 10); + }); + + it('drains recovery in batches until nothing recoverable remains', async function () { + giftDeliveryRepository.findRecoverableForPurchasedGifts + .onFirstCall() + .resolves([buildRecoverableDelivery('delivery_1'), buildRecoverableDelivery('delivery_2')]) + .onSecondCall() + .resolves([buildRecoverableDelivery('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(buildGiftDelivery({ id: 'delivery_3', status: 'sending' })); + const service = createService(); + + assert.deepEqual(await service.recoverPending(2), { + sentCount: 3, + skippedCount: 0, + failedCount: 0, + }); + sinon.assert.calledTwice(giftDeliveryRepository.findRecoverableForPurchasedGifts); + sinon.assert.calledWithExactly( + giftDeliveryRepository.findRecoverableForPurchasedGifts, + sinon.match.date, + sinon.match.date, + 2, + ); + }); + + it('re-arms future deliveries during boot recovery', async function () { + const service = createService(); + + await service.reschedulePending(); + + sinon.assert.calledOnceWithExactly(giftDeliveryScheduler.rescheduleAll); + }); + 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' }), + buildRecoverableDelivery('delivery_1'), + buildRecoverableDelivery('delivery_2'), ]); giftDeliveryRepository.tryStartDelivery .withArgs('delivery_1') @@ -198,6 +328,72 @@ describe('GiftDeliveryService', function () { }), ); sinon.assert.calledOnce(giftEmailAnalytics.schedule); + sinon.assert.notCalled(giftEmailService.sendGiftSentConfirmation); + }); + + it('sends the buyer confirmation after a scheduled gift is sent', async function () { + giftDeliveryRepository.tryStartDelivery.resolves( + buildGiftDelivery({ + status: 'sending', + scheduledAt: new Date('2026-01-02T00:00:00.000Z'), + }), + ); + giftRepository.getById.resolves( + buildGift({ + buyerName: 'Buyer', + recipientName: 'Recipient', + personalMessage: 'Enjoy this gift', + purchasedAt: new Date('2026-01-01T00:00:00.000Z'), + }), + ); + const service = createService(); + + assert.equal(await service.send('delivery_1'), 'sent'); + sinon.assert.calledOnceWithExactly( + giftEmailService.sendGiftSentConfirmation, + sinon.match({ + buyerEmail: 'buyer@example.com', + recipientEmail: 'recipient@example.com', + }), + ); + }); + + it('keeps a recipient delivery sent when the buyer confirmation fails', async function () { + giftEmailService.sendGiftSentConfirmation.rejects(new Error('SMTP unavailable')); + giftDeliveryRepository.tryStartDelivery.resolves( + buildGiftDelivery({ + status: 'sending', + scheduledAt: new Date('2026-01-02T00:00:00.000Z'), + }), + ); + giftRepository.getById.resolves( + buildGift({ + buyerName: 'Buyer', + recipientName: 'Recipient', + personalMessage: 'Enjoy this gift', + purchasedAt: new Date('2026-01-01T00:00:00.000Z'), + }), + ); + const service = createService(); + + assert.equal(await service.send('delivery_1'), 'sent'); + sinon.assert.calledOnce(giftDeliveryRepository.markSent); + }); + + it('resolves the tiers API at send time so boot wiring can hand over the uninitialised service', async function () { + const lateBoundTiers = { api: undefined as typeof tiersService.api | undefined }; + const service = new GiftDeliveryService({ + giftRepository, + giftDeliveryRepository, + tiersService: lateBoundTiers as unknown as typeof tiersService, + giftEmailService, + giftEmailAnalytics, + giftDeliveryScheduler, + }); + lateBoundTiers.api = tiersService.api; + + assert.equal(await service.send('delivery_1'), 'sent'); + sinon.assert.calledOnceWithExactly(tiersService.api.read, 'tier_1'); }); it('records transport acceptance without a provider message ID', async function () { 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 index ddd3ff1230d..dfddc4bc15d 100644 --- a/ghost/core/test/unit/server/services/gifts/gift-delivery.test.ts +++ b/ghost/core/test/unit/server/services/gifts/gift-delivery.test.ts @@ -12,6 +12,7 @@ describe('Gift delivery codec', function () { gift_id: 'gift_1', recipient_email: 'recipient@example.com', status: 'sent', + scheduled_at: null, started_at: null, email_sent_at: '2026-08-11T11:00:00.000Z', email_provider_message_id: 'provider-123', 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 31445b8dbf2..e893d9cb797 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 @@ -64,6 +64,7 @@ describe('GiftEmailService', function () { tierName: 'Gold', cadence: 'year', duration: 1, + scheduledAt: null, expiresAt: new Date('2027-04-07'), }; @@ -122,6 +123,42 @@ describe('GiftEmailService', function () { } }); + it('tells the buyer when a scheduled gift will be sent', async function () { + const scheduledAt = new Date(Date.now() + 24 * 60 * 60 * 1000); + await service.sendPurchaseConfirmation({ + ...defaultData, + recipientEmail: 'recipient@example.com', + scheduledAt, + }); + + const message = transactionalMailer.send.firstCall.firstArg; + assert.match(message.subject, /^Your gift will be sent on /); + for (const field of ['html', 'text']) { + sinon.assert.match(message[field], sinon.match('Your gift is scheduled')); + sinon.assert.match(message[field], sinon.match('will be sent to')); + } + }); + + it('sends a best-effort buyer confirmation after recipient acceptance', async function () { + await service.sendGiftSentConfirmation({ + buyerEmail: 'buyer@example.com', + recipientEmail: 'recipient@example.com', + token: 'abc-123', + expiresAt: new Date('2027-04-07'), + }); + + const message = transactionalMailer.send.firstCall.firstArg; + sinon.assert.match(message, { + to: 'buyer@example.com', + subject: 'Your gift has been sent', + disableTracking: true, + }); + for (const field of ['html', 'text']) { + sinon.assert.match(message[field], sinon.match('recipient@example.com')); + sinon.assert.match(message[field], sinon.match('https://example.com/gift/abc-123')); + } + }); + it('tells the buyer to share the gift link after permanent delivery failure', async function () { await service.sendDeliveryFailureNotification({ buyerEmail: 'buyer@example.com', diff --git a/ghost/core/test/unit/server/services/gifts/gift-flush-scheduler.test.ts b/ghost/core/test/unit/server/services/gifts/gift-flush-scheduler.test.ts new file mode 100644 index 00000000000..c8bb5c8c62f --- /dev/null +++ b/ghost/core/test/unit/server/services/gifts/gift-flush-scheduler.test.ts @@ -0,0 +1,260 @@ +import assert from 'node:assert/strict'; +import logging from '@tryghost/logging'; +import sinon from 'sinon'; +import { GiftFlushScheduler } from '../../../../../core/server/services/gifts/gift-flush-scheduler'; +import { AutoFillingMap } from '../../../../../core/server/lib/auto-filling-map'; +import type { + InternalApiKey, + InternalIntegrationSlug, +} from '../../../../../core/server/services/internal-keys'; + +// Use real signing and URL helpers; stub only cross-domain collaborators. +// Test secrets are 64-char hex so getSignedAdminToken (which decodes via +// Buffer.from(secret, 'hex')) treats them as distinct signing keys. +const HEX_CURRENT = 'aa'.repeat(32); +const HEX_OLD = '55'.repeat(32); + +function buildDeps( + overrides: { + pending?: number[]; + currentKey?: InternalApiKey; + } = {}, +) { + const apiUrl = 'https://example.com/ghost/api/admin'; + const currentKey: InternalApiKey = overrides.currentKey ?? { id: 'kid', secret: HEX_CURRENT }; + const internalKeys = new AutoFillingMap>( + (slug) => Promise.reject(new Error(`Test internalKeys not seeded for slug ${slug}`)), + ); + internalKeys.set('ghost-scheduler', Promise.resolve(currentKey)); + + return { + apiUrl, + adapter: { + schedule: sinon.stub(), + unschedule: sinon.stub(), + register: sinon.stub(), + run: sinon.stub(), + }, + internalKeys, + endpoint: 'flush_deliveries' as const, + name: 'gift_delivery', + findScheduledTimes: sinon.stub<[], Promise>().resolves(overrides.pending ?? []), + }; +} + +describe('GiftFlushScheduler', function () { + afterEach(function () { + sinon.restore(); + }); + + it('registers itself with the adapter on construction', function () { + const deps = buildDeps(); + new GiftFlushScheduler(deps); + + sinon.assert.calledOnce(deps.adapter.register); + assert.equal(typeof deps.adapter.register.firstCall.firstArg.rescheduleAll, 'function'); + }); + + describe('scheduleAt', function () { + it('arms a flush callback just past the given time, signed with the current key', async function () { + const deps = buildDeps(); + const scheduler = new GiftFlushScheduler(deps); + const time = Date.now() + 60_000; + + await scheduler.scheduleAt(time); + + sinon.assert.calledOnce(deps.adapter.schedule); + const [job] = deps.adapter.schedule.getCall(0).args; + assert.equal(job.time, Math.floor(time / 1000) * 1000 + 1000); + assert.equal(job.extra.httpMethod, 'PUT'); + assert.ok( + job.url.startsWith(`${deps.apiUrl}/gifts/flush_deliveries?token=`), + 'the URL targets the configured flush endpoint and carries a JWT', + ); + }); + + it('targets the configured endpoint', async function () { + const deps = { ...buildDeps(), endpoint: 'flush_reminders' as const }; + const scheduler = new GiftFlushScheduler(deps); + + await scheduler.scheduleAt(Date.now() + 60_000); + + const [job] = deps.adapter.schedule.getCall(0).args; + assert.ok(job.url.startsWith(`${deps.apiUrl}/gifts/flush_reminders?token=`)); + }); + + it('does not arm an already-due time', async function () { + const deps = buildDeps(); + const scheduler = new GiftFlushScheduler(deps); + + await scheduler.scheduleAt(Date.now() - 1); + + sinon.assert.notCalled(deps.adapter.schedule); + }); + + it('arms one batch flush for repeat schedules of the same time', async function () { + const deps = buildDeps(); + const scheduler = new GiftFlushScheduler(deps); + const time = Date.now() + 60_000; + + await scheduler.scheduleAt(time, { deliveryId: 'delivery_1' }); + await scheduler.scheduleAt(time, { deliveryId: 'delivery_2' }); + + sinon.assert.calledOnce(deps.adapter.schedule); + }); + + it('deduplicates times within the same persisted database second', async function () { + const deps = buildDeps(); + const scheduler = new GiftFlushScheduler(deps); + const second = Math.floor((Date.now() + 60_000) / 1000) * 1000; + + await scheduler.scheduleAt(second + 123); + await scheduler.scheduleAt(second + 987); + + sinon.assert.calledOnce(deps.adapter.schedule); + assert.equal(deps.adapter.schedule.firstCall.firstArg.time, second + 1000); + }); + + it('does not let synchronous job construction failures escape', async function () { + const deps = { ...buildDeps(), apiUrl: 'not a url' }; + const scheduler = new GiftFlushScheduler(deps); + const logError = sinon.stub(logging, 'error'); + + await scheduler.scheduleAt(Date.now() + 60_000); + + sinon.assert.notCalled(deps.adapter.schedule); + sinon.assert.calledOnce(logError); + }); + + it('retries after a synchronous adapter failure', async function () { + const deps = buildDeps(); + deps.adapter.schedule.onFirstCall().throws(new Error('adapter failed')); + const scheduler = new GiftFlushScheduler(deps); + sinon.stub(logging, 'error'); + const time = Date.now() + 60_000; + + await scheduler.scheduleAt(time); + await scheduler.scheduleAt(time); + + sinon.assert.calledTwice(deps.adapter.schedule); + }); + + it('retries scheduling a time whose earlier attempt failed to fetch a key', async function () { + const deps = buildDeps(); + const failingKeys = new AutoFillingMap>( + (slug) => Promise.reject(new Error(`Missing test key for ${slug}`)), + ); + const rejection = Promise.reject(new Error('Transient key failure')); + rejection.catch(() => {}); + failingKeys.set('ghost-scheduler', rejection); + const scheduler = new GiftFlushScheduler({ ...deps, internalKeys: failingKeys }); + const time = Date.now() + 60_000; + + await scheduler.scheduleAt(time); + sinon.assert.notCalled(deps.adapter.schedule); + + failingKeys.set('ghost-scheduler', Promise.resolve({ id: 'key', secret: HEX_CURRENT })); + await scheduler.scheduleAt(time); + + sinon.assert.calledOnce(deps.adapter.schedule); + }); + }); + + describe('rescheduleAll', function () { + it('re-signs every pending time under the current key', async function () { + const pending = [Date.now() + 30_000, Date.now() + 60_000]; + const deps = buildDeps({ pending, currentKey: { id: 'k', secret: HEX_CURRENT } }); + const scheduler = new GiftFlushScheduler(deps); + + await scheduler.rescheduleAll({ previousKey: { id: 'k', secret: HEX_OLD } }); + + sinon.assert.calledTwice(deps.adapter.unschedule); + sinon.assert.calledTwice(deps.adapter.schedule); + + // The stale job must be addressed with a token signed by the previous key. + const unscheduleUrls = deps.adapter.unschedule.getCalls().map((c) => c.args[0].url); + const scheduleUrls = deps.adapter.schedule.getCalls().map((c) => c.args[0].url); + for (let i = 0; i < pending.length; i++) { + assert.notEqual( + unscheduleUrls[i], + scheduleUrls[i], + `pending[${i}]: unschedule URL (old key) must differ from schedule URL (current key)`, + ); + } + }); + + it('unschedules stale old-key jobs during rotation', async function () { + // Non-bootstrap unscheduling tombstones the stale callback. + const deps = buildDeps({ pending: [Date.now() + 30_000] }); + const scheduler = new GiftFlushScheduler(deps); + + await scheduler.rescheduleAll({ previousKey: { id: 'k', secret: HEX_OLD } }); + + sinon.assert.calledOnce(deps.adapter.unschedule); + assert.equal(deps.adapter.unschedule.getCall(0).args[1].bootstrap, false); + }); + + it('also unschedules pre-upgrade reminder jobs during key rotation', async function () { + const time = Math.floor((Date.now() + 30_000) / 1000) * 1000; + const deps = { + ...buildDeps({ pending: [time] }), + endpoint: 'flush_reminders' as const, + }; + const scheduler = new GiftFlushScheduler(deps); + + await scheduler.rescheduleAll({ previousKey: { id: 'k', secret: HEX_OLD } }); + + sinon.assert.calledTwice(deps.adapter.unschedule); + assert.equal(deps.adapter.unschedule.firstCall.firstArg.time, time + 1000); + assert.equal(deps.adapter.unschedule.secondCall.firstArg.time, time); + sinon.assert.calledOnce(deps.adapter.schedule); + }); + + it('does not tombstone the replacement during boot rebuilds', async function () { + // Boot reuses the current-key URL, so unscheduling must use bootstrap mode. + const deps = buildDeps({ pending: [Date.now() + 30_000] }); + const scheduler = new GiftFlushScheduler(deps); + + await scheduler.rescheduleAll(); + + sinon.assert.calledOnce(deps.adapter.unschedule); + sinon.assert.calledOnce(deps.adapter.schedule); + assert.equal(deps.adapter.unschedule.getCall(0).args[1].bootstrap, true); + assert.equal( + deps.adapter.unschedule.getCall(0).args[0].url, + deps.adapter.schedule.getCall(0).args[0].url, + 'with no previousKey, both URLs are signed under the same (current) key', + ); + }); + + it('rebuilds duplicate pending times as one job', async function () { + const time = Date.now() + 30_000; + const deps = buildDeps({ pending: [time, time] }); + const scheduler = new GiftFlushScheduler(deps); + + await scheduler.rescheduleAll({ previousKey: { id: 'k', secret: HEX_OLD } }); + + sinon.assert.calledOnce(deps.adapter.schedule); + }); + + it('skips times that have already passed', async function () { + const deps = buildDeps({ pending: [Date.now() - 1] }); + const scheduler = new GiftFlushScheduler(deps); + + await scheduler.rescheduleAll({ previousKey: { id: 'k', secret: HEX_OLD } }); + + sinon.assert.notCalled(deps.adapter.unschedule); + sinon.assert.notCalled(deps.adapter.schedule); + }); + + it('is a no-op when nothing is pending', async function () { + const deps = buildDeps({ pending: [] }); + const scheduler = new GiftFlushScheduler(deps); + + await scheduler.rescheduleAll({ previousKey: { id: 'k', secret: HEX_OLD } }); + + sinon.assert.notCalled(deps.adapter.schedule); + sinon.assert.notCalled(deps.adapter.unschedule); + }); + }); +}); diff --git a/ghost/core/test/unit/server/services/gifts/gift-reminder-scheduler.test.ts b/ghost/core/test/unit/server/services/gifts/gift-reminder-scheduler.test.ts deleted file mode 100644 index 7cdb8d8205f..00000000000 --- a/ghost/core/test/unit/server/services/gifts/gift-reminder-scheduler.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import assert from 'node:assert/strict'; -import sinon from 'sinon'; -import { GiftReminderScheduler } from '../../../../../core/server/services/gifts/gift-reminder-scheduler'; -import { Gift } from '../../../../../core/server/services/gifts/gift'; -import { AutoFillingMap } from '../../../../../core/server/lib/auto-filling-map'; -import type { - InternalApiKey, - InternalIntegrationSlug, -} from '../../../../../core/server/services/internal-keys'; -import { buildGift } from './utils'; - -const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; - -/** - * Build an in-memory pretend of the cross-domain deps the scheduler takes. - * Tests assert on the queued jobs (the observable outcome) and on which - * repository rows were consulted; same-domain primitives (getSignedAdminToken, - * urlUtils) are real imports inside the class. - */ -// Test secrets are 64-char hex so getSignedAdminToken (which decodes via -// Buffer.from(secret, 'hex')) treats them as distinct signing keys. -const HEX_CURRENT = 'aa'.repeat(32); -const HEX_OLD = '55'.repeat(32); - -function buildDeps( - overrides: { - apiUrl?: string; - pending?: Gift[]; - currentKey?: InternalApiKey; - register?: sinon.SinonStub; - } = {}, -) { - const apiUrl = overrides.apiUrl ?? 'https://example.com/ghost/api/admin'; - const currentKey: InternalApiKey = overrides.currentKey ?? { id: 'kid', secret: HEX_CURRENT }; - const internalKeys = new AutoFillingMap>( - (slug) => { - throw new Error(`Test internalKeys not seeded for slug ${slug}`); - }, - ); - internalKeys.set('ghost-scheduler', Promise.resolve(currentKey)); - - const schedule = sinon.stub(); - const unschedule = sinon.stub(); - const register = overrides.register ?? sinon.stub(); - const run = sinon.stub(); - - const findUnsentReminders = sinon.stub<[], Promise>().resolves(overrides.pending ?? []); - - return { - apiUrl, - adapter: { schedule, unschedule, register, run }, - internalKeys, - findUnsentReminders, - currentKey, - }; -} - -function futureGift(daysAhead: number) { - return buildGift({ - token: `tok-${daysAhead}`, - status: 'redeemed', - redeemerMemberId: 'm_1', - redeemedAt: new Date(), - consumesAt: new Date(Date.now() + daysAhead * 24 * 60 * 60 * 1000), - }); -} - -describe('GiftReminderScheduler', function () { - afterEach(function () { - sinon.restore(); - }); - - it('registers itself with the adapter on construction', function () { - const deps = buildDeps(); - const scheduler = new GiftReminderScheduler(deps); - - sinon.assert.calledOnceWithExactly(deps.adapter.register, scheduler); - }); - - describe('scheduleFor', function () { - it('queues a reminder 7 days before consumesAt, signed with the current key', async function () { - const deps = buildDeps(); - const scheduler = new GiftReminderScheduler(deps); - const gift = futureGift(30); - - await scheduler.scheduleFor(gift); - - sinon.assert.calledOnce(deps.adapter.schedule); - const [job] = deps.adapter.schedule.getCall(0).args; - assert.equal(job.time, gift.consumesAt!.getTime() - SEVEN_DAYS_MS); - assert.equal(job.extra.httpMethod, 'PUT'); - assert.ok( - job.url.startsWith(`${deps.apiUrl}/gifts/flush_reminders?token=`), - 'the URL targets the flush_reminders endpoint and carries a JWT', - ); - }); - - it('does not queue when the gift has no consumesAt', async function () { - const deps = buildDeps(); - const scheduler = new GiftReminderScheduler(deps); - - await scheduler.scheduleFor(buildGift({ consumesAt: null })); - - sinon.assert.notCalled(deps.adapter.schedule); - }); - - it('does not queue when the reminder time has already passed', async function () { - const deps = buildDeps(); - const scheduler = new GiftReminderScheduler(deps); - // consumesAt 1 day ahead → reminder fires at consumesAt - 7d → in the past - await scheduler.scheduleFor(futureGift(1)); - - sinon.assert.notCalled(deps.adapter.schedule); - }); - }); - - describe('rescheduleAll', function () { - it('re-signs every pending reminder under the current key', async function () { - const pending = [futureGift(30), futureGift(60)]; - const deps = buildDeps({ pending, currentKey: { id: 'k', secret: HEX_CURRENT } }); - const scheduler = new GiftReminderScheduler(deps); - - await scheduler.rescheduleAll({ previousKey: { id: 'k', secret: HEX_OLD } }); - - sinon.assert.calledTwice(deps.adapter.unschedule); - sinon.assert.calledTwice(deps.adapter.schedule); - - // The schedule URLs are signed under the current key; the unschedule - // URLs are signed under the previous key. Their tokens must differ - // for the adapter to find the queued entries. - const unscheduleUrls = deps.adapter.unschedule.getCalls().map((c) => c.args[0].url); - const scheduleUrls = deps.adapter.schedule.getCalls().map((c) => c.args[0].url); - for (let i = 0; i < pending.length; i++) { - assert.notEqual( - unscheduleUrls[i], - scheduleUrls[i], - `pending[${i}]: unschedule URL (old key) must differ from schedule URL (current key)`, - ); - } - }); - - it('rotation tells the adapter to actually delete the stale queued job', async function () { - // Outcome: rotation requests a real (non-bootstrap) unschedule so - // the adapter writes a tombstone and the stale callback is - // suppressed at execution time. SchedulingDefault's own tests - // cover the tombstone semantics; here we verify GiftReminderScheduler - // honours the contract. - const deps = buildDeps({ pending: [futureGift(30)] }); - const scheduler = new GiftReminderScheduler(deps); - - await scheduler.rescheduleAll({ previousKey: { id: 'k', secret: HEX_OLD } }); - - sinon.assert.calledOnce(deps.adapter.unschedule); - assert.equal(deps.adapter.unschedule.getCall(0).args[1].bootstrap, false); - }); - - it('uses the current key for unschedule when previousKey is omitted', async function () { - const pending = [futureGift(30)]; - const deps = buildDeps({ pending }); - const scheduler = new GiftReminderScheduler(deps); - - await scheduler.rescheduleAll(); - - sinon.assert.calledOnce(deps.adapter.unschedule); - sinon.assert.calledOnce(deps.adapter.schedule); - const unscheduleUrl = deps.adapter.unschedule.getCall(0).args[0].url; - const scheduleUrl = deps.adapter.schedule.getCall(0).args[0].url; - assert.equal( - unscheduleUrl, - scheduleUrl, - 'with no previousKey, both URLs are signed under the same (current) key', - ); - }); - - it('same-key rebuild marks unschedule as bootstrap so the new job survives', async function () { - // Outcome: when no previousKey is supplied (boot), unschedule and - // schedule use the same URL. GiftReminderScheduler must mark the - // unschedule as bootstrap so the adapter skips the tombstone and - // the about-to-be-scheduled job stays pingable. - const deps = buildDeps({ pending: [futureGift(30)] }); - const scheduler = new GiftReminderScheduler(deps); - - await scheduler.rescheduleAll(); - - sinon.assert.calledOnce(deps.adapter.unschedule); - assert.equal(deps.adapter.unschedule.getCall(0).args[1].bootstrap, true); - }); - - it('skips reminders whose fire time has already passed', async function () { - const deps = buildDeps({ pending: [futureGift(1)] }); - const scheduler = new GiftReminderScheduler(deps); - - await scheduler.rescheduleAll({ previousKey: { id: 'k', secret: HEX_OLD } }); - - sinon.assert.notCalled(deps.adapter.unschedule); - sinon.assert.notCalled(deps.adapter.schedule); - }); - - it('is a no-op when the repository has nothing pending', async function () { - const deps = buildDeps({ pending: [] }); - const scheduler = new GiftReminderScheduler(deps); - - await scheduler.rescheduleAll({ previousKey: { id: 'k', secret: HEX_OLD } }); - - sinon.assert.notCalled(deps.adapter.schedule); - sinon.assert.notCalled(deps.adapter.unschedule); - }); - }); -}); 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 db90f0cf95d..248daeec8c4 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 @@ -23,6 +23,7 @@ describe('GiftService interface', function () { function createService({ customizationEnabled = false, portalPlans = ['monthly', 'yearly'], + timezone = 'Etc/UTC', } = {}) { const tier = { id: { @@ -67,6 +68,9 @@ describe('GiftService interface', function () { dispatchForGift: sinon.stub().resolves(null), cancelPendingForGift: sinon.stub().resolves(false), }; + const settingsGet = sinon.stub(); + settingsGet.withArgs('portal_plans').returns(portalPlans); + settingsGet.withArgs('timezone').returns(timezone); const service = new GiftService({ giftRepository, giftDeliveryService, @@ -84,7 +88,7 @@ describe('GiftService interface', function () { isSet: sinon.stub().withArgs('giftSubCustomization').returns(customizationEnabled), }, settingsCache: { - get: sinon.stub().withArgs('portal_plans').returns(portalPlans), + get: settingsGet, }, } as any); @@ -175,12 +179,106 @@ describe('GiftService interface', function () { { giftId: 'gift_1', recipientEmail: 'recipient@example.com', + scheduledAt: null, }, { transacting: 'trx' }, ); assert.equal(successUrl.searchParams.get('gift_delivery'), 'email'); }); + it('stores a scheduled email delivery as 09:00 in the publication timezone', async function () { + const clock = sinon.useFakeTimers(new Date('2026-08-18T12:00:00.000Z')); + const { service, checkoutAdapter, giftRepository, giftDeliveryService } = createService({ + customizationEnabled: true, + timezone: 'America/Los_Angeles', + }); + + await service.startCheckout({ + tierId: 'tier_1', + cadence: 'year', + deliveryMethod: 'email', + deliveryDate: '2026-12-25', + recipientEmail: 'recipient@example.com', + buyerName: 'Buyer', + successUrl: 'https://example.com/', + buyer: { + memberId: null, + email: 'buyer@example.com', + name: null, + isAuthenticated: false, + }, + }); + + const gift = giftRepository.create.firstCall.firstArg; + const successUrl = new URL(checkoutAdapter.createSession.firstCall.firstArg.successUrl); + const scheduledAt = new Date('2026-12-25T17:00:00.000Z'); + assert.equal(gift.expiresAt.toISOString(), '2027-12-26T07:59:59.999Z'); + sinon.assert.calledOnceWithExactly( + giftDeliveryService.createForCheckout, + { + giftId: 'gift_1', + recipientEmail: 'recipient@example.com', + scheduledAt, + }, + { transacting: 'trx' }, + ); + assert.equal(successUrl.searchParams.get('gift_delivery_date'), '2026-12-25'); + assert.equal(successUrl.searchParams.get('gift_scheduled_at'), String(scheduledAt.getTime())); + clock.restore(); + }); + + it('rejects delivery dates beyond the next publication-calendar year', async function () { + const clock = sinon.useFakeTimers(new Date('2026-08-18T12:00:00.000Z')); + const { service, checkoutAdapter } = createService({ customizationEnabled: true }); + + await assert.rejects( + () => + service.startCheckout({ + tierId: 'tier_1', + cadence: 'year', + deliveryMethod: 'email', + deliveryDate: '2027-08-19', + recipientEmail: 'recipient@example.com', + buyerName: 'Buyer', + successUrl: 'https://example.com/', + buyer: { + memberId: null, + email: 'buyer@example.com', + name: null, + isAuthenticated: false, + }, + }), + { context: 'Gift delivery date must be today or within the next 365 days' }, + ); + + sinon.assert.notCalled(checkoutAdapter.createSession); + clock.restore(); + }); + + it('accepts delivery exactly 365 publication-calendar days ahead', async function () { + const clock = sinon.useFakeTimers(new Date('2026-08-18T12:00:00.000Z')); + const { service, checkoutAdapter } = createService({ customizationEnabled: true }); + + await service.startCheckout({ + tierId: 'tier_1', + cadence: 'year', + deliveryMethod: 'email', + deliveryDate: '2027-08-18', + recipientEmail: 'recipient@example.com', + buyerName: 'Buyer', + successUrl: 'https://example.com/', + buyer: { + memberId: null, + email: 'buyer@example.com', + name: null, + isAuthenticated: false, + }, + }); + + sinon.assert.calledOnce(checkoutAdapter.createSession); + clock.restore(); + }); + it('prefers the checkout buyer name over the authenticated member name', async function () { const { service, giftRepository } = createService({ customizationEnabled: true }); 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 69128274fce..6f00ae8a1f4 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 @@ -225,7 +225,7 @@ describe('GiftService', function () { sinon.restore(); }); - let giftReminderScheduler: { scheduleFor: sinon.SinonStub }; + let giftReminderScheduler: { scheduleAt: sinon.SinonStub }; let checkoutAdapter: { getCustomerId: sinon.SinonStub; createSession: sinon.SinonStub; @@ -233,12 +233,12 @@ describe('GiftService', function () { function createService( overrides: { - giftReminderScheduler?: { scheduleFor: sinon.SinonStub }; + giftReminderScheduler?: { scheduleAt: sinon.SinonStub }; timezone?: string; } = {}, ) { giftReminderScheduler = overrides.giftReminderScheduler ?? { - scheduleFor: sinon.stub().resolves(), + scheduleAt: sinon.stub().resolves(), }; checkoutAdapter = { getCustomerId: sinon.stub().resolves(null), @@ -279,9 +279,14 @@ describe('GiftService', function () { duration: 1, currency: 'usd', amount: 5000, + expiryAnchor: null, + expiryTimeZone: 'Etc/UTC', }).bindCheckoutSession('cs_pending')!; giftRepository.getById.resolves(pending); - giftDeliveryService.dispatchForGift.resolves('recipient@example.com'); + giftDeliveryService.dispatchForGift.resolves({ + recipientEmail: 'recipient@example.com', + scheduledAt: null, + }); const service = createService(); assert.equal( @@ -303,7 +308,10 @@ describe('GiftService', function () { assert.equal(purchased.amount, 5000); assert.equal(purchased.stripePaymentIntentId, 'pi_pending'); sinon.assert.notCalled(giftRepository.create); - sinon.assert.calledOnceWithExactly(giftDeliveryService.dispatchForGift, 'gift_1'); + sinon.assert.calledOnceWithExactly( + giftDeliveryService.dispatchForGift, + sinon.match.has('giftId', 'gift_1'), + ); }); function buildAnonymousPendingGift() { @@ -319,6 +327,8 @@ describe('GiftService', function () { duration: 1, currency: 'usd', amount: 5000, + expiryAnchor: null, + expiryTimeZone: 'Etc/UTC', }).bindCheckoutSession('cs_pending')!; } @@ -440,6 +450,95 @@ describe('GiftService', function () { ); }); + it('preserves the Gift-owned deadline anchored to scheduled delivery', async function () { + const clock = sinon.useFakeTimers(new Date('2026-08-18T12:00:00.000Z')); + const scheduledAt = new Date('2026-12-25T09:00:00.000Z'); + const pending = Gift.fromCheckout({ + token: 'pending-token', + buyerEmail: 'buyer@example.com', + buyerMemberId: null, + buyerName: 'Buyer', + recipientName: 'Recipient', + personalMessage: null, + tierId: 'tier_1', + cadence: 'year', + duration: 1, + currency: 'usd', + amount: 5000, + expiryAnchor: scheduledAt, + expiryTimeZone: 'Etc/UTC', + }).bindCheckoutSession('cs_pending')!; + giftRepository.getById.resolves(pending); + giftDeliveryService.dispatchForGift.resolves({ + recipientEmail: 'recipient@example.com', + scheduledAt, + }); + const service = createService({ timezone: 'Etc/UTC' }); + + await service.completePurchase({ + giftId: 'gift_1', + buyerEmail: null, + stripeCustomerId: null, + currency: 'usd', + amount: 5000, + stripeCheckoutSessionId: 'cs_pending', + stripePaymentIntentId: 'pi_pending', + }); + + const purchased = giftRepository.update.firstCall.firstArg; + assert.equal(purchased.expiresAt.toISOString(), '2027-12-25T23:59:59.999Z'); + sinon.assert.calledWith( + giftEmailService.sendPurchaseConfirmation, + sinon.match({ scheduledAt }), + ); + clock.restore(); + }); + + it('does not move scheduled expiry when checkout completes after the delivery date', async function () { + const purchasedAt = new Date('2026-12-26T12:00:00.000Z'); + const clock = sinon.useFakeTimers(purchasedAt); + const scheduledAt = new Date('2026-12-25T09:00:00.000Z'); + const pending = Gift.fromCheckout({ + token: 'pending-token', + buyerEmail: 'buyer@example.com', + buyerMemberId: null, + buyerName: 'Buyer', + recipientName: 'Recipient', + personalMessage: null, + tierId: 'tier_1', + cadence: 'year', + duration: 1, + currency: 'usd', + amount: 5000, + expiryAnchor: scheduledAt, + expiryTimeZone: 'Etc/UTC', + }).bindCheckoutSession('cs_pending')!; + giftRepository.getById.resolves(pending); + giftDeliveryService.dispatchForGift.resolves({ + recipientEmail: 'recipient@example.com', + scheduledAt, + }); + const service = createService({ timezone: 'Etc/UTC' }); + + await service.completePurchase({ + giftId: 'gift_1', + buyerEmail: null, + stripeCustomerId: null, + currency: 'usd', + amount: 5000, + stripeCheckoutSessionId: 'cs_pending', + stripePaymentIntentId: 'pi_pending', + }); + + const purchased = giftRepository.update.firstCall.firstArg; + assert.equal(purchased.expiresAt.toISOString(), '2027-12-25T23:59:59.999Z'); + sinon.assert.calledWith( + giftEmailService.sendPurchaseConfirmation, + sinon.match({ scheduledAt }), + ); + clock.restore(); + }); + 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'); @@ -703,6 +802,34 @@ describe('GiftService', function () { }); }); + describe('getPreview', function () { + it('returns full gift details immediately after purchase', async function () { + const gift = buildGift(); + giftRepository.getByToken.resolves(gift); + const service = createService(); + + const preview = await service.getPreview(gift.token); + + assert.ok(preview); + assert.equal(preview.tier.name, 'Bronze'); + assert.equal(preview.duration, gift.duration); + }); + + it('does not promise an availability date for a refunded scheduled gift', async function () { + const gift = buildGift({ + status: 'refunded', + refundedAt: new Date('2026-06-01T00:00:00.000Z'), + }); + giftRepository.getByToken.resolves(gift); + const service = createService(); + + const preview = await service.getPreview(gift.token); + + assert.ok(preview); + assert.equal(preview.tier.name, 'Bronze'); + }); + }); + describe('getRedeemable', function () { it('returns a redemption read model when the gift is redeemable', async function () { const gift = buildGift(); @@ -732,6 +859,16 @@ describe('GiftService', function () { ); }); + it('allows a purchased gift to be redeemed before scheduled delivery', async function () { + const gift = buildGift(); + giftRepository.getByToken.resolves(gift); + + const service = createService(); + const redemption = await service.getRedeemable({ token: gift.token, memberStatus: null }); + + assert.equal(redemption.token, gift.token); + }); + const testCases = [ { name: 'redeemed gifts', @@ -1459,7 +1596,7 @@ describe('GiftService', function () { assert.equal(transactionRejected, true); sinon.assert.notCalled(staffServiceEmails.notifyGiftSubscriptionStarted); - sinon.assert.notCalled(giftReminderScheduler.scheduleFor); + sinon.assert.notCalled(giftReminderScheduler.scheduleAt); }); it('does not fail redemption when staff notification email throws', async function () { @@ -1716,7 +1853,7 @@ describe('GiftService', function () { memberRepository.get.resolves({ id: 'member_1', get: memberGet }); } - it('calls giftReminderScheduler.scheduleFor with the redeemed gift after commit', async function () { + it('arms a reminder flush at the redeemed gift reminder time after commit', async function () { stubRedeemer(); giftRepository.getByToken.resolves(buildGift()); @@ -1724,7 +1861,11 @@ describe('GiftService', function () { await service.redeem({ token: 'gift-token', memberId: 'member_1' }); const redeemed = giftRepository.update.firstCall.firstArg; - sinon.assert.calledOnceWithExactly(giftReminderScheduler.scheduleFor, redeemed); + sinon.assert.calledOnceWithExactly( + giftReminderScheduler.scheduleAt, + redeemed.reminderDueAt()!.getTime(), + { giftToken: redeemed.token }, + ); }); it('schedules even when staff notification fails', async function () { @@ -1735,7 +1876,7 @@ describe('GiftService', function () { const service = createService(); await service.redeem({ token: 'gift-token', memberId: 'member_1' }); - sinon.assert.calledOnce(giftReminderScheduler.scheduleFor); + sinon.assert.calledOnce(giftReminderScheduler.scheduleAt); }); it('schedules after an external transaction commits', async function () { @@ -1751,7 +1892,7 @@ describe('GiftService', function () { setImmediate(resolve); }); - sinon.assert.calledOnce(giftReminderScheduler.scheduleFor); + sinon.assert.calledOnce(giftReminderScheduler.scheduleAt); }); it('does NOT schedule when an external transaction rolls back', async function () { @@ -1768,7 +1909,7 @@ describe('GiftService', function () { setImmediate(resolve); }); - sinon.assert.notCalled(giftReminderScheduler.scheduleFor); + sinon.assert.notCalled(giftReminderScheduler.scheduleAt); }); }); 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 efd7b7157d7..73b12509839 100644 --- a/ghost/core/test/unit/server/services/gifts/gift.test.ts +++ b/ghost/core/test/unit/server/services/gifts/gift.test.ts @@ -15,7 +15,7 @@ describe('Gift', function () { stripeCheckoutSessionId: 'cs_123', stripePaymentIntentId: 'pi_456', purchasedAt: new Date('2026-08-18T23:30:00.000Z'), - expiresAt: new Date('2027-08-19T06:59:59.999Z'), + expiryTimeZone: 'America/Los_Angeles', }; describe('fromPurchase', function () { @@ -29,7 +29,7 @@ describe('Gift', function () { const gift = Gift.fromPurchase(purchaseData); assert.equal(gift.purchasedAt?.toISOString(), purchaseData.purchasedAt.toISOString()); - assert.equal(gift.expiresAt?.toISOString(), purchaseData.expiresAt.toISOString()); + assert.equal(gift.expiresAt?.toISOString(), '2027-08-19T06:59:59.999Z'); }); it('sets null defaults for redemption fields', function () { @@ -74,6 +74,8 @@ describe('Gift', function () { duration: 1, currency: 'usd', amount: 5000, + expiryAnchor: null, + expiryTimeZone: 'America/Los_Angeles', }); assert.equal(gift.status, 'payment_pending'); @@ -92,7 +94,7 @@ describe('Gift', function () { stripeCheckoutSessionId: 'cs_123', stripePaymentIntentId: 'pi_123', purchasedAt: new Date('2026-08-18T23:30:00.000Z'), - expiresAt: new Date('2027-08-19T06:59:59.999Z'), + expiryTimeZone: 'America/Los_Angeles', }); assert.equal(purchased?.status, 'purchased'); assert.equal(purchased?.recipientName, 'Recipient'); @@ -100,6 +102,37 @@ describe('Gift', function () { assert.equal(purchased?.amount, 5000); assert.ok(purchased?.expiresAt); }); + + it('owns the scheduled expiry deadline before purchase completes', 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, + expiryAnchor: new Date('2026-12-25T17:00:00.000Z'), + expiryTimeZone: 'America/Los_Angeles', + }); + + assert.equal(gift.expiresAt?.toISOString(), '2027-12-26T07:59:59.999Z'); + + const purchased = gift.completePurchase({ + buyerEmail: 'buyer@example.com', + buyerMemberId: null, + stripeCheckoutSessionId: 'cs_123', + stripePaymentIntentId: 'pi_123', + purchasedAt: new Date('2026-08-18T23:30:00.000Z'), + expiryTimeZone: 'America/Los_Angeles', + }); + + assert.equal(purchased?.expiresAt?.toISOString(), '2027-12-26T07:59:59.999Z'); + }); }); describe('checkRedeemable', function () { @@ -290,6 +323,24 @@ describe('Gift', function () { assert.equal(redeemed.consumesAt?.toISOString(), '2026-07-11T12:00:00.000Z'); }); + it('starts the full paid duration at redemption for a scheduled gift', function () { + const gift = buildGift({ + cadence: 'month', + duration: 6, + }); + const redeemedAt = new Date('2027-02-10T12:00:00.000Z'); + + const redeemed = gift.redeem({ + memberId: 'member_2', + redeemedAt, + }); + + assert.equal(redeemed.consumesAt?.getFullYear(), 2027); + assert.equal(redeemed.consumesAt?.getMonth(), 7); + assert.equal(redeemed.consumesAt?.getDate(), redeemedAt.getDate()); + assert.equal(redeemed.consumesAt?.getHours(), redeemedAt.getHours()); + }); + it('keeps month-end redemption math stable for shorter months', function () { const gift = buildGift({ cadence: 'month', @@ -455,6 +506,25 @@ describe('Gift', function () { }); }); + describe('reminderDueAt', function () { + it('is 7 days before the gifted access ends', function () { + const gift = buildGift({ + status: 'redeemed', + redeemerMemberId: 'member_2', + redeemedAt: new Date('2026-04-11T12:00:00.000Z'), + consumesAt: new Date('2027-04-11T12:00:00.000Z'), + }); + + assert.deepEqual(gift.reminderDueAt(), new Date('2027-04-04T12:00:00.000Z')); + }); + + it('is null before the gift is redeemed', function () { + const gift = buildGift({ consumesAt: null }); + + assert.equal(gift.reminderDueAt(), null); + }); + }); + describe('checkReassignable', function () { // An orphaned gift is one that was redeemed but whose redeemer member was later // deleted (the FK is SET NULL on delete). These should be reassignable on re-import. diff --git a/ghost/core/test/unit/server/services/gifts/utils.ts b/ghost/core/test/unit/server/services/gifts/utils.ts index 1c124f23a15..29614e8fceb 100644 --- a/ghost/core/test/unit/server/services/gifts/utils.ts +++ b/ghost/core/test/unit/server/services/gifts/utils.ts @@ -34,6 +34,7 @@ export function buildGiftDelivery(overrides: Partial = {}): Gi giftId: 'gift_1', recipientEmail: 'recipient@example.com', status: 'pending', + scheduledAt: null, startedAt: null, emailSentAt: null, emailProviderMessageId: null, diff --git a/ghost/core/test/unit/server/services/machine-payments/service.test.js b/ghost/core/test/unit/server/services/machine-payments/service.test.js index 75e2ba7d8cc..f6636186ead 100644 --- a/ghost/core/test/unit/server/services/machine-payments/service.test.js +++ b/ghost/core/test/unit/server/services/machine-payments/service.test.js @@ -106,11 +106,37 @@ describe('Unit: server/services/machine-payments/service', function () { assert.equal(response.status, 402); assert.equal(response.headers.get('WWW-Authenticate'), 'Payment realm="mpp"'); + assert.equal(response.headers.get('Content-Type'), 'application/problem+json'); sinon.assert.calledOnce(contentLoader.isPurchasable); sinon.assert.notCalled(contentLoader.loadFullEntry); sinon.assert.notCalled(renderMarkdown); }); + it('returns a markdown preview body on 402 when renderPreviewMarkdown is provided', async function () { + const service = createService(); + const renderPreviewMarkdown = sinon + .stub() + .returns('# Preview\n\n_This post is for paying subscribers only._'); + + const response = await service.challengeOrFulfill(new Request('http://example.com/paid.md'), { + entryId: 'post1', + resourceType: 'posts', + description: 'Paid', + contentLocation: '/paid.md', + renderMarkdown: () => '# full', + renderPreviewMarkdown, + }); + + assert.equal(response.status, 402); + assert.equal(response.headers.get('WWW-Authenticate'), 'Payment realm="mpp"'); + assert.equal(response.headers.get('Content-Type'), 'text/markdown; charset=utf-8'); + assert.equal(response.headers.get('Content-Location'), '/paid.md'); + assert.equal(response.headers.get('Cache-Control'), 'no-store'); + assert.match(await response.text(), /# Preview/); + sinon.assert.calledOnce(renderPreviewMarkdown); + sinon.assert.notCalled(contentLoader.loadFullEntry); + }); + it('returns 403 before charging mixed free+paid tier posts', async function () { contentLoader.isPurchasable.resolves(false); mppAdapter.canHandle.returns(true); 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 365aa585c96..9d73590229a 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 @@ -1127,6 +1127,7 @@ describe('RouterController', function () { recipientName: 'Recipient', buyerName: 'Buyer', personalMessage: 'Enjoy this gift', + deliveryDate: '2026-12-25', metadata: { ghost_gift_id: 'attacker-controlled-id', custom_key: 'preserved', @@ -1142,6 +1143,7 @@ describe('RouterController', function () { assert.equal(input.recipientName, 'Recipient'); assert.equal(input.buyerName, 'Buyer'); assert.equal(input.personalMessage, 'Enjoy this gift'); + assert.equal(input.deliveryDate, '2026-12-25'); assert.equal('metadata' in input, false); }); diff --git a/ghost/core/test/unit/server/services/members/middleware.test.js b/ghost/core/test/unit/server/services/members/middleware.test.js index f752fcba9cc..07329992f3d 100644 --- a/ghost/core/test/unit/server/services/members/middleware.test.js +++ b/ghost/core/test/unit/server/services/members/middleware.test.js @@ -93,6 +93,19 @@ describe('Members Service Middleware', function () { .expect('Location', '/blah/?action=subscribe&errorCode=GIFT_EXPIRED&success=false'); }); + it('does not append error context to the redirect', async function () { + const err = new Error('Bad request'); + err.code = 'BAD_REQUEST'; + err.context = 'internal detail'; + membersService.ssr.exchangeTokenForSession.rejects(err); + + await request(app) + .get('/members') + .query({ token: 'test', action: 'subscribe' }) + .expect(302) + .expect('Location', '/blah/?action=subscribe&errorCode=BAD_REQUEST&success=false'); + }); + it('does not append errorCode when the rejection has no code', async function () { membersService.ssr.exchangeTokenForSession.rejects(new Error('boom')); diff --git a/ghost/core/test/utils/fixtures/fixtures.json b/ghost/core/test/utils/fixtures/fixtures.json index a18f07be713..8fff6a689c1 100644 --- a/ghost/core/test/utils/fixtures/fixtures.json +++ b/ghost/core/test/utils/fixtures/fixtures.json @@ -821,6 +821,11 @@ "action_type": "flushReminders", "object_type": "gift" }, + { + "name": "Flush gift deliveries", + "action_type": "flushDeliveries", + "object_type": "gift" + }, { "name": "Manage gift links", "action_type": "manage", @@ -1157,7 +1162,7 @@ "Scheduler Integration": { "post": "publish", "automation": "poll", - "gift": "flushReminders" + "gift": ["flushReminders", "flushDeliveries"] }, "Self-Serve Migration Integration": { "db": "importContent", diff --git a/package.json b/package.json index 745d808d6ae..290657252d1 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "lint:docs": "pnpm lint:agent-skills && pnpm lint:agent-guidance && pnpm lint:markdown && pnpm lint:doc-links", "format": "node scripts/format.js", "format:check": "oxfmt --check", - "check": "pnpm lint && pnpm test", + "check": "pnpm format:check && pnpm lint && pnpm test", "test": "pnpm nx run-many -t test --exclude @tryghost/e2e --exclude ghost-admin", "test:unit": "pnpm nx run-many -t test:unit", "test:watch": "vitest", diff --git a/packages/i18n/locales/af/ghost.json b/packages/i18n/locales/af/ghost.json index 2a188bd584c..e716b769663 100644 --- a/packages/i18n/locales/af/ghost.json +++ b/packages/i18n/locales/af/ghost.json @@ -76,7 +76,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.", @@ -113,11 +115,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", "Your subscription has expired.": "", "Your subscription will expire on {date}.": "", diff --git a/packages/i18n/locales/af/portal.json b/packages/i18n/locales/af/portal.json index ddea67dd532..093a9be692b 100644 --- a/packages/i18n/locales/af/portal.json +++ b/packages/i18n/locales/af/portal.json @@ -51,6 +51,9 @@ "Check with your mail provider": "Kyk by u e-posverskaffer", "Check your inbox to verify email update": "", "Choose": "Kies", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Kies 'n ander plan", "Choose a plan": "", "Choose a tier": "", @@ -81,13 +84,14 @@ "Create a new contact": "Skep 'n nuwe kontak", "Current plan": "Huidige plan", "Delete account": "Verwyder rekening", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Het dit nie bedoel om te doen nie? Bestuur jou voorkeure .", "Don't have an account?": "Het u nie 'n rekening nie?", "Edit": "Wysig", "Email": "E-pos", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "Epos nuusbrief", "Email newsletter settings updated": "", "Email preferences": "E-pos instellings", @@ -177,6 +181,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "Ontvang u nie e-posse nie?", + "Now": "", "Now check your email!": "Kyk nou u e-pos!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "As u weer ingeskryf is, en u sien steeds nie e-posse in u posbus nie, kontroleer u spam vouer. Sommige posbusverskaffers hou 'n rekord van vorige spam klagtes en sal steeds e-posse merk. As dit gebeur, merk die nuutste nuusbrief as 'Not spam' om dit terug te skuif na u primêre posbus.", @@ -293,6 +298,7 @@ "View plans": "Kyk na planne", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Ons kon u nie afskakel nie, omdat die e-pos adres nie gevind is nie. Kontak asseblief die webwerf eienaar.", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Welkom terug, {name}!", "Welcome back!": "Welkom terug!", @@ -325,6 +331,7 @@ "Your email has failed to resubscribe, please try again": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "U insette help om te bepaal wat gepubliseer word.", diff --git a/packages/i18n/locales/ar/ghost.json b/packages/i18n/locales/ar/ghost.json index 4369cb40006..538fa976a23 100644 --- a/packages/i18n/locales/ar/ghost.json +++ b/packages/i18n/locales/ar/ghost.json @@ -84,7 +84,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "هذا البريد الاكتروني لن يتم استخدامه.", @@ -121,11 +123,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "تم إلغاء اشتراكك وستنتهى صلاحية العضوية فى {date}. يمكنك تفعيل الاشتراك مرة أخرى من اعدادات حسابك.", "Your subscription has expired.": "انتهت صلاحية اشتراكك", "Your subscription will expire on {date}.": " ستنتهى صلاحية اشتراكك فى {date}", diff --git a/packages/i18n/locales/ar/portal.json b/packages/i18n/locales/ar/portal.json index 3ccc4340f2f..f0bcc465357 100644 --- a/packages/i18n/locales/ar/portal.json +++ b/packages/i18n/locales/ar/portal.json @@ -55,6 +55,9 @@ "Check with your mail provider": "تحقق من مزود خدمة البريد الإلكتروني الخاص بك", "Check your inbox to verify email update": "تحقق من صندوق الوارد الخاص بك لتأكيد تحديث البريد الإلكتروني", "Choose": "اختر", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "اختر خطة مختلفة", "Choose a plan": "اختر خطة", "Choose a tier": "", @@ -85,13 +88,14 @@ "Create a new contact": "إنشاء جهة اتصال جديدة", "Current plan": "الخطة الحالية", "Delete account": "حذف الحساب", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": " ..": "Нямахте това предвид? Управлявайте предпочитанията си .", "Don't have an account?": "Нямате свой профил?", "Edit": "Промяна", "Email": "Имейл", "Email confirmation link expired.": "Линкът за потвърждение е изтекъл.", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "Имейл бюлетин", "Email newsletter settings updated": "Настройките за бюлетини са обновени", "Email preferences": "Настройки за имейл", @@ -177,6 +181,7 @@ "No thanks, I want to cancel": "Не, благодаря, искам да се откажа", "Not ready to share? We've also emailed a copy to your inbox.": "Не сте сигурни дали да споделите? Изпратено ви е копие и по имейл.", "Not receiving emails?": "Не получавате поща?", + "Now": "", "Now check your email!": "Проверете имейла си!", "Occasional updates from {siteTitle}": "Епизодични известия от {siteTitle}", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "След като се абонирате отново, ако все още не получавате имейли, проверете папката за спам. Някои доставчици пазят история с предишни оплаквания за спам и ще продължат да маркират имейлите. Ако вашият случай е такъв, маркирайте последния бюлетин като 'Не е спам', за да го преместите обратно в основната си папка.", @@ -293,6 +298,7 @@ "View plans": "Вижте плановете", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Невъзможно е да отпишем този имейл адрес, тъй като не беше намерен. Моля, свържете се със собственика на сайта.", "We'd hate to see you leave. How about a special offer to stay?": "Не се отказвайте! Имаме специално предложение за вас, ако останете.", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Здравейте отново, {name}!", "Welcome back!": "Добре дошли отново!", @@ -325,6 +331,7 @@ "Your email has failed to resubscribe, please try again": "Неуспешно подновяване на абонамент с този имейл, опитайте отново", "Your gift is on its way": "", "Your gift is ready": "Подаръкът ви е готов", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "Подареният ви абонамент изтича на {expiryDate}", "your inbox": "вашия имейл", "Your input helps shape what gets published.": "Вашият принос помага за създаването на съдържанието.", diff --git a/packages/i18n/locales/bn/ghost.json b/packages/i18n/locales/bn/ghost.json index 0e8699833b7..b2cc0b0981c 100644 --- a/packages/i18n/locales/bn/ghost.json +++ b/packages/i18n/locales/bn/ghost.json @@ -76,7 +76,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "এই ইমেল ঠিকানা ব্যবহার করা হবে না।", @@ -113,11 +115,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", "Your subscription has expired.": "", "Your subscription will expire on {date}.": "", diff --git a/packages/i18n/locales/bn/portal.json b/packages/i18n/locales/bn/portal.json index dc47d319e65..6ce5ee14f53 100644 --- a/packages/i18n/locales/bn/portal.json +++ b/packages/i18n/locales/bn/portal.json @@ -51,6 +51,9 @@ "Check with your mail provider": "আপনার মেইল প্রোভাইডারের সাথে চেক করুন", "Check your inbox to verify email update": "", "Choose": "বেছে নিন", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "একটি ভিন্ন পরিকল্পনা বেছে নিন", "Choose a plan": "", "Choose a tier": "", @@ -81,13 +84,14 @@ "Create a new contact": "একটি নতুন যোগাযোগ তৈরি করুন", "Current plan": "বর্তমান পরিকল্পনা", "Delete account": "অ্যাকাউন্ট মুছে ফেলুন", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "এটি করার উদ্দেশ্য ছিল না? আপনার পছন্দগুলি পরিচালনা করুন ।", "Don't have an account?": "অ্যাকাউন্ট নেই?", "Edit": "সম্পাদনা করুন", "Email": "ইমেল", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "ইমেল নিউজলেটার", "Email newsletter settings updated": "", "Email preferences": "ইমেল পছন্দ", @@ -177,6 +181,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "ইমেল পাচ্ছেন না?", + "Now": "", "Now check your email!": "এখন আপনার ইমেল চেক করুন!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "একবার পুনঃসাবস্ক্রাইব করার পর, যদি আপনি এখনও আপনার ইনবক্সে ইমেল না দেখেন, তবে আপনার স্প্যাম ফোল্ডার চেক করুন। কিছু ইনবক্স প্রোভাইডার পূর্ববর্তী স্প্যাম অভিযোগগুলির একটি রেকর্ড রাখে এবং ইমেলগুলি চিহ্নিত করতে থাকে। যদি এটি ঘটে, তাহলে সর্বশেষ নিউজলেটারটিকে 'স্প্যাম নয়' হিসাবে চিহ্নিত করুন যাতে এটি আপনার প্রাথমিক ইনবক্সে ফিরে যায়।", @@ -293,6 +298,7 @@ "View plans": "পরিকল্পনা দেখুন", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "আমরা আপনাকে আনসাবস্ক্রাইব করতে পারিনি কারণ ইমেল ঠিকানা পাওয়া যায়নি। অনুগ্রহ করে সাইট মালিকের সাথে যোগাযোগ করুন।", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "আবার স্বাগতম, {name}!", "Welcome back!": "আবার স্বাগতম!", @@ -325,6 +331,7 @@ "Your email has failed to resubscribe, please try again": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "আপনার ইনপুট প্রকাশিত হওয়ার বিষয়টি নির্ধারণ করতে সহায়তা করে।", diff --git a/packages/i18n/locales/bs/ghost.json b/packages/i18n/locales/bs/ghost.json index 973b09ff8c3..907b6e1014a 100644 --- a/packages/i18n/locales/bs/ghost.json +++ b/packages/i18n/locales/bs/ghost.json @@ -78,7 +78,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.", @@ -115,11 +117,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", "Your subscription has expired.": "", "Your subscription will expire on {date}.": "", diff --git a/packages/i18n/locales/bs/portal.json b/packages/i18n/locales/bs/portal.json index 99825521495..8498eb8fcb8 100644 --- a/packages/i18n/locales/bs/portal.json +++ b/packages/i18n/locales/bs/portal.json @@ -52,6 +52,9 @@ "Check with your mail provider": "Provjeri s Email providerom", "Check your inbox to verify email update": "", "Choose": "Odaberi", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Odaberi drugi plan", "Choose a plan": "", "Choose a tier": "", @@ -82,13 +85,14 @@ "Create a new contact": "Dodaj novi kontakt", "Current plan": "Trenutni plan", "Delete account": "Izbriši račun", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Greška u namjeri? Provjeri svoje postavke .", "Don't have an account?": "Nemaš račun?", "Edit": "Uredi", "Email": "Email", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "Email newsletter", "Email newsletter settings updated": "", "Email preferences": "Postavke Email-a", @@ -178,6 +182,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "Ne primaš Email poruke?", + "Now": "", "Now check your email!": "Provjeri svoju Email adresu!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Nakon što se ponovno pretplatite, ako još uvijek ne vidiš Email u svom inboxu, provjeri spam folder. Neki pružatelji Email usluga čuvaju zapis prethodnih pritužbi na spam i nastavit će ih označavati kao takve. Ako se to dogodi, označite najnoviji newsletter Email kao \"Nije neželjena pošta\" da biste ga vratili u primarni inbox.", @@ -294,6 +299,7 @@ "View plans": "Pregledaj planove", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Nismo te mogli odjaviti jer Email nije pronađen. Molimo kontaktiraj vlasnika web stranice.", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Dobrodošli nazad, {name}!", "Welcome back!": "Dobrodošli nazad!", @@ -328,6 +334,7 @@ "Your email has failed to resubscribe, please try again": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "Tvoj feedback pomaže pri odabiru tema koje se objavljuju.", diff --git a/packages/i18n/locales/ca/ghost.json b/packages/i18n/locales/ca/ghost.json index 8661bef49df..19ba67a56be 100644 --- a/packages/i18n/locales/ca/ghost.json +++ b/packages/i18n/locales/ca/ghost.json @@ -78,7 +78,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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à.", @@ -115,11 +117,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "La teva subscripció s'ha cancel·lat i expirarà el {date}. T'hi pots tornar a subscriure mitjançant la configuració del teu compte.", "Your subscription has expired.": "La teva subscripció ha expirat.", "Your subscription will expire on {date}.": "La teva subscripció expirarà el {date}.", diff --git a/packages/i18n/locales/ca/portal.json b/packages/i18n/locales/ca/portal.json index f7cc51933a8..d9efb4f448f 100644 --- a/packages/i18n/locales/ca/portal.json +++ b/packages/i18n/locales/ca/portal.json @@ -52,6 +52,9 @@ "Check with your mail provider": "Consulta el teu proveïdor de correu", "Check your inbox to verify email update": "Comprova la teva safata d'entrada per verificar el canvi d'adreça d'email", "Choose": "Tria", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Selecciona un pla diferent", "Choose a plan": "Selecciona un pla", "Choose a tier": "", @@ -82,13 +85,14 @@ "Create a new contact": "Crea un nou contacte", "Current plan": "Pla actual", "Delete account": "Esborra compte", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "No volies fer això? Gestiona les teves preferències .", "Don't have an account?": "No tens un compte?", "Edit": "Edita", "Email": "Correu electrònic", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "Butlletí informatiu per correu electrònic", "Email newsletter settings updated": "Preferències de butlletins actualitzades", "Email preferences": "Preferències del correu electrònic", @@ -178,6 +182,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "No reps els correus electrònics?", + "Now": "", "Now check your email!": "Revisa ara el teu correu electrònic!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Un cop et tornis a subscriure, si encara no reps correus electrònics a la teva safata d'entrada, comprova la carpeta de correu brossa. Alguns proveïdors de correu mantenen un registre de les queixes de correu brossa anteriors i continuaran marcant els correus electrònics com a tal. Si això passa, marca el darrer butlletí com a \"No és correu brossa\". ' per tornar-lo a moure a la safata d'entrada principal.", @@ -294,6 +299,7 @@ "View plans": "Mostra plans", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "No hem pogut cancel·lar la teva subscripció ja que no s'ha trobat l'adreça de correu electrònic. Si-us-plau, contacta amb el propietari del lloc web.", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Benvingut/da de nou, {name}!", "Welcome back!": "Benvingut/da de nou!", @@ -328,6 +334,7 @@ "Your email has failed to resubscribe, please try again": "La teva adreça de correu no s'ha pogut tornar a subscriure - torna-ho a intentar", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "La teva opinió ajuda a definir què es publica.", diff --git a/packages/i18n/locales/context.json b/packages/i18n/locales/context.json index b4c5e41e652..cce1bec8336 100644 --- a/packages/i18n/locales/context.json +++ b/packages/i18n/locales/context.json @@ -46,6 +46,9 @@ "Check with your mail provider": "A section title in email receiving FAQ", "Check your inbox to verify email update": "Displayed when members change email addresses in Portal", "Choose": "A button to select a plan", + "Choose a date from today onwards": "Validation message in Portal when the chosen gift delivery date is in the past", + "Choose a date within the next year": "Validation message in Portal when a gift delivery date falls outside the supported one-year range", + "Choose a delivery date": "Placeholder in the Portal gift delivery date picker", "Choose a different plan": "A button for members to switch between plans", "Choose a plan": "Header for the plan selection screen in Portal", "Choose a tier": "Accessible label for the gift membership tier selector in Portal", @@ -90,6 +93,7 @@ "Deleted": "Shown when a member deletes a comment", "Deleted member": "Name of a member used for comments when the member has been deleted", "Deleting": "Shown while a comment deletion is in progress", + "Delivery date": "Label and accessible name for the Portal gift delivery date picker", "Delivery method": "Accessible label for choosing email or link delivery in the Portal gift purchase flow", "Device:": "Label for device verification (forthcoming)", "Didn't mean to do this? Manage your preferences .": "Message shown after unsubscribing from a newsletter", @@ -100,7 +104,7 @@ "Edit this comment": "Context menu action to edit a comment", "Email": "A label for email address input", "Email confirmation link expired.": "Error message when redeeming a gift subscription via magic link", - "Email it to them now": "Option to email a purchased gift to its intended recipient immediately after payment", + "Email it to them": "Option to have Ghost email a purchased gift to its intended recipient", "Email newsletter": "Title for the email newsletter settings", "Email newsletter settings updated": "A message showing newsletter settings have been updated", "Email preferences": "A label for email settings", @@ -220,6 +224,7 @@ "No thanks, I want to cancel": "Button in Portal to decline a retention offer and proceed with subscription cancellation", "Not ready to share? We've also emailed a copy to your inbox.": "Footer note on the Portal gift success page reassuring the buyer that the gift link was also emailed to them", "Not receiving emails?": "A link in portal to take members to an FAQ area about what to do if you're not receiving emails", + "Now": "Value shown in the Portal gift delivery date picker when the gift will be sent immediately", "Now check your email!": "A confirmation message after logging in or signing up", "Occasional updates from {siteTitle}": "Description for the updates & announcements toggle in Portal's email preferences, where {siteTitle} is the publication name", "Oldest": "Text appears in the sorting selector for comments", @@ -346,7 +351,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. You can also share the link below yourself.": "Body copy in the buyer confirmation email for a scheduled monthly gift. The placeholders describe the gift, recipient, publication, and delivery date.", "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.", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. You can also share the link below yourself.": "Body copy in the buyer confirmation email for a scheduled yearly gift. The placeholders describe the gift, recipient, publication, and delivery date.", "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", @@ -415,6 +422,7 @@ "We couldn't deliver your gift to {recipientEmail}. Send them the gift link below so they can redeem it.": "Instructions in the buyer notification after permanent recipient delivery failure", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "An error message when an unsubscribe link is clicked, but we don't have any record of the address being unsubscribed. Probably because the email address on the account has been changed.", "We'd hate to see you leave. How about a special offer to stay?": "Default description in Portal retention offer section during subscription cancellation", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "Portal success message after purchasing a scheduled gift. {deliveryDate} is the chosen delivery date.", "We'll email it to the recipient. A copy will be in your inbox too.": "Confirmation shown after completing the purchase of an emailed gift", "Welcome back to {siteTitle}!": "Popup when a member logs in", "Welcome back to {siteTitle}! Your verification code is {otc}.": "Email preheader text for the sign-in email when a one-time code is sent", @@ -465,12 +473,16 @@ "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 has been sent": "Subject and heading of the buyer follow-up email after recipient gift delivery is accepted", "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 is scheduled": "Heading shown after purchasing a scheduled gift and in its buyer confirmation email", "Your gift subscription confirmation": "Heading of the gift purchase confirmation email", "Your gift subscription is ending soon": "Heading of the gift expiry reminder email", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "Body copy in the gift expiry reminder email. {siteTitle} is the publication name (e.g. 'The Daily'), {consumesAt} is a formatted date and is wrapped in tags in the HTML version", "Your gift subscription will expire on {expiryDate}": "Shown in Portal account page when a gift subscription is active. {expiryDate} is a formatted date and is wrapped in tags in the HTML version.", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "Body copy in the buyer follow-up email after gift delivery. {recipientEmail} is the intended recipient address.", + "Your gift will be sent on {deliveryDate}": "Subject of the buyer confirmation email for a scheduled gift. {deliveryDate} is the publication-calendar delivery date.", "Your input helps shape what gets published.": "Descriptive text displayed on the member feedback UI, telling people how their feedback is used", "Your name": "Form label for the name input in e.g. signup forms", "Your request will be sent to the owner of this site.": "Descriptive text displayed in the report comment modal", diff --git a/packages/i18n/locales/cs/ghost.json b/packages/i18n/locales/cs/ghost.json index 4adde83595a..d4b2d32f654 100644 --- a/packages/i18n/locales/cs/ghost.json +++ b/packages/i18n/locales/cs/ghost.json @@ -80,7 +80,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.", @@ -117,11 +119,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Vaše předplatné bylo zrušeno a vyprší {date}. Můžete si jej znovu obnovit v nastavení svého účtu.", "Your subscription has expired.": "Vaše předplatné vypršelo.", "Your subscription will expire on {date}.": "Vaše předplatné vyprší {date}.", diff --git a/packages/i18n/locales/cs/portal.json b/packages/i18n/locales/cs/portal.json index 1e13afc05bd..be221d11f9f 100644 --- a/packages/i18n/locales/cs/portal.json +++ b/packages/i18n/locales/cs/portal.json @@ -53,6 +53,9 @@ "Check with your mail provider": "Zkontrolujte u svého poskytovatele e-mailu", "Check your inbox to verify email update": "", "Choose": "Vybrat", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Vybrat jiný plán", "Choose a plan": "", "Choose a tier": "", @@ -83,13 +86,14 @@ "Create a new contact": "Vytvořit nový kontakt", "Current plan": "Aktuální plán", "Delete account": "Smazat účet", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Nechtěli jste to udělat? Spravujte své předvolby .", "Don't have an account?": "Nemáte účet?", "Edit": "Upravit", "Email": "E-mail", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "E-mailový newsletter", "Email newsletter settings updated": "", "Email preferences": "Předvolby e-mailu", @@ -179,6 +183,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "Nepřicházejí vám e-maily?", + "Now": "", "Now check your email!": "Zkontrolujte svou e-mailovou schránku!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Po opětovném přihlášení, pokud stále nevidíte e-maily ve své schránce, zkontrolujte složku spam. Někteří poskytovatelé e-mailových schránek si uchovávají záznam o předchozích stížnostech na spam a budou nadále označovat e-maily. Pokud k tomu dojde, označte nejnovější newsletter jako 'Není spam', aby se přesunul zpět do vaší hlavní schránky.", @@ -295,6 +300,7 @@ "View plans": "Zobrazit plány", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Nemohli jsme vás odhlásit, protože e-mailová adresa nebyla nalezena. Kontaktujte prosím provozovatele webu.", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Vítejte zpět, {name}!", "Welcome back!": "Vítejte zpět!", @@ -331,6 +337,7 @@ "Your email has failed to resubscribe, please try again": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "Vaše připomínky pomáhají ladit a tvořit obsah webu.", diff --git a/packages/i18n/locales/da/ghost.json b/packages/i18n/locales/da/ghost.json index f7371732a23..92e479fa77e 100644 --- a/packages/i18n/locales/da/ghost.json +++ b/packages/i18n/locales/da/ghost.json @@ -76,7 +76,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.", @@ -113,11 +115,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Dit abonnement er blevet annulleret og udløber den {date}. Du kan genoptage dit abonnement via dine kontoindstillinger.", "Your subscription has expired.": "Dit abonnement er udløbet.", "Your subscription will expire on {date}.": "Dit abonnement udløber den {date}.", diff --git a/packages/i18n/locales/da/portal.json b/packages/i18n/locales/da/portal.json index a05bb1a826d..8bdd75d45d5 100644 --- a/packages/i18n/locales/da/portal.json +++ b/packages/i18n/locales/da/portal.json @@ -51,6 +51,9 @@ "Check with your mail provider": "Tjek med din e-mailudbyder", "Check your inbox to verify email update": "Tjek din indbakke for at bekræfte e-mailopdateringen", "Choose": "Vælg", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Vælg et andet abonnement", "Choose a plan": "Vælg et abonnement", "Choose a tier": "", @@ -81,13 +84,14 @@ "Create a new contact": "Opret ny kontakt", "Current plan": "Nuværende abonnement", "Delete account": "Slet konto", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Var det ikke dette du ønskede at gøre? Administrer dine præferencer .", "Don't have an account?": "Har du ikke en konto?", "Edit": "Rediger", "Email": "E-mail", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "E-mail nyhedsbrev", "Email newsletter settings updated": "Indstillinger for nyhedsbrev blev opdateret", "Email preferences": "E-mail præferencer", @@ -177,6 +181,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "Modtager du ikke e-mails?", + "Now": "", "Now check your email!": "Tjek din e-mail!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Når du har tilmeldt dig igen, hvis du stadig ikke kan se e-mails i din indbakke, skal du tjekke din spam-mappe. Nogle e-mailudbydere registrerer tidligere spam-klager og vil blive ved med at markere e-mails. Hvis dette sker, skal du markere det seneste nyhedsbrev som 'Ikke spam' for at flytte det tilbage til din primære indbakke.", @@ -293,6 +298,7 @@ "View plans": "Se abonnementer", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Vi kunne ikke afmelde dig, da e-mailen ikke blev fundet. Kontakt venligst ejeren af siden.", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Velkommen tilbage, {name}!", "Welcome back!": "Velkommen tilbage!", @@ -325,6 +331,7 @@ "Your email has failed to resubscribe, please try again": "Din e-mail kunne ikke genabonneres, prøv venligst igen.", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "Dit input hjælper med at forme det der bliver publiceret.", diff --git a/packages/i18n/locales/de-CH/ghost.json b/packages/i18n/locales/de-CH/ghost.json index 7c99eeaf1f8..e2917a2440f 100644 --- a/packages/i18n/locales/de-CH/ghost.json +++ b/packages/i18n/locales/de-CH/ghost.json @@ -76,7 +76,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.", @@ -113,11 +115,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "Ihr Geschenk wartet auf Sie", + "Your gift is scheduled": "", "Your gift subscription confirmation": "Bestätigung Ihres Geschenkabonnements", "Your gift subscription is ending soon": "Ihr Geschenkabonnement läuft bald ab", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "Ihr Geschenkabonnement für {siteTitle} endet am {consumesAt}.", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Ihr Abo wurde gekündigt und läuft am {date} ab. Sie können Ihr Abo in den Kontoeinstellungen verlängern.", "Your subscription has expired.": "Ihr Abo ist abgelaufen.", "Your subscription will expire on {date}.": "Ihr Abo läuft am {date} ab.", diff --git a/packages/i18n/locales/de-CH/portal.json b/packages/i18n/locales/de-CH/portal.json index 712284f5f1b..6e8ad102409 100644 --- a/packages/i18n/locales/de-CH/portal.json +++ b/packages/i18n/locales/de-CH/portal.json @@ -51,6 +51,9 @@ "Check with your mail provider": "Erkundigen Sie sich bei Ihrem E-Mail-Anbieter", "Check your inbox to verify email update": "Prüfen Sie Ihr E-Mail-Postfach, um die Änderung der E-Mail-Adresse zu bestätigen.", "Choose": "Auswählen", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Wählen Sie einen anderen Tarif", "Choose a plan": "Tarif wählen", "Choose a tier": "", @@ -81,13 +84,14 @@ "Create a new contact": "Neuen Kontakt anlegen", "Current plan": "Aktuelles Abo", "Delete account": "Konto löschen", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "War das ein Versehen? Verwalten Sie Ihre Einstellungen .", "Don't have an account?": "Noch kein Konto?", "Edit": "Bearbeiten", "Email": "E-Mail", "Email confirmation link expired.": "Der E-Mail-Bestätigungslink ist abgelaufen.", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "Newsletter", "Email newsletter settings updated": "Newsletter-Einstellungen aktualisiert", "Email preferences": "E-Mail-Einstellungen", @@ -177,6 +181,7 @@ "No thanks, I want to cancel": "Nein, danke, ich möchte kündigen", "Not ready to share? We've also emailed a copy to your inbox.": "Noch nicht bereit zum Teilen? Wir haben Ihnen eine Kopie per E-Mail zugeschickt.", "Not receiving emails?": "Kein E-Mail erhalten?", + "Now": "", "Now check your email!": "Überprüfen Sie bitte Ihren Posteingang.", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Wenn Sie nach Ihrer Registrierung keine E-Mail von uns im Posteingang haben, überprüfen Sie Ihren Spam-Ordner. Einige E-Mail-Anbieter speichern frühere Spam-Beschwerden und kennzeichnen E-Mails weiterhin. Für diesen Fall markieren Sie den neusten Newsletter als \"Kein Spam\", um ihn in Ihren Posteingang zu verschieben.", @@ -293,6 +298,7 @@ "View plans": "Tarife ansehen", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Da ist etwas schief gelaufen, wir konnten Sie nicht abmelden. Bitte kontaktieren Sie {supportAddress}.", "We'd hate to see you leave. How about a special offer to stay?": "Wir würden es bedauern, wenn Sie uns verlassen würden. Wie wäre es mit einem Sonderangebot, damit Sie bleiben?", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Willkommen zurück, {name}!", "Welcome back!": "Willkommen zurück!", @@ -325,6 +331,7 @@ "Your email has failed to resubscribe, please try again": "Ihre E-Mail-Adresse konnte nicht angemeldet werden. Bitte versuchen Sie es erneut", "Your gift is on its way": "", "Your gift is ready": "Ihr Geschenk ist bereit", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "Ihr Geschenkabonnement läuft am {expiryDate} ab", "your inbox": "Ihre Inbox", "Your input helps shape what gets published.": "Ihr Beitrag kann unsere Berichterstattung beeinflussen und mitprägen.", diff --git a/packages/i18n/locales/de/ghost.json b/packages/i18n/locales/de/ghost.json index 13dd59ac2d7..ca60a2d5e2f 100644 --- a/packages/i18n/locales/de/ghost.json +++ b/packages/i18n/locales/de/ghost.json @@ -76,7 +76,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.", @@ -113,11 +115,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Dein Abo wurde storniert und endet am {date}. Du kannst dein Abo in den Kontoeinstellungen verlängern.", "Your subscription has expired.": "Dein Abo hat geendet.", "Your subscription will expire on {date}.": "Dein Abo endet am {date}.", diff --git a/packages/i18n/locales/de/portal.json b/packages/i18n/locales/de/portal.json index 02b43180f30..44653552520 100644 --- a/packages/i18n/locales/de/portal.json +++ b/packages/i18n/locales/de/portal.json @@ -51,6 +51,9 @@ "Check with your mail provider": "Erkundige dich bei deinem E-Mail-Anbieter", "Check your inbox to verify email update": "Prüfe nun dein E-Mail Postfach, um die Änderung deiner E-Mail Adresse zu bestätigen", "Choose": "Auswählen", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Wähle einen anderen Tarif", "Choose a plan": "Tarif wählen", "Choose a tier": "", @@ -81,13 +84,14 @@ "Create a new contact": "Neuen Kontakt anlegen", "Current plan": "Aktueller Tarif", "Delete account": "Konto löschen", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "War das ein Versehen? Verwalte deine Einstellungen .", "Don't have an account?": "Noch kein Konto?", "Edit": "Bearbeiten", "Email": "E-Mail", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "E-Mail-Newsletter", "Email newsletter settings updated": "E-Mail Newsletter Einstellungen wurden aktualisiert", "Email preferences": "E-Mail-Einstellungen", @@ -177,6 +181,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "Keine E-Mails erhalten?", + "Now": "", "Now check your email!": "Überprüfe jetzt deinen Posteingang!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Wenn du nach der Registrierung immer noch keine E-Mails in deinem Posteingang siehst, überprüfen deinen Spam-Ordner. Einige E-Mail-Anbieter speichern frühere Spam-Beschwerden und kennzeichnen E-Mails weiterhin. Wenn dies der Fall ist, markiere den neuesten Newsletter als \"Kein Spam\", um ihn wieder in deinen Posteingang zu verschieben.", @@ -293,6 +298,7 @@ "View plans": "Tarife ansehen", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Wir konnten dich nicht abmelden, da die E-Mail-Adresse nicht gefunden wurde. Bitte kontaktiere den/dir Seitenbetreiber*in.", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Willkommen zurück, {name}!", "Welcome back!": "Willkommen zurück!", @@ -325,6 +331,7 @@ "Your email has failed to resubscribe, please try again": "Deine E-Mailadresse konnte nicht angemeldet werden. Bitte versuche es noch einmal.", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "Dein Posteingang", "Your input helps shape what gets published.": "Dein Beitrag trägt dazu bei, was veröffentlicht wird.", diff --git a/packages/i18n/locales/el/ghost.json b/packages/i18n/locales/el/ghost.json index ba041670460..cb3ccbce7a6 100644 --- a/packages/i18n/locales/el/ghost.json +++ b/packages/i18n/locales/el/ghost.json @@ -76,7 +76,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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 δεν θα χρησιμοποιηθεί.", @@ -113,11 +115,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "Το δώρο σας είναι έτοιμο", + "Your gift is scheduled": "", "Your gift subscription confirmation": "Επιβεβαίωση της συνδρομής δώρου σας", "Your gift subscription is ending soon": "Η συνδρομή δώρου σας λήγει σύντομα", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "Η συνδρομή δώρου σας στο {siteTitle} λήγει στις {consumesAt}.", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Η συνδρομή σας έχει ακυρωθεί και θα λήξει στις {date}. Μπορείτε να επαναφέρετε τη συνδρομή σας μέσω των ρυθμίσεων του λογαριασμού σας.", "Your subscription has expired.": "Η συνδρομή σας έχει λήξει.", "Your subscription will expire on {date}.": "Η συνδρομή σας θα λήξει στις {date}.", diff --git a/packages/i18n/locales/el/portal.json b/packages/i18n/locales/el/portal.json index 8b95b33987a..420993550fc 100644 --- a/packages/i18n/locales/el/portal.json +++ b/packages/i18n/locales/el/portal.json @@ -51,6 +51,9 @@ "Check with your mail provider": "Ελέγξτε με τον παροχέα email σας", "Check your inbox to verify email update": "Ελέγξτε τα εισερχόμενά σας για να επιβεβαιώσετε την ενημέρωση του email", "Choose": "Επιλογή", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Επιλέξτε ένα διαφορετικό πρόγραμμα", "Choose a plan": "Επιλέξτε ένα πρόγραμμα", "Choose a tier": "", @@ -81,13 +84,14 @@ "Create a new contact": "Δημιουργήστε νέα επαφή", "Current plan": "Τρέχον πρόγραμμα", "Delete account": "Διαγραφή λογαριασμού", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Δεν το εννοούσατε; Διαχειριστείτε τις προτιμήσεις σας .", "Don't have an account?": "Δεν έχετε λογαριασμό;", "Edit": "Επεξεργασία", "Email": "Email", "Email confirmation link expired.": "Ο σύνδεσμος επιβεβαίωσης email έχει λήξει.", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "Ενημερωτικό δελτίο email", "Email newsletter settings updated": "Οι ρυθμίσεις ενημερωτικού δελτίου email ενημερώθηκαν", "Email preferences": "Προτιμήσεις email", @@ -177,6 +181,7 @@ "No thanks, I want to cancel": "Όχι, ευχαριστώ, θέλω να ακυρώσω", "Not ready to share? We've also emailed a copy to your inbox.": "Δεν είστε έτοιμοι να το μοιραστείτε; Σας έχουμε επίσης στείλει ένα αντίγραφο στα εισερχόμενά σας.", "Not receiving emails?": "Δεν λαμβάνετε emails;", + "Now": "", "Now check your email!": "Τώρα ελέγξτε το email σας!", "Occasional updates from {siteTitle}": "Περιστασιακές ενημερώσεις από το {siteTitle}", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Μόλις εγγραφείτε ξανά, αν εξακολουθείτε να μην βλέπετε emails στα εισερχόμενά σας, ελέγξτε τον φάκελο ανεπιθύμητης αλληλογραφίας. Ορισμένοι πάροχοι email διατηρούν αρχείο προηγούμενων αναφορών ανεπιθύμητης αλληλογραφίας και θα συνεχίσουν να επισημαίνουν emails. Αν συμβεί αυτό, επισημάνετε το τελευταίο ενημερωτικό δελτίο ως 'Μη ανεπιθύμητο' για να το μετακινήσετε ξανά στα κύρια εισερχόμενά σας.", @@ -293,6 +298,7 @@ "View plans": "Προβολή προγραμμάτων", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Δεν ήταν δυνατή η απεγγραφή σας από τη λίστα επειδή η διεύθυνση email δεν βρέθηκε. Επικοινωνήστε με τον ιδιοκτήτη του ιστότοπου.", "We'd hate to see you leave. How about a special offer to stay?": "Δεν θα θέλαμε καθόλου να σας δούμε να φεύγετε. Τι θα λέγατε για μια ειδική προσφορά για να μείνετε;", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Καλώς ήρθατε ξανά, {name}!", "Welcome back!": "Καλώς ήρθατε ξανά!", @@ -325,6 +331,7 @@ "Your email has failed to resubscribe, please try again": "Δεν ήταν δυνατή η επανεγγραφή του email σας, δοκιμάστε ξανά", "Your gift is on its way": "", "Your gift is ready": "Το δώρο σας είναι έτοιμο", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "Η συνδρομή δώρου σας θα λήξει στις {expiryDate}", "your inbox": "γραμματοκιβώτιό σας", "Your input helps shape what gets published.": "Η συνεισφορά σας βοηθά να διαμορφωθεί ό,τι δημοσιεύεται.", diff --git a/packages/i18n/locales/en/ghost.json b/packages/i18n/locales/en/ghost.json index 9670ca0c9b6..e4b76567c59 100644 --- a/packages/i18n/locales/en/ghost.json +++ b/packages/i18n/locales/en/ghost.json @@ -76,7 +76,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", @@ -113,11 +115,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", "Your subscription has expired.": "", "Your subscription will expire on {date}.": "", diff --git a/packages/i18n/locales/en/portal.json b/packages/i18n/locales/en/portal.json index c8ae5041495..12ed88dfb24 100644 --- a/packages/i18n/locales/en/portal.json +++ b/packages/i18n/locales/en/portal.json @@ -51,6 +51,9 @@ "Check with your mail provider": "", "Check your inbox to verify email update": "", "Choose": "", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "", "Choose a plan": "", "Choose a tier": "", @@ -81,13 +84,14 @@ "Create a new contact": "", "Current plan": "", "Delete account": "", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "", "Don't have an account?": "", "Edit": "", "Email": "", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "", "Email newsletter settings updated": "", "Email preferences": "", @@ -177,6 +181,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "", + "Now": "", "Now check your email!": "", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "", @@ -293,6 +298,7 @@ "View plans": "", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "", "Welcome back!": "", @@ -325,6 +331,7 @@ "Your email has failed to resubscribe, please try again": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "", diff --git a/packages/i18n/locales/eo/ghost.json b/packages/i18n/locales/eo/ghost.json index e3c0fa281bf..c53c3cd24c1 100644 --- a/packages/i18n/locales/eo/ghost.json +++ b/packages/i18n/locales/eo/ghost.json @@ -76,7 +76,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.", @@ -113,11 +115,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", "Your subscription has expired.": "", "Your subscription will expire on {date}.": "", diff --git a/packages/i18n/locales/eo/portal.json b/packages/i18n/locales/eo/portal.json index 955a55cd8a0..86b90430942 100644 --- a/packages/i18n/locales/eo/portal.json +++ b/packages/i18n/locales/eo/portal.json @@ -51,6 +51,9 @@ "Check with your mail provider": "", "Check your inbox to verify email update": "", "Choose": "", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Elektu alian planon", "Choose a plan": "", "Choose a tier": "", @@ -81,13 +84,14 @@ "Create a new contact": "", "Current plan": "", "Delete account": "Forigu konton", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "", "Don't have an account?": "Ĉu ne havas konton?", "Edit": "", "Email": "Retadreso", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "", "Email newsletter settings updated": "", "Email preferences": "Retpoŝtaj agordoj", @@ -177,6 +181,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "", + "Now": "", "Now check your email!": "Bonvolu kontroli vian retpoŝton nun!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "", @@ -293,6 +298,7 @@ "View plans": "", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Ni ne povis malaboni vin ĉar la retadreso ne estis trovita. Bonvolu kontakti la proprietulo de la retejo", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "", "Welcome back!": "", @@ -325,6 +331,7 @@ "Your email has failed to resubscribe, please try again": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "Via enigo helpas formi kio estas aperigita.", diff --git a/packages/i18n/locales/es/ghost.json b/packages/i18n/locales/es/ghost.json index 246f48bf8b4..fc5ce1eb6bd 100644 --- a/packages/i18n/locales/es/ghost.json +++ b/packages/i18n/locales/es/ghost.json @@ -78,7 +78,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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á.", @@ -115,11 +117,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Tu suscripción ha sido cancelada y caducará el {date}. Puedes reactivar tu suscripción en la configuración de tu cuenta.", "Your subscription has expired.": "Tu suscripción ha caducado.", "Your subscription will expire on {date}.": "Tu suscripción caduca el {date}.", diff --git a/packages/i18n/locales/es/portal.json b/packages/i18n/locales/es/portal.json index 0f5d9f5379e..c8a699effee 100644 --- a/packages/i18n/locales/es/portal.json +++ b/packages/i18n/locales/es/portal.json @@ -52,6 +52,9 @@ "Check with your mail provider": "Comprueba con tu proveedor de correo", "Check your inbox to verify email update": "Revisa tu bandeja de entrada para actualizar el correo electrónico", "Choose": "Elige", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Elige otro plan", "Choose a plan": "Elige un plan", "Choose a tier": "", @@ -82,13 +85,14 @@ "Create a new contact": "Crea un nuevo contacto", "Current plan": "Plan actual", "Delete account": "Eliminar cuenta", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "¿No quisiste hacer esto? Puedes gestionar tus preferencias .", "Don't have an account?": "¿No tienes una cuenta?", "Edit": "Editar", "Email": "Correo electrónico", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "Boletín informativo por correo electrónico", "Email newsletter settings updated": "Configuración del boletín actualizada", "Email preferences": "Preferencias de correo electrónico", @@ -178,6 +182,7 @@ "No thanks, I want to cancel": "No gracias, quiero cancelar", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "¿No recibes correos electrónicos?", + "Now": "", "Now check your email!": "¡Ahora revisa tu correo electrónico!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Una vez que te hayas vuelto a suscribir, si aún no ves los correos electrónicos en tu bandeja de entrada, verifica tu carpeta de spam. Algunos proveedores de correo mantienen un registro de quejas de spam anteriores y continuarán marcando los correos electrónicos. Si esto sucede, marca el último boletín como \"No es spam\" para moverlo nuevamente a tu bandeja de entrada principal.", @@ -294,6 +299,7 @@ "View plans": "Ver planes", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "No pudimos cancelar tu suscripción ya que no se encontró la dirección de correo electrónico. Por favor, contacta con el propietario del sitio.", "We'd hate to see you leave. How about a special offer to stay?": "Nos duele verte ir. ¿Qué te parece una oferta especial para cambiar de opinión?", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "¡Bienvenido de nuevo, {name}!", "Welcome back!": "¡Bienvenido de nuevo!", @@ -328,6 +334,7 @@ "Your email has failed to resubscribe, please try again": "No se ha podido volver a suscribir tu correo electrónico, inténtalo de nuevo por favor", "Your gift is on its way": "", "Your gift is ready": "Tu regalo está listo", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "Tu suscripción de regalo expirará en {expiryDate}", "your inbox": "tu bandeja de entrada", "Your input helps shape what gets published.": "Tu opinión ayuda a definir lo que se publica.", diff --git a/packages/i18n/locales/et/ghost.json b/packages/i18n/locales/et/ghost.json index 40a35321086..4cc37143443 100644 --- a/packages/i18n/locales/et/ghost.json +++ b/packages/i18n/locales/et/ghost.json @@ -76,7 +76,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.", @@ -113,11 +115,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", "Your subscription has expired.": "", "Your subscription will expire on {date}.": "", diff --git a/packages/i18n/locales/et/portal.json b/packages/i18n/locales/et/portal.json index 1cfb0c61340..3c66b740982 100644 --- a/packages/i18n/locales/et/portal.json +++ b/packages/i18n/locales/et/portal.json @@ -51,6 +51,9 @@ "Check with your mail provider": "Kontrollige oma e-posti teenusepakkujaga", "Check your inbox to verify email update": "", "Choose": "Vali", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Vali teine pakett", "Choose a plan": "Vali pakett", "Choose a tier": "", @@ -81,13 +84,14 @@ "Create a new contact": "Loo uus kontakt", "Current plan": "Praegune pakett", "Delete account": "Kustuta konto", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Ei soovinud seda teha? Halda oma eelistusi .", "Don't have an account?": "Pole kontot?", "Edit": "Muuda", "Email": "E-post", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "E-posti uudiskiri", "Email newsletter settings updated": "", "Email preferences": "E-posti eelistused", @@ -177,6 +181,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "Ei saa e-kirju?", + "Now": "", "Now check your email!": "Nüüd kontrollige oma e-posti!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Kui olete uuesti tellinud ja te ikka ei näe e-kirju oma postkastis, kontrollige oma rämpsposti kausta. Mõned postkasti teenusepakkujad säilitavad varasemaid rämpspostikaebusi ja jätkavad e-kirjade märkimist. Kui see juhtub, märkige viimane uudiskiri 'Mitte rämpspost', et see liiguks tagasi teie põhipostkasti.", @@ -293,6 +298,7 @@ "View plans": "Vaata plaane", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Me ei saanud teie tellimust tühistada, kuna e-posti aadressi ei leitud. Palun võtke ühendust saidi omanikuga.", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Tere tulemast tagasi, {name}!", "Welcome back!": "Tere tulemast tagasi!", @@ -325,6 +331,7 @@ "Your email has failed to resubscribe, please try again": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "Teie sisend aitab kujundada seda, mida avaldatakse.", diff --git a/packages/i18n/locales/eu/ghost.json b/packages/i18n/locales/eu/ghost.json index 16d728b0365..c0ef38a5b67 100644 --- a/packages/i18n/locales/eu/ghost.json +++ b/packages/i18n/locales/eu/ghost.json @@ -76,7 +76,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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", @@ -113,11 +115,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Harpidetza utzi da eta {date}(e)an amaituko da. Berriro harpidetu zaitezke zure kontuaren ezarpenen bidez.", "Your subscription has expired.": "Zure harpidetza iraungi da.", "Your subscription will expire on {date}.": "Zure harpidetza {date}(e)an iraungiko da.", diff --git a/packages/i18n/locales/eu/portal.json b/packages/i18n/locales/eu/portal.json index 3d5bd2b3db6..180d82c612e 100644 --- a/packages/i18n/locales/eu/portal.json +++ b/packages/i18n/locales/eu/portal.json @@ -51,6 +51,9 @@ "Check with your mail provider": "Jarri harremanetan zure ePosta hornitzailearekin", "Check your inbox to verify email update": "Begiratu zure postontzia ePostaren eguneraketa egiaztatzeko", "Choose": "Aukeratu", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Aukeratu beste plan bat", "Choose a plan": "Aukeratu plan bat", "Choose a tier": "", @@ -81,13 +84,14 @@ "Create a new contact": "Sortu kontaktu berria", "Current plan": "Uneko plana", "Delete account": "Ezabatu kontua", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Ez zenuen hori egin nahi? Kudeatu zure hobespenak .", "Don't have an account?": "Ez duzu konturik?", "Edit": "Editatu", "Email": "ePosta", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "ePosta buletina", "Email newsletter settings updated": "ePosta buletinaren ezarpenak eguneratu dira", "Email preferences": "ePosta hobespenak", @@ -177,6 +181,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "Ez duzu ePostarik jasotzen?", + "Now": "", "Now check your email!": "Arakatu orain zure ePosta!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Berriro harpidetuz gero, eta, hala ere, postontzian ePostarik ikusten ez baduzu, arakatu posta baztergarriaren karpeta. Hornitzaile batzuek iraganeko arau-hausleen erregistroak daramatzate eta ePosta batzuk mugatzen dituzte. Hau gertatuko balitzaizu, markatu azken buletina 'ez baztergarritzat' postontzi nagusira eramateko.", @@ -293,6 +298,7 @@ "View plans": "Ikusi aukeratzeko moduan dauden planak", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Ezin izan dugu zure harpidetza ezeztatu, ez dugulako ePosta helbidea topatu. Jarri harremanetan guneko jabearekin.", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Ongi etorri, {name}!", "Welcome back!": "Ongi etorri!", @@ -325,6 +331,7 @@ "Your email has failed to resubscribe, please try again": "Ezin izan da zure ePosta berriro harpidetu, saiatu berriro", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "Zure ekarpenak argitaratzen denari forma ematen laguntzen digu.", diff --git a/packages/i18n/locales/fa/ghost.json b/packages/i18n/locales/fa/ghost.json index 799f8ef97fc..c2194381b13 100644 --- a/packages/i18n/locales/fa/ghost.json +++ b/packages/i18n/locales/fa/ghost.json @@ -76,7 +76,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "این نشانی ایمیل استفاده نخواهد شد.", @@ -113,11 +115,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "هدیه شما آماده است", + "Your gift is scheduled": "", "Your gift subscription confirmation": "تأیید اشتراک هدیه شما", "Your gift subscription is ending soon": "اشتراک هدیه شما به\u200cزودی به پایان می\u200cرسد", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "اشتراک هدیه شما در {siteTitle} در تاریخ {consumesAt} به پایان می\u200cرسد.", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "اشتراک شما لغو شده است و در تاریخ {date} منقضی خواهد شد. می\u200cتوانید از طریق تنظیمات حساب کاربری خود اشتراک را از سر بگیرید.", "Your subscription has expired.": "اشتراک شما منقضی شده است.", "Your subscription will expire on {date}.": "اشتراک شما در تاریخ {date} منقضی خواهد شد.", diff --git a/packages/i18n/locales/fa/portal.json b/packages/i18n/locales/fa/portal.json index e220ea9c8f8..d3205c28a37 100644 --- a/packages/i18n/locales/fa/portal.json +++ b/packages/i18n/locales/fa/portal.json @@ -51,6 +51,9 @@ "Check with your mail provider": "با ارائه\u200cدهنده ایمیل خود بررسی کنید", "Check your inbox to verify email update": "برای تأیید به\u200cروزرسانی ایمیل، صندوق ورودی خود را بررسی کنید", "Choose": "انتخاب", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "بسته دیگری انتخاب کنید", "Choose a plan": "یک بسته انتخاب کنید", "Choose a tier": "", @@ -81,13 +84,14 @@ "Create a new contact": "ایجاد مخاطب جدید", "Current plan": "بسته فعلی", "Delete account": "حذف حساب کاربری", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "از این کار منصرف شدید؟ تنظیمات خود را از مدیریت کنید.", "Don't have an account?": "حساب کاربری ندارید؟", "Edit": "ویرایش", "Email": "ایمیل", "Email confirmation link expired.": "پیوند تأیید ایمیل منقضی شده است.", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "خبرنامه ایمیلی", "Email newsletter settings updated": "تنظیمات خبرنامه ایمیلی به\u200cروزرسانی شد", "Email preferences": "تنظیمات ایمیل", @@ -177,6 +181,7 @@ "No thanks, I want to cancel": "نه سپاس، می\u200cخواهم لغو کنم", "Not ready to share? We've also emailed a copy to your inbox.": "آماده اشتراک\u200cگذاری نیستید؟ ما یک نسخه هم به صندوق ورودی شما ایمیل کرده\u200cایم.", "Not receiving emails?": "ایمیلی دریافت نمی\u200cکنید؟", + "Now": "", "Now check your email!": "حالا ایمیل خود را بررسی کنید!", "Occasional updates from {siteTitle}": "به\u200cروزرسانی\u200cهای گاه\u200cبه\u200cگاه از {siteTitle}", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "پس از ازسرگیری اشتراک، اگر همچنان ایمیل\u200cها را در صندوق ورودی خود نمی\u200cبینید، پوشه هرزنامه را بررسی کنید. برخی ارائه\u200cدهندگان ایمیل سابقه شکایت\u200cهای هرزنامه را نگه می\u200cدارند و همچنان ایمیل\u200cها را علامت\u200cگذاری می\u200cکنند. در این صورت، آخرین خبرنامه را به\u200cعنوان «غیرهرزنامه» علامت بزنید تا به صندوق ورودی اصلی شما برگردد.", @@ -293,6 +298,7 @@ "View plans": "مشاهده بسته\u200cها", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "ما نتوانستیم اشتراک شما را لغو کنیم چون نشانی ایمیل یافت نشد. لطفاً با مالک سایت تماس بگیرید.", "We'd hate to see you leave. How about a special offer to stay?": "دوست نداریم شما را از دست بدهیم. چطور است با یک پیشنهاد ویژه بمانید؟", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "خوش برگشتید، {name}!", "Welcome back!": "خوش برگشتید!", @@ -325,6 +331,7 @@ "Your email has failed to resubscribe, please try again": "ازسرگیری اشتراک ایمیل شما ناموفق بود، لطفاً دوباره تلاش کنید", "Your gift is on its way": "", "Your gift is ready": "هدیه شما آماده است", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "اشتراک هدیه شما در {expiryDate} منقضی خواهد شد", "your inbox": "صندوق ورودی شما", "Your input helps shape what gets published.": "نظرات شما به شکل\u200cگیری محتوای منتشر شده کمک می\u200cکند.", diff --git a/packages/i18n/locales/fi/ghost.json b/packages/i18n/locales/fi/ghost.json index b5a54cd31b6..537c5ba865f 100644 --- a/packages/i18n/locales/fi/ghost.json +++ b/packages/i18n/locales/fi/ghost.json @@ -76,7 +76,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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ä.", @@ -113,11 +115,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Tilauksesi on peruutettu ja päättyy {date}. Voit jatkaa tilausta tilin asetuksissa.", "Your subscription has expired.": "Tilauksesi on päättynyt.", "Your subscription will expire on {date}.": "Tilauksesi päättyy {date}.", diff --git a/packages/i18n/locales/fi/portal.json b/packages/i18n/locales/fi/portal.json index 5148b20dcf9..4e1bb141822 100644 --- a/packages/i18n/locales/fi/portal.json +++ b/packages/i18n/locales/fi/portal.json @@ -51,6 +51,9 @@ "Check with your mail provider": "Tarkista sähköpostitarjoajaltasi", "Check your inbox to verify email update": "Tarkista sähköpostilaatikkosi, jotta voit vahvistaa sähköpostiosoitteen päivityksen", "Choose": "Valitse", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Valitse toinen tilaus", "Choose a plan": "Valitse tilaus", "Choose a tier": "", @@ -81,13 +84,14 @@ "Create a new contact": "Luo uusi kontakti", "Current plan": "Nykyinen tilaus", "Delete account": "Poista tilisi", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Etkö tarkoittanut tehdä näin? Muokkaa valintojasi ", "Don't have an account?": "Eikö sinulla ole tiliä?", "Edit": "Muokkaa", "Email": "Sähköposti", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "Uutiskirje sähköpostiin", "Email newsletter settings updated": "Uutiskirjeen asetukset on päivitetty", "Email preferences": "Sähköpostiasetukset", @@ -177,6 +181,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "Etkö saa sähköposteja?", + "Now": "", "Now check your email!": "Nyt tarkista sähköpostisi", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Kun olet tilannut sähköpostit uudelleen ja et vieläkään saa posteja, katso ensimmäiseksi spam-kansio. Jotkut tarjoajat pitävät listaa ennen merkityistä viesteistä ja estävät niitä jatkossakin. Jos näin tapahtuu, merkitse viesti Not Spam ja siirrä se postilaatikkoosi.", @@ -293,6 +298,7 @@ "View plans": "Katso tilausvaihtoehtoja", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Emme voineet peruuttaa tilaustasi, koska sähköpostiosoitetta ei löytynyt. Ota yhteyttä sivuston omistajaan.", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Tervetuloa takaisin, {name}!", "Welcome back!": "Tervetuloa takaisin!", @@ -325,6 +331,7 @@ "Your email has failed to resubscribe, please try again": "Tilauksen uusiminen epäonnistui sähköpostillesi. Yritäthän uudelleen.", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "sähköpostilaatikkoosi", "Your input helps shape what gets published.": "Antamasi palautteen avulla muokataan julkaistavaa sisältöä", diff --git a/packages/i18n/locales/fr/ghost.json b/packages/i18n/locales/fr/ghost.json index 15c320239ea..cd6cbe086dc 100644 --- a/packages/i18n/locales/fr/ghost.json +++ b/packages/i18n/locales/fr/ghost.json @@ -78,7 +78,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.", @@ -115,11 +117,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "Votre cadeau est prêt", + "Your gift is scheduled": "", "Your gift subscription confirmation": "Confirmation de votre abonnement offert", "Your gift subscription is ending soon": "Votre abonnement offert se termine bientôt", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "Votre abonnement offert à {siteTitle} se termine le {consumesAt}.", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Votre abonnement a été annulé et expirera le {date}. Vous pouvez reprendre votre abonnement via les paramètres de votre compte.", "Your subscription has expired.": "Votre abonnement a expiré.", "Your subscription will expire on {date}.": "Votre abonnement expirera le {date}.", diff --git a/packages/i18n/locales/fr/portal.json b/packages/i18n/locales/fr/portal.json index 44db8ba5299..0b3a1b7aab4 100644 --- a/packages/i18n/locales/fr/portal.json +++ b/packages/i18n/locales/fr/portal.json @@ -52,6 +52,9 @@ "Check with your mail provider": "Vérifiez auprès de votre fournisseur d'e-mail", "Check your inbox to verify email update": "Vérifiez votre boîte de réception pour vous assurer de la mise à jour de l'e-mail", "Choose": "Choisir", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Choisir un autre abonnement", "Choose a plan": "Choisir un abonnement", "Choose a tier": "", @@ -82,13 +85,14 @@ "Create a new contact": "Créer un nouveau contact", "Current plan": "Abonnement actuel", "Delete account": "Supprimer le compte", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Vous ne vouliez pas faire cela ? Gérez vos préférences .", "Don't have an account?": "Vous n’avez pas encore de compte ?", "Edit": "Modifier", "Email": "E-mail", "Email confirmation link expired.": "Le lien de confirmation a expiré.", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "E-mail de la newsletter", "Email newsletter settings updated": "Mise à jour des paramètres de la newsletter", "Email preferences": "Préférences e-mail", @@ -178,6 +182,7 @@ "No thanks, I want to cancel": "Non merci, je souhaite résilier", "Not ready to share? We've also emailed a copy to your inbox.": "Vous ne souhaitez pas encore le partager ? Une copie vous a aussi été envoyée par e-mail.", "Not receiving emails?": "Vous ne recevez pas les e-mails ?", + "Now": "", "Now check your email!": "Veuillez vérifier votre boîte de réception !", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Après votre réinscription, si vous ne voyez toujours pas nos e-mails dans votre boîte de réception, veuillez vérifier votre dossier d'indésirables. Certains fournisseurs gardent en mémoire les précédents signalements et continuent de marquer ces e-mails comme indésirables. Si tel était le cas, veuillez marquer la dernière newsletter comme non indésirable et la placer dans votre boîte de réception.", @@ -294,6 +299,7 @@ "View plans": "Consulter les abonnements", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Le désabonnement n’a pas fonctionné car votre adresse e-mail n’a pas été trouvée. Veuillez prendre contact avec le propriétaire du site.", "We'd hate to see you leave. How about a special offer to stay?": "Nous serions désolés de vous voir partir. Et si vous profitiez d’une offre spéciale pour rester ?", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Content de vous revoir, {name} !", "Welcome back!": "Quel plaisir de vous revoir !", @@ -328,6 +334,7 @@ "Your email has failed to resubscribe, please try again": "Votre e-mail n'a pas été pris en compte pour le réabonnement, veuillez réessayer", "Your gift is on its way": "", "Your gift is ready": "Votre abonnement offert est prêt", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "Votre abonnement offert expirera le {expiryDate}.", "your inbox": "votre boîte de réception", "Your input helps shape what gets published.": "Votre avis aide à améliorer ce qui est publié.", diff --git a/packages/i18n/locales/gd/ghost.json b/packages/i18n/locales/gd/ghost.json index c1534bad722..119801797a7 100644 --- a/packages/i18n/locales/gd/ghost.json +++ b/packages/i18n/locales/gd/ghost.json @@ -80,7 +80,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.", @@ -117,11 +119,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Chaidh stad air chur air an fho-sgrìobhadh agad, agus falbhaidh an ùine air {date}. Faodaidh tu am fo-sgrìobhadh agad ath-thòiseachadh ann an roghainnean a' chunntais agad.", "Your subscription has expired.": "Dh'fhalbh an ùine air an fho-sgrìobhadh agad.", "Your subscription will expire on {date}.": "Falbhaidh an ùine air an fho-sgrìobhadh agad {date}.", diff --git a/packages/i18n/locales/gd/portal.json b/packages/i18n/locales/gd/portal.json index 08e55d8a368..a4fae8fd5be 100644 --- a/packages/i18n/locales/gd/portal.json +++ b/packages/i18n/locales/gd/portal.json @@ -53,6 +53,9 @@ "Check with your mail provider": "Faighnich air solaraiche a’ phuist-d agad", "Check your inbox to verify email update": "Thoir sùil air a' phost-d agad gus an ùrachadh a dhearbhadh", "Choose": "Tagh", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Tagh plana eile", "Choose a plan": "Tagh plana", "Choose a tier": "", @@ -83,13 +86,14 @@ "Create a new contact": "Cruthaich neach-aithne ùr", "Current plan": "Am plana làithreach", "Delete account": "Sguab às an cunntas", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "An robh thu airson seo a dhèanamh? Rianaich .", "Don't have an account?": "Nach eil cunntas agad?", "Edit": "Deasaich", "Email": "Post-d", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "Cuairt-litir", "Email newsletter settings updated": "Chaidh roghainnean na cuairt-litreach ùrachadh", "Email preferences": "Roghainnean puist-d", @@ -179,6 +183,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "Nach eil thu a’ faighinn puist-d?", + "Now": "", "Now check your email!": "Thoir sùil air a’ phost-d agad", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Thoir sùil air a’ phasgan spama agad mura faigh thu puist-d aon uair ’s gu bheil thu air fo-sgrìobhadh a-rithist. Cumaidh cuid de sholaraichean puist de chlàr de sheann ghearanan spama agus ma dh’fhaoidte gu bheil iad fhathast gan comharradh mar spama. Comharraich \"nach e spama\" a th’ annta gus an gluasad air ais dhan bhogsa a-steach agad.", @@ -295,6 +300,7 @@ "View plans": "Seall na planaichean", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Cha deach am post-d a lorg. Leig fios dhan rianaire.", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Fàilte air ais, {name}", "Welcome back!": "Fàilte air ais!", @@ -331,6 +337,7 @@ "Your email has failed to resubscribe, please try again": "Dh'fhàillig ath-nuadhachadh an fho-sgrìobhaidh, feuch a-rithist", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "Bheir na beachdan agad buaidh air na foillseachaidhean ri teachd.", diff --git a/packages/i18n/locales/he/ghost.json b/packages/i18n/locales/he/ghost.json index 4c0480a25b8..1301964942d 100644 --- a/packages/i18n/locales/he/ghost.json +++ b/packages/i18n/locales/he/ghost.json @@ -78,7 +78,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "כתובת המייל הזו לא תהיה בשימוש.", @@ -115,11 +117,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "המינוי שלך בוטל ויפוג ב-{date}. תוכל לשחזר את המינוי דרך ההגדרות של החשבון שלך.", "Your subscription has expired.": "פג תוקפו של המינוי שלך.", "Your subscription will expire on {date}.": "תוקף המינוי שלך יפוג ב-{date}.", diff --git a/packages/i18n/locales/he/portal.json b/packages/i18n/locales/he/portal.json index 17fd670ac4a..65600d584a2 100644 --- a/packages/i18n/locales/he/portal.json +++ b/packages/i18n/locales/he/portal.json @@ -52,6 +52,9 @@ "Check with your mail provider": "בדקו עם ספק המייל שלכם", "Check your inbox to verify email update": "בדקו את תיבת המייל שלכם כדי לאשר את עדכון המייל", "Choose": "בחרו", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "בחרו מנוי אחר", "Choose a plan": "בחרו תוכנית", "Choose a tier": "", @@ -82,13 +85,14 @@ "Create a new contact": "צרו איש קשר חדש", "Current plan": "תוכנית נוכחית", "Delete account": "מחיקת חשבון", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "התחרטתם? ניתן לנהל את ההעדפות .", "Don't have an account?": "אין לכם חשבון?", "Edit": "עריכה", "Email": "מייל", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "ניוזלטר במייל", "Email newsletter settings updated": "הגדרות ניוזלטר במייל עודכנו", "Email preferences": "העדפות מייל", @@ -178,6 +182,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "לא מקבלים מיילים?", + "Now": "", "Now check your email!": "עכשיו בדקו את תיבת המייל שלכם!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "אחרי שתירשמו מחדש, אם אתם עדיין לא רואים מיילים בתיבת הנכנסות שלכם, בדקו את תיבת הספאם שלכם. חלק מספקי תיבת המייל שומרים רשומה של תלונות ספאם קודמות וימשיכו לסמן מיילים. אם זה קורה, סמנו את הניוזלטר האחרון כ\"לא ספאם\" כדי להחזיר אותו לתיבת הנכנסות הראשית שלכם.", @@ -294,6 +299,7 @@ "View plans": "צפו בתוכניות", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "לא הצלחנו להסיר את המנוי שלכם מאחר שכתובת המייל לא נמצאה. פנו לבעל האתר.", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "ברוכים השבים, {name}!", "Welcome back!": "ברוכים השבים!", @@ -328,6 +334,7 @@ "Your email has failed to resubscribe, please try again": "שגיאה בהרשמה מחדש עם כתובת המייל שלכם, נסו שוב", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "המשוב שלכם עוזר לנו לעצב את התוכן שייפורסם.", diff --git a/packages/i18n/locales/hi/ghost.json b/packages/i18n/locales/hi/ghost.json index 237df51aaf2..a094dd79eff 100644 --- a/packages/i18n/locales/hi/ghost.json +++ b/packages/i18n/locales/hi/ghost.json @@ -76,7 +76,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "इस ईमेल पते का उपयोग नहीं किया जाएगा।", @@ -113,11 +115,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "आपकी सदस्यता रद्द कर दी गई है और {date} को समाप्त होगी। आप अपनी खाता सेटिंग्स के माध्यम से इसे फिर से सक्रिय कर सकते हैं।", "Your subscription has expired.": "आपकी सदस्यता समाप्त हो चुकी है।", "Your subscription will expire on {date}.": "आपकी सदस्यता {date} को समाप्त हो जाएगी।", diff --git a/packages/i18n/locales/hi/portal.json b/packages/i18n/locales/hi/portal.json index c0ec73e58ed..db909506977 100644 --- a/packages/i18n/locales/hi/portal.json +++ b/packages/i18n/locales/hi/portal.json @@ -51,6 +51,9 @@ "Check with your mail provider": "अपने मेल प्रदाता से जांचें", "Check your inbox to verify email update": "ईमेल अपडेट को सत्यापित करने के लिए अपना इनबॉक्स जांचें", "Choose": "चुनें", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "एक अलग योजना चुनें", "Choose a plan": "एक योजना चुनें", "Choose a tier": "", @@ -81,13 +84,14 @@ "Create a new contact": "एक नया संपर्क बनाएं", "Current plan": "वर्तमान योजना", "Delete account": "खाता हटाएं", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "क्या ऐसा करने का इरादा नहीं था? अपनी प्राथमिकताएँ प्रबंधित करें।", "Don't have an account?": "क्या आपके पास खाता नहीं है?", "Edit": "बदलाव करें", "Email": "ईमेल", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "ईमेल न्यूज़लेटर", "Email newsletter settings updated": "ईमेल न्यूज़लेटर की सेटिंग्स अपडेट की गई", "Email preferences": "ईमेल प्राथमिकताएँ", @@ -177,6 +181,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "ईमेल प्राप्त नहीं हो रहे?", + "Now": "", "Now check your email!": "अब अपना ईमेल जांचें!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "पुनः सदस्यता लेने के बाद, यदि आप अभी भी अपने इनबॉक्स में ईमेल नहीं देखते हैं, तो अपना स्पैम फ़ोल्डर जांचें। कुछ इनबॉक्स प्रदाता पिछली स्पैम शिकायतों का रिकॉर्ड रखते हैं और ईमेल को फ्लैग करना जारी रखेंगे। यदि ऐसा होता है, तो नवीनतम न्यूज़लेटर को 'स्पैम नहीं' के रूप में चिह्नित करें ताकि इसे वापस आपके मुख्य इनबॉक्स में ले जाया जा सके।", @@ -293,6 +298,7 @@ "View plans": "योजनाएं देखें", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "हम आपको सदस्यता समाप्त नहीं कर सके क्योंकि ईमेल पता नहीं मिला। कृपया साइट मालिक से संपर्क करें।", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "वापसी पर स्वागत है, {name}!", "Welcome back!": "वापसी पर स्वागत है!", @@ -325,6 +331,7 @@ "Your email has failed to resubscribe, please try again": "आपका ईमेल पुनः सदस्यता लेने में विफल रहा, कृपया पुनः प्रयास करें", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "आपका इनपुट प्रकाशित होने वाली चीज़ों को आकार देने में मदद करता है।", diff --git a/packages/i18n/locales/hr/ghost.json b/packages/i18n/locales/hr/ghost.json index 912c1530860..bd5bd668f25 100644 --- a/packages/i18n/locales/hr/ghost.json +++ b/packages/i18n/locales/hr/ghost.json @@ -78,7 +78,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.", @@ -115,11 +117,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Vaša pretplata je otkazana i isteći će na dan {date}. Možete je ponovno aktivirati putem postavki računa.", "Your subscription has expired.": "Vaša pretplata je istekla.", "Your subscription will expire on {date}.": "Vaša pretplata ističe na dan {date}.", diff --git a/packages/i18n/locales/hr/portal.json b/packages/i18n/locales/hr/portal.json index 0ff64807467..5ff9d06ca41 100644 --- a/packages/i18n/locales/hr/portal.json +++ b/packages/i18n/locales/hr/portal.json @@ -52,6 +52,9 @@ "Check with your mail provider": "Provjerite s vašim pružateljem usluge e-pošte", "Check your inbox to verify email update": "Provjerite svoju pristiglu poštu kako biste potvrdili promjenu e-pošte", "Choose": "Odaberite", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Odaberite drugi plan", "Choose a plan": "Odaberite plan", "Choose a tier": "", @@ -82,13 +85,14 @@ "Create a new contact": "Napravite novi kontakt:", "Current plan": "Trenutni plan pretplate", "Delete account": "Obriši račun", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Niste to namjeravali? Upravljajte vašim računom .", "Don't have an account?": "Nemate račun?", "Edit": "Uredi", "Email": "E-pošta", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "Newsletter e-poštom", "Email newsletter settings updated": "Postavke newsletter-a su ažurirane", "Email preferences": "Postavke e-pošte", @@ -178,6 +182,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "Ne dobivate e-poštu?", + "Now": "", "Now check your email!": "Provjerite vašu e-poštu!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Nakon što se ponovno pretplatite, ako i dalje ne vidite e-poštu u svojoj pristigloj pošti, provjerite mapu neželjene pošte. Neki pružatelji usluga e-pošte vode evidenciju prethodnih pritužbi na neželjenu poštu i nastavit će označavati e-poštu. Ako se to dogodi, označite najnoviji newsletter kao \"Nije neželjena pošta\" da biste ga vratili u svoju primarnu pristiglu poštu.", @@ -294,6 +299,7 @@ "View plans": "Prikaži sve planove pretplate", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Nismo uspjeli otkazati vašu e-poštu jer je nismo pronašli u sustavu. Molimo vas kontaktirajte vlasnika stranice.", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Dobrodošli natrag {name}!", "Welcome back!": "Dobrodošli natrag!", @@ -328,6 +334,7 @@ "Your email has failed to resubscribe, please try again": "Ponovna pretplata nije uspjela, pokušajte ponovno", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "Vaš doprinos pomaže u oblikovanju sadržaja kojeg objavljujemo.", diff --git a/packages/i18n/locales/hu/ghost.json b/packages/i18n/locales/hu/ghost.json index 775693b4693..a7dbd1d1086 100644 --- a/packages/i18n/locales/hu/ghost.json +++ b/packages/i18n/locales/hu/ghost.json @@ -76,7 +76,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.", @@ -113,11 +115,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Az előfizetésed törölve lesz és lejár ekkor: {date}. A fiókodban beállíthatod az előfizetésed folytatását.", "Your subscription has expired.": "Az előfizetésed lejárt.", "Your subscription will expire on {date}.": "Az előfizetésed ekkor fog lejárni: {date}. ", diff --git a/packages/i18n/locales/hu/portal.json b/packages/i18n/locales/hu/portal.json index 926bf9e4b57..57960e0ad73 100644 --- a/packages/i18n/locales/hu/portal.json +++ b/packages/i18n/locales/hu/portal.json @@ -51,6 +51,9 @@ "Check with your mail provider": "Ellenőrizd az e-mail szolgáltatódnál", "Check your inbox to verify email update": "Ellenőrizd a beérkező üzeneteidet az e-mail frissítésének megerősítéséhez", "Choose": "Kiválaszt", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Válassz egy másik csomagot", "Choose a plan": "Válassz egy csomagot", "Choose a tier": "", @@ -81,13 +84,14 @@ "Create a new contact": "Új kapcsolat létrehozása", "Current plan": "Aktuális csomag", "Delete account": "Fiók törlése", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Ez véletlenül történt? Kattints a beállításaidhoz.", "Don't have an account?": "Még nincs fiókod?", "Edit": "Szerkesztés", "Email": "E-mail", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "Hírlevél", "Email newsletter settings updated": "A hírlevél beállításaid frissültek.", "Email preferences": "E-mail beállítások", @@ -177,6 +181,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "Nem kapsz e-maileket?", + "Now": "", "Now check your email!": "Ellenőrizd a postafiókodat most!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Miután újra feliratkoztál, de még mindig nem látod az e-maileket a beérkező mappádban, ellenőrizd a spam mappát is. Néhány e-mail szolgáltató nyilvántartást vezet korábbi spam panaszokról, és továbbra is megjelölheti az e-maileket. Ezután a legújabb hírlevelet jelöld meg 'Nem spamként', hogy visszakerüljön a beérkező levelek mappába.", @@ -293,6 +298,7 @@ "View plans": "Csomagok megtekintése", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Ehhez az e-mail-címhez nem tartozik fiók. Kérjük, lépj kapcsolatba a weboldal tulajdonosával.", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Üdv újra, {name}!", "Welcome back!": "Üdvözlünk újra!", @@ -325,6 +331,7 @@ "Your email has failed to resubscribe, please try again": "Az e-mail-címedet nem sikerült újra regisztrálni. Kérjük, próbáld újra", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "A visszajelzésed segít abban, hogy miről írjunk", diff --git a/packages/i18n/locales/id/ghost.json b/packages/i18n/locales/id/ghost.json index 36be78aee20..43f880b477c 100644 --- a/packages/i18n/locales/id/ghost.json +++ b/packages/i18n/locales/id/ghost.json @@ -74,7 +74,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.", @@ -111,11 +113,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Status berlangganan Anda telah dibatalkan dan akan berakhir pada {date}. Anda dapat melanjutkan berlangganan melalui pengaturan akun Anda.", "Your subscription has expired.": "Status berlangganan Anda telah kadaluwarsa.", "Your subscription will expire on {date}.": "Status berlangganan Anda akan berakhir pada {date}.", diff --git a/packages/i18n/locales/id/portal.json b/packages/i18n/locales/id/portal.json index f5c21b155b3..f6c40eb75d4 100644 --- a/packages/i18n/locales/id/portal.json +++ b/packages/i18n/locales/id/portal.json @@ -50,6 +50,9 @@ "Check with your mail provider": "Hubungi penyedia layanan email Anda", "Check your inbox to verify email update": "Periksa kotak masuk Anda untuk memverifikasi pembaruan email", "Choose": "Pilih", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Pilih paket yang berbeda", "Choose a plan": "Pilih paket", "Choose a tier": "", @@ -80,13 +83,14 @@ "Create a new contact": "Buat kontak baru", "Current plan": "Paket saat ini", "Delete account": "Hapus akun", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Tidak bermaksud melakukan ini? Kelola preferensi Anda .", "Don't have an account?": "Belum punya akun?", "Edit": "Edit", "Email": "Email", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "Buletin email", "Email newsletter settings updated": "Pengaturan buletin email berhasil diperbarui", "Email preferences": "Preferensi email.", @@ -176,6 +180,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "Tidak menerima email?", + "Now": "", "Now check your email!": "Sekarang periksa email Anda!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Setelah berlangganan kembali, jika Anda masih tidak melihat email di kotak masuk Anda, periksa folder spam. Beberapa penyedia layanan kotak masuk menyimpan catatan keluhan spam sebelumnya dan akan terus menandai email tersebut. Jika hal ini terjadi, tandai buletin terbaru sebagai 'Bukan spam' untuk memindahkannya kembali ke kotak masuk utama Anda.", @@ -292,6 +297,7 @@ "View plans": "Lihat paket", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Kami tidak dapat menghentikan langganan Anda karena alamat email tidak ditemukan. Harap hubungi pemilik situs.", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Selamat datang kembali, {name}!", "Welcome back!": "Selamat datang kembali!", @@ -322,6 +328,7 @@ "Your email has failed to resubscribe, please try again": "Email Anda gagal berlangganan ulang, harap coba lagi", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "Masukan Anda membantu membentuk apa yang dipublikasikan.", diff --git a/packages/i18n/locales/is/ghost.json b/packages/i18n/locales/is/ghost.json index c3807514c8b..6f6e2eaa118 100644 --- a/packages/i18n/locales/is/ghost.json +++ b/packages/i18n/locales/is/ghost.json @@ -76,7 +76,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.", @@ -113,11 +115,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", "Your subscription has expired.": "", "Your subscription will expire on {date}.": "", diff --git a/packages/i18n/locales/is/portal.json b/packages/i18n/locales/is/portal.json index 2a87fc033ad..98b5262e4fb 100644 --- a/packages/i18n/locales/is/portal.json +++ b/packages/i18n/locales/is/portal.json @@ -51,6 +51,9 @@ "Check with your mail provider": "Hafið samband við þjónustuveitanda netfangsins", "Check your inbox to verify email update": "", "Choose": "Velja", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Velja aðra áskriftarleið", "Choose a plan": "", "Choose a tier": "", @@ -81,13 +84,14 @@ "Create a new contact": "Skrá nýjan tengilið", "Current plan": "Núgildandi áskriftarleið", "Delete account": "Eyða aðgangi", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Var þetta ætlunin? Breyttu stillingunum .", "Don't have an account?": "Ertu ekki með aðgang?", "Edit": "Breyta", "Email": "Netfang", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "Fréttabréf", "Email newsletter settings updated": "", "Email preferences": "Stillingar netfangs", @@ -177,6 +181,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "Færðu ekki tölvupósta?", + "Now": "", "Now check your email!": "Athugaðu nú tölvupósthólfið þitt!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Ef þú sérð ekki tölvupósta eftir að hafa endurvakið áskrift, athugaðu spam-möppuna. Enn kunna að vera skráðar kvartanir um ruslpóst og tölvupóstarnir því flokkaður á þann veg. Ef svo er skaltu merkja síðasta fréttabréf sem 'Not spam' og færa yfir í aðalpósthólfið.", @@ -293,6 +298,7 @@ "View plans": "Skoða áskriftarleiðir", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Uppsögn mistókst vegna þess að netfangið fannst ekki. Hafðu samband við eiganda síðunnar.", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Velkominn aftur, {name}!", "Welcome back!": "Velkominn aftur!", @@ -325,6 +331,7 @@ "Your email has failed to resubscribe, please try again": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "", diff --git a/packages/i18n/locales/it/ghost.json b/packages/i18n/locales/it/ghost.json index 45df5faa531..81d465f137e 100644 --- a/packages/i18n/locales/it/ghost.json +++ b/packages/i18n/locales/it/ghost.json @@ -78,7 +78,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.", @@ -115,11 +117,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "Il tuo regalo è pronto", + "Your gift is scheduled": "", "Your gift subscription confirmation": "Conferma del tuo abbonamento regalo", "Your gift subscription is ending soon": "Il tuo abbonamento regalo sta per terminare", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "Il tuo abbonamento regalo a {siteTitle} terminerà il {consumesAt}.", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Il tuo abbonamento è stato annullato e scadrà il {date}. Puoi riattivare l'abbonamento dalle impostazioni del tuo account.", "Your subscription has expired.": "Il tuo abbonamento è scaduto.", "Your subscription will expire on {date}.": "Il tuo abbonamento scadrà il {date}.", diff --git a/packages/i18n/locales/it/portal.json b/packages/i18n/locales/it/portal.json index bcd43c00593..7004f314b4e 100644 --- a/packages/i18n/locales/it/portal.json +++ b/packages/i18n/locales/it/portal.json @@ -52,6 +52,9 @@ "Check with your mail provider": "Contatta il tuo provider di posta elettronica", "Check your inbox to verify email update": "Controlla la tua casella di posta per verificare l’aggiornamento dell’email", "Choose": "Scegli", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Scegli un piano differente", "Choose a plan": "Scegli un piano", "Choose a tier": "", @@ -82,13 +85,14 @@ "Create a new contact": "Crea un nuovo contatto", "Current plan": "Piano attuale", "Delete account": "Elimina account", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "C’è qualcosa che non va? Gestisci le tue preferenze.", "Don't have an account?": "Non hai un account?", "Edit": "Modifica", "Email": "Email", "Email confirmation link expired.": "Link di conferma email scaduto.", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "Newsletter", "Email newsletter settings updated": "Impostazioni delle newsletter aggiornate", "Email preferences": "Preferenze email", @@ -178,6 +182,7 @@ "No thanks, I want to cancel": "No grazie, voglio annullare", "Not ready to share? We've also emailed a copy to your inbox.": "Non sei ancora pronto a condividerlo? Ti abbiamo anche inviato una copia nella tua casella email.", "Not receiving emails?": "Non ricevi le email?", + "Now": "", "Now check your email!": "Ora controlla la tua email!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Se ancora non vedi le email una volta reiscritto, controlla nello spam. Alcuni provider tengono nota dei reclami e continuano a segnalare le email. Se questo dovesse succedere, segnala l'ultima email ricevuta come \"non spam\" e spostala nella tua posta in arrivo.", @@ -294,6 +299,7 @@ "View plans": "Vedi piani", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Non è stato possibile disiscriverti poiché l'indirizzo email non è stato trovato. Si prega di contattare il proprietario del sito.", "We'd hate to see you leave. How about a special offer to stay?": "Ci dispiacerebbe vederti andare via. Che ne dici di un’offerta speciale per restare?", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "È bello rivederti, {name}!", "Welcome back!": "Bentornato!", @@ -328,6 +334,7 @@ "Your email has failed to resubscribe, please try again": "La re-iscrizione della tua e-mail è fallita, per favore riprova", "Your gift is on its way": "", "Your gift is ready": "Il tuo regalo è pronto", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "Il tuo abbonamento regalo scadrà il {expiryDate}", "your inbox": "la tua casella di posta", "Your input helps shape what gets published.": "Il tuo contributo aiuta a dare forma a ciò che viene pubblicato.", diff --git a/packages/i18n/locales/ja/ghost.json b/packages/i18n/locales/ja/ghost.json index cd37755ad94..6c54125e09d 100644 --- a/packages/i18n/locales/ja/ghost.json +++ b/packages/i18n/locales/ja/ghost.json @@ -74,7 +74,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "このメールアドレスは使用されていません。", @@ -111,11 +113,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", "Your subscription has expired.": "", "Your subscription will expire on {date}.": "", diff --git a/packages/i18n/locales/ja/portal.json b/packages/i18n/locales/ja/portal.json index 96f289dd1c2..73c19024dd0 100644 --- a/packages/i18n/locales/ja/portal.json +++ b/packages/i18n/locales/ja/portal.json @@ -50,6 +50,9 @@ "Check with your mail provider": "メールプロバイダーに確認してください", "Check your inbox to verify email update": "", "Choose": "選択", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "別のプランを選択", "Choose a plan": "プランを選択", "Choose a tier": "", @@ -80,13 +83,14 @@ "Create a new contact": "新しい連絡先を作成", "Current plan": "現在のプラン", "Delete account": "アカウントを削除", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "意図しなかった場合は、で設定を変更してください。", "Don't have an account?": "アカウントをお持ちでない場合", "Edit": "編集", "Email": "メール", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "ニュースレターのメール", "Email newsletter settings updated": "ニュースレターの設定が更新されました", "Email preferences": "メールの設定", @@ -176,6 +180,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "メールが受信されない場合", + "Now": "", "Now check your email!": "メールを確認してください", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "再購読した後も受信トレイにメールが表示されない場合は、スパムフォルダを確認してください。一部の受信トレイは以前のスパムの記録を保持し、引き続きメールを判定します。これが起こった場合は、最新のニュースレターを「スパムではない」とマークし、受信トレイに移動してください。", @@ -292,6 +297,7 @@ "View plans": "プランを表示", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "メールアドレスが見つからなかったため、購読解除できませんでした。サイトのオーナーに連絡してください。", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "おかえりなさい、{name}さん!", "Welcome back!": "おかえりなさい!", @@ -322,6 +328,7 @@ "Your email has failed to resubscribe, please try again": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "あなたの感想を今後の内容の参考にさせていただきます。", diff --git a/packages/i18n/locales/ko/ghost.json b/packages/i18n/locales/ko/ghost.json index 8a9f21f7674..dc68977d979 100644 --- a/packages/i18n/locales/ko/ghost.json +++ b/packages/i18n/locales/ko/ghost.json @@ -74,7 +74,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "이 이메일 주소는 사용하지 않을게요.", @@ -111,11 +113,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "선물이 준비됐어요", + "Your gift is scheduled": "", "Your gift subscription confirmation": "선물 구독 확인", "Your gift subscription is ending soon": "선물 구독이 곧 종료돼요", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "{siteTitle}의 선물 구독이 {consumesAt}에 종료돼요.", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "구독이 취소되었으며 {date}에 만료돼요. 계정 설정에서 구독을 다시 시작할 수 있어요.", "Your subscription has expired.": "구독이 만료되었어요.", "Your subscription will expire on {date}.": "구독이 {date}에 만료돼요.", diff --git a/packages/i18n/locales/ko/portal.json b/packages/i18n/locales/ko/portal.json index 068f6937634..da33a079d7c 100644 --- a/packages/i18n/locales/ko/portal.json +++ b/packages/i18n/locales/ko/portal.json @@ -50,6 +50,9 @@ "Check with your mail provider": "메일 제공업체에 문의해 주세요", "Check your inbox to verify email update": "이메일 업데이트를 확인하기 위해 받은 편지함을 확인해 주세요", "Choose": "선택", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "다른 요금제 선택", "Choose a plan": "요금제 선택", "Choose a tier": "", @@ -80,13 +83,14 @@ "Create a new contact": "새 연락처 만들기", "Current plan": "현재 요금제", "Delete account": "계정 삭제", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "의도치 않았나요? 기본 설정을 에서 관리할 수 있어요.", "Don't have an account?": "계정이 없으신가요?", "Edit": "편집", "Email": "이메일", "Email confirmation link expired.": "이메일 확인 링크가 만료되었어요.", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "이메일 뉴스레터", "Email newsletter settings updated": "이메일 뉴스레터 설정이 업데이트되었어요", "Email preferences": "이메일 설정", @@ -176,6 +180,7 @@ "No thanks, I want to cancel": "아니요, 취소할게요", "Not ready to share? We've also emailed a copy to your inbox.": "아직 공유할 준비가 안 되셨나요? 받은 편지함에도 사본을 보내드렸어요.", "Not receiving emails?": "이메일을 받지 못하고 계신가요?", + "Now": "", "Now check your email!": "지금 이메일을 확인해 주세요!", "Occasional updates from {siteTitle}": "{siteTitle}에서 가끔 전하는 소식", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "다시 구독한 후에도 받은 편지함에 이메일이 표시되지 않는다면 스팸 폴더를 확인해 주세요. 일부 받은 편지함 제공업체는 이전 스팸 신고 기록을 유지하고 계속해서 이메일을 표시해요. 이런 경우 최신 뉴스레터를 '스팸이 아님'으로 표시하여 기본 받은 편지함으로 옮겨주세요.", @@ -292,6 +297,7 @@ "View plans": "플랜 보기", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "이메일 주소를 찾을 수 없어 구독을 취소할 수 없어요. 사이트 관리자에게 문의해 주세요.", "We'd hate to see you leave. How about a special offer to stay?": "떠나신다니 아쉬워요. 계속 함께하실 수 있도록 특별 혜택은 어떠세요?", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "{name}님, 다시 돌아오신 것을 환영해요!", "Welcome back!": "다시 돌아오신 것을 환영해요!", @@ -322,6 +328,7 @@ "Your email has failed to resubscribe, please try again": "이메일 재구독에 실패했어요. 다시 시도해 주세요", "Your gift is on its way": "", "Your gift is ready": "선물이 준비됐어요", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "선물 구독이 {expiryDate}에 만료돼요", "your inbox": "받은 편지함", "Your input helps shape what gets published.": "회원님의 의견은 게시물을 제작하는 것에 큰 도움이 돼요.", diff --git a/packages/i18n/locales/kz/ghost.json b/packages/i18n/locales/kz/ghost.json index 8051e3290a7..c84138702ca 100644 --- a/packages/i18n/locales/kz/ghost.json +++ b/packages/i18n/locales/kz/ghost.json @@ -78,7 +78,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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 адрес қолданылмайды.", @@ -115,11 +117,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", "Your subscription has expired.": "", "Your subscription will expire on {date}.": "", diff --git a/packages/i18n/locales/kz/portal.json b/packages/i18n/locales/kz/portal.json index 05d010aefa1..5d91ee1a9f3 100644 --- a/packages/i18n/locales/kz/portal.json +++ b/packages/i18n/locales/kz/portal.json @@ -51,6 +51,9 @@ "Check with your mail provider": "Пошта провайдері арқылы тексеріңіз", "Check your inbox to verify email update": "", "Choose": "Таңдау", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Басқа жоспарды таңдау", "Choose a plan": "", "Choose a tier": "", @@ -81,13 +84,14 @@ "Create a new contact": "Жаңа контакт құру", "Current plan": "Ағымдағы жоспар", "Delete account": "Аккаунтты жою", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Мұны жасағыңыз келмеді ме? Теңшелімдерді басқарыңыз.", "Don't have an account?": "Аккаунтыңыз жоқ па?", "Edit": "Өңдеу", "Email": "Email", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "Email бюллетені", "Email newsletter settings updated": "", "Email preferences": "Email теңшелімдері", @@ -177,6 +181,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "Хаттар келмей жатыр ма?", + "Now": "", "Now check your email!": "Енді электрондық поштаңызды тексеріңіз!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Қайта жазылғаннан кейін, егер әлі де кіріс жәшігіңізде хаттар көрінбесе, спам қалтасын тексеріңіз. Кейбір пошта провадерлері бұрынғы спам шағымдарын сақтап, хаттарды белгілеуін жалғастырады. Егер солай болса, соңғы ақпараттық бюллетеньді \"Спам емес\" деп белгілеңіз және оны негізгі кіріс жәшігіне жылжытыңыз.", @@ -293,6 +298,7 @@ "View plans": "Жоспарларды көру", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Біз сізді жазылымнан айыра алмадық, өйткені электрондық пошта мекенжайы табылмады. Сайт иесіне хабарласыңыз.", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Қош келдіңіз, {name}!", "Welcome back!": "Қош келдіңіз!", @@ -325,6 +331,7 @@ "Your email has failed to resubscribe, please try again": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "Сіздің пікіріңіз жарияланатын мазмұнды қалыптастыруға көмектеседі.", diff --git a/packages/i18n/locales/lt/ghost.json b/packages/i18n/locales/lt/ghost.json index da1c2b0b20e..7590761a31e 100644 --- a/packages/i18n/locales/lt/ghost.json +++ b/packages/i18n/locales/lt/ghost.json @@ -80,7 +80,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.", @@ -117,11 +119,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", "Your subscription has expired.": "", "Your subscription will expire on {date}.": "", diff --git a/packages/i18n/locales/lt/portal.json b/packages/i18n/locales/lt/portal.json index f433389ffeb..bb574ced45c 100644 --- a/packages/i18n/locales/lt/portal.json +++ b/packages/i18n/locales/lt/portal.json @@ -53,6 +53,9 @@ "Check with your mail provider": "Pasitarkite su savo el. pašto paslaugų teikėju", "Check your inbox to verify email update": "", "Choose": "Pasirinkite", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Pasirinkite kitą planą", "Choose a plan": "", "Choose a tier": "", @@ -83,13 +86,14 @@ "Create a new contact": "Sukurti naują kontaktą", "Current plan": "Dabartinis planas", "Delete account": "Ištrinti paskyrą", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Neplanavote to daryti? Galite keisti nustatymus .", "Don't have an account?": "Dar neturite paskyros?", "Edit": "Redaguoti", "Email": "El. paštas", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "Naujienlaiškis el. paštu", "Email newsletter settings updated": "", "Email preferences": "El. pašto nustatymai", @@ -179,6 +183,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "Negaunate el. laiškų?", + "Now": "", "Now check your email!": "Dabar patikrinkite savo el. paštą", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Jei užsiprenumeravę iš naujo nematote el. laiškų gautuosiuose, patikrinkite šlamšto aplanką. Kai kurie el. paslaugų teikėjai registruoja ankstesnius laiškų žymėjimus dėl šlamšto ir toliau juos žymi. Jei taip atsitiks, pažymėkite naujausią gautą laišką kaip „Ne šlamštą“, kad grąžintumėte jį į pagrindinių gautųjų sąrašą.", @@ -295,6 +300,7 @@ "View plans": "Peržiūrėti planus", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Nepavyko atšaukti laiškų prenumeratos, nes nepavyko rasti jūsų el. pašto adreso sistemoje. Susisiekite su svetainės savininku. ", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Sveiki sugrįžę, {name}!", "Welcome back!": "Sveiki sugrįžę!", @@ -331,6 +337,7 @@ "Your email has failed to resubscribe, please try again": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "Jūsų indėlis padeda kurti tai, kas yra viešinama.", diff --git a/packages/i18n/locales/lv/ghost.json b/packages/i18n/locales/lv/ghost.json index 62b2c4891c9..12f9e83a49b 100644 --- a/packages/i18n/locales/lv/ghost.json +++ b/packages/i18n/locales/lv/ghost.json @@ -78,7 +78,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.", @@ -115,11 +117,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Jūsu abonements ir atcelts, un tā derīguma termiņš beigsies {date}. Varat atsākt abonementu, izmantojot sava konta iestatījumus.", "Your subscription has expired.": "Jūsu abonements ir beidzies.", "Your subscription will expire on {date}.": "Jūsu abonementa derīguma termiņš beigsies {date}.", diff --git a/packages/i18n/locales/lv/portal.json b/packages/i18n/locales/lv/portal.json index 66d4cd3ef24..88eadceeec8 100644 --- a/packages/i18n/locales/lv/portal.json +++ b/packages/i18n/locales/lv/portal.json @@ -52,6 +52,9 @@ "Check with your mail provider": "Sazinieties ar savu pasta pakalpojumu sniedzēju", "Check your inbox to verify email update": "Pārbaudiet savu iesūtni, lai apstiprinātu e-pasta atjauninājumu", "Choose": "Izvēlieties", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Izvēlieties citu plānu", "Choose a plan": "Izvēlieties plānu", "Choose a tier": "", @@ -82,13 +85,14 @@ "Create a new contact": "Izveidojiet jaunu kontaktu", "Current plan": "Pašreizējais plāns", "Delete account": "Dzēst kontu", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Vai nevēlējāties to darīt? Pārvaldiet savas preferences .", "Don't have an account?": "Vai jums nav konta?", "Edit": "Rediģēt", "Email": "E-pasts", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "E-pasta biļetens", "Email newsletter settings updated": "E-pasta biļetena iestatījumi ir atjaunināti", "Email preferences": "E-pasta preferences", @@ -178,6 +182,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "Vai nesaņemat e-pastus?", + "Now": "", "Now check your email!": "Tagad pārbaudiet savu e-pastu!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Ja pēc atkārtotas abonēšanas iesūtnē joprojām neredzat e-pasta ziņojumus, pārbaudiet surogātpasta mapi. Daži iesūtnes pakalpojumu sniedzēji reģistrē iepriekšējās surogātpasta sūdzības un turpinās atzīmēt e-pastus. Ja tā notiek, atzīmējiet jaunāko biļetenu kā “Nav mēstules”, lai pārvietotu to atpakaļ uz savu primāro iesūtni.", @@ -294,6 +299,7 @@ "View plans": "Skatīt plānus", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Mēs nevarējām anulēt jūsu abonementu, jo e-pasta adrese netika atrasta. Lūdzu, sazinieties ar vietnes īpašnieku.", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Laipni lūdzam atpakaļ, {name}!", "Welcome back!": "Laipni lūdzam atpakaļ!", @@ -328,6 +334,7 @@ "Your email has failed to resubscribe, please try again": "Jūsu e-pasta abonēšana neizdevās atkārtoti. Lūdzu, mēģiniet vēlreiz", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "Jūsu ieguldījums palīdz veidot publicējamo saturu.", diff --git a/packages/i18n/locales/mk/ghost.json b/packages/i18n/locales/mk/ghost.json index c737db05bc5..8054398aaf8 100644 --- a/packages/i18n/locales/mk/ghost.json +++ b/packages/i18n/locales/mk/ghost.json @@ -76,7 +76,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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 адреса нема да биде употребена.", @@ -113,11 +115,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", "Your subscription has expired.": "", "Your subscription will expire on {date}.": "", diff --git a/packages/i18n/locales/mk/portal.json b/packages/i18n/locales/mk/portal.json index 49e3a58089c..428aadacb1d 100644 --- a/packages/i18n/locales/mk/portal.json +++ b/packages/i18n/locales/mk/portal.json @@ -51,6 +51,9 @@ "Check with your mail provider": "Проверете со вашиот mail сервис", "Check your inbox to verify email update": "", "Choose": "Изберете", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Изберете друг пакет", "Choose a plan": "", "Choose a tier": "", @@ -81,13 +84,14 @@ "Create a new contact": "Создајте нов контакт", "Current plan": "Моментален пакет", "Delete account": "Избришете сметка", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Не мислевте да го сторите ова? Променете ги вашите поставки .", "Don't have an account?": "Немате сметка?", "Edit": "Променете", "Email": "Email", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "Email билтен", "Email newsletter settings updated": "", "Email preferences": "Email поставки", @@ -177,6 +181,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "Не добивате пораки?", + "Now": "", "Now check your email!": "Сега проверете ги пораките1", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Кога ќе се претплатите повторно, доколку сè уште не добивате пораки, проверте ја папката за спам. Некои сервиси чуваат записи од претходни поплаки за спам и продолжуваат да ги обележуваат пораките како такви. Ако ова се случи, обележете ја последната порака од билтенот дека не е спам и префрлете ја во главното поштенско сандаче.", @@ -293,6 +298,7 @@ "View plans": "Видете пакети", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Ние не можевме да ве отпишеме бидејќи email адресата не беше пронајдена. Ве молиме контактирајте го сопственикот на страницата.", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Добредојдовте назад, {name}!", "Welcome back!": "Добредојдовте назад!", @@ -325,6 +331,7 @@ "Your email has failed to resubscribe, please try again": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "Вашиот прилог помага да се оформи она кое ќе биде објавувано.", diff --git a/packages/i18n/locales/mn/ghost.json b/packages/i18n/locales/mn/ghost.json index 2e3ec938812..7b5394a6711 100644 --- a/packages/i18n/locales/mn/ghost.json +++ b/packages/i18n/locales/mn/ghost.json @@ -76,7 +76,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "Энэхүү имэйл хаяг ашиглагдахгүй.", @@ -113,11 +115,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Таны захиалга цуцлагдсан бөгөөд {date}-нд дуусна. Та бүртгэлийн тохиргоогоороо захиалгаа үргэлжлүүлж болно.", "Your subscription has expired.": "Таны захиалга дууссан.", "Your subscription will expire on {date}.": "Таны захиалга {date}-нд дуусна.", diff --git a/packages/i18n/locales/mn/portal.json b/packages/i18n/locales/mn/portal.json index a88c55d5aba..77439f47b9e 100644 --- a/packages/i18n/locales/mn/portal.json +++ b/packages/i18n/locales/mn/portal.json @@ -51,6 +51,9 @@ "Check with your mail provider": "Имэйл үйлчилгээ үзүүлэгчтэйгээ холбогдоно уу", "Check your inbox to verify email update": "Имэйл шинэчлэлтээ баталгаажуулахын тулд инбоксоо шалгана уу", "Choose": "Сонгох", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Өөр багц сонгох", "Choose a plan": "Багц сонгох", "Choose a tier": "", @@ -81,13 +84,14 @@ "Create a new contact": "Шинэ харилцагч үүсгэх", "Current plan": "Одоогийн багц", "Delete account": "Бүртгэлээ устгах", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Үүнийг хийхийг хүсээгүй юу? Тохиргоогоо удирдана уу.", "Don't have an account?": "Бүртгэлгүй юу?", "Edit": "Засах", "Email": "Имэйл", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "Имэйл товхимол", "Email newsletter settings updated": "Имэйл товхимолын тохиргоо шинэчлэгдлээ", "Email preferences": "Имэйлийн тохиргоо", @@ -177,6 +181,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "Имэйл ирээгүй юу?", + "Now": "", "Now check your email!": "Одоо имэйлээ шалгана уу!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Дахин бүртгүүлсний дараа, хэрэв та имэйлүүдийг инбоксдоо харахгүй байвал спам хавтсаа шалгана уу. Зарим имэйл үйлчилгээ үзүүлэгчид өмнөх спам тэмдэглэгээнүүдийг хадгалдаг бөгөөд имэйлүүдийг тэмдэглэсээр байна. Хэрэв ийм байдал үүсвэл, сүүлийн үеийн товхимолыг 'Спам биш' гэж тэмдэглэж үндсэн инбокс руугаа буцааж зөөнө үү.", @@ -293,6 +298,7 @@ "View plans": "Багцуудыг харах", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Энэхүү имэйл хаяг олдоогүй учир захиалгыг цуцлах боломжгүй. Та сайтын админд хандана уу.", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Дахин тавтай морил, {name}!", "Welcome back!": "Дахин тавтай морил!", @@ -325,6 +331,7 @@ "Your email has failed to resubscribe, please try again": "Таны имэйлийг дахин захиалахад алдаа гарлаа, дахин оролдоно уу", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "Таны санал дараа дараагийн нийтлэлийг илүү чанартай болгоход туслана", diff --git a/packages/i18n/locales/ms/ghost.json b/packages/i18n/locales/ms/ghost.json index 81f5957b317..05f085abae9 100644 --- a/packages/i18n/locales/ms/ghost.json +++ b/packages/i18n/locales/ms/ghost.json @@ -74,7 +74,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.", @@ -111,11 +113,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", "Your subscription has expired.": "", "Your subscription will expire on {date}.": "", diff --git a/packages/i18n/locales/ms/portal.json b/packages/i18n/locales/ms/portal.json index c3fe2402972..0003fee633d 100644 --- a/packages/i18n/locales/ms/portal.json +++ b/packages/i18n/locales/ms/portal.json @@ -50,6 +50,9 @@ "Check with your mail provider": "Semak dengan pembekal mel anda", "Check your inbox to verify email update": "", "Choose": "Pilih", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Pilih pelan yang berbeza", "Choose a plan": "", "Choose a tier": "", @@ -80,13 +83,14 @@ "Create a new contact": "Cipta kenalan baru", "Current plan": "Pelam semasa", "Delete account": "Padam akaun", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Tidak bermaksud untuk melakukan ini? Urus pilihan anda di sini.", "Don't have an account?": "Tiada akaun?", "Edit": "Sunting", "Email": "E-mel", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "Newsletter e-mel", "Email newsletter settings updated": "", "Email preferences": "Emel pilihan", @@ -176,6 +180,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "Tidak menerima e-mel?", + "Now": "", "Now check your email!": "Semak e-mel anda sekarang!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Setelah melanggan semula, jika anda masih tidak melihat e-mel dalam peti masuk anda, semak folder spam anda. Sesetengah pembekal peti masuk menyimpan rekod aduan spam sebelumnya dan akan terus membenderakan e-mel. Jika ini berlaku, tandakan surat berita terkini sebagai 'Bukan spam' untuk mengalihkannya kembali ke peti masuk utama anda.", @@ -292,6 +297,7 @@ "View plans": "Lihat pelan", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Kami tidak dapat membatalkan langganan anda kerana alamat e-mel tidak ditemui. Sila hubungi pemilik laman web.", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Selamat kembali, {name}!", "Welcome back!": "Selamat kembali!", @@ -322,6 +328,7 @@ "Your email has failed to resubscribe, please try again": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "Input anda membantu membentuk apa yang diterbitkan.", diff --git a/packages/i18n/locales/nb/ghost.json b/packages/i18n/locales/nb/ghost.json index c4d13a08cab..477a2e5e5d9 100644 --- a/packages/i18n/locales/nb/ghost.json +++ b/packages/i18n/locales/nb/ghost.json @@ -76,7 +76,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.", @@ -113,11 +115,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Abonnementet ditt er kansellert og utløper den {date}. Du kan gjenoppta abonnementet via kontoinnstillingene dine.", "Your subscription has expired.": "Abonnementet ditt er utløpt.", "Your subscription will expire on {date}.": "Abonnementet ditt vil utløpe den {date}.", diff --git a/packages/i18n/locales/nb/portal.json b/packages/i18n/locales/nb/portal.json index 44163aeb1b0..bcc26dd1610 100644 --- a/packages/i18n/locales/nb/portal.json +++ b/packages/i18n/locales/nb/portal.json @@ -51,6 +51,9 @@ "Check with your mail provider": "Sjekk med din e-postleverandør", "Check your inbox to verify email update": "Sjekk innboksen for å bekrefte e-postoppdatering", "Choose": "Velg", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Velg en annen avtale", "Choose a plan": "Velg en avtale", "Choose a tier": "", @@ -81,13 +84,14 @@ "Create a new contact": "Opprett en ny kontakt", "Current plan": "Nåværende avtale", "Delete account": "Slett konto", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Var dette en feil? Administrer innstillingene dine .", "Don't have an account?": "Har du ikke konto?", "Edit": "Rediger", "Email": "E-post", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "E-postnyhetsbrev", "Email newsletter settings updated": "Innstillinger for e-postnyhetsbrev oppdatert", "Email preferences": "E-postinnstillinger", @@ -177,6 +181,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "Mottar du ikke e-poster?", + "Now": "", "Now check your email!": "Sjekk e-posten din nå!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Blir e-poster markert som søppelpost? Sjekk e-postmappen for søppelpost og merk som 'ikke søppel'.", @@ -293,6 +298,7 @@ "View plans": "Se planer", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Vi kunne ikke melde deg av siden e-postadressen ikke ble funnet. Vennligst kontakt nettstedseieren.", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Velkommen tilbake, {name}!", "Welcome back!": "Velkommen tilbake!", @@ -325,6 +331,7 @@ "Your email has failed to resubscribe, please try again": "E-posten din kunne ikke bli meldt på igjen, vennligst prøv på nytt", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "Din tilbakemelding bidrar til å påvirke hva som blir publisert.", diff --git a/packages/i18n/locales/ne/ghost.json b/packages/i18n/locales/ne/ghost.json index 092a0626ae1..e0076a0aeec 100644 --- a/packages/i18n/locales/ne/ghost.json +++ b/packages/i18n/locales/ne/ghost.json @@ -76,7 +76,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "यो इमेल ठेगाना प्रयोग हुने छैन।", @@ -113,11 +115,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "तपाईंको सदस्यता रद्द गरिएको छ र {date} मा म्याद सकिने छ। तपाईं आफ्नो खाता सेटिङ्हरू मार्फत सदस्यता पुनः सुरु गर्न सक्नुहुन्छ।", "Your subscription has expired.": "तपाईंको सदस्यताको म्याद सकिएको छ।", "Your subscription will expire on {date}.": "तपाईंको सदस्यताको म्याद {date} मा सकिने छ।", diff --git a/packages/i18n/locales/ne/portal.json b/packages/i18n/locales/ne/portal.json index c8ae5041495..12ed88dfb24 100644 --- a/packages/i18n/locales/ne/portal.json +++ b/packages/i18n/locales/ne/portal.json @@ -51,6 +51,9 @@ "Check with your mail provider": "", "Check your inbox to verify email update": "", "Choose": "", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "", "Choose a plan": "", "Choose a tier": "", @@ -81,13 +84,14 @@ "Create a new contact": "", "Current plan": "", "Delete account": "", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "", "Don't have an account?": "", "Edit": "", "Email": "", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "", "Email newsletter settings updated": "", "Email preferences": "", @@ -177,6 +181,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "", + "Now": "", "Now check your email!": "", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "", @@ -293,6 +298,7 @@ "View plans": "", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "", "Welcome back!": "", @@ -325,6 +331,7 @@ "Your email has failed to resubscribe, please try again": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "", diff --git a/packages/i18n/locales/nl/ghost.json b/packages/i18n/locales/nl/ghost.json index 19c40dbd998..d74311af665 100644 --- a/packages/i18n/locales/nl/ghost.json +++ b/packages/i18n/locales/nl/ghost.json @@ -76,7 +76,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.", @@ -113,11 +115,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Je abonnement is geannuleerd en verloopt op {date}. Je kunt je abonnement hervatten via je accountinstellingen.", "Your subscription has expired.": "Je abonnement is verlopen.", "Your subscription will expire on {date}.": "Je abonnement verloopt op {date}.", diff --git a/packages/i18n/locales/nl/portal.json b/packages/i18n/locales/nl/portal.json index fa6b49b6e23..09a0870e2eb 100644 --- a/packages/i18n/locales/nl/portal.json +++ b/packages/i18n/locales/nl/portal.json @@ -51,6 +51,9 @@ "Check with your mail provider": "Check bij je e-mailprovider", "Check your inbox to verify email update": "Check je inbox om de e-mailupdate te verifiëren", "Choose": "Kies", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Kies een ander abonnement", "Choose a plan": "Kies een abonnement", "Choose a tier": "", @@ -81,13 +84,14 @@ "Create a new contact": "Maak een nieuw contact", "Current plan": "Huidig abonnement", "Delete account": "Account verwijderen", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Dit niet bedoeld? Beheer je voorkeuren .", "Don't have an account?": "Heb je nog geen account?", "Edit": "Bewerken", "Email": "E-mail", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "Nieuwsbrief", "Email newsletter settings updated": "Instellingen voor nieuwsbrief bijgewerkt", "Email preferences": "E-mailinstellingen", @@ -177,6 +181,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "Ontvang je geen e-mails?", + "Now": "", "Now check your email!": "Check nu je e-mail!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Als je opnieuw geabonneerd bent en je ziet nog steeds geen e-mails in je inbox, controleer dan je spammap. Sommige providers houden een register bij van eerdere spamklachten en blijven e-mails markeren. Markeer de nieuwste nieuwsbrief als 'Geen spam' om deze terug te plaatsen naar je hoofdmap.", @@ -293,6 +298,7 @@ "View plans": "Bekijk abonnementen", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Het is niet gelukt om je uit te schrijven, omdat je e-mailadres niet is gevonden. Neem contact op met de eigenaar.", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Welkom terug, {name}!", "Welcome back!": "Welkom terug!", @@ -325,6 +331,7 @@ "Your email has failed to resubscribe, please try again": "Je e-mail is niet opnieuw geabonneerd, probeer het opnieuw", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "Jouw mening helpt bepalen wat er gepubliceerd wordt.", diff --git a/packages/i18n/locales/nn/ghost.json b/packages/i18n/locales/nn/ghost.json index c4a691c83a8..aee710de4b6 100644 --- a/packages/i18n/locales/nn/ghost.json +++ b/packages/i18n/locales/nn/ghost.json @@ -76,7 +76,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.", @@ -113,11 +115,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", "Your subscription has expired.": "", "Your subscription will expire on {date}.": "", diff --git a/packages/i18n/locales/nn/portal.json b/packages/i18n/locales/nn/portal.json index 728ec9c8739..5eb46bda844 100644 --- a/packages/i18n/locales/nn/portal.json +++ b/packages/i18n/locales/nn/portal.json @@ -51,6 +51,9 @@ "Check with your mail provider": "Sjekk med e-postleverandøren din", "Check your inbox to verify email update": "", "Choose": "Vel", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Vel eit anna abonnement", "Choose a plan": "", "Choose a tier": "", @@ -81,13 +84,14 @@ "Create a new contact": "Lag ein ny kontakt", "Current plan": "Noverande abonnement", "Delete account": "Slett brukar", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Var det ikkje meininga å gjera dette? Du kan endra preferansane dine .", "Don't have an account?": "Har du ikkje ein brukar?", "Edit": "Endra", "Email": "E-post", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "E-post nyheitsbrev", "Email newsletter settings updated": "", "Email preferences": "E-post preferansar.", @@ -177,6 +181,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "Får du ikkje e-postar?", + "Now": "", "Now check your email!": "Sjekk e-posten din!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Sjekk spam-mappa di om du framleis ikkje ser e-postane etter å ha abonnert på ny. Nokon e-postleverandørar kan flagga e-postar som spam basert på tidlegare e-postar. Viss dette skjer, marker den siste du mottok i spam-mappa som 'ikkje spam' og flytt tilbake til innboksen din.", @@ -293,6 +298,7 @@ "View plans": "Sjå abonnementa", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Me kunne ikkje avslutta abonnementet ditt då me ikkje fann e-postadressa di. Vennligst ta kontakt med eigaren av sida.", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Velkommen tilbake, {name}!", "Welcome back!": "Velkommen tilbake!", @@ -325,6 +331,7 @@ "Your email has failed to resubscribe, please try again": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "Dine tilbakemeldinger hjelper oss å forma tilbodet vårt.", diff --git a/packages/i18n/locales/pa/ghost.json b/packages/i18n/locales/pa/ghost.json index 72900ad7838..d58c513fb9a 100644 --- a/packages/i18n/locales/pa/ghost.json +++ b/packages/i18n/locales/pa/ghost.json @@ -76,7 +76,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "ਇਹ ਈਮੇਲ ਪਤਾ ਨਹੀਂ ਵਰਤਿਆ ਜਾਵੇਗਾ।", @@ -113,11 +115,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "ਤੁਹਾਡੀ ਸਦੱਸਤਾ ਰੱਦ ਕਰ ਦਿੱਤੀ ਗਈ ਹੈ ਅਤੇ {date} ਨੂੰ ਮਿਆਦ ਪੁੱਗ ਜਾਵੇਗੀ। ਤੁਸੀਂ ਆਪਣੀਆਂ ਖਾਤਾ ਸੈਟਿੰਗਾਂ ਰਾਹੀਂ ਆਪਣੀ ਸਦੱਸਤਾ ਮੁੜ ਸ਼ੁਰੂ ਕਰ ਸਕਦੇ ਹੋ।", "Your subscription has expired.": "ਤੁਹਾਡੀ ਸਦੱਸਤਾ ਦੀ ਮਿਆਦ ਪੁੱਗ ਗਈ ਹੈ।", "Your subscription will expire on {date}.": "ਤੁਹਾਡੀ ਸਦੱਸਤਾ {date} ਨੂੰ ਮਿਆਦ ਪੁੱਗ ਜਾਵੇਗੀ।", diff --git a/packages/i18n/locales/pa/portal.json b/packages/i18n/locales/pa/portal.json index 1825cdc7878..012b4c39534 100644 --- a/packages/i18n/locales/pa/portal.json +++ b/packages/i18n/locales/pa/portal.json @@ -51,6 +51,9 @@ "Check with your mail provider": "ਆਪਣੇ ਮੇਲ ਪ੍ਰਦਾਤਾ ਨਾਲ ਜਾਂਚ ਕਰੋ", "Check your inbox to verify email update": "ਈਮੇਲ ਅੱਪਡੇਟ ਦੀ ਪੁਸ਼ਟੀ ਕਰਨ ਲਈ ਆਪਣਾ ਇਨਬਾਕਸ ਚੈੱਕ ਕਰੋ", "Choose": "ਚੁਣੋ", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "ਵੱਖਰਾ ਪਲਾਨ ਚੁਣੋ", "Choose a plan": "ਇੱਕ ਪਲਾਨ ਚੁਣੋ", "Choose a tier": "", @@ -81,13 +84,14 @@ "Create a new contact": "ਨਵਾਂ ਸੰਪਰਕ ਬਣਾਓ", "Current plan": "ਮੌਜੂਦਾ ਯੋਜਨਾ", "Delete account": "ਖਾਤਾ ਮਿਟਾਓ", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "ਕੀ ਇਹ ਗਲਤੀ ਨਾਲ ਹੋਇਆ? ਆਪਣੀਆਂ ਤਰਜੀਹਾਂ ਦਾ ਪ੍ਰਬੰਧਨ ਕਰੋ।", "Don't have an account?": "ਕੀ ਤੁਹਾਡਾ ਖਾਤਾ ਨਹੀਂ ਹੈ?", "Edit": "ਸੰਪਾਦਿਤ ਕਰੋ", "Email": "ਈਮੇਲ", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "ਈਮੇਲ ਖ਼ਬਰਨਾਮਾ", "Email newsletter settings updated": "ਈਮੇਲ ਖ਼ਬਰਨਾਮਾ ਸੈਟਿੰਗਾਂ ਅੱਪਡੇਟ ਕੀਤੀਆਂ ਗਈਆਂ", "Email preferences": "ਈਮੇਲ ਤਰਜੀਹਾਂ", @@ -177,6 +181,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "ਈਮੇਲਾਂ ਪ੍ਰਾਪਤ ਨਹੀਂ ਹੋ ਰਹੀਆਂ?", + "Now": "", "Now check your email!": "ਹੁਣ ਆਪਣੀ ਈਮੇਲ ਚੈੱਕ ਕਰੋ!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "ਮੁੜ ਸਦੱਸਤਾ ਲੈਣ ਤੋਂ ਬਾਅਦ, ਜੇਕਰ ਤੁਹਾਨੂੰ ਅਜੇ ਵੀ ਆਪਣੇ ਇਨਬਾਕਸ ਵਿੱਚ ਈਮੇਲਾਂ ਨਹੀਂ ਦਿਸਦੀਆਂ, ਤਾਂ ਆਪਣਾ ਸਪੈਮ ਫੋਲਡਰ ਚੈੱਕ ਕਰੋ। ਕੁਝ ਇਨਬਾਕਸ ਪ੍ਰਦਾਤਾ ਪਿਛਲੀਆਂ ਸਪੈਮ ਸ਼ਿਕਾਇਤਾਂ ਦਾ ਰਿਕਾਰਡ ਰੱਖਦੇ ਹਨ ਅਤੇ ਈਮੇਲਾਂ ਨੂੰ ਫਲੈਗ ਕਰਨਾ ਜਾਰੀ ਰੱਖਣਗੇ। ਜੇਕਰ ਅਜਿਹਾ ਹੁੰਦਾ ਹੈ, ਤਾਂ ਨਵੀਨਤਮ ਨਿਊਜ਼ਲੈਟਰ ਨੂੰ 'ਸਪੈਮ ਨਹੀਂ' ਵਜੋਂ ਮਾਰਕ ਕਰੋ ਤਾਂ ਜੋ ਇਸਨੂੰ ਤੁਹਾਡੇ ਪ੍ਰਾਇਮਰੀ ਇਨਬਾਕਸ ਵਿੱਚ ਵਾਪਸ ਭੇਜਿਆ ਜਾ ਸਕੇ।", @@ -293,6 +298,7 @@ "View plans": "ਯੋਜਨਾਵਾਂ ਦੇਖੋ", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "ਅਸੀਂ ਤੁਹਾਨੂੰ ਸਦੱਸਤਾ ਤੋਂ ਹਟਾ ਨਹੀਂ ਸਕੇ ਕਿਉਂਕਿ ਈਮੇਲ ਪਤਾ ਨਹੀਂ ਮਿਲਿਆ। ਕਿਰਪਾ ਕਰਕੇ ਸਾਈਟ ਮਾਲਕ ਨਾਲ ਸੰਪਰਕ ਕਰੋ।", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "{name}, ਵਾਪਸ ਆਉਣ 'ਤੇ ਸੁਆਗਤ ਹੈ!", "Welcome back!": "ਵਾਪਸ ਆਉਣ 'ਤੇ ਸੁਆਗਤ ਹੈ!", @@ -325,6 +331,7 @@ "Your email has failed to resubscribe, please try again": "ਤੁਹਾਡੀ ਈਮੇਲ ਮੁੜ-ਸਦੱਸਤਾ ਲੈਣ ਵਿੱਚ ਅਸਫਲ ਰਹੀ ਹੈ, ਕਿਰਪਾ ਕਰਕੇ ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "ਤੁਹਾਡਾ ਇਨਪੁਟ ਇਹ ਆਕਾਰ ਦੇਣ ਵਿੱਚ ਮਦਦ ਕਰਦਾ ਹੈ ਕਿ ਕੀ ਪ੍ਰਕਾਸ਼ਿਤ ਹੁੰਦਾ ਹੈ।", diff --git a/packages/i18n/locales/pl/ghost.json b/packages/i18n/locales/pl/ghost.json index 15d494c24c5..49b2df50160 100644 --- a/packages/i18n/locales/pl/ghost.json +++ b/packages/i18n/locales/pl/ghost.json @@ -80,7 +80,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.", @@ -117,11 +119,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Twoja subskrypcja została anulowana i wygaśnie w dniu {date}. Możesz wznowić subskrypcję w ustawieniach swojego konta.", "Your subscription has expired.": "Twoja subskrypcja wygasła", "Your subscription will expire on {date}.": "Twoja subskrypcja wygaśnie w dniu {date}", diff --git a/packages/i18n/locales/pl/portal.json b/packages/i18n/locales/pl/portal.json index c11f187b965..080709f7f09 100644 --- a/packages/i18n/locales/pl/portal.json +++ b/packages/i18n/locales/pl/portal.json @@ -53,6 +53,9 @@ "Check with your mail provider": "Skontaktuj się z dostawcą poczty elektronicznej", "Check your inbox to verify email update": "Sprawdź skrzynkę odbiorczą, aby zweryfikować aktualizację adresu e-mail", "Choose": "Wybierz", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Wybierz inny plan", "Choose a plan": "Wybierz plan", "Choose a tier": "", @@ -83,13 +86,14 @@ "Create a new contact": "Utwórz nowy kontakt", "Current plan": "Obecny plan", "Delete account": "Skasuj konto", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Nie chcesz tego robić? Zarządzaj preferencjami .", "Don't have an account?": "Nie masz jeszcze konta?", "Edit": "Edytuj", "Email": "Email", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "Newsletter email", "Email newsletter settings updated": "Zaktualizowano ustawienia newslettera email", "Email preferences": "Ustawienia email", @@ -179,6 +183,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "Nie dostajesz emaili?", + "Now": "", "Now check your email!": "Teraz sprawdź swoją pocztę!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Po ponownym zapisaniu się, jeśli nadal nie widzisz wiadomości e-mail w skrzynce odbiorczej, sprawdź folder ze spamem. Niektórzy dostawcy skrzynek odbiorczych przechowują rejestr poprzednich skarg na spam i będą nadal oznaczać wiadomości e-mail flagami. Jeśli tak się stanie, oznacz najnowszy newsletter jako „To nie jest spam”, aby przenieść go z powrotem do głównej skrzynki odbiorczej.", @@ -295,6 +300,7 @@ "View plans": "Zobacz plany", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Nie udało się wypisać, bo nie znaleziono adresu email. Skontaktuj się w właścicielem strony.", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Witamy ponownie, {name}!", "Welcome back!": "Witamy ponownie!", @@ -331,6 +337,7 @@ "Your email has failed to resubscribe, please try again": "Ponowna subskrypcja Twojego emaila nie powiodła się, spróbuj ponownie", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "Twoja ocena pomoże nam lepiej kształtować nasze publikacje.", diff --git a/packages/i18n/locales/pt-BR/ghost.json b/packages/i18n/locales/pt-BR/ghost.json index 6063e0f5762..f2b2cae491f 100644 --- a/packages/i18n/locales/pt-BR/ghost.json +++ b/packages/i18n/locales/pt-BR/ghost.json @@ -78,7 +78,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.", @@ -115,11 +117,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "Seu presente está pronto", + "Your gift is scheduled": "", "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", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "Sua assinatura de presente para {siteTitle} termina em {consumesAt}.", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Sua assinatura foi cancelada e expirará em {date}. Você pode retomar sua assinatura nas configurações de sua conta.", "Your subscription has expired.": "Sua assinatura expirou.", "Your subscription will expire on {date}.": "Sua assinatura expirará em {date}.", diff --git a/packages/i18n/locales/pt-BR/portal.json b/packages/i18n/locales/pt-BR/portal.json index 974c0958134..62b3231ac56 100644 --- a/packages/i18n/locales/pt-BR/portal.json +++ b/packages/i18n/locales/pt-BR/portal.json @@ -52,6 +52,9 @@ "Check with your mail provider": "Verificar com seu provedor de e-mail", "Check your inbox to verify email update": "Verifique sua caixa de entrada para confirmar a atualização de e-mail", "Choose": "Escolher", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Escolher um plano diferente", "Choose a plan": "Escolher um plano", "Choose a tier": "", @@ -82,13 +85,14 @@ "Create a new contact": "Criar um novo contato", "Current plan": "Plano atual", "Delete account": "Excluir conta", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Não era isso que você queria? Gerencie suas preferências .", "Don't have an account?": "Não tem uma conta?", "Edit": "Editar", "Email": "E-mail", "Email confirmation link expired.": "O link de confirmação de e-mail expirou.", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "Newsletter por e-mail", "Email newsletter settings updated": "Configurações de newsletter por e-mail atualizadas com sucesso", "Email preferences": "Preferências de e-mail", @@ -178,6 +182,7 @@ "No thanks, I want to cancel": "Não, obrigado, quero cancelar", "Not ready to share? We've also emailed a copy to your inbox.": "Ainda não quer compartilhar? Também enviamos uma cópia para sua caixa de entrada.", "Not receiving emails?": "Não está recebendo e-mails?", + "Now": "", "Now check your email!": "Agora confira seu e-mail!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Depois de se inscrever novamente, se você ainda não vir e-mails na sua caixa de entrada, verifique sua pasta de spam. Alguns provedores de caixa de entrada mantêm um registro de reclamações anteriores de spam e continuarão a sinalizar e-mails. Se isso acontecer, marque a newsletter mais recente como 'Não é spam' para movê-la de volta para sua caixa de entrada principal.", @@ -294,6 +299,7 @@ "View plans": "Ver planos", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Não foi possível cancelar sua inscrição, pois o e-mail não foi encontrado. Por favor, entre em contato com o proprietário do site.", "We'd hate to see you leave. How about a special offer to stay?": "Não queremos ver você partir. Que tal uma oferta especial para ficar?", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Bem-vindo de volta, {name}!", "Welcome back!": "Bem-vindo de volta!", @@ -328,6 +334,7 @@ "Your email has failed to resubscribe, please try again": "Não foi possível reinscrever seu e-mail, por favor, tente novamente.", "Your gift is on its way": "", "Your gift is ready": "Seu presente está pronto", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "Sua assinatura de presente expirará em {expiryDate}", "your inbox": "sua caixa de entrada", "Your input helps shape what gets published.": "Sua resposta ajuda a definir o que será publicado.", diff --git a/packages/i18n/locales/pt/ghost.json b/packages/i18n/locales/pt/ghost.json index 3d89cefd6b7..897f43cc506 100644 --- a/packages/i18n/locales/pt/ghost.json +++ b/packages/i18n/locales/pt/ghost.json @@ -78,7 +78,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.", @@ -115,11 +117,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "A sua subscrição foi cancelada e irá expirar a {date}. Pode recomeçar a sua subscrição nas definições da sua conta.", "Your subscription has expired.": "A sua subscrição expirou.", "Your subscription will expire on {date}.": "A sua subscrição expira a {date}.", diff --git a/packages/i18n/locales/pt/portal.json b/packages/i18n/locales/pt/portal.json index 0a4c4ad5846..1e695e6a3f0 100644 --- a/packages/i18n/locales/pt/portal.json +++ b/packages/i18n/locales/pt/portal.json @@ -52,6 +52,9 @@ "Check with your mail provider": "Entre em contacto com o seu serviço de email", "Check your inbox to verify email update": "", "Choose": "Escolher", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Escolha um plano diferente", "Choose a plan": "Escolha um plano", "Choose a tier": "", @@ -82,13 +85,14 @@ "Create a new contact": "Criar novo contacto", "Current plan": "Plano atual", "Delete account": "Eliminar a conta", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Não era essa a intenção? Gerir preferências .", "Don't have an account?": "Não tem conta?", "Edit": "Editar", "Email": "Email", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "Newsletter", "Email newsletter settings updated": "", "Email preferences": "Preferências de email", @@ -178,6 +182,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "Não está a receber emails?", + "Now": "", "Now check your email!": "Verifica o teu email agora!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Se depois de ser inscrever novamente, os emails não chegaram à sua caixa de entrada, verifique por favor a sua pasta de spam. Alguns provedores de mail têm disponível um registro de reclamações anteriores de spam e continuarão a sinalizar emails. Se isso acontecer, marque a newsletter mais recente como 'Não é spam' para movê-la de volta para sua caixa de entrada principal.", @@ -294,6 +299,7 @@ "View plans": "Ver planos", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Não conseguimos cancelar a sua inscrição, visto que o email não foi encontrado. Por favor, entre em contato com o proprietário do site.", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Bem-vindo de volta, {name}!", "Welcome back!": "Bem-vindo de volta!", @@ -328,6 +334,7 @@ "Your email has failed to resubscribe, please try again": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "O seu feedback ajudará a decidir o conteúdo que será publicado no futuro.", diff --git a/packages/i18n/locales/ro/ghost.json b/packages/i18n/locales/ro/ghost.json index 60bea2ff7de..e78b47ff701 100644 --- a/packages/i18n/locales/ro/ghost.json +++ b/packages/i18n/locales/ro/ghost.json @@ -78,7 +78,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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ă.", @@ -115,11 +117,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Abonamentul tău a fost anulat și va expira pe {date}. Poți relua abonamentul din setările contului.", "Your subscription has expired.": "Abonamentul tău a expirat.", "Your subscription will expire on {date}.": "Abonamentul tău va expira pe {date}.", diff --git a/packages/i18n/locales/ro/portal.json b/packages/i18n/locales/ro/portal.json index ff9daeaf395..ecc1647a676 100644 --- a/packages/i18n/locales/ro/portal.json +++ b/packages/i18n/locales/ro/portal.json @@ -52,6 +52,9 @@ "Check with your mail provider": "Verifică cu furnizorul tău de email", "Check your inbox to verify email update": "Verifică-ți căsuța poștală pentru a valida actualizarea adresei de email.", "Choose": "Alege", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Alege un plan diferit", "Choose a plan": "", "Choose a tier": "", @@ -82,13 +85,14 @@ "Create a new contact": "Creează un nou contact", "Current plan": "Planul curent", "Delete account": "Șterge contul", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Nu ai intenționat acest lucru? Îți poți gestiona preferințele .", "Don't have an account?": "Nu ai un cont?", "Edit": "Editează", "Email": "Email", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "Newsletter prin email", "Email newsletter settings updated": "", "Email preferences": "Preferințe email", @@ -178,6 +182,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "Nu primești emailuri?", + "Now": "", "Now check your email!": "Acum verifică-ți emailul!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Odată reabonat, dacă încă nu vedeți emailuri în inbox, verificați dosarul de spam. Unii furnizori de inbox păstrează un istoric al sesizărilor de spam anterioare și vor continua să marcheze emailurile. Dacă se întâmplă acest lucru, marcați cel mai recent buletin informativ ca 'Nu este spam' pentru a-l muta înapoi în inbox-ul primar.", @@ -294,6 +299,7 @@ "View plans": "Vezi planurile", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Nu am putut să te dezabonăm deoarece adresa de email nu a fost găsită. Te rugăm să contactezi proprietarul site-ului.", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Bun revenit, {name}!", "Welcome back!": "Bun revenit!", @@ -328,6 +334,7 @@ "Your email has failed to resubscribe, please try again": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "Contribuția ta ajută la conturarea a ceea ce se publică.", diff --git a/packages/i18n/locales/ru/ghost.json b/packages/i18n/locales/ru/ghost.json index 54e2563fd74..3941e410340 100644 --- a/packages/i18n/locales/ru/ghost.json +++ b/packages/i18n/locales/ru/ghost.json @@ -80,7 +80,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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 адрес не будет использоваться.", @@ -117,11 +119,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Ваша подписка была отменена и истечёт {date}. Вы можете возобновить подписку в настройках своего аккаунта.", "Your subscription has expired.": "Ваша подписка истекла.", "Your subscription will expire on {date}.": "Ваша подписка истечёт {date}.", diff --git a/packages/i18n/locales/ru/portal.json b/packages/i18n/locales/ru/portal.json index 6b3aa66b998..0128d11b51b 100644 --- a/packages/i18n/locales/ru/portal.json +++ b/packages/i18n/locales/ru/portal.json @@ -53,6 +53,9 @@ "Check with your mail provider": "Обратитесь к своему почтовому провайдеру", "Check your inbox to verify email update": "Проверьте свой email, чтобы подтвердить обновление адреса электронной почты.", "Choose": "Выбрать", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Выбрать другой план", "Choose a plan": "Выберите план", "Choose a tier": "", @@ -83,13 +86,14 @@ "Create a new contact": "Создать новый контакт", "Current plan": "Текущий план", "Delete account": "Удалить аккаунт", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Вы не хотели этого делать? Управляйте своими подписками .", "Don't have an account?": "У вас нет аккаунта?", "Edit": "Редактировать", "Email": "Email", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "Email рассылки", "Email newsletter settings updated": "Настройки email рассылки обновлены", "Email preferences": "Настройки email адреса", @@ -179,6 +183,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "Не получаете письма?", + "Now": "", "Now check your email!": "Теперь проверьте свою электронную почту!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Если после повторной подписки вы всё ещё не видите писем в своём почтовом ящике, проверьте папку со спамом. Некоторые провайдеры электронной почты сохраняют записи предыдущих жалоб на спам и продолжают помечать письма как спам. Если именно это и происходит, отметьте последнюю рассылку как «Не спам», чтобы переместить письмо обратно в основную папку входящих писем вашего почтового ящика.", @@ -295,6 +300,7 @@ "View plans": "Ознакомиться с планами", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Мы не смогли отписать вас, так как указанный email адрес не был найден. Пожалуйста, свяжитесь с владельцем сайта.", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Добро пожаловать, {name}, с возвращением!", "Welcome back!": "Добро пожаловать, с возвращением!", @@ -331,6 +337,7 @@ "Your email has failed to resubscribe, please try again": "Не удалось подписаться повторно используя ваш email, попробуйте ещё раз", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "Ваш отзыв помогает формировать понимание, что вам интересно и что будет опубликовано в будущем.", diff --git a/packages/i18n/locales/si/ghost.json b/packages/i18n/locales/si/ghost.json index 0e7540d532f..e42988c7cac 100644 --- a/packages/i18n/locales/si/ghost.json +++ b/packages/i18n/locales/si/ghost.json @@ -76,7 +76,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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 ලිපිනය භාවිතා නොවනු ඇත.", @@ -113,11 +115,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", "Your subscription has expired.": "", "Your subscription will expire on {date}.": "", diff --git a/packages/i18n/locales/si/portal.json b/packages/i18n/locales/si/portal.json index 7e6f4a97ab3..01103262573 100644 --- a/packages/i18n/locales/si/portal.json +++ b/packages/i18n/locales/si/portal.json @@ -51,6 +51,9 @@ "Check with your mail provider": "ඔබගේ email සේවා සපයන්නා සමඟින් පරීක්ෂා කර බලන්න", "Check your inbox to verify email update": "", "Choose": "තෝරන්න", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "වෙනත් plan එකක් තෝරන්න", "Choose a plan": "", "Choose a tier": "", @@ -81,13 +84,14 @@ "Create a new contact": "අලුත් contact එකක් නිර්මාණය කරන්න", "Current plan": "දැනට පවතින plan එක", "Delete account": "ගිණුම Delete කරන්න", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "මෙය සිදු කිරීමට අදහස් නොකලේ ද? ඔබගේ preferences manage කරගන්න.", "Don't have an account?": "ගිණුමක් නොමැතිද?", "Edit": "Edit කරන්න", "Email": "Email", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "Email newsletter", "Email newsletter settings updated": "", "Email preferences": "Email preferences", @@ -177,6 +181,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "Emails ලැබෙන්නේ නැද්ද?", + "Now": "", "Now check your email!": "දැන් ඔබගේ email එක පරික්ෂා කරන්න!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "නැවත subscribe කළ විටත් ඔබගේ inbox එකට email ලැබෙන්නේ නැතිනම්, spam folder එක පරීක්ෂා කරන්න. ඇතැම් සේවා සපයන්නන් මීට පෙර spam සම්බන්ධව ලැබුණු පැමිණිලි පාදක කරගෙන තවදුරටත් emails spam ලෙස ලකුණු කරනු ලබනවා. එසේ වී ඇත්නම්, අලුතින්ම ලැබුණු newsletter එක ඔබගේ primary inbox එකට යැවීමට 'Not spam' ලෙස සළකුනු කරන්න.", @@ -293,6 +298,7 @@ "View plans": "Plans පෙන්වන්න", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "ඔබගේ email ලිපිනය හමු නොවීම නිසාවෙන් ඔබව unsubscribe කරවීමට නොහැකි විය. කරුණාකර වෙබ් අඩවියෙහි හිමිකරු සම්බන්ධ කරගන්න.", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "{name}, ඔබව සාදරයෙන් පිළිගන්නවා!", "Welcome back!": "ඔබව සාදරයෙන් පිළිගන්නවා!", @@ -325,6 +331,7 @@ "Your email has failed to resubscribe, please try again": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "ඔබගේ අදහස් ඉදිරියේදී සිදු කරන පළකිරීම් වැඩිදියුණු කිරීමට උදව් කරනු ඇත.", diff --git a/packages/i18n/locales/sk/ghost.json b/packages/i18n/locales/sk/ghost.json index 61aec839f01..29e86299ba1 100644 --- a/packages/i18n/locales/sk/ghost.json +++ b/packages/i18n/locales/sk/ghost.json @@ -80,7 +80,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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á.", @@ -117,11 +119,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Vaše predplatné bolo zrušené a skončí {date}. Svoje predplatné môžete obnoviť v nastaveniach svojho účtu.", "Your subscription has expired.": "Vaše predplatné vypršalo", "Your subscription will expire on {date}.": "Vaše predplatné vyprší {date}.", diff --git a/packages/i18n/locales/sk/portal.json b/packages/i18n/locales/sk/portal.json index 2840261067b..a9fdb25e304 100644 --- a/packages/i18n/locales/sk/portal.json +++ b/packages/i18n/locales/sk/portal.json @@ -53,6 +53,9 @@ "Check with your mail provider": "Overte si to s vašim e-mailovým poskytovateľom.", "Check your inbox to verify email update": "Skontrolujte si doručenú poštu a overte si aktualizáciu e-mailu", "Choose": "Vybrať", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Vybrať iný plán", "Choose a plan": "Vyberte si plán", "Choose a tier": "", @@ -83,13 +86,14 @@ "Create a new contact": "Vytvoriť nový kontakt", "Current plan": "Aktuálny plán", "Delete account": "Vymazať účet", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Nechceli ste to urobiť? Spravujte svoje nastavenia .", "Don't have an account?": "Ešte nemáte účet?", "Edit": "Upraviť", "Email": "E-mail", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "E-mail newsletteru", "Email newsletter settings updated": "Nastavenia e-mailu newslettera aktualizované", "Email preferences": "E-mailové nastavnia", @@ -179,6 +183,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "Nedostávate e-maily?", + "Now": "", "Now check your email!": "Skontrolujte svoju emailovú schránku!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Ak po opätovnom prihlásení stále nevidíte e-maily v doručenej pošte, skontrolujte priečinok so spamom. Niektorí poskytovatelia schránok uchovávajú záznamy o predchádzajúcich sťažnostiach na nevyžiadanú poštu a budú naďalej označovať e-maily. Ak sa tak stane, označte najnovší newsletter ako „Nie je spam“, aby ste ho presunuli späť do primárnej schránky.", @@ -295,6 +300,7 @@ "View plans": "Prezrieť plány", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Nepodarilo sa nám vymazať z odberu váš email, pretože nebol nenájdený. Prosím kontaktujte vlastníka stránky.", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Vitaje späť, {name}!", "Welcome back!": "Vitajte späť!", @@ -331,6 +337,7 @@ "Your email has failed to resubscribe, please try again": "Váš email zlyhal pri obnovení predplatného, prosím skúste to znova", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "Vaše pripomienky pomáhajú spoluvytvárať obsah webu.", diff --git a/packages/i18n/locales/sl/ghost.json b/packages/i18n/locales/sl/ghost.json index 2614e7dd3da..ac03351e238 100644 --- a/packages/i18n/locales/sl/ghost.json +++ b/packages/i18n/locales/sl/ghost.json @@ -80,7 +80,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.", @@ -117,11 +119,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Vaša naročnina je bila preklicana in bo potekla {date}. Naročnino lahko obnovite prek nastavitev svojega računa.", "Your subscription has expired.": "Vaša naročnina je potekla.", "Your subscription will expire on {date}.": "Vaša naročnina bo potekla {date}.", diff --git a/packages/i18n/locales/sl/portal.json b/packages/i18n/locales/sl/portal.json index fc7e5a243b2..74309cb3c0b 100644 --- a/packages/i18n/locales/sl/portal.json +++ b/packages/i18n/locales/sl/portal.json @@ -53,6 +53,9 @@ "Check with your mail provider": "Preverite pri svojem ponudniku e-pošte", "Check your inbox to verify email update": "Preverite svoj e-poštni nabiralnik, da potrdite posodobitev e-pošte", "Choose": "Izberite", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Izberite drug paket", "Choose a plan": "Izberite paket", "Choose a tier": "", @@ -83,13 +86,14 @@ "Create a new contact": "Ustvari nov stik", "Current plan": "Trenutni paket", "Delete account": "Izbriši račun", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Niste želeli narediti tega? Upravljajte svoje nastavitve .", "Don't have an account?": "Nimate računa?", "Edit": "Uredi", "Email": "E-pošta", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "E-poštne novice", "Email newsletter settings updated": "Nastavitve e-poštnih novic so bile posodobljene", "Email preferences": "Nastavitve e-pošte", @@ -179,6 +183,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "Ne prejemate e-pošte", + "Now": "", "Now check your email!": "Preverite svojo e-pošto!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Ko boste ponovno naročeni in še vedno ne vidite e-pošte v svoji mapi Prejeto, preverite mapo za vsiljeno pošto. Nekateri ponudniki hranijo zgodovino prejšnjih pritožb zaradi vsiljene pošte in bodo še naprej označevali e-pošto. Če se to zgodi, označite najnovejše novice kot 'Ni vsiljena pošta', da jih premaknete nazaj v svojo glavno mapo Prejeto.", @@ -295,6 +300,7 @@ "View plans": "Ogled paketov", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Nismo vas mogli odjaviti, ker e-poštnega naslova nismo našli. Prosimo, obrnite se na lastnika spletnega mesta.", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Dobrodošli nazaj, {name}!", "Welcome back!": "Dobrodošli nazaj!", @@ -331,6 +337,7 @@ "Your email has failed to resubscribe, please try again": "Vašega e-poštnega naslova nismo uspeli ponovno prijaviti, prosimo, poskusite znova", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "Vaše mnenje nam pomaga pri izbiri objavljenih vsebin.", diff --git a/packages/i18n/locales/sq/ghost.json b/packages/i18n/locales/sq/ghost.json index 8fbec839d37..91d4127455a 100644 --- a/packages/i18n/locales/sq/ghost.json +++ b/packages/i18n/locales/sq/ghost.json @@ -76,7 +76,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.", @@ -113,11 +115,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "Dhurata juaj është gati", + "Your gift is scheduled": "", "Your gift subscription confirmation": "Konfirmimi i abonimit tuaj dhuratë", "Your gift subscription is ending soon": "Abonimi juaj dhuratë po përfundon së shpejti", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Abonimi juaj është anuluar dhe do të skadojë më {date}. Mund ta rifilloni abonimin përmes cilësimeve të llogarisë suaj.", "Your subscription has expired.": "Abonimi juaj ka skaduar.", "Your subscription will expire on {date}.": "Abonimi juaj do të skadojë më {date}.", diff --git a/packages/i18n/locales/sq/portal.json b/packages/i18n/locales/sq/portal.json index a537c1270fa..bd8e6f2d16f 100644 --- a/packages/i18n/locales/sq/portal.json +++ b/packages/i18n/locales/sq/portal.json @@ -51,6 +51,9 @@ "Check with your mail provider": "Kontrolloni me ofruesin tuaj të postës", "Check your inbox to verify email update": "", "Choose": "Zgjidh", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Zgjidh nje plan ndryshe", "Choose a plan": "", "Choose a tier": "", @@ -81,13 +84,14 @@ "Create a new contact": "Shto nje kontakt te ri", "Current plan": "Plani aktual", "Delete account": "Fshini llogarine", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Nuk doje ta bëje këtë? Menaxho preferencat e tua .", "Don't have an account?": "Nuk keni nje llogari?", "Edit": "Modifiko", "Email": "Email", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "Buletini i emailit", "Email newsletter settings updated": "", "Email preferences": "Preferenat e emailit", @@ -177,6 +181,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "Nuk po merr emaile?", + "Now": "", "Now check your email!": "Kontrollo emailin tend tani!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Pasi të regjistroheni përsëri, nëse ende nuk i shihni emailet në kutinë tuaj hyrëse, kontrolloni dosjen tuaj të postës së padëshiruar. Disa ofrues të kutisë hyrëse mbajnë një regjistër të ankesave të mëparshme të postës së padëshiruar dhe do të vazhdojnë të raportojnë emailet. Nëse kjo ndodh, shëno buletinin më të fundit si 'Jo e padëshiruar' për ta zhvendosur atë në kutinë hyrëse kryesore.", @@ -293,6 +298,7 @@ "View plans": "Shiko planet", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Mire se erdhe, {name}!", "Welcome back!": "Mire se erdhe!", @@ -325,6 +331,7 @@ "Your email has failed to resubscribe, please try again": "", "Your gift is on its way": "", "Your gift is ready": "Dhurata juaj është gati", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "Të dhënat tuaja ndihmojnë në formimin e asaj që publikohet.", diff --git a/packages/i18n/locales/sr-Cyrl/ghost.json b/packages/i18n/locales/sr-Cyrl/ghost.json index 3db730f71f5..26d744d7102 100644 --- a/packages/i18n/locales/sr-Cyrl/ghost.json +++ b/packages/i18n/locales/sr-Cyrl/ghost.json @@ -78,7 +78,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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 адреса неће бити коришћена.", @@ -115,11 +117,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Ваша претплата је отказана и истиче {date}. Можете наставити претплату у подешавањима Вашег профила.", "Your subscription has expired.": "Ваша претплата је истекла.", "Your subscription will expire on {date}.": "Ваша претплата истиче {date}.", diff --git a/packages/i18n/locales/sr-Cyrl/portal.json b/packages/i18n/locales/sr-Cyrl/portal.json index e8898ee3810..c6fddb0a570 100644 --- a/packages/i18n/locales/sr-Cyrl/portal.json +++ b/packages/i18n/locales/sr-Cyrl/portal.json @@ -52,6 +52,9 @@ "Check with your mail provider": "Проверите са Вашим mail провајдером", "Check your inbox to verify email update": "Проверите Ваш inbox да верификујете промену email-а", "Choose": "Изаберите", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Изаберите други план", "Choose a plan": "Изаберите план", "Choose a tier": "", @@ -82,13 +85,14 @@ "Create a new contact": "Направите нови контакт", "Current plan": "Тренутни план", "Delete account": "Обришите налог", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Нисте желели ово да урадите? Управљајте својим преференцама .", "Don't have an account?": "Немате налог?", "Edit": "Уредите", "Email": "Email", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "Email билтен", "Email newsletter settings updated": "Подешавања email билтена ажурирана.", "Email preferences": "Преференце за email", @@ -178,6 +182,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "Не примате email-ове?", + "Now": "", "Now check your email!": "Сада проверите свој email!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Када се поново претплатите, ако и даље не видите email-ове у свом inbox-у, проверите фасциклу за нежељену пошту. Неки inbox провајдери чувају записе о претходним жалбама на нежељену пошту и наставиће да означавају email-ове. Ако се то деси, означите најновији билтен као 'Није нежељено' да га преместите назад у свој inbox.", @@ -294,6 +299,7 @@ "View plans": "Погледајте планове", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Нисмо могли да Вас одјавимо јер email адреса није пронађена. Молимо Вас да контактирате власника сајта.", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Добро дошли назад, {name}!", "Welcome back!": "Добро дошли назад!", @@ -328,6 +334,7 @@ "Your email has failed to resubscribe, please try again": "Поновна претплата на Ваш email није успела, покушајте поново", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "Ваш inbox", "Your input helps shape what gets published.": "Ваше учешће помаже у обликовању онога што ће бити објављено.", diff --git a/packages/i18n/locales/sr/ghost.json b/packages/i18n/locales/sr/ghost.json index f2c19a69402..be6bf32269b 100644 --- a/packages/i18n/locales/sr/ghost.json +++ b/packages/i18n/locales/sr/ghost.json @@ -78,7 +78,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.", @@ -115,11 +117,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "Vaš poklon je spreman", + "Your gift is scheduled": "", "Your gift subscription confirmation": "Potvrda Vaše poklon-pretplate", "Your gift subscription is ending soon": "Vaša poklon-pretplata uskoro ističe", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "Vaša poklon-pretplata na {siteTitle} ističe {consumesAt}.", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Vaša pretplata je otkazana i ističe {date}. Možete nastaviti pretplatu u podešavanjima Vašeg profila.", "Your subscription has expired.": "Vaša pretplata je istekla.", "Your subscription will expire on {date}.": "Vaša pretplata ističe {date}.", diff --git a/packages/i18n/locales/sr/portal.json b/packages/i18n/locales/sr/portal.json index e5c59ff2721..879a63b07cf 100644 --- a/packages/i18n/locales/sr/portal.json +++ b/packages/i18n/locales/sr/portal.json @@ -52,6 +52,9 @@ "Check with your mail provider": "Proverite sa Vašim mail provajderom", "Check your inbox to verify email update": "Proverite Vaš inbox da verifikujete promenu email-a", "Choose": "Izaberite", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Izaberite drugi plan", "Choose a plan": "Izaberite plan", "Choose a tier": "", @@ -82,13 +85,14 @@ "Create a new contact": "Napravite novi kontakt", "Current plan": "Trenutni plan", "Delete account": "Obrišite nalog", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Niste želeli ovo da uradite? Upravljajte svojim preferencama .", "Don't have an account?": "Nemate nalog?", "Edit": "Uredite", "Email": "Email", "Email confirmation link expired.": "Link za potvrdu email adrese je istekao.", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "Email bilten", "Email newsletter settings updated": "Podešavanja email biltena ažurirana.", "Email preferences": "Preference za email", @@ -178,6 +182,7 @@ "No thanks, I want to cancel": "Ne, hvala, želim da otkažem", "Not ready to share? We've also emailed a copy to your inbox.": "Niste spremni za deljenje? Poslali smo kopiju i u Vaše sanduče.", "Not receiving emails?": "Ne primate email-ove?", + "Now": "", "Now check your email!": "Sada proverite svoj email!", "Occasional updates from {siteTitle}": "Povremene novosti od {siteTitle}", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Kada se ponovo pretplatite, ako i dalje ne vidite email-ove u svom inbox-u, proverite fasciklu za neželjenu poštu. Neki inbox provajderi čuvaju zapise o prethodnim žalbama na neželjenu poštu i nastaviće da označavaju email-ove. Ako se to desi, označite najnoviji bilten kao 'Nije neželjeno' da ga premestite nazad u svoj inbox.", @@ -294,6 +299,7 @@ "View plans": "Pogledajte planove", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Nismo mogli da Vas odjavimo jer email adresa nije pronađena. Molimo Vas da kontaktirate vlasnika sajta.", "We'd hate to see you leave. How about a special offer to stay?": "Žao nam je što odlazite. Šta kažete na specijalnu ponudu da ostanete?", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Dobro došli nazad, {name}!", "Welcome back!": "Dobro došli nazad!", @@ -328,6 +334,7 @@ "Your email has failed to resubscribe, please try again": "Ponovna pretplata na Vaš email nije uspela, pokušajte ponovo", "Your gift is on its way": "", "Your gift is ready": "Vaš poklon je spreman", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "Vaša poklon-pretplata ističe {expiryDate}", "your inbox": "Vaš inbox", "Your input helps shape what gets published.": "Vaše učešće pomaže u oblikovanju onoga što će biti objavljeno.", diff --git a/packages/i18n/locales/sv/ghost.json b/packages/i18n/locales/sv/ghost.json index 72cd86ee4c8..f777090f71f 100644 --- a/packages/i18n/locales/sv/ghost.json +++ b/packages/i18n/locales/sv/ghost.json @@ -76,7 +76,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.", @@ -113,11 +115,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "Din gåva är färdig", + "Your gift is scheduled": "", "Your gift subscription confirmation": "Bekräftelse av din gåvoprenumeration", "Your gift subscription is ending soon": "Din gåvoprenumeration slutar snart", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "Din gåvoprenumeration på {siteTitle} slutar {consumesAt}.", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Din prenumeration har avbrutits och kommer löpa ut {date}. Du kan reaktivera din prenumeration via dina kontoinställningar.", "Your subscription has expired.": "Din prenumeration har löpt ut.", "Your subscription will expire on {date}.": "Din prenumeration kommer att löpa ut {date}.", diff --git a/packages/i18n/locales/sv/portal.json b/packages/i18n/locales/sv/portal.json index 884be676b95..e88499528b2 100644 --- a/packages/i18n/locales/sv/portal.json +++ b/packages/i18n/locales/sv/portal.json @@ -51,6 +51,9 @@ "Check with your mail provider": "Verifiera att e-posten fungerar för ditt konto", "Check your inbox to verify email update": "Kolla din inbox för att verifiera e-post uppdatering", "Choose": "Välj", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Välj en annan plan", "Choose a plan": "Välj en plan", "Choose a tier": "", @@ -81,13 +84,14 @@ "Create a new contact": "Skapa en ny kontakt", "Current plan": "Nuvarande plan", "Delete account": "Radera konto", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Ville du inte göra det här? Ändra dina inställningar .", "Don't have an account?": "Har du inget konto?", "Edit": "Redigera", "Email": "E-post", "Email confirmation link expired.": "E-postlänken för bekräftelse har löpt ut.", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "Nyhetsbrev via e-post", "Email newsletter settings updated": "Inställningar för nyhetsbrev uppdaterade", "Email preferences": "E-postinställningar", @@ -177,6 +181,7 @@ "No thanks, I want to cancel": "Nej tack, jag vill avsluta", "Not ready to share? We've also emailed a copy to your inbox.": "Inte redo att dela? Vi har också e-postat en kopia till din adress.", "Not receiving emails?": "Får du inga e-postmeddelanden?", + "Now": "", "Now check your email!": "Kolla nu din e-post!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Om du fortfarande inte ser e-post i din inkorg efter att du återaktiverat utskicken, kontrollera din skräppostmapp. Vissa e-postleverantörer behåller en historik över tidigare spamklagomål och fortsätter att markera e-post som spam. Om detta händer, markera det senaste nyhetsbrevet som 'Inte spam' för att flytta tillbaka det till din huvudsakliga inkorg.", @@ -293,6 +298,7 @@ "View plans": "Visa prenumerationsalternativ", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Vi kunde inte avsluta kontot eftersom e-postadressen inte hittades. Vänligen kontakta webbplatsens ägare.", "We'd hate to see you leave. How about a special offer to stay?": "Det vore tråkigt om du lämnade. Vad sägs om ett specialerbjudande för att stanna?", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Välkommen tillbaka, {name}!", "Welcome back!": "Välkommen tillbaka!", @@ -325,6 +331,7 @@ "Your email has failed to resubscribe, please try again": "Din e-postadress kunde inte återanmälas, vänligen försök igen", "Your gift is on its way": "", "Your gift is ready": "Din gåva är färdig", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "Din gåvoprenumeration kommer att löpa ut {expiryDate}", "your inbox": "din inkorg", "Your input helps shape what gets published.": "Din åsikt hjälper till att forma vad som publiceras.", diff --git a/packages/i18n/locales/sw/ghost.json b/packages/i18n/locales/sw/ghost.json index b16f2b8da4e..cd92b096ff3 100644 --- a/packages/i18n/locales/sw/ghost.json +++ b/packages/i18n/locales/sw/ghost.json @@ -76,7 +76,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.", @@ -113,11 +115,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", "Your subscription has expired.": "", "Your subscription will expire on {date}.": "", diff --git a/packages/i18n/locales/sw/portal.json b/packages/i18n/locales/sw/portal.json index b4e841f6b86..eda526f9906 100644 --- a/packages/i18n/locales/sw/portal.json +++ b/packages/i18n/locales/sw/portal.json @@ -51,6 +51,9 @@ "Check with your mail provider": "Thibitisha na mtoa huduma wako wa barua", "Check your inbox to verify email update": "", "Choose": "Chagua", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Chagua mpango tofauti", "Choose a plan": "", "Choose a tier": "", @@ -81,13 +84,14 @@ "Create a new contact": "Unda mawasiliano mapya", "Current plan": "Mpango wa sasa", "Delete account": "Futa akaunti", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Hukumaanisha kufanya hivi? Dhibiti mapendeleo yako .", "Don't have an account?": "Huna akaunti?", "Edit": "Hariri", "Email": "Barua pepe", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "Jarida la barua pepe", "Email newsletter settings updated": "", "Email preferences": "Mapendeleo ya barua pepe", @@ -177,6 +181,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "Hupokei barua pepe?", + "Now": "", "Now check your email!": "Sasa angalia barua pepe yako!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Mara baada ya kujiandikisha tena, ikiwa bado huoni barua pepe kwenye kikasha chako, kagua folda yako ya spam. Watoa huduma wengine wa kikasha huweka rekodi ya malalamiko ya awali ya spam na wataendelea kuweka alama kwenye barua pepe. Ikiwa hili litatokea, weka alama kwenye jarida la hivi karibuni kama 'Si spam' ili kuirudisha kwenye kikasha chako kikuu.", @@ -293,6 +298,7 @@ "View plans": "Tazama mipango", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Hatukuweza kukuondoa kwenye usajili kwa sababu anwani ya barua pepe haikupatikana. Tafadhali wasiliana na mmiliki wa tovuti.", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Karibu tena, {name}!", "Welcome back!": "Karibu tena!", @@ -325,6 +331,7 @@ "Your email has failed to resubscribe, please try again": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "Maoni yako yanasaidia kuunda yaliyochapishwa.", diff --git a/packages/i18n/locales/ta/ghost.json b/packages/i18n/locales/ta/ghost.json index c0a9b72a01e..df82f9fbbdb 100644 --- a/packages/i18n/locales/ta/ghost.json +++ b/packages/i18n/locales/ta/ghost.json @@ -76,7 +76,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "இந்த மின்னஞ்சல் முகவரி பயன்படுத்தப்படாது.", @@ -113,11 +115,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", "Your subscription has expired.": "", "Your subscription will expire on {date}.": "", diff --git a/packages/i18n/locales/ta/portal.json b/packages/i18n/locales/ta/portal.json index 779e6893d0b..8e47710d87e 100644 --- a/packages/i18n/locales/ta/portal.json +++ b/packages/i18n/locales/ta/portal.json @@ -51,6 +51,9 @@ "Check with your mail provider": "உங்கள் அஞ்சல் வழங்குநரிடம் சரிபார்க்கவும்", "Check your inbox to verify email update": "மின்னஞ்சல் புதுப்பிப்பை சரிபார்க்க உங்கள் உள்பெட்டியைச் சரிபார்க்கவும்", "Choose": "தேர்வு செய்", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "வேறு திட்டத்தைத் தேர்வு செய்யவும்", "Choose a plan": "ஒரு திட்டத்தைத் தேர்வு செய்யவும்", "Choose a tier": "", @@ -81,13 +84,14 @@ "Create a new contact": "புதிய தொடர்பை உருவாக்கு", "Current plan": "தற்போதைய திட்டம்", "Delete account": "கணக்கை நீக்கு", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "இதைச் செய்ய விரும்பவில்லையா? உங்கள் விருப்பங்களை நிர்வகிக்கவும்.", "Don't have an account?": "கணக்கு இல்லையா?", "Edit": "திருத்து", "Email": "மின்னஞ்சல்", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "மின்னஞ்சல் செய்திமடல்", "Email newsletter settings updated": "மின்னஞ்சல் செய்திமடல் அமைப்புகள் புதுப்பிக்கப்பட்டன", "Email preferences": "மின்னஞ்சல் விருப்பங்கள்", @@ -177,6 +181,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "மின்னஞ்சல்களைப் பெறவில்லையா?", + "Now": "", "Now check your email!": "இப்போது உங்கள் மின்னஞ்சலைச் சரிபார்க்கவும்!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "மீண்டும் சந்தா செய்த பிறகும், உங்கள் உள்பெட்டியில் மின்னஞ்சல்களைக் காணவில்லை என்றால், உங்கள் ஸ்பாம் கோப்புறையைச் சரிபார்க்கவும். சில உள்பெட்டி வழங்குநர்கள் முந்தைய ஸ்பாம் புகார்களின் பதிவை வைத்திருப்பார்கள் மற்றும் மின்னஞ்சல்களை தொடர்ந்து கொடியிடுவார்கள். இது நடந்தால், சமீபத்திய செய்திமடலை 'ஸ்பாம் அல்ல' என்று குறிக்கவும், இது அதை உங்கள் முதன்மை உள்பெட்டிக்கு திருப்பி அனுப்பும்.", @@ -293,6 +298,7 @@ "View plans": "திட்டங்களைக் காண்க", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "மின்னஞ்சல் முகவரி காணப்படவில்லை என்பதால் உங்களை சந்தா நீக்க முடியவில்லை. தயவுசெய்து தள உரிமையாளரைத் தொடர்பு கொள்ளவும்.", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "மீண்டும் வரவேற்கிறோம், {name}!", "Welcome back!": "மீண்டும் வரவேற்கிறோம்!", @@ -325,6 +331,7 @@ "Your email has failed to resubscribe, please try again": "உங்கள் மின்னஞ்சல் மீண்டும் சந்தா செய்ய முடியவில்லை, தயவுசெய்து மீண்டும் முயற்சிக்கவும்", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "உங்கள் உள்ளீடு வெளியிடப்படுவதை வடிவமைக்க உதவுகிறது.", diff --git a/packages/i18n/locales/th/ghost.json b/packages/i18n/locales/th/ghost.json index 516611fd82a..ad6956ecaff 100644 --- a/packages/i18n/locales/th/ghost.json +++ b/packages/i18n/locales/th/ghost.json @@ -74,7 +74,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "ที่อยู่อีเมลนี้จะไม่ถูกใช้งาน", @@ -111,11 +113,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", "Your subscription has expired.": "", "Your subscription will expire on {date}.": "", diff --git a/packages/i18n/locales/th/portal.json b/packages/i18n/locales/th/portal.json index d575e86681a..7fb20d2cf6b 100644 --- a/packages/i18n/locales/th/portal.json +++ b/packages/i18n/locales/th/portal.json @@ -50,6 +50,9 @@ "Check with your mail provider": "ตรวจสอบกับผู้ให้บริการอีเมลของคุณ", "Check your inbox to verify email update": "", "Choose": "เลือก", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "เลือกแผนอื่น", "Choose a plan": "", "Choose a tier": "", @@ -80,13 +83,14 @@ "Create a new contact": "สร้างผู้ติดต่อใหม่", "Current plan": "แผนปัจจุบัน", "Delete account": "ลบบัญชี", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "ไม่ได้ตั้งใจที่จะทำเช่นนี้? จัดการการตั้งค่าของคุณ", "Don't have an account?": "ยังไม่มีบัญชี?", "Edit": "แก้ไข", "Email": "อีเมล", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "จดหมายข่าวทางอีเมล", "Email newsletter settings updated": "", "Email preferences": "การตั้งค่าอีเมล", @@ -176,6 +180,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "ไม่ได้รับอีเมล?", + "Now": "", "Now check your email!": "ตรวจสอบอีเมลของคุณตอนนี้!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "เมื่อรับสมัครข้อมูลใหม่แล้ว, หากคุณยังคงไม่เห็นอีเมลในกล่องจดหมายของคุณ ให้ตรวจสอบโฟลเดอร์สแปมของคุณ. ผู้ให้บริการกล่องจดหมายบางราย เก็บบันทึกการร้องเรียนเกี่ยวกับสแปมก่อนหน้านี้ และจะทำการตั้งค่าสถานะอีเมลต่อไป หากเกิดเหตุการณ์เช่นนี้ ให้ทำเครื่องหมายจดหมายข่าวล่าสุดว่า 'ไม่ใช่สแปม' เพื่อย้ายกลับไปยังกล่องจดหมายหลักของคุณ", @@ -292,6 +297,7 @@ "View plans": "ดูแผน", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "เราไม่สามารถยกเลิกการรับสมัครข้อมูลของคุณได้ เนื่องจากไม่พบที่อยู่อีเมล โปรดติดต่อเจ้าของเว็บไซต์", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "ยินดีต้อนรับกลับมา, {name}!", "Welcome back!": "ยินดีต้อนรับกลับ!", @@ -322,6 +328,7 @@ "Your email has failed to resubscribe, please try again": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "ข้อมูลของคุณ จะช่วยกำหนดสิ่งที่จะได้รับการเผยแพร่ในอนาคต", diff --git a/packages/i18n/locales/tr/ghost.json b/packages/i18n/locales/tr/ghost.json index e8a404b7500..cbe677d2f93 100644 --- a/packages/i18n/locales/tr/ghost.json +++ b/packages/i18n/locales/tr/ghost.json @@ -76,7 +76,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.", @@ -113,11 +115,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "Hediyeniz hazır", + "Your gift is scheduled": "", "Your gift subscription confirmation": "Hediye abonelik onayı", "Your gift subscription is ending soon": "Hediye aboneliğiniz yakında sona eriyor", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "{siteTitle} için hediye aboneliğiniz {consumesAt} tarihinde sona eriyor.", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Aboneliğiniz iptal edilmiştir ve {date} tarihinde sona erecektir. Aboneliğinizi hesap ayarları üzerinden tekrar başlatabilirsiniz.", "Your subscription has expired.": "Aboneliğiniz sona erdi.", "Your subscription will expire on {date}.": "Aboneliğiniz {date} tarihinde sona erecektir.", diff --git a/packages/i18n/locales/tr/portal.json b/packages/i18n/locales/tr/portal.json index c6d77d58aa2..531793e472c 100644 --- a/packages/i18n/locales/tr/portal.json +++ b/packages/i18n/locales/tr/portal.json @@ -51,6 +51,9 @@ "Check with your mail provider": "Posta sağlayıcınızla kontrol edin", "Check your inbox to verify email update": "E-posta güncellemesini doğrulamak için gelen kutunuzu kontrol edin", "Choose": "Seç", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Farklı bir plan seç", "Choose a plan": "Bir plan seçin", "Choose a tier": "", @@ -81,13 +84,14 @@ "Create a new contact": "Yeni bir kullanıcı oluştur", "Current plan": "Mevcut plan", "Delete account": "Hesabı sil", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Bunu yanlışlıkla yaptıysanız, tercihlerinizi yönetebilirsiniz.", "Don't have an account?": "Hesabın yok mu?", "Edit": "Düzenle", "Email": "E-posta", "Email confirmation link expired.": "E-posta onay bağlantısı zaman aşımına uğradı.", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "E-posta bülteni", "Email newsletter settings updated": "E-posta bülteni ayarları güncellendi", "Email preferences": "E-posta tercihleri", @@ -177,6 +181,7 @@ "No thanks, I want to cancel": "Hayır teşekkür, iptal etmek istiyorum", "Not ready to share? We've also emailed a copy to your inbox.": "Paylaşmaya hazır değil misiniz? Gelen kutunuza bir kopyasını da e-posta ile gönderdik.", "Not receiving emails?": "E-posta almıyor musun?", + "Now": "", "Now check your email!": "Şimdi e-posta kutunu kontrol et!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Yeniden abone olduktan sonra hala e-postaları gelen kutunuzda görmüyorsanız, spam klasörünü kontrol edin. Bazı e-posta sağlayıcıları önceki spam şikayetlerini kaydedebilir ve e-postaları işaretlemeye devam edebilir. Bu durumda, en son bülteni 'Spam değil' olarak işaretleyerek ana gelen kutunuza geri taşıyabilirsiniz.", @@ -293,6 +298,7 @@ "View plans": "Planları göster", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "E-posta adresi bulunamadığı için sizi abonelikten çıkaramadık. Lütfen site sahibi ile iletişime geçin.", "We'd hate to see you leave. How about a special offer to stay?": "Gitmeni görmek istemeyiz. Kalman için özel bir teklife ne dersin?", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Tekrar hoş geldin {name}!", "Welcome back!": "Tekrar hoş geldiniz!", @@ -325,6 +331,7 @@ "Your email has failed to resubscribe, please try again": "E-postanız yeniden abone olmayı başaramadı, lütfen tekrar deneyin", "Your gift is on its way": "", "Your gift is ready": "Hediyeniz hazır", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "Hediye aboneliğin {expiryDate} tarihinde sona erecek.", "your inbox": "gelen kutunuz", "Your input helps shape what gets published.": "Yorumun yayımlanan içeriklerin şekillenmesine yardımcı olur.", diff --git a/packages/i18n/locales/uk/ghost.json b/packages/i18n/locales/uk/ghost.json index 8c45c181ceb..92aba32703f 100644 --- a/packages/i18n/locales/uk/ghost.json +++ b/packages/i18n/locales/uk/ghost.json @@ -80,7 +80,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "Ця електронна пошта не буде використовуватися.", @@ -117,11 +119,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", "Your subscription has expired.": "", "Your subscription will expire on {date}.": "", diff --git a/packages/i18n/locales/uk/portal.json b/packages/i18n/locales/uk/portal.json index 271ab7efe0a..e75bca474e0 100644 --- a/packages/i18n/locales/uk/portal.json +++ b/packages/i18n/locales/uk/portal.json @@ -53,6 +53,9 @@ "Check with your mail provider": "Зверніться до свого постачальника послуг електронної пошти", "Check your inbox to verify email update": "Перевірте свою поштову скриньку, щоб підтвердити оновлення електронної пошти", "Choose": "Оберіть", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Оберіть інший план", "Choose a plan": "Оберіть план", "Choose a tier": "", @@ -83,13 +86,14 @@ "Create a new contact": "Створити новий контакт", "Current plan": "Поточний план", "Delete account": "Видалити обліковий запис", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Ви не хотіли цього робити? Керуйте своїми параметрами .", "Don't have an account?": "Не маєш облікового запису?", "Edit": "Редагувати", "Email": "Електронна пошта", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "Електронна розсилка", "Email newsletter settings updated": "Оновлено параметри електронної розсилки", "Email preferences": "Налаштування електронної пошти", @@ -179,6 +183,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "Не приходять листи?", + "Now": "", "Now check your email!": "А тепер перевір свою пошту!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Якщо після повторної підписки ви все ще не бачите електронних листів у папці \"Вхідні\", перевірте папку зі спамом. Деякі постачальники вхідних повідомлень реєструють попередні скарги на спам і продовжуватимуть позначати електронні листи. Якщо це станеться, позначте устанню розсилку як \"Не спам\", щоб повернути його до основної папки \"Вхідні\".", @@ -295,6 +300,7 @@ "View plans": "Переглянути плани", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Ми не можемо вас відписати, оскільки адреса електронної пошти не знайдена. Будь ласка, зв'яжись з власником сайту.", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "З поверненням, {name}!", "Welcome back!": "З поверненням!", @@ -331,6 +337,7 @@ "Your email has failed to resubscribe, please try again": "Не вдалося повторно підписатися за вашою електронну адресою, спробуйте ще раз", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "Ваш відгук допомагає обирати що публікувати далі.", diff --git a/packages/i18n/locales/ur/ghost.json b/packages/i18n/locales/ur/ghost.json index cf009787ca5..e668e6bb675 100644 --- a/packages/i18n/locales/ur/ghost.json +++ b/packages/i18n/locales/ur/ghost.json @@ -76,7 +76,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "یہ ای میل پتہ استعمال نہیں ہو گا۔", @@ -113,11 +115,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", "Your subscription has expired.": "", "Your subscription will expire on {date}.": "", diff --git a/packages/i18n/locales/ur/portal.json b/packages/i18n/locales/ur/portal.json index e0ade7fe055..0ce0b38e1c4 100644 --- a/packages/i18n/locales/ur/portal.json +++ b/packages/i18n/locales/ur/portal.json @@ -51,6 +51,9 @@ "Check with your mail provider": "آپکے میل فراہم کنندہ کے ساتھ چیک کریں", "Check your inbox to verify email update": "", "Choose": "منتخب کریں", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "مختلف منصوبہ منتخب کریں", "Choose a plan": "", "Choose a tier": "", @@ -81,13 +84,14 @@ "Create a new contact": "نیا رابطہ بنائیں", "Current plan": "موجودہ منصوبہ", "Delete account": "اکاؤنٹ حذف کریں", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "یہ کرنا میرا مطلب نہیں تھا؟ اپنی ترجیحات کا منظم کریں ۔", "Don't have an account?": "اکاؤنٹ نہیں ہے؟", "Edit": "ترتیب", "Email": "ای میل", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "ای میل نیوزلیٹر", "Email newsletter settings updated": "", "Email preferences": "ای میل کی ترجیحات", @@ -177,6 +181,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "ای میلز نہیں آ رہے؟", + "Now": "", "Now check your email!": "اب اپنے ای میل چیک کریں!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "جب ایک بار دوبارہ سبسکرائب ہو جائے، اگر آپکو مزید ای میلز اپنے ان باکس میں نہیں دکھتے ہیں تو اپنے ان باکس کی سپیم فولڈر چیک کریں۔ کچھ ان باکس فراہم کنندگان نے پچھلے اسپیم شکایات کا ریکارڈ رکھا ہوتا ہے اور وہ ای میلز کو جاری رکھتے ہیں۔ اگر یہ ہوتا ہے، تو آخری نیوزلیٹر کو 'غیر فعال' مارک کریں تاکہ یہ اسے دوبارہ آپکے پرائمری ان باکس میں منتقل کریں۔", @@ -293,6 +298,7 @@ "View plans": "پلان دیکھیں", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "ہم آپ کو ہوشیار نہیں کر سکے کیونکہ ای میل ایڈریس نہیں ملا۔ براہ کرم سائٹ کے مالک سے رابطہ کریں۔", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "خوش آمدید واپس، {name}!", "Welcome back!": "خوش آمدید واپس!", @@ -325,6 +331,7 @@ "Your email has failed to resubscribe, please try again": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "آپ کا مدخلہ وہ چیزیں شکل دینے میں مدد فراہم کرتا ہے جو شائع ہوتی ہیں۔", diff --git a/packages/i18n/locales/uz/ghost.json b/packages/i18n/locales/uz/ghost.json index 8a8ff3f1fd8..2f5e039bf7f 100644 --- a/packages/i18n/locales/uz/ghost.json +++ b/packages/i18n/locales/uz/ghost.json @@ -76,7 +76,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.", @@ -113,11 +115,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription confirmation": "", "Your gift subscription is ending soon": "", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", "Your subscription has expired.": "", "Your subscription will expire on {date}.": "", diff --git a/packages/i18n/locales/uz/portal.json b/packages/i18n/locales/uz/portal.json index 2c593c37683..97ed94161f9 100644 --- a/packages/i18n/locales/uz/portal.json +++ b/packages/i18n/locales/uz/portal.json @@ -51,6 +51,9 @@ "Check with your mail provider": "", "Check your inbox to verify email update": "", "Choose": "", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Boshqa rejani tanlang", "Choose a plan": "", "Choose a tier": "", @@ -81,13 +84,14 @@ "Create a new contact": "", "Current plan": "", "Delete account": "Hisobni o'chirmoq", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "", "Don't have an account?": "Hisobingiz yoqmi?", "Edit": "", "Email": "Email", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "", "Email newsletter settings updated": "", "Email preferences": "Email sozlamalari", @@ -177,6 +181,7 @@ "No thanks, I want to cancel": "", "Not ready to share? We've also emailed a copy to your inbox.": "", "Not receiving emails?": "Elektron xatlar olmayapsizmi?", + "Now": "", "Now check your email!": "Endi elektron pochtangizni tekshiring!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "", @@ -293,6 +298,7 @@ "View plans": "", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Email manzili topilmagani uchun obunangizni bekor qila olmadik. Iltimos, sayt egasi bilan bog'laning.", "We'd hate to see you leave. How about a special offer to stay?": "", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "", "Welcome back!": "", @@ -325,6 +331,7 @@ "Your email has failed to resubscribe, please try again": "", "Your gift is on its way": "", "Your gift is ready": "", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "", "your inbox": "", "Your input helps shape what gets published.": "", diff --git a/packages/i18n/locales/vi/ghost.json b/packages/i18n/locales/vi/ghost.json index d74b591d705..67f95eed5bb 100644 --- a/packages/i18n/locales/vi/ghost.json +++ b/packages/i18n/locales/vi/ghost.json @@ -74,7 +74,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.", @@ -111,11 +113,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "Món quà của bạn đã chuẩn bị xong", + "Your gift is scheduled": "", "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", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "Gói thành viên mà bạn được tặng trên {siteTitle} hết hạn vào {consumesAt}.", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "Gói thành viên của bạn đã bị hủy và sẽ hết hạn vào {date}. Bạn có thể tiếp tục lại trong cài đặt tài khoản.", "Your subscription has expired.": "Gói thành viên của bạn đã hết hạn.", "Your subscription will expire on {date}.": "Gói thành viên của bạn sẽ hết hạn vào {date}.", diff --git a/packages/i18n/locales/vi/portal.json b/packages/i18n/locales/vi/portal.json index f6e810c3a44..5c6e580fd78 100644 --- a/packages/i18n/locales/vi/portal.json +++ b/packages/i18n/locales/vi/portal.json @@ -50,6 +50,9 @@ "Check with your mail provider": "Yêu cầu nhà cung cấp dịch vụ email hỗ trợ", "Check your inbox to verify email update": "Kiểm tra email của bạn để xác minh cập nhật email", "Choose": "Chọn", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "Chọn gói khác", "Choose a plan": "Chọn một gói", "Choose a tier": "", @@ -80,13 +83,14 @@ "Create a new contact": "Tạo liên hệ mới", "Current plan": "Gói hiện tại", "Delete account": "Xóa tài khoản", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "Không muốn như vậy? Quản lý thiết lập .", "Don't have an account?": "Chưa có tài khoản?", "Edit": "Chỉnh sửa", "Email": "Email", "Email confirmation link expired.": "", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "Bản tin email", "Email newsletter settings updated": "Đã cập nhật thiết lập bản tin email", "Email preferences": "Thiết lập email", @@ -176,6 +180,7 @@ "No thanks, I want to cancel": "Cảm ơn, tôi vẫn muốn hủy", "Not ready to share? We've also emailed a copy to your inbox.": "Chưa sẵn sàng chia sẻ? Chúng tôi cũng đã gửi email một bản sao cho bạn.", "Not receiving emails?": "Bạn không nhận được email?", + "Now": "", "Now check your email!": "Kiểm tra hộp thư ngay!", "Occasional updates from {siteTitle}": "Thông tin cập nhật định kỳ từ {siteTitle}", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "Sau khi chọn nhận lại, nếu bạn vẫn không thấy email trong hộp thư đến của mình, hãy kiểm tra thư mục Spam. Một số nhà cung cấp email lưu giữ hồ sơ về các khiếu nại thư rác trước đây và sẽ tiếp tục phân loại email. Nếu điều này xảy ra, hãy đánh dấu bản tin email mới nhất là 'Không phải Spam' để chuyển nó trở lại hộp thư đến của bạn.", @@ -292,6 +297,7 @@ "View plans": "Xem các gói", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "Chúng tôi không thể hủy gói vì không tìm thấy địa chỉ email. Vui lòng liên hệ với chủ sở hữu trang web.", "We'd hate to see you leave. How about a special offer to stay?": "Chúng tôi không muốn bạn rời đi. Bạn có muốn nhận ưu đãi đặc biệt để ở lại không?", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "Chào mừng trở lại, {name}!", "Welcome back!": "Chào mừng trở lại!", @@ -322,6 +328,7 @@ "Your email has failed to resubscribe, please try again": "Không thể gia hạn với email của bạn, vui lòng thử lại", "Your gift is on its way": "", "Your gift is ready": "Món quà của bạn đã sẵn sàng", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "Quà tặng gói thành viên sẽ hết hạn vào {expiryDate}", "your inbox": "hộp thư đến của bạn", "Your input helps shape what gets published.": "Thông tin bạn chọn giúp định hình nội dung được xuất bản.", diff --git a/packages/i18n/locales/zh-Hant/ghost.json b/packages/i18n/locales/zh-Hant/ghost.json index 398c20139ae..59e2e9181c4 100644 --- a/packages/i18n/locales/zh-Hant/ghost.json +++ b/packages/i18n/locales/zh-Hant/ghost.json @@ -74,7 +74,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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 將不會被使用。", @@ -111,11 +113,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "您的禮物已準備就緒", + "Your gift is scheduled": "", "Your gift subscription confirmation": "您的贈送訂閱確認", "Your gift subscription is ending soon": "您的贈送訂閱即將到期", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "您在 {siteTitle} 的贈送訂閱將於 {consumesAt} 結束。", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "您的訂閱已經取消,並且會在{date}後過期。您可以在會員設定中重新訂閱。", "Your subscription has expired.": "您的訂閱已經到期。", "Your subscription will expire on {date}.": "您的訂閱將會在{date}到期。", diff --git a/packages/i18n/locales/zh-Hant/portal.json b/packages/i18n/locales/zh-Hant/portal.json index f2a5c418393..4e04be8433c 100644 --- a/packages/i18n/locales/zh-Hant/portal.json +++ b/packages/i18n/locales/zh-Hant/portal.json @@ -50,6 +50,9 @@ "Check with your mail provider": "請向您的電子郵件服務提供商確認", "Check your inbox to verify email update": "請檢查您的收件匣確認更新電子郵件", "Choose": "選擇", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "選擇其他方案", "Choose a plan": "選擇方案", "Choose a tier": "", @@ -80,13 +83,14 @@ "Create a new contact": "建立新的聯絡人", "Current plan": "目前方案", "Delete account": "刪除帳號", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "不小心按到?在可以變更您的偏好設定。", "Don't have an account?": "還沒有帳號嗎?", "Edit": "編輯", "Email": "電子郵件", "Email confirmation link expired.": "電子郵件確認連結已過期。", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "電子報", "Email newsletter settings updated": "電子報設定已更新", "Email preferences": "電子郵件偏好設定", @@ -176,6 +180,7 @@ "No thanks, I want to cancel": "不用了,我想取消", "Not ready to share? We've also emailed a copy to your inbox.": "還不打算分享嗎?我們也已將副本發送至您的收件匣。", "Not receiving emails?": "沒有收到電子郵件?", + "Now": "", "Now check your email!": "立即檢查您的電子郵件!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "重新訂閱後,如果您在收件夾中仍然看不到郵件,請檢查您的垃圾郵件匣。一些電子郵件服務商會保留先前的垃圾郵件記錄並持續標記此類郵件。如果發生這種情況,請將最新的電子報標記為「非垃圾郵件」,將其移回您的主要收件匣。", @@ -292,6 +297,7 @@ "View plans": "查詢訂閱方案", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "我們無法取消您的訂閱,因為找不到該電子郵箱地址。請聯繫網站擁有者。", "We'd hate to see you leave. How about a special offer to stay?": "我們很遺憾看到您離開。何不看看我們為您保留的專屬優惠?", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "歡迎回來,{name}!", "Welcome back!": "歡迎回來!", @@ -322,6 +328,7 @@ "Your email has failed to resubscribe, please try again": "您的電子郵件重新訂閱失敗,請再試一次", "Your gift is on its way": "", "Your gift is ready": "您的禮物已準備就緒", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "您的贈送訂閱方案將於 {expiryDate} 到期", "your inbox": "您的收件匣", "Your input helps shape what gets published.": "您的建議將使我們變得更好。", diff --git a/packages/i18n/locales/zh/ghost.json b/packages/i18n/locales/zh/ghost.json index 8d2730be0e9..4aad9a3c8c2 100644 --- a/packages/i18n/locales/zh/ghost.json +++ b/packages/i18n/locales/zh/ghost.json @@ -74,7 +74,9 @@ "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}-month {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "", + "Thank you for your support. Your gift — a {duration}-year {tierName} membership to {siteTitle} — will be sent to {recipientEmail} on {deliveryDate}. 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.": "此电子邮件地址将不会被使用。", @@ -111,11 +113,15 @@ "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 has been sent": "", "Your gift is on its way": "", "Your gift is ready": "您的礼物已准备就绪", + "Your gift is scheduled": "", "Your gift subscription confirmation": "您的赠送订阅确认", "Your gift subscription is ending soon": "您的赠送订阅即将到期", "Your gift subscription to {siteTitle} ends on {consumesAt}.": "您在 {siteTitle} 的赠送订阅将于 {consumesAt} 结束。", + "Your gift was sent to {recipientEmail}. You can also share the link below yourself.": "", + "Your gift will be sent on {deliveryDate}": "", "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "您的订阅已取消,将于{date}到期。您可通过账户设置重新订阅。", "Your subscription has expired.": "您的订阅已过期。", "Your subscription will expire on {date}.": "您的订阅将于{date}过期。", diff --git a/packages/i18n/locales/zh/portal.json b/packages/i18n/locales/zh/portal.json index eb7e360d635..8ccc63904db 100644 --- a/packages/i18n/locales/zh/portal.json +++ b/packages/i18n/locales/zh/portal.json @@ -50,6 +50,9 @@ "Check with your mail provider": "与您的邮件服务商确认", "Check your inbox to verify email update": "请检查您的收件箱以验证电子邮件更新", "Choose": "选择", + "Choose a date from today onwards": "", + "Choose a date within the next year": "", + "Choose a delivery date": "", "Choose a different plan": "选择其他订阅方案", "Choose a plan": "选择一个订阅方案", "Choose a tier": "", @@ -80,13 +83,14 @@ "Create a new contact": "创建新联系", "Current plan": "当前订阅", "Delete account": "删除账户", + "Delivery date": "", "Delivery method": "", "Didn't mean to do this? Manage your preferences .": "操作有误?可以在修改您的偏好设置。", "Don't have an account?": "还没有账户吗?", "Edit": "编辑", "Email": "电子邮件", "Email confirmation link expired.": "电子邮件确认链接已过期。", - "Email it to them now": "", + "Email it to them": "", "Email newsletter": "新闻信", "Email newsletter settings updated": "新闻信设置已更新", "Email preferences": "电子邮件偏好设置", @@ -176,6 +180,7 @@ "No thanks, I want to cancel": "不用了,我想取消", "Not ready to share? We've also emailed a copy to your inbox.": "还没准备好分享?我们也向您的收件箱发送了一份副本。", "Not receiving emails?": "没有收到电子邮件?", + "Now": "", "Now check your email!": "现在请检查您的电子邮件!", "Occasional updates from {siteTitle}": "", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "重新订阅后,如果收件箱中仍然没有看到邮件,请检查垃圾邮件文件夹。一些邮件服务商会保留以往的垃圾邮件投诉记录,并继续对邮件进行标记。如果出现这种情况,请将最新的邮件通讯标记为“非垃圾邮件”,并移回主收件箱。", @@ -292,6 +297,7 @@ "View plans": "查阅订阅方案", "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "我们无法取消您的订阅,因为找不到该电子邮件地址。请联系网站所有者。", "We'd hate to see you leave. How about a special offer to stay?": "我们很遗憾看到您离开。给您提供一个专属优惠,考虑留下来如何?", + "We'll email it to the recipient on {deliveryDate}. A copy is in your inbox too.": "", "We'll email it to the recipient. A copy will be in your inbox too.": "", "Welcome back, {name}!": "欢迎回来,{name}!", "Welcome back!": "欢迎回来!", @@ -322,6 +328,7 @@ "Your email has failed to resubscribe, please try again": "您的邮箱未能成功重新订阅,请重试。", "Your gift is on its way": "", "Your gift is ready": "您的礼物已准备就绪", + "Your gift is scheduled": "", "Your gift subscription will expire on {expiryDate}": "您的赠送订阅将于 {expiryDate} 到期", "your inbox": "您的收件箱", "Your input helps shape what gets published.": "您的建议将使我们变得更好。", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8c24231f22..0702678aa45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -177,6 +177,9 @@ catalogs: '@tryghost/metrics': specifier: 3.5.0 version: 3.5.0 + '@tryghost/mg-clean-html': + specifier: 0.12.6 + version: 0.12.6 '@tryghost/mongo-knex': specifier: 0.11.2 version: 0.11.2 @@ -408,6 +411,9 @@ catalogs: react: specifier: 18.3.1 version: 18.3.1 + react-day-picker: + specifier: 9.14.0 + version: 9.14.0 react-dom: specifier: 18.3.1 version: 18.3.1 @@ -1697,6 +1703,9 @@ importers: react: specifier: catalog:react17 version: 17.0.2 + react-day-picker: + specifier: 'catalog:' + version: 9.14.0(react@17.0.2) react-dom: specifier: catalog:react17 version: 17.0.2(react@17.0.2) @@ -2311,6 +2320,9 @@ importers: '@tryghost/kg-lexical-html-renderer': specifier: workspace:* version: link:../../koenig/kg-lexical-html-renderer + '@tryghost/kg-markdown-html-renderer': + specifier: workspace:* + version: link:../../koenig/kg-markdown-html-renderer '@tryghost/limit-service': specifier: 'catalog:' version: 1.5.6 @@ -2320,6 +2332,9 @@ importers: '@tryghost/metrics': specifier: 'catalog:' version: 3.5.0(supports-color@10.2.2) + '@tryghost/mg-clean-html': + specifier: 'catalog:' + version: 0.12.6(encoding@0.1.13) '@tryghost/mongo-knex': specifier: 'catalog:' version: 0.11.2(supports-color@10.2.2) @@ -6656,6 +6671,9 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} + '@nodable/entities@3.0.0': + resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -9371,6 +9389,17 @@ packages: '@tryghost/job-manager@1.0.9': resolution: {integrity: sha512-Wlwr1R8oeU6HLB7RB1g6VxAH6VEZIaXTVV2GdvCbipK+TA0MIo6YOQUgrIky25RpyRqKXR0UuDIfXDB4+S+GgQ==} + '@tryghost/kg-default-cards@10.3.5': + resolution: {integrity: sha512-eJ6V8MNarip8f0iDoowB6JpwFXXKjzFj3Z6AbxT/ErERIuLEO0F3nJLN7qMenef68j5btsV8+goGHcMS6ZiGfA==} + engines: {node: ^22.13.1 || ^24.0.0} + + '@tryghost/kg-markdown-html-renderer@7.2.5': + resolution: {integrity: sha512-qAhuJd/ceIT80tAh2rhEYDrR2YJq+yttJgq0GWoysc3rRGgxJlXcW4WhGRpZ7NZXDU4cZhuFoGBySDJiL9CoCA==} + engines: {node: ^22.13.1 || ^24.0.0} + + '@tryghost/kg-utils@1.1.5': + resolution: {integrity: sha512-SlS6C1+j6czfX61zAU38z4jC2hPKaXaOh1nN8UplKpEY2wvGTd99udtTZScl+7Fc2k9/CasTKFWErH6+KE4Qyg==} + '@tryghost/limit-service@1.5.6': resolution: {integrity: sha512-wR+Hm2v5k2FjOUhLhfPZ0p0acCfaTQoSEhuw96uw8c15CLZtm/HKsLnxA5d3AXwYmjRo5gkYskP8UYxrCKNcoQ==} @@ -9380,6 +9409,12 @@ packages: '@tryghost/metrics@3.5.0': resolution: {integrity: sha512-fBIKCodafcG23Eq5rjhmixI+CSzVZ555jNqj7tdDeCXtfVA9ws3X3H3dNo7jgxgZs2SbC7ElYbhBSj4dHX8RKw==} + '@tryghost/mg-clean-html@0.12.6': + resolution: {integrity: sha512-ped/cNQdKvcX4F0B8Z5j0VnLuFjyHthElxT4Gny2yWtPHczvFsbVQXlynuHPpQcM3ZLE6o9lemm7Bin1uG/whQ==} + + '@tryghost/mg-utils@0.11.6': + resolution: {integrity: sha512-or0H4aZyInx8xWX/pe60PO9eeuaEgLxezlyc2/ypUIyCjAWl4AojELYsAKt/zjFVedLRVnZOz+SMdCFjx5NhYQ==} + '@tryghost/mongo-knex@0.11.2': resolution: {integrity: sha512-0l7L/J609p+12+rA15Muya1JoIeSfBxIUZRB2FfROtJ2U+FTEOzZ/c0HgxB8eAArJMW2VXECmLCAbNUwygRzfg==} @@ -10690,6 +10725,9 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + anynum@1.0.1: + resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==} + apache-arrow@21.1.0: resolution: {integrity: sha512-kQrYLxhC+NTVVZ4CCzGF6L/uPVOzJmD1T3XgbiUnP7oTeVFOFgEUu6IKNwCDkpFoBVqDKQivlX4RUFqqnWFlEA==} hasBin: true @@ -11416,6 +11454,10 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + boolbase@2.0.0: + resolution: {integrity: sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==} + engines: {node: '>=20.19.0'} + boolean@3.2.0: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. @@ -12639,6 +12681,10 @@ packages: css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + css-select@7.0.0: + resolution: {integrity: sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==} + engines: {node: '>=20.19.0'} + css-tree@1.0.0-alpha.29: resolution: {integrity: sha512-sRNb1XydwkW9IOci6iB2xmy8IGCj6r/fr+JWitvJ2JxQRPzN3T4AGGVWCMlVmVwM1gtgALJRmGIlWv5ppnGGkg==} engines: {node: '>=0.10.0'} @@ -12674,6 +12720,10 @@ packages: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} + css-what@8.0.0: + resolution: {integrity: sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==} + engines: {node: '>=20.19.0'} + css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} @@ -12745,6 +12795,9 @@ packages: cssom@0.4.4: resolution: {integrity: sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw==} + cssom@0.5.0: + resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + cssstyle@2.3.0: resolution: {integrity: sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==} engines: {node: '>=8'} @@ -13156,6 +13209,10 @@ packages: dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + dom-serializer@3.1.1: + resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==} + engines: {node: '>=20.19.0'} + domain-browser@1.2.0: resolution: {integrity: sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA==} engines: {node: '>=0.4', npm: '>=1.2'} @@ -13166,6 +13223,10 @@ packages: domelementtype@2.3.0: resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + domelementtype@3.0.0: + resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==} + engines: {node: '>=20.19.0'} + domexception@2.0.1: resolution: {integrity: sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg==} engines: {node: '>=8'} @@ -13186,6 +13247,10 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + domhandler@6.0.1: + resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==} + engines: {node: '>=20.19.0'} + dompurify@3.4.13: resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==} @@ -13201,6 +13266,10 @@ packages: domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + domutils@4.0.2: + resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} + engines: {node: '>=20.19.0'} + dot-case@3.0.4: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} @@ -14481,6 +14550,13 @@ packages: fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fast-xml-builder@1.3.1: + resolution: {integrity: sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==} + + fast-xml-parser@5.10.1: + resolution: {integrity: sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==} + hasBin: true + fastboot-transform@0.1.3: resolution: {integrity: sha512-6otygPIJw1ARp1jJb+6KVO56iKBjhO+5x59RSC9qiZTbZRrv+HZAuP00KD3s+nWMvcFDemtdkugki9DNFTTwCQ==} @@ -15304,6 +15380,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + html-minifier@4.0.0: resolution: {integrity: sha512-aoGxanpFPLg7MkIl/DDFYtb0iWz7jMFGqFhvEDZga6/4QTjneiD8I/NXL1x5aaoCp7FSIT6h/OhykDdPsbtMig==} engines: {node: '>=6'} @@ -15603,6 +15682,9 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + inline-style-parser@0.2.9: + resolution: {integrity: sha512-IttN8BqKLxzUrnTqHI+/hwAJQItBAUjdoHERCDv+oOuX48VP6bzW8+QjOXSfoevzq3YrYAwX1uLHp8TqO9yIeg==} + inquirer@6.5.2: resolution: {integrity: sha512-cntlB5ghuB0iuO65Ovoi8ogLHiWGs/5yNrtUcKjFhSSiVeAIVpD7koaSU9RM8mpXw5YDi9RdYXGQMaOURB7ycQ==} engines: {node: '>=6.0.0'} @@ -15993,6 +16075,9 @@ packages: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} + is-unsafe@2.0.0: + resolution: {integrity: sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==} + is-uri@1.2.14: resolution: {integrity: sha512-YhTAOlejk4o85c3bPk9ayhr6VLm3e01mp4pBhtYOEXXlkK+Eoj/Nr3x+bqTwZvUI0z0L6TVk0HZViXdxUtsWDQ==} engines: {node: '>= 4'} @@ -16775,6 +16860,15 @@ packages: resolution: {integrity: sha512-cNOjgCnLB+FnvWWtyRTzmB3POJ+cXxTA81LoW7u8JdmhfXzriropYwpjShnz1QLLWsQwY7nIxoDmcPTwphDK9w==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + linkedom@0.18.13: + resolution: {integrity: sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw==} + engines: {node: '>=16'} + peerDependencies: + canvas: '>= 2' + peerDependenciesMeta: + canvas: + optional: true + linkify-it@2.2.0: resolution: {integrity: sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw==} @@ -18234,6 +18328,10 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + nth-check@3.0.1: + resolution: {integrity: sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==} + engines: {node: '>=20.19.0'} + null-prototype-object@1.2.7: resolution: {integrity: sha512-pSZWCUew0zed2gxCerA4zdXRGg8ezLiWwvE8RvncezTcROXL0uz3PjSZXl5NsI04Egz0Uu46o1wyX6qr3u/ZWA==} engines: {node: '>= 20'} @@ -18644,6 +18742,10 @@ packages: resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-expression-matcher@1.6.2: + resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==} + engines: {node: '>=14.0.0'} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -21085,6 +21187,9 @@ packages: resolution: {integrity: sha512-hrA79fjmN2Eb6K3kxkDzU4ODeVGGjXQsuVaAPSUro6I9MM3X+BvIsVqdphm3BXWfimAGFvUqWtPtHy25mICY1w==} engines: {node: ^8.1 || >=10.*} + strnum@2.4.2: + resolution: {integrity: sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==} + strtok3@10.3.5: resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} engines: {node: '>=18'} @@ -21107,6 +21212,9 @@ packages: style-to-object@1.0.14: resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + style-to-object@2.0.2: + resolution: {integrity: sha512-QmbXPUylqU80HAgTOJeEOdbA40W9KtxmjMhQy+tq+b8F2aoh3aQLuy82hh2jm2qsC9hR9eI0+eQidTMydKcqRA==} + styled_string@0.0.1: resolution: {integrity: sha512-DU2KZiB6VbPkO2tGSqQ9n96ZstUPjW7X4sGO6V2m1myIQluX0p1Ol8BrA/l6/EesqhMqXOIXs3cJNOy1UuU2BA==} @@ -21753,6 +21861,9 @@ packages: engines: {node: '>=0.8.0'} hasBin: true + uhyphen@0.2.0: + resolution: {integrity: sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==} + uid-safe@2.1.5: resolution: {integrity: sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==} engines: {node: '>= 0.8'} @@ -22658,6 +22769,10 @@ packages: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} + xml-naming@0.3.0: + resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==} + engines: {node: '>=16.0.0'} + xml@1.0.1: resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==} @@ -26020,6 +26135,8 @@ snapshots: '@noble/hashes@1.8.0': {} + '@nodable/entities@3.0.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -28887,6 +29004,34 @@ snapshots: - '@75lb/nature' - supports-color + '@tryghost/kg-default-cards@10.3.5(encoding@0.1.13)': + dependencies: + '@tryghost/kg-markdown-html-renderer': 7.2.5 + '@tryghost/kg-utils': 1.1.5 + '@tryghost/string': 0.3.5 + '@tryghost/url-utils': 5.2.7 + handlebars: 4.7.9 + juice: 9.1.0(encoding@0.1.13) + luxon: 3.7.2 + transitivePeerDependencies: + - encoding + + '@tryghost/kg-markdown-html-renderer@7.2.5': + dependencies: + '@tryghost/kg-utils': 1.1.5 + markdown-it: 14.3.0 + markdown-it-footnote: 4.0.0 + markdown-it-image-lazy-loading: 2.0.1 + markdown-it-lazy-headers: 0.1.3 + markdown-it-mark: 4.0.0 + markdown-it-sub: 2.0.0 + markdown-it-sup: 2.0.0 + semver: 7.8.5 + + '@tryghost/kg-utils@1.1.5': + dependencies: + semver: 7.8.5 + '@tryghost/limit-service@1.5.6': dependencies: '@tryghost/errors': 3.3.9 @@ -28919,6 +29064,24 @@ snapshots: - '@75lb/nature' - supports-color + '@tryghost/mg-clean-html@0.12.6(encoding@0.1.13)': + dependencies: + '@tryghost/mg-utils': 0.11.6(encoding@0.1.13) + escape-string-regexp: 5.0.0 + style-to-object: 2.0.2 + transitivePeerDependencies: + - canvas + - encoding + + '@tryghost/mg-utils@0.11.6(encoding@0.1.13)': + dependencies: + '@tryghost/kg-default-cards': 10.3.5(encoding@0.1.13) + fast-xml-parser: 5.10.1 + linkedom: 0.18.13 + transitivePeerDependencies: + - canvas + - encoding + '@tryghost/mongo-knex@0.11.2(supports-color@10.2.2)': dependencies: debug: 4.4.3(supports-color@10.2.2) @@ -30755,6 +30918,8 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.2 + anynum@1.0.1: {} + apache-arrow@21.1.0: dependencies: '@swc/helpers': 0.5.23 @@ -31867,6 +32032,8 @@ snapshots: boolbase@1.0.0: {} + boolbase@2.0.0: {} + boolean@3.2.0: {} bottleneck@2.19.5: {} @@ -33584,6 +33751,14 @@ snapshots: domutils: 3.2.2 nth-check: 2.1.1 + css-select@7.0.0: + dependencies: + boolbase: 2.0.0 + css-what: 8.0.0 + domhandler: 6.0.1 + domutils: 4.0.2 + nth-check: 3.0.1 + css-tree@1.0.0-alpha.29: dependencies: mdn-data: 1.1.4 @@ -33620,6 +33795,8 @@ snapshots: css-what@6.2.2: {} + css-what@8.0.0: {} + css.escape@1.5.1: {} cssdb@7.11.2: {} @@ -33736,6 +33913,8 @@ snapshots: cssom@0.4.4: {} + cssom@0.5.0: {} + cssstyle@2.3.0: dependencies: cssom: 0.3.8 @@ -34147,12 +34326,20 @@ snapshots: domhandler: 5.0.3 entities: 4.5.0 + dom-serializer@3.1.1: + dependencies: + domelementtype: 3.0.0 + domhandler: 6.0.1 + entities: 8.0.0 + domain-browser@1.2.0: {} domelementtype@1.3.1: {} domelementtype@2.3.0: {} + domelementtype@3.0.0: {} + domexception@2.0.1: dependencies: webidl-conversions: 5.0.0 @@ -34173,6 +34360,10 @@ snapshots: dependencies: domelementtype: 2.3.0 + domhandler@6.0.1: + dependencies: + domelementtype: 3.0.0 + dompurify@3.4.13: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -34199,6 +34390,12 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 + domutils@4.0.2: + dependencies: + dom-serializer: 3.1.1 + domelementtype: 3.0.0 + domhandler: 6.0.1 + dot-case@3.0.4: dependencies: no-case: 3.0.4 @@ -36923,6 +37120,20 @@ snapshots: dependencies: fast-string-width: 3.0.2 + fast-xml-builder@1.3.1: + dependencies: + path-expression-matcher: 1.6.2 + xml-naming: 0.3.0 + + fast-xml-parser@5.10.1: + dependencies: + '@nodable/entities': 3.0.0 + fast-xml-builder: 1.3.1 + is-unsafe: 2.0.0 + path-expression-matcher: 1.6.2 + strnum: 2.4.2 + xml-naming: 0.3.0 + fastboot-transform@0.1.3(supports-color@10.2.2): dependencies: broccoli-stew: 1.6.0(supports-color@10.2.2) @@ -37998,6 +38209,8 @@ snapshots: html-escaper@2.0.2: {} + html-escaper@3.0.3: {} + html-minifier@4.0.0: dependencies: camel-case: 3.0.0 @@ -38314,6 +38527,8 @@ snapshots: inline-style-parser@0.2.7: {} + inline-style-parser@0.2.9: {} + inquirer@6.5.2: dependencies: ansi-escapes: 3.2.0 @@ -38701,6 +38916,8 @@ snapshots: is-unicode-supported@2.1.0: {} + is-unsafe@2.0.0: {} + is-uri@1.2.14: dependencies: parse-uri: 2.0.6 @@ -39835,6 +40052,14 @@ snapshots: lines-and-columns@2.0.3: {} + linkedom@0.18.13: + dependencies: + css-select: 7.0.0 + cssom: 0.5.0 + html-escaper: 3.0.3 + htmlparser2: 10.1.0 + uhyphen: 0.2.0 + linkify-it@2.2.0: dependencies: uc.micro: 1.0.6 @@ -41605,6 +41830,10 @@ snapshots: dependencies: boolbase: 1.0.0 + nth-check@3.0.1: + dependencies: + boolbase: 2.0.0 + null-prototype-object@1.2.7: {} num2fraction@1.2.2: {} @@ -42248,6 +42477,8 @@ snapshots: path-exists@5.0.0: {} + path-expression-matcher@1.6.2: {} + path-is-absolute@1.0.1: {} path-key@2.0.1: {} @@ -43511,6 +43742,14 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + react-day-picker@9.14.0(react@17.0.2): + dependencies: + '@date-fns/tz': 1.5.0 + '@tabby_ai/hijri-converter': 1.0.5 + date-fns: 4.4.0 + date-fns-jalali: 4.1.0-0 + react: 17.0.2 + react-day-picker@9.14.0(react@18.3.1): dependencies: '@date-fns/tz': 1.5.0 @@ -45164,6 +45403,10 @@ snapshots: '@types/node': 26.0.0 qs: 6.15.3 + strnum@2.4.2: + dependencies: + anynum: 1.0.1 + strtok3@10.3.5: dependencies: '@tokenizer/token': 0.3.0 @@ -45188,6 +45431,10 @@ snapshots: dependencies: inline-style-parser: 0.2.7 + style-to-object@2.0.2: + dependencies: + inline-style-parser: 0.2.9 + styled_string@0.0.1: {} stylehacks@4.0.3: @@ -46022,6 +46269,8 @@ snapshots: uglify-js@3.19.3: {} + uhyphen@0.2.0: {} + uid-safe@2.1.5: dependencies: random-bytes: 1.0.0 @@ -47214,6 +47463,8 @@ snapshots: xml-name-validator@5.0.0: {} + xml-naming@0.3.0: {} + xml@1.0.1: {} xmlchars@2.2.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 956902de3fb..6e1602ef216 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -88,6 +88,7 @@ catalog: '@tryghost/helpers': 1.1.106 '@tryghost/limit-service': 1.5.6 '@tryghost/logging': 5.4.0 + '@tryghost/mg-clean-html': 0.12.6 '@tryghost/metrics': 3.5.0 '@tryghost/mongo-knex': 0.11.2 '@tryghost/nql': 0.13.4 @@ -160,6 +161,7 @@ catalog: preact: ^10.29.2 prettier: 3.9.6 react: 18.3.1 + react-day-picker: 9.14.0 react-dom: 18.3.1 react-router: 7.18.1 semver: 7.8.5