diff --git a/apps/admin-x-framework/src/api/gift-links.ts b/apps/admin-x-framework/src/api/gift-links.ts new file mode 100644 index 00000000000..3a2c149267e --- /dev/null +++ b/apps/admin-x-framework/src/api/gift-links.ts @@ -0,0 +1,71 @@ +import {createMutation, createQueryWithId} from '../utils/api/hooks'; + +// A gift link as the admin API emits it. Usage counts (visits/views) are NOT +// stored on the link — they come from the analytics pipeline (see the +// gift-link usage hook in the posts app), so the resource is just the token. +export type GiftLink = { + token: string; + created_at: string; +}; + +export type GiftLinksResponseType = { + gift_links: GiftLink[]; +}; + +export type RemoveAllGiftLinksResponseType = { + meta: { + count: number; + }; +}; + +// Gift links hang off a post or a page; both map to the same id-keyed, +// type-agnostic controller server-side, but we address the canonical route so +// the resource stays explicit. +export type GiftLinkResource = 'posts' | 'pages'; + +export type GiftLinkMutationPayload = { + id: string; + resource?: GiftLinkResource; +}; + +const dataType = 'GiftLinksResponseType'; + +const giftLinkPath = ({id, resource = 'posts'}: GiftLinkMutationPayload) => `/${resource}/${id}/gift_links/`; + +// GET /posts/:id/gift_links/ — read the active link for a post without minting +// one (empty when none exists yet). Read-only, so it's safe to fire on render +// (e.g. the analytics gift-link card). Posts only; the analytics screen never +// reads pages. +export const useReadGiftLink = createQueryWithId({ + dataType, + path: id => `/posts/${id}/gift_links/` +}); + +// PUT /:resource/:id/gift_links/ — idempotently ensure (create-or-get) the +// active link, so opening the UI always has a URL to show. +export const useEnsureGiftLink = createMutation({ + method: 'PUT', + path: giftLinkPath, + invalidateQueries: { + dataType + } +}); + +// POST /:resource/:id/gift_links/ — mint a fresh link, invalidating the old +// token. Surfaced in the UI as "reset", but the backend controller is `create`. +export const useCreateGiftLink = createMutation({ + method: 'POST', + path: giftLinkPath, + invalidateQueries: { + dataType + } +}); + +// PUT /gift_links/remove_all/ — site-wide kill switch (Owner/Admin), danger zone. +export const useRemoveAllGiftLinks = createMutation({ + method: 'PUT', + path: () => '/gift_links/remove_all/', + invalidateQueries: { + dataType + } +}); diff --git a/apps/admin-x-settings/src/components/settings/advanced/danger-zone.tsx b/apps/admin-x-settings/src/components/settings/advanced/danger-zone.tsx index 4cdbb8d65cb..da01b583b30 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/danger-zone.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/danger-zone.tsx @@ -8,17 +8,20 @@ import {useDeleteAllContent} from '@tryghost/admin-x-framework/api/db'; import {useGlobalData} from '../../providers/global-data-provider'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useQueryClient} from '@tryghost/admin-x-framework'; +import {useRemoveAllGiftLinks} from '@tryghost/admin-x-framework/api/gift-links'; import {useResetAuth} from '@tryghost/admin-x-framework/api/security'; const DangerZone: React.FC<{ keywords: string[] }> = ({keywords}) => { const {mutateAsync: deleteAllContent} = useDeleteAllContent(); const {mutateAsync: resetAuth} = useResetAuth(); + const {mutateAsync: removeAllGiftLinks} = useRemoveAllGiftLinks(); const client = useQueryClient(); const handleError = useHandleError(); const {config} = useGlobalData(); const {totalUsers} = useStaffUsers(); const resetAuthEnabled = Boolean(config?.labs?.dangerZoneResetAuth); + const giftLinksEnabled = Boolean(config?.labs?.giftLinks); const resetAuthStaffSentence = totalUsers === 1 ? 'You will be signed out and must reset your password before signing back in.' @@ -83,6 +86,29 @@ const DangerZone: React.FC<{ keywords: string[] }> = ({keywords}) => { }); }; + const handleRemoveAllGiftLinks = () => { + NiceModal.show(ConfirmationModal, { + title: 'Reset all gift links?', + prompt: 'This immediately invalidates every active gift link across your site. Anyone holding one will lose access. New gift links can still be created afterwards.', + okLabel: 'Reset all gift links', + okRunningLabel: 'Resetting...', + okColor: 'red', + onOk: async (modal) => { + try { + const response = await removeAllGiftLinks(null); + const count = response?.meta?.count ?? 0; + showToast({ + title: `Reset ${count} gift ${count === 1 ? 'link' : 'links'}.`, + type: 'success' + }); + modal?.remove(); + } catch (e) { + handleError(e); + } + } + }); + }; + return ( = ({keywords}) => { title='Reset all authentication' /> )} + {giftLinksEnabled && ( + } + bgOnHover={false} + detail='Invalidate every active gift link across your site. Anyone holding one will lose access.' + testId='reset-all-gift-links' + title='Reset all gift links' + /> + )} ); diff --git a/apps/admin-x-settings/test/acceptance/advanced/dangerzone.test.ts b/apps/admin-x-settings/test/acceptance/advanced/dangerzone.test.ts index 218147da6f8..dd1b482d532 100644 --- a/apps/admin-x-settings/test/acceptance/advanced/dangerzone.test.ts +++ b/apps/admin-x-settings/test/acceptance/advanced/dangerzone.test.ts @@ -45,4 +45,29 @@ test.describe('DangerZone', async () => { expect(lastApiRequests.resetAuth).toBeTruthy(); }); + + test('Reset all gift links', async ({page}) => { + toggleLabsFlag('giftLinks', true); + + const {lastApiRequests} = await mockApi({page, requests: { + ...globalDataRequests, + removeAllGiftLinks: { + method: 'PUT', + path: '/gift_links/remove_all/', + response: {meta: {count: 3}} + } + }}); + + await page.goto('/'); + + const dangerZone = page.getByTestId('dangerzone'); + await dangerZone.getByTestId('reset-all-gift-links').getByRole('button', {name: 'Reset'}).click(); + + const modal = page.getByTestId('confirmation-modal'); + await modal.getByRole('button', {name: 'Reset all gift links'}).click(); + + await expect(page.getByTestId('toast-success')).toContainText('Reset 3 gift links'); + + expect(lastApiRequests.removeAllGiftLinks).toBeTruthy(); + }); }); diff --git a/apps/admin/src/ember-bridge/ember-bridge.tsx b/apps/admin/src/ember-bridge/ember-bridge.tsx index b154658968d..0842ab4d398 100644 --- a/apps/admin/src/ember-bridge/ember-bridge.tsx +++ b/apps/admin/src/ember-bridge/ember-bridge.tsx @@ -12,6 +12,7 @@ export type StateBridgeEventMap = { subscriptionChange: SubscriptionState; sidebarVisibilityChange: SidebarVisibilityChangeEvent; routeChange: RouteChangeEvent; + openGiftLinkModal: OpenGiftLinkModalEvent; } export interface StateBridge { @@ -59,6 +60,11 @@ export interface RouteChangeEvent { queryParams: Record; } +export interface OpenGiftLinkModalEvent { + id: string; + resource: 'posts' | 'pages'; +} + export type EmberRouting = Pick; /** @@ -208,6 +214,18 @@ export function useSubscriptionStatus() { return subscriptionStatus; } +/** + * Subscribes to Ember's request to open the (React-owned) gift-link modal. + * + * Ember surfaces — the posts/pages list context menu — fire `openGiftLinkModal` + * over the bridge instead of rendering their own modal. The consumer owns the + * modal's open/close state and just reacts to each request. Returns an + * unsubscribe function. + */ +export function subscribeOpenGiftLinkModal(handler: (event: OpenGiftLinkModalEvent) => void): () => void { + return onEmberStateBridgeEvent('openGiftLinkModal', handler); +} + // 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 0b7453c401e..948b296ce75 100644 --- a/apps/admin/src/ember-bridge/index.ts +++ b/apps/admin/src/ember-bridge/index.ts @@ -3,6 +3,6 @@ export { EmberProvider } from "./ember-provider"; export { useEmberContext } from "./ember-context"; export { EmberFallback } from "./ember-fallback"; export { ForceUpgradeGuard } from "./force-upgrade-guard"; -export { useEmberAuthSync, useEmberDataSync, useSidebarVisibility, useSubscriptionStatus, useEmberRouting, useForceUpgrade } from "./ember-bridge"; -export type { EmberDataChangeEvent, EmberRouting } from "./ember-bridge"; +export { useEmberAuthSync, useEmberDataSync, useSidebarVisibility, useSubscriptionStatus, useEmberRouting, useForceUpgrade, subscribeOpenGiftLinkModal } from "./ember-bridge"; +export type { EmberDataChangeEvent, EmberRouting, OpenGiftLinkModalEvent } from "./ember-bridge"; export type { RouteHandle } from "./force-upgrade-guard"; diff --git a/apps/admin/src/gift-link-modal-host.tsx b/apps/admin/src/gift-link-modal-host.tsx new file mode 100644 index 00000000000..cc53154dd84 --- /dev/null +++ b/apps/admin/src/gift-link-modal-host.tsx @@ -0,0 +1,56 @@ +import { Suspense, lazy, useEffect, useState } from "react"; +import { EmberFallback, subscribeOpenGiftLinkModal } from "./ember-bridge"; +import type { OpenGiftLinkModalEvent } from "./ember-bridge"; + +// The gift-link modal is React-owned but triggered from the Ember posts/pages +// list. It's only needed once someone opens it, so lazy-load it rather than +// pulling the posts bundle into every list view. +const GiftLinkModal = lazy(() => import("@tryghost/posts/gift-link-modal")); + +/** + * Bridges the Ember posts/pages context menu to the React gift-link modal. + * + * Subscribes to the bridge on mount and owns the modal's open/close state: + * each request opens the modal for the named post/page (reopening the same one + * just re-fires the event), and closing flips `open` while keeping the target + * so the modal can animate out. + */ +function GiftLinkModalHost() { + const [target, setTarget] = useState(null); + const [open, setOpen] = useState(false); + + useEffect(() => subscribeOpenGiftLinkModal((event) => { + setTarget(event); + setOpen(true); + }), []); + + if (!target) { + return null; + } + + return ( + + + + ); +} + +/** + * Route element for the Ember-backed posts and pages lists. Delegates the page + * itself to Ember (EmberFallback) while keeping the React gift-link modal host + * mounted so the list's context menu can open it. + */ +export function EmberListWithGiftLinks() { + return ( + <> + + + + ); +} diff --git a/apps/admin/src/routes.tsx b/apps/admin/src/routes.tsx index b200f3f0fbd..f24e6ee0cc4 100644 --- a/apps/admin/src/routes.tsx +++ b/apps/admin/src/routes.tsx @@ -13,6 +13,7 @@ import MyProfileRedirect from "./my-profile-redirect"; // Ember import { EmberFallback, ForceUpgradeGuard } from "./ember-bridge"; import type { RouteHandle } from "./ember-bridge"; +import { EmberListWithGiftLinks } from "./gift-link-modal-host"; import { MembersRoute } from "./members-route"; import { OnboardingRedirect } from "./onboarding/onboarding-redirect"; @@ -31,11 +32,11 @@ const EMBER_ROUTES: string[] = [ "/signup/*", "/reset/*", "/pro/*", - "/posts", + // "/posts" and "/pages" are Ember-rendered but get the gift-link modal host + // layered on top — see giftLinkEmberRoutes below. "/posts/analytics/:postId/mentions", "/posts/analytics/:postId/debug", "/restore", - "/pages", "/editor/*", "/tags/new", "/explore/*", @@ -134,6 +135,10 @@ export const routes: RouteObject[] = [ lazy: lazyComponent(() => import("./settings/settings")), handle: { allowInForceUpgrade: true } satisfies RouteHandle, }, + // The posts and pages lists stay in Ember, but mount the React + // gift-link modal host alongside so their context menu can open it. + {path: "/posts", Component: EmberListWithGiftLinks, handle: emberFallbackHandle}, + {path: "/pages", Component: EmberListWithGiftLinks, handle: emberFallbackHandle}, // Ember-handled routes ...emberFallbackRoutes, { diff --git a/apps/posts/package.json b/apps/posts/package.json index a6627043c67..daaf1819242 100644 --- a/apps/posts/package.json +++ b/apps/posts/package.json @@ -21,7 +21,8 @@ "require": "./dist/posts.umd.cjs" }, "./api": "./src/api.ts", - "./members": "./src/views/members/members.tsx" + "./members": "./src/views/members/members.tsx", + "./gift-link-modal": "./src/views/PostAnalytics/modals/gift-link-modal.tsx" }, "private": true, "scripts": { diff --git a/apps/posts/src/hooks/use-can-manage-gift-link.ts b/apps/posts/src/hooks/use-can-manage-gift-link.ts new file mode 100644 index 00000000000..04e77322143 --- /dev/null +++ b/apps/posts/src/hooks/use-can-manage-gift-link.ts @@ -0,0 +1,21 @@ +import {isAdminUser, isAuthorUser, isEditorUser, isOwnerUser} from '@tryghost/admin-x-framework/api/users'; +import {useCurrentUser} from '@tryghost/admin-x-framework/api/current-user'; +import {useGlobalData} from '@src/providers/post-analytics-context'; +import {useMemo} from 'react'; + +// Whether the current user can manage a gift link for this post. Mirrors +// canCopyGiftLink in the Ember app/utils/gift-link.js: requires the labs flag, +// a published gated (non-public) post, and a managing role. +export const useCanManageGiftLink = (post?: {status?: string; visibility?: string}) => { + const {data: globalData} = useGlobalData(); + const {data: currentUser} = useCurrentUser(); + + return useMemo(() => { + if (!globalData?.labs?.giftLinks || !post || !currentUser) { + return false; + } + const eligible = post.status === 'published' && Boolean(post.visibility) && post.visibility !== 'public'; + const canManage = isOwnerUser(currentUser) || isAdminUser(currentUser) || isEditorUser(currentUser) || isAuthorUser(currentUser); + return eligible && canManage; + }, [globalData?.labs?.giftLinks, post, currentUser]); +}; diff --git a/apps/posts/src/hooks/use-gift-link-usage.ts b/apps/posts/src/hooks/use-gift-link-usage.ts new file mode 100644 index 00000000000..8965e1552eb --- /dev/null +++ b/apps/posts/src/hooks/use-gift-link-usage.ts @@ -0,0 +1,57 @@ +import {StatsConfig, useTinybirdQuery} from '@tryghost/admin-x-framework'; +import {useBrowseConfig} from '@tryghost/admin-x-framework/api/config'; +import {useMemo} from 'react'; + +export interface GiftLinkUsage { + visits: number; + views: number; +} + +interface GiftLinkVisitsRow { + gift: string; + visits: number | string; + views: number | string; +} + +// Reads gift-link usage from the web-analytics pipeline (the same Tinybird +// mechanism every other analytics surface uses), keyed on the link token. +// +// Usage tracking is best-effort and entirely optional: analytics may be turned +// off (no statsConfig), the per-link pipe may not be deployed yet, or the query +// may error. In every one of those cases `usage` is `undefined` and the caller +// simply hides the count — the rest of the gift-link UI keeps working. +export const useGiftLinkUsage = ({postUuid, token, enabled = true}: { + postUuid?: string; + token?: string; + enabled?: boolean; +}) => { + const {data: configData} = useBrowseConfig(); + const statsConfig = configData?.config?.stats as StatsConfig | undefined; + + const params = useMemo(() => ({ + site_uuid: statsConfig?.id || '', + post_uuid: postUuid || '' + }), [statsConfig?.id, postUuid]); + + const {data, loading, error} = useTinybirdQuery({ + endpoint: 'api_gift_link_visits', + statsConfig: statsConfig || {id: ''}, + params, + enabled: enabled && Boolean(statsConfig?.id) && Boolean(postUuid) + }); + + const usage = useMemo(() => { + // No token yet, the query is disabled/loading, or it errored → no data + // to show. Distinct from "ran and found zero", which yields {visits: 0}. + if (!token || error || !Array.isArray(data)) { + return undefined; + } + const row = (data as unknown as GiftLinkVisitsRow[]).find(r => r.gift === token); + return { + visits: Number(row?.visits) || 0, + views: Number(row?.views) || 0 + }; + }, [data, token, error]); + + return {usage, loading}; +}; diff --git a/apps/posts/src/hooks/use-post-details.ts b/apps/posts/src/hooks/use-post-details.ts new file mode 100644 index 00000000000..cc780ceecc0 --- /dev/null +++ b/apps/posts/src/hooks/use-post-details.ts @@ -0,0 +1,50 @@ +import {GiftLinkResource} from '@tryghost/admin-x-framework/api/gift-links'; +import {POST_ANALYTICS_INCLUDE} from '@src/utils/constants'; +import {useBrowsePages} from '@tryghost/admin-x-framework/api/pages'; +import {useBrowsePosts} from '@tryghost/admin-x-framework/api/posts'; + +// The post/page fields the gift-link modal needs: the canonical URL to hang the +// token off, the visibility for the copy, and the uuid for usage stats. +export interface PostDetails { + url: string; + title: string; + visibility?: string; + uuid?: string; +} + +// Fetches the details for a post or page by id. Posts reuse the post-analytics +// query (same include → shared cache with the analytics screen); pages fetch on +// their own route. Gated pages are eligible for gift links too, hence both. +export const usePostDetails = ({postId, resource = 'posts', enabled = true}: { + postId: string; + resource?: GiftLinkResource; + enabled?: boolean; +}): {post: PostDetails | undefined; isLoading: boolean} => { + const isPost = resource === 'posts'; + + const {data: postsData, isLoading: isPostLoading} = useBrowsePosts({ + searchParams: {filter: `id:${postId}`, include: POST_ANALYTICS_INCLUDE}, + enabled: enabled && isPost + }); + + const {data: pagesData, isLoading: isPageLoading} = useBrowsePages({ + searchParams: {filter: `id:${postId}`}, + enabled: enabled && !isPost + }); + + if (isPost) { + const post = postsData?.posts?.[0]; + return { + post: post && {url: post.url, title: post.title, visibility: post.visibility, uuid: post.uuid}, + isLoading: isPostLoading + }; + } + + // The Page type omits visibility/uuid, but the API returns them (shared + // posts table); read them through a widened view. + const page = pagesData?.pages?.[0] as undefined | {url: string; title: string; visibility?: string; uuid?: string}; + return { + post: page && {url: page.url, title: page.title, visibility: page.visibility, uuid: page.uuid}, + isLoading: isPageLoading + }; +}; diff --git a/apps/posts/src/providers/post-analytics-context.tsx b/apps/posts/src/providers/post-analytics-context.tsx index 71b5a94e4bb..b36950101d3 100644 --- a/apps/posts/src/providers/post-analytics-context.tsx +++ b/apps/posts/src/providers/post-analytics-context.tsx @@ -1,7 +1,7 @@ import {Config, useBrowseConfig} from '@tryghost/admin-x-framework/api/config'; +import {POST_ANALYTICS_INCLUDE, STATS_RANGES} from '@src/utils/constants'; import {Post as PostBase, useBrowsePosts} from '@tryghost/admin-x-framework/api/posts'; import {ReactNode, createContext, useContext, useState} from 'react'; -import {STATS_RANGES} from '@src/utils/constants'; import {Setting, useBrowseSettings} from '@tryghost/admin-x-framework/api/settings'; import {StatsConfig, useTinybirdToken} from '@tryghost/admin-x-framework'; import {useBrowseSite} from '@tryghost/admin-x-framework/api/site'; @@ -78,11 +78,12 @@ const PostAnalyticsProvider = ({children}: { children: ReactNode }) => { const hasStatsConfig = Boolean(config.data?.config?.stats); const tinybirdTokenQuery = useTinybirdToken({enabled: hasStatsConfig}); - // Fetch post data with all required includes + // Fetch post data with all required includes. The gift-link modal reuses + // POST_ANALYTICS_INCLUDE for the same query key, so both read one cached post. const {data: {posts: [post]} = {posts: []}, isLoading: isPostLoading} = useBrowsePosts({ searchParams: { filter: `id:${postId}`, - include: 'email,authors,tags,tiers,count.clicks,count.signups,count.paid_conversions,count.positive_feedback,count.negative_feedback,newsletter' + include: POST_ANALYTICS_INCLUDE } }); diff --git a/apps/posts/src/utils/constants.ts b/apps/posts/src/utils/constants.ts index 2b76ed96c6d..e9d2133dcd8 100644 --- a/apps/posts/src/utils/constants.ts +++ b/apps/posts/src/utils/constants.ts @@ -1,3 +1,8 @@ +// The includes the post-analytics screen fetches for a post. Shared so any +// consumer wanting the *same* cached post (e.g. the gift-link modal opened from +// the analytics header) hits the identical query key instead of a near-miss. +export const POST_ANALYTICS_INCLUDE = 'email,authors,tags,tiers,count.clicks,count.signups,count.paid_conversions,count.positive_feedback,count.negative_feedback,newsletter'; + export const STATS_RANGES = { TODAY: {name: 'Today', value: 1}, LAST_7_DAYS: {name: 'Last 7 days', value: 7}, diff --git a/apps/posts/src/utils/gift-link.ts b/apps/posts/src/utils/gift-link.ts new file mode 100644 index 00000000000..5b0d05ac9a1 --- /dev/null +++ b/apps/posts/src/utils/gift-link.ts @@ -0,0 +1,12 @@ +// Build the public gift-link URL for a post. +// +// Gift links hang off the canonical post URL as a `?gift=` query param +// (Fastly bypasses the cache on the param), so we just append to post.url — +// which already carries any subdirectory and the trailing slash. Returns '' when +// either input is missing so callers can guard the UI. +export function buildGiftLinkUrl(postUrl?: string, token?: string): string { + if (!postUrl || !token) { + return ''; + } + return `${postUrl}?gift=${encodeURIComponent(token)}`; +} diff --git a/apps/posts/src/views/PostAnalytics/Overview/overview.tsx b/apps/posts/src/views/PostAnalytics/Overview/overview.tsx index 6a9f85df5a6..7b6e2afb5b3 100644 --- a/apps/posts/src/views/PostAnalytics/Overview/overview.tsx +++ b/apps/posts/src/views/PostAnalytics/Overview/overview.tsx @@ -1,4 +1,5 @@ import DisabledSourcesIndicator from '../components/disabled-sources-indicator'; +import GiftLinkModal from '../modals/gift-link-modal'; import KpiCard, {KpiCardContent, KpiCardLabel, KpiCardValue} from '../components/kpi-card'; import NewsletterOverview from './components/newsletter-overview'; import PostAnalyticsContent from '../components/post-analytics-content'; @@ -14,8 +15,11 @@ import {centsToDollars} from '../Growth/growth'; import {formatQueryDate, getRangeDates, getRangeForStartDate, sanitizeChartData} from '@tryghost/shade/app'; import {hasBeenEmailed, isPublishedOnly, useNavigate, useTinybirdQuery} from '@tryghost/admin-x-framework'; import {useAppContext} from '@src/providers/posts-app-context'; -import {useEffect, useMemo} from 'react'; +import {useCanManageGiftLink} from '@src/hooks/use-can-manage-gift-link'; +import {useEffect, useMemo, useState} from 'react'; +import {useGiftLinkUsage} from '@src/hooks/use-gift-link-usage'; import {usePostReferrers} from '@hooks/use-post-referrers'; +import {useReadGiftLink} from '@tryghost/admin-x-framework/api/gift-links'; const Overview: React.FC = () => { const navigate = useNavigate(); @@ -24,6 +28,14 @@ const Overview: React.FC = () => { const {appSettings} = useAppContext(); const {emailTrackClicks: emailTrackClicksEnabled, emailTrackOpens: emailTrackOpensEnabled} = appSettings?.analytics || {}; + // Gift link card: only for eligible posts. Read the active link (without + // minting) to scope the usage count to the current token, matching the modal. + const canManageGiftLink = useCanManageGiftLink(post); + const {data: giftLinkData} = useReadGiftLink(postId, {enabled: canManageGiftLink}); + const giftToken = giftLinkData?.gift_links?.[0]?.token; + const {usage: giftLinkUsage} = useGiftLinkUsage({postUuid: post?.uuid, token: giftToken, enabled: canManageGiftLink}); + const [isGiftLinkOpen, setIsGiftLinkOpen] = useState(false); + // Calculate chart range based on days between today and post publication date const chartRange = useMemo(() => { if (!post?.published_at) { @@ -136,12 +148,14 @@ const Overview: React.FC = () => { post={post as Post} /> )} - {showGrowthSection && ( - -
- - - + {(showGrowthSection || (canManageGiftLink && post)) && ( +
+ {showGrowthSection && ( + +
+ + + Growth @@ -190,13 +204,51 @@ const Overview: React.FC = () => { } - + + )} + {canManageGiftLink && post && ( + +
+ + + + Gift link + + + +
+ + + Visitors + + + {formatNumber(giftLinkUsage?.visits || 0)} + + +
+ )} +
)} - {!showWebSection && !showNewsletterSection && !showGrowthSection && ( + {!showWebSection && !showNewsletterSection && !showGrowthSection && !canManageGiftLink && ( )}
+ {canManageGiftLink && post && ( + + )} ); }; diff --git a/apps/posts/src/views/PostAnalytics/components/post-analytics-header.tsx b/apps/posts/src/views/PostAnalytics/components/post-analytics-header.tsx index a2f9cf50853..7b67c5a5f46 100644 --- a/apps/posts/src/views/PostAnalytics/components/post-analytics-header.tsx +++ b/apps/posts/src/views/PostAnalytics/components/post-analytics-header.tsx @@ -1,3 +1,4 @@ +import GiftLinkModal from '../modals/gift-link-modal'; import React, {useMemo, useState} from 'react'; import {AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, Button, DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, Navbar, PageMenu, PageMenuItem} from '@tryghost/shade/components'; import {H1} from '@tryghost/shade/primitives'; @@ -7,6 +8,7 @@ import {PostShareModal} from '@tryghost/shade/posts-stats'; import {getSiteTimezone} from '@src/utils/get-site-timezone'; import {hasBeenEmailed, isEmailOnly, isPublishedAndEmailed, isPublishedOnly, useActiveVisitors, useNavigate} from '@tryghost/admin-x-framework'; import {useAppContext} from '@src/providers/posts-app-context'; +import {useCanManageGiftLink} from '@src/hooks/use-can-manage-gift-link'; import {useDeletePost} from '@tryghost/admin-x-framework/api/posts'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; @@ -25,7 +27,9 @@ const PostAnalyticsHeader:React.FC = ({ const handleError = useHandleError(); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [isShareOpen, setIsShareOpen] = useState(false); + const [isGiftLinkOpen, setIsGiftLinkOpen] = useState(false); const {settings, site, statsConfig, post, isPostLoading, postId} = useGlobalData(); + const canManageGiftLink = useCanManageGiftLink(post); const siteTimezone = getSiteTimezone(settings); @@ -132,9 +136,11 @@ const PostAnalyticsHeader:React.FC = ({ {!post?.email_only && ( = ({ siteTitle={site?.title || ''} onClose={() => setIsShareOpen(false)} onOpenChange={setIsShareOpen} + onShareAsGift={() => { + setIsShareOpen(false); + setIsGiftLinkOpen(true); + }} > @@ -242,6 +252,15 @@ const PostAnalyticsHeader:React.FC = ({ {children} + {canManageGiftLink && postId && ( + + )} + diff --git a/apps/posts/src/views/PostAnalytics/modals/gift-link-modal.tsx b/apps/posts/src/views/PostAnalytics/modals/gift-link-modal.tsx new file mode 100644 index 00000000000..845496fd26b --- /dev/null +++ b/apps/posts/src/views/PostAnalytics/modals/gift-link-modal.tsx @@ -0,0 +1,194 @@ +import React, {useEffect, useRef, useState} from 'react'; +import {Badge, Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from '@tryghost/shade/components'; +import {GiftLinkResource, useCreateGiftLink, useEnsureGiftLink} from '@tryghost/admin-x-framework/api/gift-links'; +import {ShareModal} from '@tryghost/shade/patterns'; +import {buildGiftLinkUrl} from '@src/utils/gift-link'; +import {formatNumber} from '@tryghost/shade/utils'; +import {useGiftLinkUsage} from '@src/hooks/use-gift-link-usage'; +import {useHandleError} from '@tryghost/admin-x-framework/hooks'; +import {usePostDetails} from '@src/hooks/use-post-details'; + +function visitorsLabel(count: number) { + if (count === 0) { + return 'No visitors yet'; + } + return `${formatNumber(count)} ${count === 1 ? 'visitor' : 'visitors'}`; +} + +type ResetState = 'idle' | 'confirm'; + +interface GiftLinkModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + postId: string; + resource?: GiftLinkResource; +} + +const GiftLinkModal: React.FC = ({open, onOpenChange, postId, resource = 'posts'}) => { + const handleError = useHandleError(); + const {mutateAsync: ensureGiftLink} = useEnsureGiftLink(); + const {mutateAsync: createGiftLink} = useCreateGiftLink(); + const {post} = usePostDetails({postId, resource, enabled: open}); + + const [token, setToken] = useState(undefined); + const [resetState, setResetState] = useState('idle'); + const [resetting, setResetting] = useState(false); + const [ensuring, setEnsuring] = useState(false); + const cancelResetRef = useRef(null); + + // Usage is best-effort: undefined when analytics is off / unavailable, in + // which case we simply omit the visitor count. + const {usage} = useGiftLinkUsage({postUuid: post?.uuid, token, enabled: open}); + + // Ensure (create-or-get) the link as soon as the modal opens so there's a + // URL to show. Idempotent on the server. + useEffect(() => { + if (!open) { + return; + } + let cancelled = false; + setEnsuring(true); + ensureGiftLink({id: postId, resource}) + .then((response) => { + if (cancelled) { + return; + } + setToken(response.gift_links[0]?.token); + }) + .catch((e) => { + if (!cancelled) { + handleError(e); + } + }) + .finally(() => { + if (!cancelled) { + setEnsuring(false); + } + }); + return () => { + cancelled = true; + }; + }, [open, postId, resource, ensureGiftLink, handleError]); + + // Reset transient UI on close so the next open starts clean. + useEffect(() => { + if (!open) { + setResetState('idle'); + } + }, [open]); + + useEffect(() => { + if (resetState === 'confirm') { + cancelResetRef.current?.focus(); + } + }, [resetState]); + + const giftLinkUrl = buildGiftLinkUrl(post?.url, token); + const memberType = post?.visibility === 'members' ? 'member' : 'paid member'; + const resourceLabel = resource === 'pages' ? 'page' : 'post'; + const description = `Anyone you share this link with will be able to access this ${resourceLabel} without becoming a ${memberType}.`; + + const handleConfirmReset = async () => { + if (resetting) { + return; + } + setResetting(true); + try { + const response = await createGiftLink({id: postId, resource}); + setToken(response.gift_links[0]?.token); + setResetState('idle'); + } catch (e) { + handleError(e); + } finally { + setResetting(false); + } + }; + + const handleOpenChange = (isOpen: boolean) => { + if (!isOpen) { + setResetState('idle'); + } + onOpenChange(isOpen); + }; + + return ( + + + {resetState === 'idle' && ( + <> + +
+ Gift link + {usage && ( + + {visitorsLabel(usage.visits)} + + )} +
+ + {description} + +
+ + + + + + + + + + + )} + + {resetState === 'confirm' && ( + <> + + Reset gift link + + Are you sure you want to reset this link? Anyone with the current link will lose access to this {resourceLabel}. + + + + + + + + )} +
+
+ ); +}; + +export default GiftLinkModal; diff --git a/apps/posts/test/unit/utils/gift-link.test.ts b/apps/posts/test/unit/utils/gift-link.test.ts new file mode 100644 index 00000000000..3f104e08eb3 --- /dev/null +++ b/apps/posts/test/unit/utils/gift-link.test.ts @@ -0,0 +1,25 @@ +import {buildGiftLinkUrl} from '@src/utils/gift-link'; +import {describe, expect, it} from 'vitest'; + +describe('buildGiftLinkUrl', () => { + it('appends ?gift= to the canonical post url', () => { + expect(buildGiftLinkUrl('https://example.com/my-post/', 'tok123')) + .toBe('https://example.com/my-post/?gift=tok123'); + }); + + it('preserves a subdirectory in the post url', () => { + expect(buildGiftLinkUrl('https://example.com/blog/my-post/', 'tok123')) + .toBe('https://example.com/blog/my-post/?gift=tok123'); + }); + + it('url-encodes the token', () => { + expect(buildGiftLinkUrl('https://example.com/p/', 'a/b+c=')) + .toBe('https://example.com/p/?gift=a%2Fb%2Bc%3D'); + }); + + it('returns an empty string when either input is missing', () => { + expect(buildGiftLinkUrl('', 'tok')).toBe(''); + expect(buildGiftLinkUrl('https://example.com/p/', '')).toBe(''); + expect(buildGiftLinkUrl(undefined, undefined)).toBe(''); + }); +}); diff --git a/apps/shade/src/components/posts-stats/post-share-modal.tsx b/apps/shade/src/components/posts-stats/post-share-modal.tsx index f8c26008a38..70ebab83563 100644 --- a/apps/shade/src/components/posts-stats/post-share-modal.tsx +++ b/apps/shade/src/components/posts-stats/post-share-modal.tsx @@ -6,12 +6,15 @@ import React from 'react'; interface PostShareModalProps extends React.ComponentPropsWithoutRef { author?: string; + canShareAsGift?: boolean; children?: React.ReactNode; description?: React.ReactNode; emailOnly?: boolean; faviconURL?: string; featureImageURL?: string; + giftAccessLabel?: string; onClose?: () => void; + onShareAsGift?: () => void; postExcerpt?: string; postTitle?: string; postURL?: string; @@ -22,12 +25,15 @@ interface PostShareModalProps extends React.ComponentPropsWithoutRef = ({ author = '', + canShareAsGift = false, children, description = '', emailOnly = false, faviconURL = '', featureImageURL = '', + giftAccessLabel = '', onClose = () => {}, + onShareAsGift = () => {}, postExcerpt = '', postTitle = '', postURL = '', @@ -104,22 +110,33 @@ const PostShareModal: React.FC = ({
- - {emailOnly ? ( - - ) : ( - <> - - - +
+ + {emailOnly ? ( + + ) : ( + <> + + + + )} + + {canShareAsGift && !emailOnly && ( +

+ Want to share full access to this {giftAccessLabel} post?{' '} + + . +

)} - +
); diff --git a/ghost/admin/app/components/posts-list/context-menu.hbs b/ghost/admin/app/components/posts-list/context-menu.hbs index aaf381b853a..5699662fe45 100644 --- a/ghost/admin/app/components/posts-list/context-menu.hbs +++ b/ghost/admin/app/components/posts-list/context-menu.hbs @@ -8,7 +8,14 @@ {{/if}} -
  • + {{#if this.canCopyGiftLink}} +
  • + +
  • + {{/if}} +