Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
fbe25ac
Fixed invalid MySQL2 connection options errors polluting acceptance t…
kevinansfield Aug 27, 2026
15c3818
Moved application styles out of the Shade stylesheet (#30318)
9larsons Aug 27, 2026
bf98309
🐛 Fixed stale sidebar selections across Admin routes (#30334)
peterzimon Aug 27, 2026
f8179ce
Changed Tinybird queries to react-query (#30286)
9larsons Aug 27, 2026
a425db9
Added a Labs flag for the redesigned members import
rob-ghost Aug 27, 2026
814ee63
Changed the redesigned import to check for custom fields itself
rob-ghost Aug 27, 2026
a956975
Removed custom field support from the import as it shipped
rob-ghost Aug 27, 2026
da82ba1
Changed the import field picker to render what it is handed
rob-ghost Aug 27, 2026
1978999
Fixed a type error in the active-visitors hook test (#30345)
9larsons Aug 27, 2026
f3cc89c
Added media inlining to CSV imports (#30340)
PaulAdamDavis Aug 27, 2026
3da4870
Moved the measured Stripe Checkout constraints into a shared package
rob-ghost Aug 27, 2026
4143c1b
Added the shared checkout package as an e2e dependency
rob-ghost Aug 27, 2026
8f023e4
Changed the Stripe probe to report what a session will collect
rob-ghost Aug 27, 2026
368af3a
Changed a tier that delivers everywhere to store no country list
rob-ghost Aug 27, 2026
31e60c1
Added a shared custom field picker with inline creation
renatoworks Aug 27, 2026
ede78e7
Added tier checkout collection settings to the tier modal
renatoworks Aug 27, 2026
63199f0
Added checkout collection to the tier creation flow
renatoworks Aug 27, 2026
d462d25
Improved tier checkout robustness from review feedback
renatoworks Aug 27, 2026
d59b93a
Changed checkout "all countries" to store no country list
rob-ghost Aug 27, 2026
6300317
Changed Admin to read the shared Stripe allowed-countries list
rob-ghost Aug 27, 2026
7f3a2c2
Changed checkout destination pickers to read the shared port table
rob-ghost Aug 27, 2026
624c4de
Changed a refused checkout destination to report on its own picker
rob-ghost Aug 27, 2026
9159286
Changed the checkout wire contract to accept shipping with no countries
rob-ghost Aug 27, 2026
afeac50
Updated automation browse stats type (#30312)
EvanHahn Aug 27, 2026
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
1 change: 0 additions & 1 deletion apps/admin-x-framework/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@
"dependencies": {
"@sentry/react": "catalog:",
"@tanstack/react-query": "catalog:",
"@tinybirdco/charts": "0.3.0",
"@tryghost/custom-field-types": "workspace:*",
"@tryghost/limit-service": "catalog:",
"@tryghost/nql-string": "workspace:*",
Expand Down
82 changes: 82 additions & 0 deletions apps/admin-x-framework/src/api/tiers-checkout-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { Meta, createMutation, createQuery } from '../utils/api/hooks';

/**
* A tier's checkout configuration, as the API serves and takes it.
*
* Mirrors the server's wire shape (tier-checkout-config/serializers.ts): each collectable
* thing is its own named block, present in a response only when the tier collects it, so
* a client reads presence rather than a flag. A write states only the blocks it wants to
* change; an unnamed block is left alone.
*/

export type TierCheckoutQuestion = {
key: string;
label: string | null;
optional: boolean;
};

export type TierCheckoutCollection = {
collect: true;
custom_field_key: string;
};

export type TierCheckoutConfig = {
tier_id: string;
custom_fields: TierCheckoutQuestion[];
/**
* One toggle for the shipping step, two destinations: the processor collects the
* recipient name and the address together, and each lands in its own field.
*/
shipping?: {
collect: true;
/** Absent means everywhere the processor ships; a list is a restriction. */
allowed_countries?: string[];
name: { custom_field_key: string };
address: { custom_field_key: string };
};
/**
* The tax number itself stays on Stripe, against the member's invoices; Ghost only
* records that the checkout asks for one, so there is no destination to name.
*/
tax_number?: { collect: true };
phone?: TierCheckoutCollection;
};

/** The blocks a write may state. `collect: false` turns a collection off. */
export type TierCheckoutConfigInput = {
custom_fields?: Array<{ key: string; label?: string | null; optional?: boolean }>;
shipping?:
| { collect: false }
| {
collect: true;
/** Omit to deliver everywhere. An empty list is refused, not read as everywhere. */
allowed_countries?: string[];
name: { custom_field_key: string };
address: { custom_field_key: string };
};
tax_number?: { collect: boolean };
phone?: { collect: false } | { collect: true; custom_field_key: string };
};

export interface TiersCheckoutConfigResponseType {
meta?: Meta;
tiers_checkout_config: TierCheckoutConfig[];
}

const dataType = 'TiersCheckoutConfigResponseType';

// Every tier's configuration in one read, so a tier list needs one request.
export const useBrowseTiersCheckoutConfig = createQuery<TiersCheckoutConfigResponseType>({
dataType,
path: '/tiers/checkout_config/',
});

export const useEditTierCheckoutConfig = createMutation<
TiersCheckoutConfigResponseType,
{ tierId: string; config: TierCheckoutConfigInput }
>({
method: 'PUT',
path: ({ tierId }) => `/tiers/${tierId}/checkout_config/`,
body: ({ config }) => ({ tiers_checkout_config: [config] }),
invalidateQueries: { dataType },
});
24 changes: 6 additions & 18 deletions apps/admin-x-framework/src/hooks/use-active-visitors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,38 +8,26 @@ interface UseActiveVisitorsOptions {
enabled?: boolean;
}

export const ACTIVE_VISITORS_REFETCH_INTERVAL = 60 * 1000;

export const useActiveVisitors = (options: UseActiveVisitorsOptions = {}) => {
const { postUuid, statsConfig, enabled = true } = options;
const [refreshKey, setRefreshKey] = useState(0);
const [lastKnownCount, setLastKnownCount] = useState<number | null>(null);

// Set up 60-second interval only if enabled
useEffect(() => {
if (!enabled) {
return;
}

const interval = setInterval(() => {
setRefreshKey((prev) => prev + 1);
}, 60000); // 60 seconds

return () => clearInterval(interval);
}, [enabled]);

const params = {
site_uuid: statsConfig?.id || '',
// Add postUuid if provided
...(postUuid && { post_uuid: postUuid }),
// Add refreshKey to force refetch
_refresh: refreshKey.toString(),
};

// Use useTinybirdQuery for consistent token handling
const { data, loading, error } = useTinybirdQuery({
statsConfig,
endpoint: 'api_active_visitors',
params,
enabled,
refetchInterval: ACTIVE_VISITORS_REFETCH_INTERVAL,
// Keep counting while the tab is hidden (matches the old interval tick,
// which the browser throttled to roughly this cadence anyway).
refetchIntervalInBackground: true,
});

const currentCount = data?.[0]?.active_visitors;
Expand Down
165 changes: 148 additions & 17 deletions apps/admin-x-framework/src/hooks/use-tinybird-query.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,134 @@
import { useQuery } from '@tinybirdco/charts';
import { useQuery } from '@tanstack/react-query';
import { useTinybirdToken } from './use-tinybird-token';
import { StatsConfig } from '../providers/framework-provider';
import { getStatEndpointUrl } from '../utils/stats-config';
import { useWebAnalyticsEnabled } from '../api/settings';

export type TinybirdRow = Record<string, string | number>;

export interface TinybirdMeta {
name: string;
type: string;
}

interface TinybirdPipeResponse {
data?: TinybirdRow[] | null;
meta?: TinybirdMeta[] | null;
}

export interface UseTinybirdQueryOptions {
statsConfig?: StatsConfig | null;
endpoint: string;
params: Record<string, string>;
enabled?: boolean;
/** Poll interval in ms (e.g. active visitors); no polling by default. */
refetchInterval?: number;
refetchIntervalInBackground?: boolean;
}

export interface UseTinybirdQueryResult {
data: TinybirdRow[] | null;
meta: TinybirdMeta[] | null;
loading: boolean;
error: Error | null;
}

// Analytics reads fresh: pipe responses go stale after a minute instead of
// the app-wide five.
export const TINYBIRD_STALE_TIME = 60 * 1000;

/** Full pipe request URL with params applied; undefined disables the query. */
export const buildTinybirdRequestUrl = (
endpointUrl: string | undefined,
params: Record<string, string>,
): string | undefined => {
if (!endpointUrl) {
return undefined;
}
let url: URL;
try {
url = new URL(endpointUrl);
} catch {
return undefined;
}
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
return url.toString();
};

interface FetchTinybirdPipeOptions {
url: string;
token: string;
refreshToken: () => Promise<string | undefined>;
signal?: AbortSignal;
}

// Wrapper around Tinybird's useQuery hook that handles the token loading state
export const useTinybirdQuery = (options: UseTinybirdQueryOptions) => {
const { statsConfig, endpoint, params, enabled = true } = options;
// Direct browser→Tinybird request: bearer token only, no Ghost cookies.
export const fetchTinybirdPipe = async ({
url,
token,
refreshToken,
signal,
}: FetchTinybirdPipeOptions): Promise<TinybirdPipeResponse> => {
const request = (bearer: string) =>
fetch(url, {
credentials: 'omit',
headers: { Authorization: `Bearer ${bearer}` },
signal,
});

let response = await request(token);

// A fetch can race a server-side token rotation: refresh the token once
// and retry before surfacing the error.
if (response.status === 401 || response.status === 403) {
const freshToken = await refreshToken();
if (freshToken && freshToken !== token) {
response = await request(freshToken);
}
}

if (!response.ok) {
const body = await response.text();
throw new Error(`Tinybird request failed (${response.status}): ${body || response.statusText}`);
}

const body: unknown = await response.json();
return parseTinybirdPipeResponse(body);
};

/**
* Structural guard for the external response envelope. Rows are pipe-shaped
* (each pipe returns its own columns), so cells are deliberately not
* validated per field — only the envelope: `data`/`meta` must be arrays of
* objects when present.
*/
export const parseTinybirdPipeResponse = (body: unknown): TinybirdPipeResponse => {
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
throw new Error('Tinybird returned an unexpected response shape');
}
const { data, meta } = body as { data?: unknown; meta?: unknown };
const isObjectArray = (value: unknown): boolean =>
Array.isArray(value) && value.every((row) => row !== null && typeof row === 'object');
if (data !== undefined && data !== null && !isObjectArray(data)) {
throw new Error('Tinybird returned an unexpected response shape');
}
if (meta !== undefined && meta !== null && !isObjectArray(meta)) {
throw new Error('Tinybird returned an unexpected response shape');
}
return body as TinybirdPipeResponse;
};

export const useTinybirdQuery = (options: UseTinybirdQueryOptions): UseTinybirdQueryResult => {
const {
statsConfig,
endpoint,
params,
enabled = true,
refetchInterval,
refetchIntervalInBackground,
} = options;
// Web analytics kill-switch, read from settings so no call site threads it.
// When off, shouldQuery is false and the hook returns empty state.
const webAnalyticsEnabled = useWebAnalyticsEnabled();
Expand All @@ -22,22 +137,38 @@ export const useTinybirdQuery = (options: UseTinybirdQueryOptions) => {
const tokenQuery = useTinybirdToken({ enabled: shouldQuery });
const endpointUrl =
shouldQuery && statsConfig ? getStatEndpointUrl(statsConfig, endpoint) : undefined;
const requestUrl = buildTinybirdRequestUrl(endpointUrl, params);
const token = tokenQuery.token;
const refreshToken = tokenQuery.refetch;

// Set the endpoint to undefined if:
// - Token is not loaded (prevents 403 errors)
// - Query is disabled via enabled flag
// - No statsConfig provided
// - No endpoint specified
const { data, meta, loading, error } = useQuery({
endpoint: !tokenQuery.isLoading && tokenQuery.token && shouldQuery ? endpointUrl : undefined,
token: shouldQuery ? tokenQuery.token : undefined,
params: params,
const query = useQuery<TinybirdPipeResponse, Error>({
// The token stays out of the key — it is auth material, not data identity;
// keying on it would refetch every pipe when the scheduled token refresh
// lands. The queryFn reads the current token instead.
queryKey: ['tinybird', requestUrl],
// Fetch only once the token has loaded (prevents guaranteed 403s).
enabled: Boolean(shouldQuery && requestUrl && token),
staleTime: TINYBIRD_STALE_TIME,
retry: false,
// Analytics wants fresh data on return to the tab — the app-wide
// focus-refetch opt-out is for CRUD data, and the old charts-lib SWR
// layer revalidated on focus too.
refetchOnWindowFocus: true,
refetchInterval,
refetchIntervalInBackground,
queryFn: ({ signal }) =>
fetchTinybirdPipe({
url: requestUrl as string,
token: token as string,
refreshToken,
signal,
}),
});

return {
data: shouldQuery ? data : null,
meta: shouldQuery ? meta : null,
loading: shouldQuery ? tokenQuery.isLoading || loading : false,
error: shouldQuery ? (error ?? tokenQuery.error) : null,
data: shouldQuery ? (query.data?.data ?? null) : null,
meta: shouldQuery ? (query.data?.meta ?? null) : null,
loading: shouldQuery ? tokenQuery.isLoading || query.isLoading : false,
error: shouldQuery ? (query.error ?? tokenQuery.error) : null,
};
};
15 changes: 12 additions & 3 deletions apps/admin-x-framework/src/hooks/use-tinybird-token.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { useCallback } from 'react';
import { useTinybirdTokenQuery } from '../api/tinybird';
import { useWebAnalyticsEnabled } from '../api/settings';

export interface UseTinybirdTokenResult {
token: string | undefined;
isLoading: boolean;
error: Error | null;
refetch: () => void;
/** Refetches the token query and resolves the fresh token, if any. */
refetch: () => Promise<string | undefined>;
}

export interface UseTinybirdTokenOptions {
Expand All @@ -22,14 +24,21 @@ export const useTinybirdToken = (options: UseTinybirdTokenOptions = {}): UseTiny
const effectiveEnabled = enabled && webAnalyticsEnabled;
const tinybirdQuery = useTinybirdTokenQuery({ enabled: effectiveEnabled });

const refetchQuery = tinybirdQuery.refetch;
const refetch = useCallback(async () => {
const result = await refetchQuery();
const freshToken = result.data?.tinybird?.token;
return typeof freshToken === 'string' && freshToken ? freshToken : undefined;
}, [refetchQuery]);

// A disabled React Query can keep cached data/errors, so return an idle
// result — else direct consumers (the providers) leak a stale token.
if (!effectiveEnabled) {
return {
token: undefined,
isLoading: false,
error: null,
refetch: tinybirdQuery.refetch,
refetch,
};
}

Expand All @@ -52,6 +61,6 @@ export const useTinybirdToken = (options: UseTinybirdTokenOptions = {}): UseTiny
token: apiToken && typeof apiToken === 'string' ? apiToken : undefined,
isLoading: tinybirdQuery.isLoading,
error,
refetch: tinybirdQuery.refetch,
refetch,
};
};
Loading
Loading