From 0f9b6932f5253d0aae865ed4c5e7fb808be317e7 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Thu, 25 Jun 2026 14:29:58 +0200 Subject: [PATCH 1/2] Added gift links admin UI for analytics, posts list, and settings ref https://linear.app/ghost/issue/BER-3729 - one shared React gift-link modal is reused across the post-analytics screen and the Ember posts/pages list, rather than maintaining a separate Ember modal: the list's right-click menu fires an `openGiftLinkModal` event over the state bridge and a host mounted alongside the Ember fallback opens the React modal in place - the modal fetches link details (admin API) and usage (the same Tinybird analytics path as everything else) separately, degrading to no visitor count when analytics is off or the usage pipe isn't deployed yet; the share URL is the canonical post URL + `?gift=` - adds the danger-zone "reset all gift links" action and a "share as a gift" entry in the post share modal - all gated behind the existing private `giftLinks` flag --- apps/admin-x-framework/src/api/gift-links.ts | 71 +++++++ .../settings/advanced/danger-zone.tsx | 35 ++++ .../acceptance/advanced/dangerzone.test.ts | 25 +++ apps/admin/src/ember-bridge/ember-bridge.tsx | 18 ++ apps/admin/src/ember-bridge/index.ts | 4 +- apps/admin/src/gift-link-modal-host.tsx | 56 +++++ apps/admin/src/routes.tsx | 9 +- apps/posts/package.json | 3 +- .../src/hooks/use-can-manage-gift-link.ts | 21 ++ apps/posts/src/hooks/use-gift-link-usage.ts | 57 ++++++ apps/posts/src/hooks/use-post-details.ts | 50 +++++ .../src/providers/post-analytics-context.tsx | 7 +- apps/posts/src/utils/constants.ts | 5 + apps/posts/src/utils/gift-link.ts | 12 ++ .../views/PostAnalytics/Overview/overview.tsx | 70 ++++++- .../components/post-analytics-header.tsx | 19 ++ .../PostAnalytics/modals/gift-link-modal.tsx | 193 ++++++++++++++++++ apps/posts/test/unit/utils/gift-link.test.ts | 25 +++ .../posts-stats/post-share-modal.tsx | 19 ++ .../components/posts-list/context-menu.hbs | 7 + .../app/components/posts-list/context-menu.js | 28 +++ ghost/admin/app/services/feature.js | 1 + ghost/admin/app/services/state-bridge.js | 9 + ghost/admin/app/utils/gift-link.js | 18 ++ .../admin/tests/unit/utils/gift-link-test.js | 38 ++++ 25 files changed, 783 insertions(+), 17 deletions(-) create mode 100644 apps/admin-x-framework/src/api/gift-links.ts create mode 100644 apps/admin/src/gift-link-modal-host.tsx create mode 100644 apps/posts/src/hooks/use-can-manage-gift-link.ts create mode 100644 apps/posts/src/hooks/use-gift-link-usage.ts create mode 100644 apps/posts/src/hooks/use-post-details.ts create mode 100644 apps/posts/src/utils/gift-link.ts create mode 100644 apps/posts/src/views/PostAnalytics/modals/gift-link-modal.tsx create mode 100644 apps/posts/test/unit/utils/gift-link.test.ts create mode 100644 ghost/admin/app/utils/gift-link.js create mode 100644 ghost/admin/tests/unit/utils/gift-link-test.js 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..c37315b5bd9 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 + + + {giftLinkUsage ? formatNumber(giftLinkUsage.visits) : '—'} + + +
+ )} +
)} - {!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..26431b4b63c --- /dev/null +++ b/apps/posts/src/views/PostAnalytics/modals/gift-link-modal.tsx @@ -0,0 +1,193 @@ +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 description = `Anyone you share this link with will be able to access this post 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 post. + + + + + + + + )} +
+
+ ); +}; + +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..05e20f3b462 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,6 +110,19 @@ const PostShareModal: React.FC = ({
+ {canShareAsGift && !emailOnly && ( +
+
+ Share as a gift + + Let anyone read this {giftAccessLabel} post without an account. + +
+ +
+ )} {emailOnly ? ( + + {{/if}} {{#if this.canFeatureSelection}} {{#if this.shouldFeatureSelection}}
  • diff --git a/ghost/admin/app/components/posts-list/context-menu.js b/ghost/admin/app/components/posts-list/context-menu.js index 8d00d50f973..30843060d6a 100644 --- a/ghost/admin/app/components/posts-list/context-menu.js +++ b/ghost/admin/app/components/posts-list/context-menu.js @@ -7,6 +7,7 @@ import UnschedulePostsModal from './modals/unschedule-posts'; import copyTextToClipboard from 'ghost-admin/utils/copy-text-to-clipboard'; import nql from '@tryghost/nql'; import {action} from '@ember/object'; +import {canCopyGiftLink} from 'ghost-admin/utils/gift-link'; import {capitalizeFirstLetter} from 'ghost-admin/helpers/capitalize-first-letter'; import {inject as service} from '@ember/service'; import {task, timeout} from 'ember-concurrency'; @@ -66,11 +67,27 @@ export default class PostsContextMenu extends Component { @service store; @service notifications; @service membersUtils; + @service feature; + @service stateBridge; get menu() { return this.args.menu; } + // Gift links apply to a single published, gated (non-public) post/page, and + // only for users who can manage them. Eligibility and URL shape live in + // app/utils/gift-link.js (shared with the React modal's expectations). + get canCopyGiftLink() { + if (!this.selectionList.isSingle) { + return false; + } + return canCopyGiftLink({ + feature: this.feature, + user: this.session.user, + post: this.selectionList.first + }); + } + get selectionList() { return this.menu.selectionList; } @@ -96,6 +113,17 @@ export default class PostsContextMenu extends Component { this.menu.performTask(this.copyPreviewLinkTask); } + // The gift-link modal lives in React; hand off to it over the state bridge + // rather than duplicating the modal in Ember. + @action + openGiftLink() { + this.stateBridge.triggerOpenGiftLinkModal({ + id: this.selectionList.first.id, + resource: this.type === 'page' ? 'pages' : 'posts' + }); + this.menu.close(); + } + @action async featurePosts() { this.menu.performTask(this.featurePostsTask); diff --git a/ghost/admin/app/services/feature.js b/ghost/admin/app/services/feature.js index 7a12091f5b4..66a7a4069c6 100644 --- a/ghost/admin/app/services/feature.js +++ b/ghost/admin/app/services/feature.js @@ -68,6 +68,7 @@ export default class FeatureService extends Service { @feature('editorExcerpt') editorExcerpt; @feature('tagsX') tagsX; @feature('commentModeration') commentModeration; + @feature('giftLinks') giftLinks; _user = null; @computed('settings.labs') diff --git a/ghost/admin/app/services/state-bridge.js b/ghost/admin/app/services/state-bridge.js index 1f4d271f476..385992edc1c 100644 --- a/ghost/admin/app/services/state-bridge.js +++ b/ghost/admin/app/services/state-bridge.js @@ -10,6 +10,7 @@ const emberDataTypeMapping = { 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 + GiftLinksResponseType: null, // gift links only exist in React admin IntegrationsResponseType: {type: 'integration'}, InvitesResponseType: {type: 'invite'}, LabelsResponseType: null, // labels only exist in React admin @@ -223,6 +224,14 @@ 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. + @action + triggerOpenGiftLinkModal({id, resource}) { + this.trigger('openGiftLinkModal', {id, resource}); + } + get sidebarVisible() { // Sidebar is visible when NOT in fullscreen mode return !this.ui.isFullScreen; diff --git a/ghost/admin/app/utils/gift-link.js b/ghost/admin/app/utils/gift-link.js new file mode 100644 index 00000000000..3376bd4be56 --- /dev/null +++ b/ghost/admin/app/utils/gift-link.js @@ -0,0 +1,18 @@ +// Whether the current user can copy a gift link for the given post/page. +// +// Requires the giftLinks flag, a user who can manage links +// (Owner/Administrator/Editor/Super Editor/Author), and a published, gated +// (non-public) post/page. Authors are limited to their own posts server-side; +// in the lists they only see posts they can edit, so the role check is +// sufficient for surfacing the control here. +// +// The gift-link URL itself is built on the React side (the modal owns it), so +// this util only decides whether to show the entry point. +export function canCopyGiftLink({feature, user, post} = {}) { + if (!feature || !feature.giftLinks || !post) { + return false; + } + const canManage = Boolean(user && (user.isAdmin || user.isEitherEditor || user.isAuthor)); + const eligible = Boolean(post.isPublished && post.visibility && post.visibility !== 'public'); + return canManage && eligible; +} diff --git a/ghost/admin/tests/unit/utils/gift-link-test.js b/ghost/admin/tests/unit/utils/gift-link-test.js new file mode 100644 index 00000000000..9bebf1b6554 --- /dev/null +++ b/ghost/admin/tests/unit/utils/gift-link-test.js @@ -0,0 +1,38 @@ +import {canCopyGiftLink} from 'ghost-admin/utils/gift-link'; +import {describe, it} from 'mocha'; +import {expect} from 'chai'; + +describe('Unit | Utility | gift-link', function () { + const flagOn = {giftLinks: true}; + const admin = {isAdmin: true, isEitherEditor: false, isAuthor: false}; + const author = {isAdmin: false, isEitherEditor: false, isAuthor: true}; + const contributor = {isAdmin: false, isEitherEditor: false, isAuthor: false}; + const gatedPublished = {isPublished: true, visibility: 'paid'}; + + it('allows a managing user on a published, gated post', function () { + expect(canCopyGiftLink({feature: flagOn, user: admin, post: gatedPublished})).to.be.true; + expect(canCopyGiftLink({feature: flagOn, user: author, post: gatedPublished})).to.be.true; + expect(canCopyGiftLink({feature: flagOn, user: admin, post: {isPublished: true, visibility: 'members'}})).to.be.true; + }); + + it('is false when the labs flag is off', function () { + expect(canCopyGiftLink({feature: {giftLinks: false}, user: admin, post: gatedPublished})).to.be.false; + }); + + it('is false for users who cannot manage links', function () { + expect(canCopyGiftLink({feature: flagOn, user: contributor, post: gatedPublished})).to.be.false; + }); + + it('is false for public, draft, or non-gated posts', function () { + expect(canCopyGiftLink({feature: flagOn, user: admin, post: {isPublished: true, visibility: 'public'}})).to.be.false; + expect(canCopyGiftLink({feature: flagOn, user: admin, post: {isPublished: false, visibility: 'paid'}})).to.be.false; + expect(canCopyGiftLink({feature: flagOn, user: admin, post: {isPublished: true, visibility: null}})).to.be.false; + }); + + it('is false when feature, user, or post is missing', function () { + expect(canCopyGiftLink({user: admin, post: gatedPublished})).to.be.false; + expect(canCopyGiftLink({feature: flagOn, post: gatedPublished})).to.be.false; + expect(canCopyGiftLink({feature: flagOn, user: admin})).to.be.false; + expect(canCopyGiftLink()).to.be.false; + }); +}); From cb0bb20e1ef7b9c21e9aee1ec9479d32e4e2b348 Mon Sep 17 00:00:00 2001 From: Weyland Swart <49831538+weylandswart@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:04:53 +0100 Subject: [PATCH 2/2] Design fixes --- .../views/PostAnalytics/Overview/overview.tsx | 2 +- .../PostAnalytics/modals/gift-link-modal.tsx | 7 +-- .../posts-stats/post-share-modal.tsx | 54 +++++++++---------- .../components/posts-list/context-menu.hbs | 16 +++--- .../admin/app/styles/components/dropdowns.css | 1 + ghost/admin/public/assets/icons/gift-link.svg | 7 +++ 6 files changed, 47 insertions(+), 40 deletions(-) create mode 100644 ghost/admin/public/assets/icons/gift-link.svg diff --git a/apps/posts/src/views/PostAnalytics/Overview/overview.tsx b/apps/posts/src/views/PostAnalytics/Overview/overview.tsx index c37315b5bd9..7b6e2afb5b3 100644 --- a/apps/posts/src/views/PostAnalytics/Overview/overview.tsx +++ b/apps/posts/src/views/PostAnalytics/Overview/overview.tsx @@ -229,7 +229,7 @@ const Overview: React.FC = () => { Visitors - {giftLinkUsage ? formatNumber(giftLinkUsage.visits) : '—'} + {formatNumber(giftLinkUsage?.visits || 0)} diff --git a/apps/posts/src/views/PostAnalytics/modals/gift-link-modal.tsx b/apps/posts/src/views/PostAnalytics/modals/gift-link-modal.tsx index 26431b4b63c..845496fd26b 100644 --- a/apps/posts/src/views/PostAnalytics/modals/gift-link-modal.tsx +++ b/apps/posts/src/views/PostAnalytics/modals/gift-link-modal.tsx @@ -85,7 +85,8 @@ const GiftLinkModal: React.FC = ({open, onOpenChange, postId const giftLinkUrl = buildGiftLinkUrl(post?.url, token); const memberType = post?.visibility === 'members' ? 'member' : 'paid member'; - const description = `Anyone you share this link with will be able to access this post without becoming a ${memberType}.`; + 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) { @@ -145,7 +146,7 @@ const GiftLinkModal: React.FC = ({open, onOpenChange, postId - - )} - - {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 cd85f9c64cb..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}} + {{/if}} {{/if}} - {{#if this.canCopyGiftLink}} -
  • - -
  • - {{/if}} {{#if this.canFeatureSelection}} {{#if this.shouldFeatureSelection}}
  • diff --git a/ghost/admin/app/styles/components/dropdowns.css b/ghost/admin/app/styles/components/dropdowns.css index 2b8c0bb4696..6ff834449f4 100644 --- a/ghost/admin/app/styles/components/dropdowns.css +++ b/ghost/admin/app/styles/components/dropdowns.css @@ -390,6 +390,7 @@ Post context menu stroke-width: 1.8px; } +.gh-posts-context-menu li.gh-posts-context-menu-separated::before, .gh-posts-context-menu li:last-child::before, .gh-analytics-actions-menu li:last-child::before { display: block; diff --git a/ghost/admin/public/assets/icons/gift-link.svg b/ghost/admin/public/assets/icons/gift-link.svg new file mode 100644 index 00000000000..f726aed321a --- /dev/null +++ b/ghost/admin/public/assets/icons/gift-link.svg @@ -0,0 +1,7 @@ + + gift-link + + + + +