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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CONTEXT-MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,14 @@ Path: `ghost/core/core/server/services/gift-links/CONTEXT.md`

Gift Links covers shareable access to individual protected posts and pages without creating a membership.

### Newsletter Email Sending

Path: `ghost/core/core/server/services/email-service/CONTEXT.md`

Newsletter Email Sending covers the preparation and submission of newsletter emails to the configured email provider.

## Relationships

- **Portal ↔ Gift Subscriptions**: Portal presents the purchase and redemption journeys for gift subscriptions.
- **Gift Subscriptions ↔ Gift Links**: A gift-subscription redemption link claims fixed-duration membership access; a Gift Link grants access to one protected post or page.
- **Newsletter Email Sending ↔ Gift Subscriptions**: Newsletter Email Sending describes the status of a whole newsletter send; Gift Subscriptions' email delivery status describes one recipient's gift email.
5 changes: 3 additions & 2 deletions apps/admin-x-framework/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,16 +80,17 @@
"dependencies": {
"@sentry/react": "catalog:",
"@tanstack/react-query": "catalog:",
"@tryghost/custom-field-types": "workspace:*",
"@tryghost/limit-service": "catalog:",
"@tryghost/metafield-types": "workspace:*",
"@tryghost/nql-string": "workspace:*",
"@tryghost/shade": "workspace:*",
"@tryghost/string": "catalog:",
"bson-objectid": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-router": "catalog:",
"sonner": "catalog:"
"sonner": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@internal/cfg-eslint-react": "workspace:*",
Expand Down
22 changes: 14 additions & 8 deletions apps/admin-x-framework/src/api/email-previews.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { createMutation, createQueryWithId } from '../utils/api/hooks';
import { z } from 'zod';

export type EmailPreview = {
html: string;
plaintext: string;
subject: string;
};
export const EmailPreviewSchema = z.object({
html: z.string(),
plaintext: z.string(),
subject: z.string(),
});

export interface EmailPreviewResponseType {
email_previews: EmailPreview[];
}
export const EmailPreviewResponseSchema = z.object({
email_previews: z.array(EmailPreviewSchema).min(1),
});

export type EmailPreview = z.infer<typeof EmailPreviewSchema>;
export type EmailPreviewResponseType = z.infer<typeof EmailPreviewResponseSchema>;

export interface EmailPreviewParams {
memberStatus?: 'free' | 'paid';
Expand All @@ -23,6 +27,7 @@ const dataType = 'EmailPreviewResponseType';
const useEmailPreviewQuery = createQueryWithId<EmailPreviewResponseType>({
dataType,
path: (id) => `/email_previews/posts/${id}/`,
parseResponse: (data) => EmailPreviewResponseSchema.parse(data),
});

export const useEmailPreview = (
Expand Down Expand Up @@ -57,6 +62,7 @@ export interface SendTestEmailPayload {
/** Sends a test email for a post. Responds 204 with no body. */
export const useSendTestEmail = createMutation<unknown, SendTestEmailPayload>({
method: 'POST',
sessionExpiryRedirect: false,
path: ({ postId }) => `/email_previews/posts/${postId}/`,
body: ({ emails, memberStatus, memberTier, newsletter }) => ({
emails,
Expand Down
20 changes: 10 additions & 10 deletions apps/admin-x-framework/src/api/member-custom-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,22 @@ import {
type FieldType,
type PartType,
type PartsOf,
} from '@tryghost/custom-field-types';
import { csvColumnsForField } from '@tryghost/custom-field-types/csv';
} from '@tryghost/metafield-types';
import { csvColumnsForField } from '@tryghost/metafield-types/csv';
import { Meta, createMutation, createQuery } from '../utils/api/hooks';

// Re-exported so the import mapping can recognize a custom_fields.* column (same reason
// as the re-exports below).
export { isCustomFieldColumn } from '@tryghost/custom-field-types/csv';
export type { FieldIdentity, FieldIdentityString } from '@tryghost/custom-field-types/identity';
export { isMetafieldColumn } from '@tryghost/metafield-types/csv';
export type { FieldIdentity, FieldIdentityString } from '@tryghost/metafield-types/identity';

// Re-exported so admin apps can type address values and validate against the
// same schemas the server enforces, without a direct dependency on the shared
// catalog package — the framework is their surface for everything custom-fields.
export type { Address as MemberCustomFieldAddress } from '@tryghost/custom-field-types';
export { FIELD_TYPES as MEMBER_CUSTOM_FIELD_TYPES } from '@tryghost/custom-field-types';
export { FIELD_KINDS as MEMBER_CUSTOM_FIELD_KINDS } from '@tryghost/custom-field-types';
export type { FieldKind as MemberCustomFieldKind } from '@tryghost/custom-field-types';
export type { Address as MemberCustomFieldAddress } from '@tryghost/metafield-types';
export { FIELD_TYPES as MEMBER_CUSTOM_FIELD_TYPES } from '@tryghost/metafield-types';
export { FIELD_KINDS as MEMBER_CUSTOM_FIELD_KINDS } from '@tryghost/metafield-types';
export type { FieldKind as MemberCustomFieldKind } from '@tryghost/metafield-types';

export type MemberCustomField = {
namespace: string;
Expand All @@ -44,7 +44,7 @@ export type MemberCustomField = {
/**
* The user-type catalog: the presentation layer over the shared field types.
*
* The shared catalog (@tryghost/custom-field-types) owns what a field type *is*
* The shared catalog (@tryghost/metafield-types) owns what a field type *is*
* - its storage and validation. This catalog owns what a publisher is told it is:
* its name, and which control collects a value. Admin surfaces (settings
* list/modal, member detail) render from here so every surface presents fields
Expand Down Expand Up @@ -169,7 +169,7 @@ export const memberCustomFieldCsvColumns = (
});
};

export type { PartType as MemberCustomFieldPartType } from '@tryghost/custom-field-types';
export type { PartType as MemberCustomFieldPartType } from '@tryghost/metafield-types';

/** One part of a composite field type: the key the value schema declares, its label, and its declared type. */
export type MemberCustomFieldPart<T extends FieldType = FieldType> = {
Expand Down
2 changes: 1 addition & 1 deletion apps/admin-x-framework/src/api/members.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
createQueryWithId,
} from '../utils/api/hooks';
import { apiUrl } from '../utils/api/fetch-api';
import type { FieldValue } from '@tryghost/custom-field-types';
import type { FieldValue } from '@tryghost/metafield-types';
import { useCurrentUser } from './current-user';
import { canManageMembers } from './users';
import { FREE_SEGMENT, PAID_SEGMENT } from '../utils/recipient-filter';
Expand Down
135 changes: 80 additions & 55 deletions apps/admin-x-framework/src/api/newsletters.ts
Original file line number Diff line number Diff line change
@@ -1,71 +1,96 @@
import { InfiniteData } from '@tanstack/react-query';
import { Meta, createInfiniteQuery, createMutation } from '../utils/api/hooks';
import { insertToQueryCache, updateQueryCache } from '../utils/api/update-queries';
import { z } from 'zod';

export type Newsletter = {
id: string;
uuid: string;
name: string;
description: string | null;
feedback_enabled: boolean;
slug: string;
sender_name: string | null;
sender_email: string | null;
sender_reply_to: string;
status: string;
visibility: string;
subscribe_on_signup: boolean;
sort_order: number;
header_image: string | null;
show_header_icon: boolean;
show_header_title: boolean;
title_font_category: string;
title_font_weight: string;
title_alignment: string;
show_excerpt: boolean;
show_feature_image: boolean;
body_font_category: string;
footer_content: string | null;
show_badge: boolean;
show_header_name: boolean;
show_post_title_section: boolean;
show_comment_cta: boolean;
show_share_button: boolean;
show_subscription_details: boolean;
show_latest_posts: boolean;
background_color: string;
header_background_color: string;
button_color: string | null;
link_color: string | null;
post_title_color: string | null;
section_title_color: string | null;
divider_color: string | null;
button_corners: string | null;
button_style: string | null;
image_corners: string | null;
link_style: string | null;
divider_style: string | null;
created_at: string;
updated_at: string;
count?: {
posts?: number;
active_members?: number;
};
};
export const NewsletterSchema = z.object({
id: z.string(),
uuid: z.string(),
name: z.string(),
description: z.string().nullable(),
feedback_enabled: z.boolean(),
slug: z.string(),
sender_name: z.string().nullable(),
sender_email: z.string().nullable(),
sender_reply_to: z.string(),
status: z.string(),
visibility: z.string(),
subscribe_on_signup: z.boolean(),
sort_order: z.number(),
header_image: z.string().nullable(),
show_header_icon: z.boolean(),
show_header_title: z.boolean(),
title_font_category: z.string(),
title_font_weight: z.string(),
title_alignment: z.string(),
show_excerpt: z.boolean(),
show_feature_image: z.boolean(),
body_font_category: z.string(),
footer_content: z.string().nullable(),
show_badge: z.boolean(),
show_header_name: z.boolean(),
show_post_title_section: z.boolean(),
show_comment_cta: z.boolean(),
show_share_button: z.boolean(),
show_subscription_details: z.boolean(),
show_latest_posts: z.boolean(),
background_color: z.string(),
header_background_color: z.string(),
button_color: z.string().nullable(),
link_color: z.string().nullable(),
post_title_color: z.string().nullable(),
section_title_color: z.string().nullable(),
divider_color: z.string().nullable(),
button_corners: z.string().nullable(),
button_style: z.string().nullable(),
image_corners: z.string().nullable(),
link_style: z.string().nullable(),
// Older and current Core versions may omit this design setting.
divider_style: z.string().nullish(),
created_at: z.string(),
updated_at: z.string(),
count: z
.object({
posts: z.number().optional(),
active_members: z.number().optional(),
})
.optional(),
});

export interface NewslettersResponseType {
meta?: Meta;
newsletters: Newsletter[];
}
const NewslettersMetaSchema = z.object({
capabilities: z
.object({
dislikes: z.boolean().optional(),
})
.optional(),
pagination: z.object({
page: z.number(),
limit: z.union([z.number(), z.literal('all')]),
pages: z.number(),
total: z.number(),
next: z.number().nullable(),
prev: z.number().nullable(),
}),
});

export const NewslettersResponseSchema = z.object({
meta: NewslettersMetaSchema.optional(),
newsletters: z.array(NewsletterSchema),
});

export type Newsletter = z.infer<typeof NewsletterSchema>;
export type NewslettersResponseType = z.infer<typeof NewslettersResponseSchema>;

const dataType = 'NewslettersResponseType';
export const newslettersDataType = dataType;

export const useBrowseNewsletters = createInfiniteQuery<
NewslettersResponseType & { isEnd: boolean }
NewslettersResponseType & { isEnd: boolean },
NewslettersResponseType
>({
dataType,
path: '/newsletters/',
parseResponse: (data) => NewslettersResponseSchema.parse(data),
defaultSearchParams: { include: 'count.active_members,count.posts', limit: '50' },
defaultNextPageParams: (lastPage, otherParams) => ({
...otherParams,
Expand Down
11 changes: 7 additions & 4 deletions apps/admin-x-framework/src/hooks/use-feature-flag.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { useBrowseConfig } from '../api/config';
import { useFeatureFlagOverrides } from '../providers/feature-flag-overrides-context';

/**
* Returns whether a Labs flag is explicitly enabled. Only boolean `true`
* counts — `false` while config is loading, missing, or failed.
* Avoids refetching stale config when a feature-gated component mounts.
* Returns whether a Labs flag is explicitly enabled by config or the current
* session's URL overrides. Only boolean `true` config values count. Avoids
* refetching stale config when a feature-gated component mounts.
*/
export const useFeatureFlag = (flag: string): boolean => {
const { data: config } = useBrowseConfig({ refetchOnMount: false });
return config?.config.labs?.[flag] === true;
const { enabledFlags } = useFeatureFlagOverrides();

return config?.config.labs?.[flag] === true || enabledFlags.includes(flag);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { createContext, useContext } from 'react';

interface FeatureFlagOverridesContextValue {
enabledFlags: string[];
}

export const FeatureFlagOverridesContext = createContext<FeatureFlagOverridesContextValue>({
enabledFlags: [],
});

export const useFeatureFlagOverrides = () => useContext(FeatureFlagOverridesContext);
2 changes: 2 additions & 0 deletions apps/admin-x-framework/src/providers/framework-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export interface FrameworkProviderProps {
onUpdate: (dataType: string, response: unknown) => void;
onInvalidate: (dataType: string) => void;
onDelete: (dataType: string, id: string) => void;
// Called after URL overrides are synced to sessionStorage. May return cleanup work.
onFeatureFlagOverridesChange?: () => void | (() => void);

// Optional QueryClient override. Defaults to the shared window-level
// singleton; test harnesses pass a fresh client per render for isolation.
Expand Down
25 changes: 24 additions & 1 deletion apps/admin-x-framework/src/providers/router-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
import { useFramework } from './framework-provider';
import { NavigationStackProvider } from './navigation-stack-provider';
import { ErrorPage } from '@tryghost/shade/primitives';
import { syncFeatureFlagOverrides } from '../utils/feature-flag-overrides';
import { FeatureFlagOverridesContext } from './feature-flag-overrides-context';

/**
* This provider uses React Router to provide a router context to React apps
Expand All @@ -33,6 +35,23 @@ export interface RouterProviderProps {
children?: React.ReactNode;
}

function FeatureFlagOverridesRouteProvider({ children }: { children: React.ReactNode }) {
const { search } = useLocation();
const { onFeatureFlagOverridesChange } = useFramework();
const enabledFlags = useMemo(() => syncFeatureFlagOverrides(search), [search]);
const value = useMemo(() => ({ enabledFlags }), [enabledFlags]);

useEffect(() => {
return onFeatureFlagOverridesChange?.();
}, [enabledFlags, onFeatureFlagOverridesChange]);

return (
<FeatureFlagOverridesContext.Provider value={value}>
{children}
</FeatureFlagOverridesContext.Provider>
);
}

// Store scroll positions globally
const scrollPositions = new Map<string, number>();

Expand Down Expand Up @@ -100,7 +119,11 @@ export function RouterProvider({ routes, prefix, errorElement, children }: Route
// Create a root route that wraps all routes with NavigationStackProvider
// and any additional children (providers) so they have access to routing
const rootRoute: RouteObject = {
element: <NavigationStackProvider>{children}</NavigationStackProvider>,
element: (
<FeatureFlagOverridesRouteProvider>
<NavigationStackProvider>{children}</NavigationStackProvider>
</FeatureFlagOverridesRouteProvider>
),
hydrateFallbackElement: <></>,
children: routes.map((route) => ({
...route,
Expand Down
Loading
Loading