From 446d6dc100bc66ea48f97461def3d4b0d2ca297a Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Wed, 22 Jul 2026 11:15:14 -0500 Subject: [PATCH 01/11] Updated offers table to use Shade primitives (#29534) no ref - replaced the raw offers table markup with Shade `Table` primitives - replaced bespoke active, inactive and archived pills with Shade `Badge` - moved table surfaces, borders and secondary text onto semantic Shade tokens - changed click-only anchors into buttons while keeping redemption destinations as links - fixed sorting so Date added, Name, Redemptions and direction apply to every visible signup and retention row - derived retention-row dates from the latest underlying retention offer - added acceptance coverage for every sorting option and both directions --- .../settings/growth/offers/offers-index.tsx | 269 +++++++++++------- .../growth/offers/offers-retention.tsx | 27 +- .../src/settings/offers.acceptance.test.tsx | 44 ++- apps/admin/src/settings/offers.screen.ts | 11 + .../testing/test-data/src/selectors/offers.ts | 1 + 5 files changed, 249 insertions(+), 103 deletions(-) diff --git a/apps/admin-x-settings/src/components/settings/growth/offers/offers-index.tsx b/apps/admin-x-settings/src/components/settings/growth/offers/offers-index.tsx index cdea877afe0..2563e9af7f8 100644 --- a/apps/admin-x-settings/src/components/settings/growth/offers/offers-index.tsx +++ b/apps/admin-x-settings/src/components/settings/growth/offers/offers-index.tsx @@ -1,15 +1,15 @@ +import {Badge, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@tryghost/shade/components'; import {Button, type ButtonProps, showToast} from '@tryghost/admin-x-design-system'; import {ButtonGroup} from '@tryghost/admin-x-design-system'; -import {DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger} from '@tryghost/shade/components'; import {Icon} from '@tryghost/admin-x-design-system'; +import {Inline, Stack} from '@tryghost/shade/primitives'; import {LucideIcon, formatNumber} from '@tryghost/shade/utils'; import {Modal} from '@tryghost/admin-x-design-system'; +import {type Offer, useBrowseOffers} from '@tryghost/admin-x-framework/api/offers'; import {type RetentionOffer, getRetentionOffers} from './offers-retention'; import {type Tier, getPaidActiveTiers, useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers'; import {createOfferRedemptionFilterUrl, createOfferRedemptionsFilterUrl} from './offer-helpers'; import {currencyToDecimal, getSymbol} from '../../../../utils/currency'; -import {numberWithCommas} from '../../../../utils/helpers'; -import {useBrowseOffers} from '@tryghost/admin-x-framework/api/offers'; import {useModal} from '@ebay/nice-modal-react'; import {useOffersShowArchived, useSortingState} from '../../../providers/settings-app-provider'; import {useRouting} from '@tryghost/admin-x-framework/routing'; @@ -24,29 +24,26 @@ export const getOfferDuration = (duration: string): string => { return (duration === 'once' ? 'First payment' : duration === 'repeating' ? 'Repeating' : 'Forever'); }; -export const getOfferDiscount = (type: string, amount: number, cadence: string, currency: string, tier: Tier | undefined): {discountColor: string, discountOffer: string, originalPriceWithCurrency: string, updatedPriceWithCurrency: string} => { - let discountColor = ''; +export const getOfferDiscount = (type: string, amount: number, cadence: string, currency: string, tier: Tier | undefined): {discountOffer: string, originalPriceWithCurrency: string, updatedPriceWithCurrency: string} => { let discountOffer = ''; const originalPrice = cadence === 'month' ? tier?.monthly_price ?? 0 : tier?.yearly_price ?? 0; let updatedPrice = originalPrice; const formatToTwoDecimals = (num: number): number => parseFloat(num.toFixed(2)); + const formatPrice = (num: number): string => formatNumber(formatToTwoDecimals(currencyToDecimal(num)), {maximumFractionDigits: 2}); - let originalPriceWithCurrency = getSymbol(currency) + numberWithCommas(formatToTwoDecimals(currencyToDecimal(originalPrice))); + let originalPriceWithCurrency = getSymbol(currency) + formatPrice(originalPrice); switch (type) { case 'percent': - discountColor = 'text-green'; discountOffer = `${formatNumber(amount)}% off`; updatedPrice = originalPrice - ((originalPrice * amount) / 100); break; case 'fixed': - discountColor = 'text-blue'; - discountOffer = numberWithCommas(formatToTwoDecimals(currencyToDecimal(amount))) + ' ' + currency + ' off'; + discountOffer = `${formatPrice(amount)} ${currency} off`; updatedPrice = originalPrice - amount; break; case 'trial': - discountColor = 'text-pink'; discountOffer = `${formatNumber(amount)} days free`; originalPriceWithCurrency = ''; break; @@ -58,10 +55,9 @@ export const getOfferDiscount = (type: string, amount: number, cadence: string, updatedPrice = 0; } - const updatedPriceWithCurrency = getSymbol(currency) + numberWithCommas(formatToTwoDecimals(currencyToDecimal(updatedPrice))); + const updatedPriceWithCurrency = getSymbol(currency) + formatPrice(updatedPrice); return { - discountColor, discountOffer, originalPriceWithCurrency, updatedPriceWithCurrency @@ -110,31 +106,33 @@ const RetentionOfferRow: React.FC<{ : undefined; return ( - - - - - - - - - - - + + {redemptionFilterUrl ? ( )} - - - - - + + ); }; +const SignupOfferRow: React.FC<{ + archived: boolean; + offer: Offer; + tier: Tier; + onClick: () => void; +}> = ({archived, offer, tier, onClick}) => { + const {discountOffer, originalPriceWithCurrency, updatedPriceWithCurrency} = getOfferDiscount(offer.type, offer.amount, offer.cadence, offer.currency || 'USD', tier); + + return ( + + + + + + + + + + + + {offer.redemption_count > 0 && offer.id ? ( + {formatNumber(offer.redemption_count)} + ) : ( + + )} + + + + + + ); +}; + +type OfferListItem = + | {kind: 'retention'; offer: RetentionOffer} + | {kind: 'signup'; offer: Offer}; + +const getOfferListItemName = (item: OfferListItem): string => item.offer.name; +const getOfferListItemRedemptions = (item: OfferListItem): number => item.kind === 'retention' ? item.offer.redemptions : item.offer.redemption_count; +const getOfferListItemCreatedAt = (item: OfferListItem): string | null => item.kind === 'retention' ? item.offer.createdAt : item.offer.created_at || null; + +const sortOfferListItems = (items: OfferListItem[], sortOption: string, sortDirection: string): OfferListItem[] => { + const multiplier = sortDirection === 'desc' ? -1 : 1; + + return [...items].sort((item1, item2) => { + let result: number; + + switch (sortOption) { + case 'name': + result = getOfferListItemName(item1).localeCompare(getOfferListItemName(item2)); + break; + case 'redemptions': + result = getOfferListItemRedemptions(item1) - getOfferListItemRedemptions(item2); + break; + default: { + const date1 = getOfferListItemCreatedAt(item1); + const date2 = getOfferListItemCreatedAt(item2); + + if (!date1 && !date2) { + result = 0; + } else if (!date1) { + return 1; + } else if (!date2) { + return -1; + } else { + result = new Date(date1).getTime() - new Date(date2).getTime(); + } + break; + } + } + + return (result || getOfferListItemName(item1).localeCompare(getOfferListItemName(item2))) * multiplier; + }); +}; + export const OffersIndexModal: React.FC = () => { const modal = useModal(); const {updateRoute} = useRouting(); @@ -193,22 +284,9 @@ export const OffersIndexModal: React.FC = () => { updateRoute(`offers/edit/retention/${id}`); }; - const sortedOffers = signupOffers - .sort((offer1, offer2) => { - const multiplier = sortDirection === 'desc' ? -1 : 1; - switch (sortOption) { - case 'name': - return multiplier * offer1.name.localeCompare(offer2.name); - case 'redemptions': - return multiplier * (offer1.redemption_count - offer2.redemption_count); - default: - return multiplier * ((offer1.created_at ? new Date(offer1.created_at).getTime() : 0) - (offer2.created_at ? new Date(offer2.created_at).getTime() : 0)); - } - }); - const paidActiveTiers = getPaidActiveTiers(allTiers || []); - const filteredSignupOffers = sortedOffers.filter((offer) => { + const filteredSignupOffers = signupOffers.filter((offer) => { const offerTier = allTiers?.find(tier => tier.id === offer?.tier?.id); const isActive = offer.status === 'active' && offerTier && offerTier.active === true; const isArchived = offer.status === 'archived' || (offerTier && offerTier.active === false); @@ -222,6 +300,11 @@ export const OffersIndexModal: React.FC = () => { return false; }); + const sortedOfferListItems = sortOfferListItems([ + ...retentionOffers.map(offer => ({kind: 'retention' as const, offer})), + ...filteredSignupOffers.map(offer => ({kind: 'signup' as const, offer})) + ], sortOption, sortDirection); + const handleSortChange = (selectedOption: string) => { setSortingState?.([{ type: 'offers', @@ -241,7 +324,7 @@ export const OffersIndexModal: React.FC = () => { const isOfferArchived = (offer: typeof signupOffers[0]) => { const offerTier = allTiers?.find(tier => tier.id === offer?.tier?.id); - return offer.status === 'archived' || (offerTier && offerTier.active === false); + return offer.status === 'archived' || offerTier?.active === false; }; const buttons: ButtonProps[] = [ @@ -272,7 +355,7 @@ export const OffersIndexModal: React.FC = () => { ]; const listLayoutOutput =
- +
@@ -280,14 +363,14 @@ export const OffersIndexModal: React.FC = () => { - - - - - - - - - - - {retentionOffers.map(offer => ( - handleRetentionOfferEdit(offer.id)} - /> - ))} - {filteredSignupOffers.map((offer) => { + + + + + + {sortedOfferListItems.map((item) => { + if (item.kind === 'retention') { + return ( + handleRetentionOfferEdit(item.offer.id)} + /> + ); + } + + const offer = item.offer; const offerTier = allTiers?.find(tier => tier.id === offer?.tier?.id); if (!offerTier) { @@ -318,28 +405,18 @@ export const OffersIndexModal: React.FC = () => { const archived = isOfferArchived(offer); - const {discountOffer, originalPriceWithCurrency, updatedPriceWithCurrency} = getOfferDiscount(offer.type, offer.amount, offer.cadence, offer.currency || 'USD', offerTier); - return ( - - - - - - - + handleOfferEdit(offer.id)} + /> ); })} - -
NameTermsPriceRedemptions - + + + Name + Terms + Price + Redemptions + + Status { onDirectionChange={handleDirectionChange} onSortChange={handleSortChange} /> - -
handleOfferEdit(offer.id)}>{offer?.name}
{offerTier.name} {getOfferCadence(offer.cadence)}
handleOfferEdit(offer.id)}>{discountOffer}
{offer.type !== 'trial' ? getOfferDuration(offer.duration) : 'Trial period'}
handleOfferEdit(offer.id)}>{updatedPriceWithCurrency} {offer.type !== 'trial' ? {originalPriceWithCurrency} : null} 0 && offer.id ? createOfferRedemptionFilterUrl(offer.id) : undefined} onClick={offer.redemption_count === 0 && offer.id ? () => handleOfferEdit(offer.id) : undefined}>{formatNumber(offer.redemption_count)} - handleOfferEdit(offer.id)}> - {archived ? ( - Archived - ) : ( - Active - )} - -
+ +
; return { topRightContent={} width={1140} > -
+ {listLayoutOutput} -
+
; }; diff --git a/apps/admin-x-settings/src/components/settings/growth/offers/offers-retention.tsx b/apps/admin-x-settings/src/components/settings/growth/offers/offers-retention.tsx index de200d81225..6261872c061 100644 --- a/apps/admin-x-settings/src/components/settings/growth/offers/offers-retention.tsx +++ b/apps/admin-x-settings/src/components/settings/growth/offers/offers-retention.tsx @@ -1,4 +1,5 @@ import {type Offer} from '@tryghost/admin-x-framework/api/offers'; +import {formatNumber} from '@tryghost/shade/utils'; type RetentionCadence = 'month' | 'year'; @@ -11,6 +12,7 @@ export type RetentionOffer = { redemptionOfferIds: string[]; redemptions: number; status: 'active' | 'inactive'; + createdAt: string | null; }; const getActiveRetentionOfferByCadence = (offers: Offer[], cadence: RetentionCadence): Offer | null => { @@ -39,6 +41,19 @@ const getRetentionOfferIdsByCadence = (offers: Offer[], cadence: RetentionCadenc .map(offer => offer.id); }; +const getLatestRetentionOfferDateByCadence = (offers: Offer[], cadence: RetentionCadence): string | null => { + const timestamps = offers + .filter(offer => offer.redemption_type === 'retention' && offer.cadence === cadence && offer.created_at) + .map(offer => new Date(offer.created_at!).getTime()) + .filter(timestamp => !Number.isNaN(timestamp)); + + if (timestamps.length === 0) { + return null; + } + + return new Date(Math.max(...timestamps)).toISOString(); +}; + const isFreeMonthsOffer = (offer: Offer): boolean => { return offer.type === 'percent' && offer.amount === 100 && offer.duration === 'repeating'; }; @@ -51,11 +66,11 @@ const getRetentionTerms = (offer: Offer | null): string | null => { if (isFreeMonthsOffer(offer)) { const months = offer.duration_in_months || 0; const monthLabel = months === 1 ? 'month' : 'months'; - return `${months} ${monthLabel} free`; + return `${formatNumber(months)} ${monthLabel} free`; } if (offer.type === 'percent') { - return `${offer.amount}% OFF`; + return `${formatNumber(offer.amount)}% OFF`; } return null; @@ -76,7 +91,7 @@ const getRetentionTermsDetail = (offer: Offer | null): string | null => { if (offer.duration === 'repeating' && offer.duration_in_months) { const monthLabel = offer.duration_in_months === 1 ? 'month' : 'months'; - return `For ${offer.duration_in_months} ${monthLabel}`; + return `For ${formatNumber(offer.duration_in_months)} ${monthLabel}`; } if (offer.duration === 'forever') { @@ -103,7 +118,8 @@ export const getRetentionOffers = (offers: Offer[]): RetentionOffer[] => { termsDetail: getRetentionTermsDetail(monthlyOffer), redemptionOfferIds: monthlyOfferIds, redemptions: monthlyRedemptions, - status: monthlyOffer ? 'active' : 'inactive' + status: monthlyOffer ? 'active' : 'inactive', + createdAt: getLatestRetentionOfferDateByCadence(offers, 'month') }, { id: 'yearly', @@ -113,7 +129,8 @@ export const getRetentionOffers = (offers: Offer[]): RetentionOffer[] => { termsDetail: getRetentionTermsDetail(yearlyOffer), redemptionOfferIds: yearlyOfferIds, redemptions: yearlyRedemptions, - status: yearlyOffer ? 'active' : 'inactive' + status: yearlyOffer ? 'active' : 'inactive', + createdAt: getLatestRetentionOfferDateByCadence(offers, 'year') } ]; }; diff --git a/apps/admin/src/settings/offers.acceptance.test.tsx b/apps/admin/src/settings/offers.acceptance.test.tsx index 7cdc2ff65d8..2b7a0031b35 100644 --- a/apps/admin/src/settings/offers.acceptance.test.tsx +++ b/apps/admin/src/settings/offers.acceptance.test.tsx @@ -194,6 +194,46 @@ describe("Offers", () => { await expect.element(offersScreen.listModal()).toHaveTextContent("Third offer"); }); + it("sorts signup and retention offers by date, name and redemptions", async () => { + fakeSettingsScreens(); + const supporter = supporterTier(); + const tierRef = { id: supporter.id, name: supporter.name }; + fakeTiers([supporter]); + fakeOffers([ + offer({ name: "Alpha signup", created_at: "2026-01-04T12:00:00.000Z", redemption_count: 5, tier: tierRef }), + offer({ name: "Zulu signup", created_at: "2026-01-01T12:00:00.000Z", redemption_count: 1, tier: tierRef }), + retentionOffer({ name: "Monthly retention offer", created_at: "2026-01-03T12:00:00.000Z", redemption_count: 7 }), + retentionOffer({ name: "Yearly retention offer", cadence: "year", created_at: "2026-01-02T12:00:00.000Z", redemption_count: 3 }), + ]); + await renderAdminApp("/settings/offers/edit", withStripe()); + + const rows = offersScreen.tableRows(); + await expect(rows).toHaveCount(4); + + const expectOrder = async (names: string[]) => { + for (const [index, name] of names.entries()) { + await expect.element(rows.nth(index)).toHaveTextContent(name); + } + }; + + await expectOrder(["Alpha signup", "Monthly retention", "Yearly retention", "Zulu signup"]); + + await offersScreen.sortOffersBy("Name"); + await expectOrder(["Zulu signup", "Yearly retention", "Monthly retention", "Alpha signup"]); + + await offersScreen.toggleSortDirection("Descending"); + await expectOrder(["Alpha signup", "Monthly retention", "Yearly retention", "Zulu signup"]); + + await offersScreen.sortOffersBy("Redemptions"); + await expectOrder(["Zulu signup", "Yearly retention", "Alpha signup", "Monthly retention"]); + + await offersScreen.toggleSortDirection("Ascending"); + await expectOrder(["Monthly retention", "Alpha signup", "Yearly retention", "Zulu signup"]); + + await offersScreen.sortOffersBy("Date added"); + await expectOrder(["Alpha signup", "Monthly retention", "Yearly retention", "Zulu signup"]); + }); + it("supports updating an offer", async () => { fakeSettingsScreens(); const supporter = supporterTier(); @@ -275,7 +315,7 @@ describe("Offers", () => { const rows = offersScreen.retentionRows(); await expect(rows).toHaveCount(2); - const monthlyRow = rows.nth(0); + const monthlyRow = rows.filter({ hasText: "Monthly retention" }); await expect.element(monthlyRow).toHaveTextContent("Monthly retention"); await expect.element(monthlyRow).toHaveTextContent("25% OFF"); await expect.element(monthlyRow).toHaveTextContent("First payment"); @@ -285,7 +325,7 @@ describe("Offers", () => { .element(offersScreen.retentionRedemptionsLink("monthly")) .toHaveAttribute("href", "/ghost/#/members?filter=offer_redemptions%3A%5Bretention-month-active%2Cretention-month-archived%5D"); - const yearlyRow = rows.nth(1); + const yearlyRow = rows.filter({ hasText: "Yearly retention" }); await expect.element(yearlyRow).toHaveTextContent("Yearly retention"); await expect.element(yearlyRow).toHaveTextContent("Inactive"); await expect.element(yearlyRow).toHaveTextContent("9"); diff --git a/apps/admin/src/settings/offers.screen.ts b/apps/admin/src/settings/offers.screen.ts index 55f98559fc4..3d5b46400c4 100644 --- a/apps/admin/src/settings/offers.screen.ts +++ b/apps/admin/src/settings/offers.screen.ts @@ -10,6 +10,7 @@ export const offersScreen = { listModal: () => page.getByTestId(testIds.listModal), listRows: () => page.getByTestId(testIds.listRow), + tableRows: () => page.getByTestId(testIds.tableBody).getByRole("row"), retentionRows: () => page.getByTestId(testIds.retentionRow), retentionRedemptionsLink: (cadence: "monthly" | "yearly") => page.getByTestId(testIds.retentionRedemptionsLink(cadence)), newOfferButton: () => page.getByTestId(testIds.listModal).getByRole("button", { name: names.newOfferButton }), @@ -38,4 +39,14 @@ export const offersScreen = { await offersScreen.listModal().getByRole("button", { name: names.filterOptionsButton }).click(); await page.getByRole("menuitemcheckbox", { name: names.showArchivedToggle }).click(); }, + + async sortOffersBy(option: "Date added" | "Name" | "Redemptions"): Promise { + await offersScreen.listModal().getByRole("button", { name: names.filterOptionsButton }).click(); + await page.getByRole("menuitemradio", { name: option }).click(); + }, + + async toggleSortDirection(direction: "Ascending" | "Descending"): Promise { + await offersScreen.listModal().getByRole("button", { name: names.filterOptionsButton }).click(); + await page.getByRole("menuitem").filter({ hasText: direction }).click(); + }, }; diff --git a/packages/testing/test-data/src/selectors/offers.ts b/packages/testing/test-data/src/selectors/offers.ts index a9236488430..a89c397e531 100644 --- a/packages/testing/test-data/src/selectors/offers.ts +++ b/packages/testing/test-data/src/selectors/offers.ts @@ -6,6 +6,7 @@ export const offersSelectors = { testIds: { section: "offers", listModal: "offers-modal", + tableBody: "offers-table-body", listRow: "offer-item", retentionRow: "retention-offer-item", retentionRedemptionsLink: (cadence: "monthly" | "yearly") => `retention-redemptions-link-${cadence}`, From ba93b3c25f6d945b9b6883af759dc6a4a6f09945 Mon Sep 17 00:00:00 2001 From: Aileen Booker Date: Wed, 22 Jul 2026 20:08:45 +0400 Subject: [PATCH 02/11] Fixed stale Ember admin assets being shipped from the Nx remote cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref [GVA-868](https://linear.app/ghost/issue/GVA-868/allow-billing-to-navigate-to-approved-ghost-admin-settings) Ember-only changes silently never shipped: `@tryghost/admin:build` lists `ghost-admin` in `dependsOn`, which orders the Ember build before the React build but creates no project-graph edge, and Nx computes the `^default` input hash from graph dependencies only. An Ember-only source change therefore never invalidated `@tryghost/admin:build`, so CI restored the whole combined `apps/admin/dist` (and `ghost/core/core/built/admin`) from the remote cache while the freshly built Ember output was silently discarded — the build for merge commit `e91932a4037` served an Ember bundle byte-identical to the pre-merge build, with the new billing navigation code missing, even though `ghost-admin:build` itself ran as a cache miss in the same job. Declaring `ghost-admin` as a `workspace:*` devDependency of `@tryghost/admin` gives pnpm and Nx the same source of truth: Nx derives the graph edge from it, putting Ember sources into the build hash (`nx show projects --affected --files=apps/ember-admin/...` now lists `@tryghost/admin`), and pnpm's filtered installs follow it, so the Playwright acceptance job's `pnpm install --filter @tryghost/admin...` installs the Ember app's toolchain for the `ghost-admin:build` task that now runs in its `^build` chain. `apps/ember-admin` has no dependency back on `@tryghost/admin`, so no cycle is introduced. --- apps/admin/package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/apps/admin/package.json b/apps/admin/package.json index 755c4c405bc..c4312df3842 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -63,6 +63,7 @@ "eslint-plugin-react-hooks": "catalog:", "eslint-plugin-react-refresh": "catalog:", "eslint-plugin-tailwindcss": "catalog:", + "ghost-admin": "workspace:*", "globals": "catalog:", "jest-extended": "7.0.0", "jsdom": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09180e28153..1213a0e0ad5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -759,6 +759,9 @@ importers: eslint-plugin-tailwindcss: specifier: 'catalog:' version: 4.0.4(@typescript/typescript6@6.0.2)(eslint@9.39.4(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(tailwindcss@4.2.2) + ghost-admin: + specifier: workspace:* + version: link:../ember-admin globals: specifier: 'catalog:' version: 17.7.0 From 49d24c7bcd5056783bee152ca60459227204e7e8 Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Wed, 22 Jul 2026 09:35:12 -0700 Subject: [PATCH 03/11] Added `track_clicks` and `clicked_at` columns to `automated_email_recipients` table (#29529) closes https://linear.app/ghost/issue/NY-1487/ ## Summary - Added track_clicks and clicked_at columns to automated email recipients - Updated the current schema definition and integrity hash --- ...-click-tracking-to-automated-email-recipients.js | 13 +++++++++++++ ghost/core/core/server/data/schema/schema.js | 2 ++ .../automations/database-automations-repository.ts | 2 ++ .../automations/welcome-email-automation-poll.js | 3 ++- .../test/unit/server/data/schema/integrity.test.js | 2 +- .../automations/automations-repository.test.ts | 5 +++++ 6 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 ghost/core/core/server/data/migrations/versions/6.54/2026-07-22-10-41-08-add-click-tracking-to-automated-email-recipients.js diff --git a/ghost/core/core/server/data/migrations/versions/6.54/2026-07-22-10-41-08-add-click-tracking-to-automated-email-recipients.js b/ghost/core/core/server/data/migrations/versions/6.54/2026-07-22-10-41-08-add-click-tracking-to-automated-email-recipients.js new file mode 100644 index 00000000000..4bae0fce5aa --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/6.54/2026-07-22-10-41-08-add-click-tracking-to-automated-email-recipients.js @@ -0,0 +1,13 @@ +const {combineNonTransactionalMigrations, createAddColumnMigration} = require('../../utils'); + +module.exports = combineNonTransactionalMigrations( + createAddColumnMigration('automated_email_recipients', 'track_clicks', { + type: 'boolean', + nullable: false, + defaultTo: false + }, {algorithm: 'auto'}), + createAddColumnMigration('automated_email_recipients', 'clicked_at', { + type: 'dateTime', + nullable: true + }, {algorithm: 'auto'}) +); diff --git a/ghost/core/core/server/data/schema/schema.js b/ghost/core/core/server/data/schema/schema.js index 5bb72194f2f..b73a4b7dcf4 100644 --- a/ghost/core/core/server/data/schema/schema.js +++ b/ghost/core/core/server/data/schema/schema.js @@ -1340,7 +1340,9 @@ module.exports = { mailgun_message_id: {type: 'string', maxlength: 1000, nullable: true}, delivered_at: {type: 'dateTime', nullable: true}, opened_at: {type: 'dateTime', nullable: true}, + clicked_at: {type: 'dateTime', nullable: true}, track_opens: {type: 'boolean', nullable: false, defaultTo: false}, + track_clicks: {type: 'boolean', nullable: false, defaultTo: false}, created_at: {type: 'dateTime', nullable: false}, updated_at: {type: 'dateTime', nullable: true} }, diff --git a/ghost/core/core/server/services/automations/database-automations-repository.ts b/ghost/core/core/server/services/automations/database-automations-repository.ts index a7a2ef38fcd..74bd1f5a816 100644 --- a/ghost/core/core/server/services/automations/database-automations-repository.ts +++ b/ghost/core/core/server/services/automations/database-automations-repository.ts @@ -242,6 +242,8 @@ export function createDatabaseAutomationsRepository({ automation_action_revision_id: options.automationActionRevisionId, ...(options.mailgunMessageId ? {mailgun_message_id: options.mailgunMessageId} : {}), track_opens: options.trackOpens, + // TODO(NY-1491) Snapshot the email_track_clicks setting when creating recipients. + track_clicks: false, created_at: now, updated_at: now }); diff --git a/ghost/core/core/server/services/automations/welcome-email-automation-poll.js b/ghost/core/core/server/services/automations/welcome-email-automation-poll.js index 69945ecedf0..a70344bdcac 100644 --- a/ghost/core/core/server/services/automations/welcome-email-automation-poll.js +++ b/ghost/core/core/server/services/automations/welcome-email-automation-poll.js @@ -235,7 +235,8 @@ async function processRun({ member_uuid: member.get('uuid'), member_email: member.get('email'), member_name: member.get('name'), - track_opens: false + track_opens: false, + track_clicks: false }, {transacting}); await markExited(run.id, 'finished', transacting); diff --git a/ghost/core/test/unit/server/data/schema/integrity.test.js b/ghost/core/test/unit/server/data/schema/integrity.test.js index 5e67e4ef416..723f20655ab 100644 --- a/ghost/core/test/unit/server/data/schema/integrity.test.js +++ b/ghost/core/test/unit/server/data/schema/integrity.test.js @@ -35,7 +35,7 @@ const validateRouteSettings = require('../../../../../core/server/services/route */ describe('DB version integrity', function () { // Only these variables should need updating - const currentSchemaHash = 'eec8860c75b34885e8698f996367a936'; + const currentSchemaHash = '2f3efab004dbe06fa13f9902af24aa56'; const currentFixturesHash = '065b413e1d1f4f95fa7cb7734c5e7934'; const currentSettingsHash = '397be8628c753b1959b8954d5610f83f'; const currentRoutesHash = '3d180d52c663d173a6be791ef411ed01'; diff --git a/ghost/core/test/unit/server/services/automations/automations-repository.test.ts b/ghost/core/test/unit/server/services/automations/automations-repository.test.ts index 3d0df1d6784..f9aa1029e03 100644 --- a/ghost/core/test/unit/server/services/automations/automations-repository.test.ts +++ b/ghost/core/test/unit/server/services/automations/automations-repository.test.ts @@ -131,7 +131,9 @@ const createDatabase = async (): Promise => { table.text('mailgun_message_id'); table.datetime('delivered_at'); table.datetime('opened_at'); + table.datetime('clicked_at'); table.boolean('track_opens').notNullable().defaultTo(false); + table.boolean('track_clicks').notNullable().defaultTo(false); table.datetime('created_at'); table.datetime('updated_at'); }); @@ -1879,7 +1881,9 @@ describe('automations repository', function () { mailgun_message_id: 'mailgun-message-id', delivered_at: null, opened_at: null, + clicked_at: null, track_opens: 1, + track_clicks: 0, created_at: recipient.created_at, updated_at: recipient.updated_at }); @@ -1911,6 +1915,7 @@ describe('automations repository', function () { assert.equal(recipient.mailgun_message_id, null); assert.equal(recipient.member_name, null); assert.equal(recipient.track_opens, 0); + assert.equal(recipient.track_clicks, 0); }); }); From 3bc63bfdcea2cb13c49f0ed72c7c929812fb0d79 Mon Sep 17 00:00:00 2001 From: Troy Ciesco Date: Wed, 22 Jul 2026 12:39:08 -0400 Subject: [PATCH 04/11] Added logic to show/hide automation email opens/clicks (#29497) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes [NY-1466](https://linear.app/ghost/issue/NY-1466) When "Email opens" or "Email clicks" is disabled in Settings → Analytics, automation email metrics show a muted "Off" instead of a number — on the canvas step footer and in the step sidebar's Email performance panel. Metrics stay in place rather than hiding, and "Off" is distinct from "--" (tracking on, no data yet). Untracked sidebar tiles get a hover card explaining why, their chart rings fade to an empty track, and the status is exposed to screen readers. Display-only — re-enabling tracking restores the numbers immediately. Gated behind the `automationAnalytics` labs flag. To test: open an automation with a Send email step and toggle the two settings. Note that there's no actual click data yet, so you'll only see "Off" or "--". demo: https://github.com/user-attachments/assets/d9b39db9-2401-49fe-b418-ffbca01b1be1 --- .../canvas/email-performance-section.tsx | 153 ++++++++++++------ .../automations/components/canvas/nodes.tsx | 35 ++-- .../components/canvas/off-value.tsx | 13 ++ apps/admin/src/automations/editor.test.tsx | 116 ++++++++++--- .../hooks/use-email-tracking-settings.ts | 8 + 5 files changed, 238 insertions(+), 87 deletions(-) create mode 100644 apps/admin/src/automations/components/canvas/off-value.tsx create mode 100644 apps/admin/src/automations/hooks/use-email-tracking-settings.ts diff --git a/apps/admin/src/automations/components/canvas/email-performance-section.tsx b/apps/admin/src/automations/components/canvas/email-performance-section.tsx index a2f3fcbc7ed..c75906b5791 100644 --- a/apps/admin/src/automations/components/canvas/email-performance-section.tsx +++ b/apps/admin/src/automations/components/canvas/email-performance-section.tsx @@ -1,9 +1,11 @@ import React from 'react'; -import {ChartContainer, Separator} from '@tryghost/shade/components'; +import {useEmailTrackingSettings} from '@/automations/hooks/use-email-tracking-settings'; +import {ChartContainer, HoverCard, HoverCardContent, HoverCardTrigger, Separator} from '@tryghost/shade/components'; import type {ChartConfig} from '@tryghost/shade/components'; import type {AutomationEmailStats} from '@tryghost/admin-x-framework/api/automations'; -import {Recharts, formatNumber} from '@tryghost/shade/utils'; +import {Recharts, cn, formatNumber} from '@tryghost/shade/utils'; import {formatRate} from './format-stats'; +import {OffValue, TRACKING_OFF_MESSAGE} from './off-value'; const EMAIL_PERFORMANCE_CHART_CONFIG = { value: {label: 'Rate'} @@ -15,13 +17,19 @@ const EmailPerformanceRing: React.FC<{ color: 'purple' | 'blue' | 'teal'; innerRadius: number; outerRadius: number; -}> = ({datatype, value, color, innerRadius, outerRadius}) => { + tracked?: boolean; +}> = ({datatype, value, color, innerRadius, outerRadius, tracked = true}) => { const gradientId = `emailRing-${color}`; const colorVar = `var(--chart-${color})`; return ( - + = ({openRate}) => ( +const EmailPerformanceChart: React.FC<{ + clickRate: number; + openRate: number; + clicksTracked: boolean; + opensTracked: boolean; +}> = ({clickRate, openRate, clicksTracked, opensTracked}) => (
= ({openRate}) => ( datatype='Opened' innerRadius={EMAIL_CHART_RINGS.opened.innerRadius} outerRadius={EMAIL_CHART_RINGS.opened.outerRadius} + tracked={opensTracked} value={openRate} /> - {/* @TODO: NY-1457 */} - {/* */} + />
); -const KPI_CLASS_NAME = 'group/kpi -mx-2 -my-1 flex flex-col gap-0.5 rounded-md px-2 py-1 transition-colors hover:bg-muted'; +const KPI_CLASS_NAME = 'group/kpi -mx-2 -my-1 flex flex-col gap-0.5 rounded-md px-2 py-1 transition-colors hover:bg-muted focus-visible:bg-muted focus-visible:outline-none'; -export const EmailPerformanceSection: React.FC<{stats: AutomationEmailStats}> = ({stats}) => ( -
- -
-

- Email performance -

-
-
- - - {formatNumber(stats.email_sent_count)} -
-
- - +const Kpi: React.FC<{ + label: string; + color: string; + tracked?: boolean; + value: string; + hoverValue?: string; +}> = ({label, color, tracked = true, value, hoverValue}) => { + const tile = ( +
+ + + {tracked + ? ( - {formatRate(stats.opened_rate)} - {stats.email_sent_count > 0 ? formatNumber(stats.email_opened_count) : '--'} + {value} + {hoverValue ?? value} + ) + : } +
+ ); + if (tracked) { + return tile; + } + return ( + + {tile} + {TRACKING_OFF_MESSAGE} + + ); +}; + +export const EmailPerformanceSection: React.FC<{stats: AutomationEmailStats}> = ({stats}) => { + const {emailTrackOpens, emailTrackClicks} = useEmailTrackingSettings(); + + return ( +
+ +
+

+ Email performance +

+
+ + 0 ? formatNumber(stats.email_opened_count) : '--'} + label='Opened' + tracked={emailTrackOpens} + value={formatRate(stats.opened_rate)} + /> +
- {/* @TODO: NY-1457 */} - {/*
- - - {formatRate(stats.clicked_rate)} -
*/} +
- + {/* @TODO: NY-1457 — hidden entirely when click tracking is off; the + off-state is already conveyed by the Clicked KPI and ring above. */} + {/* {emailTrackClicks && ( + <> + +
+ Top clicked links + Members +
+ + )} */}
- {/* @TODO: NY-1457 */} - {/* -
- Top clicked links - Members -
*/} -
-); + ); +}; diff --git a/apps/admin/src/automations/components/canvas/nodes.tsx b/apps/admin/src/automations/components/canvas/nodes.tsx index fa633c102ba..354ff9b8abd 100644 --- a/apps/admin/src/automations/components/canvas/nodes.tsx +++ b/apps/admin/src/automations/components/canvas/nodes.tsx @@ -1,12 +1,14 @@ import '@xyflow/react/dist/style.css'; import React, {useRef, useState} from 'react'; import StepPicker, {type StepPickerType} from './step-picker'; +import {useEmailTrackingSettings} from '@/automations/hooks/use-email-tracking-settings'; import {ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, Popover, PopoverContent, PopoverTrigger} from '@tryghost/shade/components'; import {Handle, Position} from '@xyflow/react'; import type {Node, NodeProps} from '@xyflow/react'; import type {AutomationEmailStats} from '@tryghost/admin-x-framework/api/automations'; import {LucideIcon, cn, formatNumber} from '@tryghost/shade/utils'; import {formatRate} from './format-stats'; +import {OffValue} from './off-value'; // React Flow node IDs for the trigger and tail nodes. The canvas builds the visual graph using // these; they are not action IDs and never reach the API. @@ -167,24 +169,27 @@ const StepNodeContent: React.FC<{data: StepNodeData}> = ({data}) => { ); }; -const EmailStepStatsFooter: React.FC<{stats: AutomationEmailStats}> = ({stats}) => ( -
-
- Sent - {formatNumber(stats.email_sent_count)} -
-
- Opened - {formatRate(stats.opened_rate)} -
- {/* @TODO: NY-1457 */} - {/*
- Clicked - {formatRate(stats?.clicked_rate)} -
*/} +const FooterMetric: React.FC<{label: string; tracked?: boolean; children: React.ReactNode}> = ({label, tracked = true, children}) => ( +
+ {label} + {tracked + ? {children} + : }
); +const EmailStepStatsFooter: React.FC<{stats: AutomationEmailStats}> = ({stats}) => { + const {emailTrackOpens, emailTrackClicks} = useEmailTrackingSettings(); + + return ( +
+ {formatNumber(stats.email_sent_count)} + {formatRate(stats.opened_rate)} + {formatRate(stats.clicked_rate)} +
+ ); +}; + const TriggerNode = React.memo>(({data}) => ( diff --git a/apps/admin/src/automations/components/canvas/off-value.tsx b/apps/admin/src/automations/components/canvas/off-value.tsx new file mode 100644 index 00000000000..a4f28a3342c --- /dev/null +++ b/apps/admin/src/automations/components/canvas/off-value.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import {cn} from '@tryghost/shade/utils'; + +export const TRACKING_OFF_MESSAGE = 'Tracking is off in Analytics settings.'; + +export const OffValue: React.FC<{ + className?: string; +}> = ({className}) => ( + + + {TRACKING_OFF_MESSAGE} + +); diff --git a/apps/admin/src/automations/editor.test.tsx b/apps/admin/src/automations/editor.test.tsx index 666f1ce653f..2ba8eaf94fb 100644 --- a/apps/admin/src/automations/editor.test.tsx +++ b/apps/admin/src/automations/editor.test.tsx @@ -1,5 +1,7 @@ import AutomationEditor from './editor'; import React from 'react'; +import {AppProvider} from '@tryghost/admin-x-framework'; +import type {AppSettings} from '@tryghost/admin-x-framework'; import {MAX_AUTOMATION_ACTIONS} from '@tryghost/admin-x-framework/api/automations'; import type {AutomationDetail, AutomationDetailResponseType, EditAutomationPayload} from '@tryghost/admin-x-framework/api/automations'; import {RouterProvider, createMemoryRouter} from 'react-router'; @@ -14,6 +16,21 @@ const {mockToastError} = vi.hoisted(() => ({ const NON_EMPTY_EMAIL_LEXICAL = '{"root":{"children":[{"type":"paragraph","children":[{"type":"text","text":"Welcome email body"}]}]}}'; +const createAppSettings = (analyticsOverrides: Partial = {}): AppSettings => ({ + paidMembersEnabled: true, + newslettersEnabled: true, + analytics: { + emailTrackClicks: true, + emailTrackOpens: true, + membersTrackSources: true, + outboundLinkTagging: true, + webAnalytics: true, + ...analyticsOverrides + } +}); + +let mockAppSettings: AppSettings | undefined; + vi.mock('sonner', () => ({ toast: { error: mockToastError @@ -263,7 +280,11 @@ const renderEditor = (initialEntries = ['/automations/automation-id-1']) => { return { router, - ...render() + ...render( + + + + ) }; }; @@ -282,6 +303,29 @@ const withEmptyEmailBodies = (fixture: AutomationDetail): AutomationDetail => ({ )) }); +const mockAutomationWithEmailStats = () => { + mockUseReadAutomation.mockReturnValue({ + data: { + automations: [{ + ...automationDetail, + actions: automationDetail.actions.map(action => (action.type === 'send_email' + ? { + ...action, + stats: { + email_sent_count: 1247, + email_opened_count: 780, + opened_rate: 65, + clicked_rate: 24 + } + } + : action)) + }] + }, + isLoading: false, + isError: false + }); +}; + describe('AutomationEditor', () => { beforeEach(() => { mockUseReadAutomation.mockReset(); @@ -295,6 +339,7 @@ describe('AutomationEditor', () => { mockEditMutation.variables = undefined; mockToastError.mockReset(); mockLabs.current = {}; + mockAppSettings = createAppSettings(); }); it('renders the loading state while the automation is fetching', () => { @@ -383,26 +428,7 @@ describe('AutomationEditor', () => { }); it('renders send-email node stats only when the automationAnalytics labs flag is enabled', () => { - mockUseReadAutomation.mockReturnValue({ - data: { - automations: [{ - ...automationDetail, - actions: automationDetail.actions.map(action => (action.type === 'send_email' - ? { - ...action, - stats: { - email_sent_count: 1247, - email_opened_count: 780, - opened_rate: 65, - clicked_rate: null - } - } - : action)) - }] - }, - isLoading: false, - isError: false - }); + mockAutomationWithEmailStats(); const {unmount} = renderEditor(); expect(screen.queryByText('Sent')).not.toBeInTheDocument(); @@ -414,8 +440,52 @@ describe('AutomationEditor', () => { const emailStep = screen.getByRole('button', {name: 'Send email: Welcome to The Blueprint'}); expect(within(emailStep).getByText('Sent').nextElementSibling).toHaveTextContent('1,247'); expect(within(emailStep).getByText('Opened').nextElementSibling).toHaveTextContent('65%'); - // @TODO: NY-1457 — Clicked is deferred until click data is available - expect(within(emailStep).queryByText('Clicked')).not.toBeInTheDocument(); + expect(within(emailStep).getByText('Clicked').nextElementSibling).toHaveTextContent('24%'); + }); + + it.each([ + {emailTrackOpens: true, emailTrackClicks: true}, + {emailTrackOpens: true, emailTrackClicks: false}, + {emailTrackOpens: false, emailTrackClicks: true}, + {emailTrackOpens: false, emailTrackClicks: false} + ])('shows a muted Off for untracked email metrics when opens=$emailTrackOpens and clicks=$emailTrackClicks', ({emailTrackOpens, emailTrackClicks}) => { + mockLabs.current = {automationAnalytics: true}; + mockAppSettings = createAppSettings({emailTrackOpens, emailTrackClicks}); + mockAutomationWithEmailStats(); + + renderEditor(); + + const emailStep = screen.getByRole('button', {name: 'Send email: Welcome to The Blueprint'}); + expect(within(emailStep).getByText('Sent').nextElementSibling).toHaveTextContent('1,247'); + expect(within(emailStep).getByText('Opened').nextElementSibling).toHaveTextContent(emailTrackOpens ? '65%' : 'Off'); + expect(within(emailStep).getByText('Clicked').nextElementSibling).toHaveTextContent(emailTrackClicks ? '24%' : 'Off'); + + fireEvent.click(emailStep); + const sidebar = screen.getByRole('complementary', {name: 'Step details'}); + expect(within(sidebar).queryAllByText('65%').length > 0).toBe(emailTrackOpens); + expect(within(sidebar).queryAllByText('24%').length > 0).toBe(emailTrackClicks); + expect(within(sidebar).queryAllByText('Off')).toHaveLength(Number(!emailTrackOpens) + Number(!emailTrackClicks)); + expect(within(sidebar).getByTestId('email-performance-sent-ring')).toHaveAttribute('data-tracked', 'true'); + expect(within(sidebar).getByTestId('email-performance-opened-ring')).toHaveAttribute('data-tracked', String(emailTrackOpens)); + expect(within(sidebar).getByTestId('email-performance-clicked-ring')).toHaveAttribute('data-tracked', String(emailTrackClicks)); + }); + + it('treats unresolved app settings as untracked', () => { + mockLabs.current = {automationAnalytics: true}; + mockAppSettings = undefined; + mockAutomationWithEmailStats(); + + renderEditor(); + + const emailStep = screen.getByRole('button', {name: 'Send email: Welcome to The Blueprint'}); + expect(within(emailStep).getByText('Opened').nextElementSibling).toHaveTextContent('Off'); + expect(within(emailStep).getByText('Clicked').nextElementSibling).toHaveTextContent('Off'); + + fireEvent.click(emailStep); + const sidebar = screen.getByRole('complementary', {name: 'Step details'}); + expect(within(sidebar).queryAllByText('Off')).toHaveLength(2); + expect(within(sidebar).getByTestId('email-performance-opened-ring')).toHaveAttribute('data-tracked', 'false'); + expect(within(sidebar).getByTestId('email-performance-clicked-ring')).toHaveAttribute('data-tracked', 'false'); }); it('does not render send-email node stats when the action has no stats', () => { diff --git a/apps/admin/src/automations/hooks/use-email-tracking-settings.ts b/apps/admin/src/automations/hooks/use-email-tracking-settings.ts new file mode 100644 index 00000000000..12fb8274fb8 --- /dev/null +++ b/apps/admin/src/automations/hooks/use-email-tracking-settings.ts @@ -0,0 +1,8 @@ +import {useAppContext} from '@tryghost/admin-x-framework'; + +export const useEmailTrackingSettings = () => { + const {appSettings} = useAppContext(); + const {emailTrackOpens = false, emailTrackClicks = false} = appSettings?.analytics ?? {}; + + return {emailTrackOpens, emailTrackClicks}; +}; From f33292174befa76c9a80f9f3a576429879062f7c Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Wed, 22 Jul 2026 11:46:32 -0500 Subject: [PATCH 05/11] Removed legacy settings list and table components (#29535) no ref - added a small compound `ActionList` component to Shade for reusable title/detail/action rows - migrated Settings lists and row-style pseudo-tables to `ActionList` - migrated the webhooks grid to the semantic Shade `Table` - kept recommendation reveal/loading behavior local to recommendations - removed the legacy Admin X `List`, `Table`, `DynamicTable`, `Pagination`, and duplicate pagination hook, including their stories and tests --- .../src/global/layout/page.stories.tsx | 137 +--------- .../src/global/layout/view-container.tsx | 25 +- .../src/global/list-heading.tsx | 47 ---- .../src/global/list-item.stories.tsx | 48 ---- .../src/global/list-item.tsx | 79 ------ .../src/global/list.stories.tsx | 42 --- .../admin-x-design-system/src/global/list.tsx | 65 ----- .../src/global/pagination.stories.tsx | 61 ----- .../src/global/pagination.tsx | 34 --- .../src/global/table-cell.tsx | 36 --- .../src/global/table-head.tsx | 30 --- .../src/global/table-row.stories.tsx | 62 ----- .../src/global/table-row.tsx | 55 ---- .../src/global/table.stories.tsx | 243 ------------------ .../src/global/table.tsx | 198 -------------- .../global/table/dynamic-table.stories.tsx | 206 --------------- .../src/global/table/dynamic-table.tsx | 233 ----------------- .../src/hooks/use-pagination.tsx | 50 ---- apps/admin-x-design-system/src/index.ts | 22 -- .../test/unit/hooks/use-pagination.test.ts | 117 --------- .../settings/advanced/danger-zone.tsx | 49 ++-- .../settings/advanced/history-modal.tsx | 35 +-- .../settings/advanced/integrations.tsx | 37 +-- .../advanced/integrations/webhooks-table.tsx | 67 +++-- .../advanced/integrations/zapier-modal.tsx | 21 +- .../settings/advanced/labs/beta-features.tsx | 7 +- .../settings/advanced/labs/lab-item.tsx | 16 +- .../advanced/labs/migration-options.tsx | 7 +- .../advanced/labs/private-features.tsx | 6 +- .../src/components/settings/email/emails.tsx | 37 ++- .../email/newsletters/newsletters-list.tsx | 52 ++-- .../src/components/settings/general/users.tsx | 72 +++--- .../settings/growth/recommendations.tsx | 6 +- .../incoming-recommendation-list.tsx | 89 ++++--- .../recommendations/recommendation-icon.tsx | 2 +- .../recommendations/recommendation-list.tsx | 50 ++-- .../settings/membership/custom-fields.tsx | 50 ++-- .../settings/membership/member-emails.tsx | 59 +++-- .../membership/portal/portal-links.tsx | 83 +++--- .../site/theme/advanced-theme-settings.tsx | 30 ++- apps/shade/src/components.ts | 1 + .../src/components/ui/action-list.stories.tsx | 52 ++++ apps/shade/src/components/ui/action-list.tsx | 72 ++++++ 43 files changed, 520 insertions(+), 2170 deletions(-) delete mode 100644 apps/admin-x-design-system/src/global/list-heading.tsx delete mode 100644 apps/admin-x-design-system/src/global/list-item.stories.tsx delete mode 100644 apps/admin-x-design-system/src/global/list-item.tsx delete mode 100644 apps/admin-x-design-system/src/global/list.stories.tsx delete mode 100644 apps/admin-x-design-system/src/global/list.tsx delete mode 100644 apps/admin-x-design-system/src/global/pagination.stories.tsx delete mode 100644 apps/admin-x-design-system/src/global/pagination.tsx delete mode 100644 apps/admin-x-design-system/src/global/table-cell.tsx delete mode 100644 apps/admin-x-design-system/src/global/table-head.tsx delete mode 100644 apps/admin-x-design-system/src/global/table-row.stories.tsx delete mode 100644 apps/admin-x-design-system/src/global/table-row.tsx delete mode 100644 apps/admin-x-design-system/src/global/table.stories.tsx delete mode 100644 apps/admin-x-design-system/src/global/table.tsx delete mode 100644 apps/admin-x-design-system/src/global/table/dynamic-table.stories.tsx delete mode 100644 apps/admin-x-design-system/src/global/table/dynamic-table.tsx delete mode 100644 apps/admin-x-design-system/src/hooks/use-pagination.tsx delete mode 100644 apps/admin-x-design-system/test/unit/hooks/use-pagination.test.ts create mode 100644 apps/shade/src/components/ui/action-list.stories.tsx create mode 100644 apps/shade/src/components/ui/action-list.tsx diff --git a/apps/admin-x-design-system/src/global/layout/page.stories.tsx b/apps/admin-x-design-system/src/global/layout/page.stories.tsx index 2f8ed9c8929..d7c2eeef646 100644 --- a/apps/admin-x-design-system/src/global/layout/page.stories.tsx +++ b/apps/admin-x-design-system/src/global/layout/page.stories.tsx @@ -3,11 +3,8 @@ import type {Meta, StoryObj} from '@storybook/react-vite'; import Page, {CustomGlobalAction} from './page'; import ViewContainer from './view-container'; -import {testColumns, testRows} from '../table/dynamic-table.stories'; import {exampleActions as exampleActionButtons} from './view-container.stories'; -import DynamicTable from '../table/dynamic-table'; import Heading from '../heading'; -import {tableRowHoverBgClasses} from '../table-row'; import Button from '../button'; const meta = { @@ -77,138 +74,6 @@ export const CustomGlobalActions: Story = { } }; -const currentAdminExample = - -; - -export const ExampleCurrentAdminList: Story = { - name: 'Example: List in Current Admin', - args: { - children: currentAdminExample - } -}; - -const simpleList = - Just a regular table footer} - pageHasSidebar={false} - rows={testRows(100)} - /> -; - -export const ExampleSimpleList: Story = { - name: 'Example: Simple List', - args: { - pageTabs: pageTabs, - showAppMenu: true, - showGlobalActions: true, - children: simpleList - } -}; - -const stickyList = - Sticky footer} - pageHasSidebar={false} - rows={testRows(40)} - stickyFooter - stickyHeader - /> -; - -export const ExampleStickyList: Story = { - name: 'Example: Sticky Header/Footer List', - args: { - pageTabs: pageTabs, - showAppMenu: true, - showGlobalActions: true, - children: stickyList - } -}; - -const examplePrimaryAction = { - alert('Clicked primary action'); - } - }} - title='Members' - type='page' -> - Sticky footer} - pageHasSidebar={false} - rows={testRows(40)} - stickyFooter - stickyHeader - /> -; - -export const ExamplePrimaryAction: Story = { - name: 'Example: Primary Action', - args: { - pageTabs: pageTabs, - showAppMenu: true, - showGlobalActions: true, - children: examplePrimaryAction - } -}; - -const exampleActionsContent = { - alert('Clicked primary action'); - } - }} - title='Members' - type='page' -> - Sticky footer} - pageHasSidebar={false} - rows={testRows(40)} - stickyFooter - stickyHeader - /> -; - -export const ExampleActions: Story = { - name: 'Example: Custom Actions', - args: { - pageTabs: pageTabs, - showAppMenu: true, - showGlobalActions: true, - children: exampleActionsContent - } -}; - const mockIdeaCards = () => { const cards = []; @@ -263,7 +128,7 @@ const mockPosts = () => { for (let i = 0; i < 11; i++) { posts.push( -
+
diff --git a/apps/admin-x-design-system/src/global/layout/view-container.tsx b/apps/admin-x-design-system/src/global/layout/view-container.tsx index 7c60aa6f86c..757bf991bc0 100644 --- a/apps/admin-x-design-system/src/global/layout/view-container.tsx +++ b/apps/admin-x-design-system/src/global/layout/view-container.tsx @@ -3,7 +3,6 @@ import Heading from '../heading'; import clsx from 'clsx'; import Button, {ButtonColor, ButtonProps} from '../button'; import {ButtonGroupProps} from '../button-group'; -import DynamicTable, {DynamicTableProps} from '../table/dynamic-table'; export interface View { id: string; @@ -118,25 +117,7 @@ const ViewContainer: React.FC = ({ let toolbar = <>; let mainContent:React.ReactNode = <>; - let isSingleDynamicTable; - let singleDynamicTableIsSticky = false; - - if (React.isValidElement(children) && children.type === DynamicTable) { - isSingleDynamicTable = true; - const dynTable = (children as React.ReactElement); - if (dynTable.props.stickyHeader || dynTable.props.stickyFooter) { - singleDynamicTableIsSticky = true; - children = isSingleDynamicTable - ? React.cloneElement(dynTable, { - ...(dynTable.props as DynamicTableProps), - singlePageTable: true - }) - : children; - } - mainContent = children; - } else { - mainContent = children; - } + mainContent = children; toolbarWrapperClassName = clsx( 'z-50', @@ -197,10 +178,6 @@ const ViewContainer: React.FC = ({ mainContainerClassName ); - if (singleDynamicTableIsSticky) { - contentFullBleed = true; - } - contentWrapperClassName = clsx( 'relative mx-auto w-full flex-auto', (!contentFullBleed && type === 'page') && 'max-w-7xl px-[4vw] tablet:px-12', diff --git a/apps/admin-x-design-system/src/global/list-heading.tsx b/apps/admin-x-design-system/src/global/list-heading.tsx deleted file mode 100644 index d0e1f2088d3..00000000000 --- a/apps/admin-x-design-system/src/global/list-heading.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import React from 'react'; -import Heading from './heading'; -import Separator from './separator'; - -export type ListHeadingSize = 'sm' | 'lg'; - -export interface ListHeadingProps { - title?: React.ReactNode; - titleSize?: ListHeadingSize, - actions?: React.ReactNode; - titleSeparator?: boolean; -} - -const ListHeading: React.FC = ({ - title, - titleSize = 'sm', - actions, - titleSeparator -}) => { - let heading; - - if (title) { - const headingTitle = titleSize === 'sm' ? - {title} - : - {title}; - heading = actions ? ( -
- {headingTitle} - {actions} -
- ) : headingTitle; - } - - if (heading || titleSeparator) { - return ( -
- {heading} - {titleSeparator && } -
- ); - } - - return <>; -}; - -export default ListHeading; diff --git a/apps/admin-x-design-system/src/global/list-item.stories.tsx b/apps/admin-x-design-system/src/global/list-item.stories.tsx deleted file mode 100644 index b4cb4b4dcaa..00000000000 --- a/apps/admin-x-design-system/src/global/list-item.stories.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import React, {ReactNode} from 'react'; -import type {Meta, StoryObj} from '@storybook/react-vite'; - -import Button from './button'; -import ListItem from './list-item'; - -const meta = { - title: 'Global / List / List Item', - component: ListItem, - tags: ['autodocs'], - decorators: [(_story: () => ReactNode) => (
{_story()}
)], - argTypes: { - title: {control: 'text'}, - detail: {control: 'text'} - } -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - id: 'list-item', - title: 'A list item', - detail: 'Some details', - action: - -
- ); - - /* If there is more than X items total, where X is the number of items per page that we want to show */ - } else { - return ( -
Showing {total ?? '?'} in total -
- ); - } -}; - -export default Pagination; diff --git a/apps/admin-x-design-system/src/global/table-cell.tsx b/apps/admin-x-design-system/src/global/table-cell.tsx deleted file mode 100644 index 67aace0c3c3..00000000000 --- a/apps/admin-x-design-system/src/global/table-cell.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import clsx from 'clsx'; -import React, {HTMLProps} from 'react'; - -export interface TableCellProps extends HTMLProps { - padding?: boolean; - align?: 'left' | 'center' | 'right'; - valign?: 'top' | 'middle' | 'bottom'; -} - -const TableCell: React.FC = ({ - className, - children, - padding = true, - align = 'left', - valign = 'top', - ...props -}) => { - const tableCellClasses = clsx( - padding ? 'py-3! pr-6! pl-0!' : '', - (align === 'center' && 'text-center'), - (align === 'right' && 'text-right'), - (valign === 'top' && 'align-top'), - (valign === 'middle' && 'align-middle'), - (valign === 'bottom' && 'align-bottom'), - props.onClick && 'hover:cursor-pointer', - className - ); - - return ( - - {children} - - ); -}; - -export default TableCell; diff --git a/apps/admin-x-design-system/src/global/table-head.tsx b/apps/admin-x-design-system/src/global/table-head.tsx deleted file mode 100644 index a64de7586da..00000000000 --- a/apps/admin-x-design-system/src/global/table-head.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import clsx from 'clsx'; -import React, {HTMLProps} from 'react'; -import Heading from './heading'; - -export interface TableHeadProps extends HTMLProps { - sticky?: boolean; -} - -const TableHead: React.FC = ({ - className, - children, - colSpan, - sticky = false, - ...props -}) => { - const tableCellClasses = clsx( - 'pr-6! pl-0! text-left align-top', - sticky && 'sticky top-0 bg-white', - props.onClick && 'hover:cursor-pointer', - className - ); - - return ( - - {children} - - ); -}; - -export default TableHead; diff --git a/apps/admin-x-design-system/src/global/table-row.stories.tsx b/apps/admin-x-design-system/src/global/table-row.stories.tsx deleted file mode 100644 index 4ad1ee9b302..00000000000 --- a/apps/admin-x-design-system/src/global/table-row.stories.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import {ReactNode} from 'react'; -import type {Meta, StoryObj} from '@storybook/react-vite'; - -import Button from './button'; -import TableCell from './table-cell'; -import TableHead from './table-head'; -import TableRow from './table-row'; - -const meta = { - title: 'Global / Table / Table Row', - component: TableRow, - tags: ['autodocs'] -} satisfies Meta; - -const tableHeaderCells = ( - <> - Name - Email - -); - -const tableCells = ( - <> - Jamie Larson - jamie@example.com - -); - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - children: tableCells, - action: -
- ); -}; - -const Table: React.FC = ({ - header, - children, - borderTop, - hint, - hintSeparator, - pageTitle, - className, - pagination, - showMore, - isLoading, - fillContainer = false, - horizontalScroll = false, - paddingXClassName -}) => { - const table = React.useRef(null); - const maxTableHeight = React.useRef(0); - const [tableHeight, setTableHeight] = React.useState(undefined); - - const multiplePages = pagination && pagination.pages && pagination.pages > 1; - - // Observe the height of the table content. This is used to: - // 1) avoid layout jumps when loading a new page of the table - // 2) keep the same table height between pages, cf. https://github.com/TryGhost/Product/issues/3881 - React.useEffect(() => { - if (table.current) { - const resizeObserver = new ResizeObserver((entries) => { - const height = entries[0].target.clientHeight; - setTableHeight(height); - - if (height > maxTableHeight.current) { - maxTableHeight.current = height; - } - }); - - resizeObserver.observe(table.current); - - return () => { - resizeObserver.disconnect(); - }; - } - }, [isLoading, pagination]); - - const loadingStyle = React.useMemo(() => { - if (tableHeight === undefined) { - return { - height: 'auto' - }; - } - - return { - height: maxTableHeight.current - }; - }, [tableHeight]); - - const spaceHeightStyle = React.useMemo(() => { - if (tableHeight === undefined) { - return { - height: 0 - }; - } - - return { - height: maxTableHeight.current - tableHeight - }; - }, [tableHeight]); - - const headerClasses = clsx( - 'h-8 border-b border-grey-200 dark:border-grey-600' - ); - - /** - * To have full-bleed scroll try this: - * - unset width of table - * - set minWidth of table to 100% - * - set side padding of table to 40px - * - unset tableContainer width - * - set minWidth of tableContainer to 100% - * - unset mainContainer width - * - set minWidth of mainContainer to 100% - * - set side margins of outer container to -40px - * - set footer side paddings to 40px - */ - - const tableClasses = clsx( - 'w-full', - fillContainer ? 'min-w-full' : 'w-full', - (borderTop || pageTitle) && 'border-t border-grey-300', - pageTitle ? 'mt-14 mb-0' : 'my-0', - className - ); - - const mainContainerClasses = clsx( - horizontalScroll ? 'overflow-x-auto' : '', - fillContainer ? 'absolute inset-0 min-w-full' : 'w-full' - ); - - const tableContainerClasses = clsx( - fillContainer ? 'max-h-[calc(100%-38px)] w-full overflow-y-auto' : 'w-full', - paddingXClassName - ); - - const footerClasses = clsx( - 'sticky bottom-0 -mt-px bg-white pb-3 dark:bg-black', - paddingXClassName - ); - - return ( - <> -
- {pageTitle && {pageTitle}} - -
- - {header && - {header} - } - {!isLoading && - {children} - } - - {multiplePages &&
} -
-
- - {isLoading &&
} - - {(hint || pagination || showMore) && -
- {(hintSeparator || pagination) && } -
- - {hint ?? ' '} - -
-
} -
- - ); -}; - -export default Table; diff --git a/apps/admin-x-design-system/src/global/table/dynamic-table.stories.tsx b/apps/admin-x-design-system/src/global/table/dynamic-table.stories.tsx deleted file mode 100644 index 91010b31b1a..00000000000 --- a/apps/admin-x-design-system/src/global/table/dynamic-table.stories.tsx +++ /dev/null @@ -1,206 +0,0 @@ -import type {Meta, StoryObj} from '@storybook/react-vite'; -import DynamicTable, {DynamicTableColumn, DynamicTableRow} from './dynamic-table'; -import Pagination from '../pagination'; -import Button from '../button'; - -const meta = { - title: 'Global / Table / Dynamic Table', - component: DynamicTable, - tags: ['autodocs'], - excludeStories: ['testColumns', 'testRows'] -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const testColumns: DynamicTableColumn[] = [ - { - title: 'Member' - }, - { - title: 'Status' - }, - { - title: 'Open rate' - }, - { - title: 'Location', - noWrap: true - }, - { - title: 'Created', - noWrap: true - }, - { - title: 'Signed up on post', - noWrap: true, - maxWidth: '150px' - }, - { - title: 'Newsletter' - }, - { - title: 'Billing period' - }, - { - title: 'Email sent' - }, - { - title: '', - hidden: true, - disableRowClick: true - } -]; - -export const testRows = (noOfRows: number) => { - const data: DynamicTableRow[] = []; - for (let i = 0; i < noOfRows; i++) { - data.push( - { - onClick: () => { - alert('Clicked on row: ' + i); - }, - cells: [ - (
-
- {i % 3 === 0 &&
Jamie Larson
} - {i % 3 === 1 &&
Giana Septimus
} - {i % 3 === 2 &&
Zaire Bator
} -
jamie@larson.com
-
-
), - 'Free', - '40%', - 'London, UK', - '22 June 2023', - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit', - 'Subscribed', - 'Monthly', - '1,303', -
+ + +
Reset all gift links
+
Invalidate every active gift link across your site. Anyone holding one will lose access.
+
+ + + {buttons} +
; }; const BuiltInIntegrations: React.FC = () => { @@ -170,7 +175,7 @@ const BuiltInIntegrations: React.FC = () => { ]; return ( - + {sortedItems.map(item => ( { title={item.title} /> ))} - + ); }; @@ -196,7 +201,7 @@ const CustomIntegrations: React.FC<{integrations: Integration[]}> = ({integratio if (integrations.length) { return ( - + {integrations.map(integration => ( = ({integratio }} />) )} - + ); } else { return diff --git a/apps/admin-x-settings/src/components/settings/advanced/integrations/webhooks-table.tsx b/apps/admin-x-settings/src/components/settings/advanced/integrations/webhooks-table.tsx index f2945af56fc..456f236c5f3 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/integrations/webhooks-table.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/integrations/webhooks-table.tsx @@ -1,8 +1,8 @@ import NiceModal from '@ebay/nice-modal-react'; import WebhookModal from './webhook-modal'; -import {Button, ConfirmationModal, Table, TableRow, showToast} from '@tryghost/admin-x-design-system'; -import {Inline} from '@tryghost/shade/primitives'; +import {Button, ConfirmationModal, showToast} from '@tryghost/admin-x-design-system'; import {type Integration} from '@tryghost/admin-x-framework/api/integrations'; +import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@tryghost/shade/components'; import {formatNumber} from '@tryghost/shade/utils'; import {getWebhookEventLabel} from './webhook-event-options'; import {useDeleteWebhook} from '@tryghost/admin-x-framework/api/webhooks'; @@ -35,42 +35,33 @@ const WebhooksTable: React.FC<{integration: Integration}> = ({integration}) => { return (
- - -
{formatNumber(integration.webhooks?.length || 0)} {integration.webhooks?.length === 1 ? 'webhook' : 'webhooks'}
-
Last triggered
-
-
- {integration.webhooks?.map(webhook => ( - { - e?.stopPropagation(); - handleDelete(webhook.id); - }} /> - } - bgOnHover={false} - hideActions - onClick={() => { - NiceModal.show(WebhookModal, { - webhook, - integrationId: - integration.id - }); - }} - > - -
+ + + {formatNumber(integration.webhooks?.length || 0)} {integration.webhooks?.length === 1 ? 'webhook' : 'webhooks'} + Last triggered + Actions + + + + {integration.webhooks?.map(webhook => ( + { + NiceModal.show(WebhookModal, { + webhook, + integrationId: integration.id + }); + }}> +
{webhook.name}
- Event: + Event: {getWebhookEventLabel(webhook.event)} - URL: + URL: {webhook.target_url}
-
-
+ + {webhook.last_triggered_at && new Date(webhook.last_triggered_at).toLocaleString('default', { weekday: 'short', month: 'short', @@ -80,10 +71,16 @@ const WebhooksTable: React.FC<{integration: Integration}> = ({integration}) => { minute: '2-digit', second: '2-digit' })} -
-
-
- ))} + + +
+ + ); }; diff --git a/apps/admin-x-settings/src/components/settings/email/newsletters/newsletters-list.tsx b/apps/admin-x-settings/src/components/settings/email/newsletters/newsletters-list.tsx index dd43f2d9649..bb8e487a608 100644 --- a/apps/admin-x-settings/src/components/settings/email/newsletters/newsletters-list.tsx +++ b/apps/admin-x-settings/src/components/settings/email/newsletters/newsletters-list.tsx @@ -1,9 +1,8 @@ import React from 'react'; -import {Button, DragIndicator, type SortableItemContainerProps, SortableList, Table, TableRow} from '@tryghost/admin-x-design-system'; -import {Inline} from '@tryghost/shade/primitives'; +import {ActionList, ActionListItem, ActionListItemActions, ActionListItemContent, LoadingIndicator, NoValueLabel, NoValueLabelIcon} from '@tryghost/shade/components'; +import {Button, DragIndicator, type SortableItemContainerProps, SortableList} from '@tryghost/admin-x-design-system'; import {MailX} from 'lucide-react'; import {type Newsletter} from '@tryghost/admin-x-framework/api/newsletters'; -import {NoValueLabel, NoValueLabelIcon} from '@tryghost/shade/components'; import {formatNumber} from '@tryghost/shade/utils'; import {useRouting} from '@tryghost/admin-x-framework/routing'; @@ -32,54 +31,45 @@ const NewsletterItemContainer: React.FC> = ( }; const container = ( - } - className={isDragging ? 'opacity-75' : ''} - hideActions={false} - style={style} - onClick={showDetails} - > - + + {(props.dragHandleAttributes || isDragging) &&
} - {children} -
-
+ + + + ) : ( +
+ + + {title} + {user.email} + +
+ )} + + {canEdit && + + {!incomingRecommendation.recommending_back && ( +
- ) - } testId='incoming-recommendation-list-item' hideActions> - -
- - - {incomingRecommendation.title || incomingRecommendation.url} - -
-
- {(signups === 0) ? ( - - - ) : ( -
- {formatNumber(signups)} -
- )} -
-
- {(signups === 0) ? (null) : ( -
- {freeMembersLabel} -
- )} -
- {incomingRecommendation.recommending_back &&
Recommending
} -
- +
+ )} + ); }; -const IncomingRecommendationList: React.FC = ({incomingRecommendations, stats, pagination, showMore, isLoading}) => { - if (isLoading || incomingRecommendations.length) { - return - {incomingRecommendations.map(rec => )} -
; +const IncomingRecommendationList: React.FC = ({incomingRecommendations, stats, showMore, isLoading}) => { + if (isLoading) { + return
; + } + + if (incomingRecommendations.length) { + return <> + + {incomingRecommendations.map(rec => )} + + {showMore?.hasMore &&
} + ; } else { return No one’s recommended you yet. Once they do, you’ll find them here along with how many memberships they’ve driven. diff --git a/apps/admin-x-settings/src/components/settings/growth/recommendations/recommendation-icon.tsx b/apps/admin-x-settings/src/components/settings/growth/recommendations/recommendation-icon.tsx index 4c024004cec..0c0fd18de16 100644 --- a/apps/admin-x-settings/src/components/settings/growth/recommendations/recommendation-icon.tsx +++ b/apps/admin-x-settings/src/components/settings/growth/recommendations/recommendation-icon.tsx @@ -24,7 +24,7 @@ const RecommendationIcon: React.FC = ({title, favicon, featured_image, is const hint = isGhostSite ? 'This is a Ghost site that supports one-click subscribe' : ''; return ( -
+
{title} {isGhostSite && Ghost Logo}
diff --git a/apps/admin-x-settings/src/components/settings/growth/recommendations/recommendation-list.tsx b/apps/admin-x-settings/src/components/settings/growth/recommendations/recommendation-list.tsx index 2920a5d4851..181a9c9fe8a 100644 --- a/apps/admin-x-settings/src/components/settings/growth/recommendations/recommendation-list.tsx +++ b/apps/admin-x-settings/src/components/settings/growth/recommendations/recommendation-list.tsx @@ -3,17 +3,16 @@ import NiceModal from '@ebay/nice-modal-react'; import React, {useState} from 'react'; import RecommendationIcon from './recommendation-icon'; import useSettingGroup from '../../../../hooks/use-setting-group'; -import {Button, Link, type PaginationData, type ShowMoreData, Table, TableRow} from '@tryghost/admin-x-design-system'; +import {ActionList, ActionListItem, ActionListItemContent, LoadingIndicator, NoValueLabel, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from '@tryghost/shade/components'; +import {Button, Link} from '@tryghost/admin-x-design-system'; import {Inline} from '@tryghost/shade/primitives'; -import {NoValueLabel, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from '@tryghost/shade/components'; import {type Recommendation} from '@tryghost/admin-x-framework/api/recommendations'; import {formatNumber} from '@tryghost/shade/utils'; import {useRouting} from '@tryghost/admin-x-framework/routing'; interface RecommendationListProps { recommendations: Recommendation[], - pagination?: PaginationData, - showMore?: ShowMoreData, + showMore?: {hasMore: boolean; loadMore: () => void}, isLoading: boolean } @@ -37,8 +36,9 @@ const RecommendationItem: React.FC<{recommendation: Recommendation}> = ({recomme const clicks = count === 1 ? 'click' : 'clicks'; return ( - - + + + + + ); }; -const RecommendationList: React.FC = ({recommendations, pagination, showMore, isLoading}) => { +const RecommendationList: React.FC = ({recommendations, showMore, isLoading}) => { const { siteData } = useSettingGroup(); @@ -84,10 +85,18 @@ const RecommendationList: React.FC = ({recommendations, setTimeout(() => setCopied(false), 2000); }; - if (isLoading || recommendations.length) { - return + if (isLoading) { + return
; + } + + if (recommendations.length) { + return <> + + {recommendations.map(recommendation => )} + +
+ {showMore?.hasMore && } +
Shared with new members after signup, or anytime using this link @@ -97,7 +106,7 @@ const RecommendationList: React.FC = ({recommendations, color='clear' hideLabel={true} icon={copied ? 'check-circle' : 'duplicate'} - iconColorClass={copied ? 'text-green w-[14px] h-[14px]' : 'text-grey-600 hover:opacity-80 w-[14px] h-[14px]'} + iconColorClass={copied ? 'text-green w-[14px] h-[14px]' : 'text-muted-foreground hover:opacity-80 w-[14px] h-[14px]'} label={copied ? 'Copied' : 'Copy'} unstyled={true} onClick={copyRecommendationsUrl} @@ -106,14 +115,9 @@ const RecommendationList: React.FC = ({recommendations, {copied ? 'Copied' : 'Copy link'} - - } - isLoading={isLoading} - pagination={pagination} - showMore={showMore} - hintSeparator> - {recommendations && recommendations.map(recommendation => )} -
; +
+
+ ; } else { return
- } - detail={userType.label} - testId='custom-field-list-item' - title={{field.name}} - separator - onClick={() => openModal(field)} - /> + + + {field.name} + {userType.label} + + + + +
)} diff --git a/apps/admin-x-settings/src/components/settings/membership/member-emails.tsx b/apps/admin-x-settings/src/components/settings/membership/member-emails.tsx index 82d76720ba2..ca7b8defbde 100644 --- a/apps/admin-x-settings/src/components/settings/membership/member-emails.tsx +++ b/apps/admin-x-settings/src/components/settings/membership/member-emails.tsx @@ -5,8 +5,8 @@ import WelcomeEmailCustomizeModal from './member-emails/welcome-email-customize- import WelcomeEmailModal from './member-emails/welcome-email-modal'; import useQueryParams from '../../../hooks/use-query-params'; import {APIError} from '@tryghost/admin-x-framework/errors'; -import {Button, ConfirmationModal, Icon, Table, TableRow, showToast} from '@tryghost/admin-x-design-system'; -import {Switch} from '@tryghost/shade/components'; +import {ActionList, ActionListItem, ActionListItemActions, ActionListItemContent, Switch} from '@tryghost/shade/components'; +import {Button, ConfirmationModal, Icon, showToast} from '@tryghost/admin-x-design-system'; import {WELCOME_EMAIL_SLUGS, type WelcomeEmailType, getDefaultWelcomeEmailRecord, getDefaultWelcomeEmailValues} from './member-emails/default-welcome-email-values'; import {checkStripeEnabled, getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {useAddAutomatedEmail, useBrowseAutomatedEmails, useEditAutomatedEmail, useVerifyAutomatedEmailSender} from '@tryghost/admin-x-framework/api/automated-emails'; @@ -37,10 +37,29 @@ const EmailPreviewRow: React.FC<{ onToggle }) => { return ( - + + + + + +
{isInitialLoading ? ( -
+
) : ( Edit -
} - hideActions={false} - testId={`${emailType}-welcome-email-row`} - > -
- -
- +
+ + ); }; @@ -106,7 +105,7 @@ const MemberEmailsTable: React.FC<{ onPaidToggle }) => { return ( - + )} -
+ ); }; diff --git a/apps/admin-x-settings/src/components/settings/membership/portal/portal-links.tsx b/apps/admin-x-settings/src/components/settings/membership/portal/portal-links.tsx index 6dc49e83a63..24c4a5ef434 100644 --- a/apps/admin-x-settings/src/components/settings/membership/portal/portal-links.tsx +++ b/apps/admin-x-settings/src/components/settings/membership/portal/portal-links.tsx @@ -1,6 +1,6 @@ import React, {useEffect, useId, useState} from 'react'; -import {Button, List, ListItem, ModalPage, TextField} from '@tryghost/admin-x-design-system'; -import {Field, FieldLabel, Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@tryghost/shade/components'; +import {ActionList, ActionListItem, ActionListItemActions, ActionListItemContent, Field, FieldLabel, Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@tryghost/shade/components'; +import {Button, ModalPage, TextField} from '@tryghost/admin-x-design-system'; import {getHomepageUrl} from '@tryghost/admin-x-framework/api/site'; import {getPaidActiveTiers, useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; @@ -15,22 +15,20 @@ const PortalLink: React.FC = ({name, value}) => { const id = useId(); return ( - { + + + + + +
+ + + + + {paidMembersEnabled && } + + - - -
+
+

Tiers

+ + + Tier: Tier @@ -88,20 +91,24 @@ const PortalLinks: React.FC = () => { -
-
- - - -
+ +
+ + + + + - - - - - - - +
+

Account

+ + + + + + + +
); diff --git a/apps/admin-x-settings/src/components/settings/site/theme/advanced-theme-settings.tsx b/apps/admin-x-settings/src/components/settings/site/theme/advanced-theme-settings.tsx index ab89d813c7e..6a944b2899e 100644 --- a/apps/admin-x-settings/src/components/settings/site/theme/advanced-theme-settings.tsx +++ b/apps/admin-x-settings/src/components/settings/site/theme/advanced-theme-settings.tsx @@ -2,7 +2,8 @@ import InvalidThemeModal, {type FatalErrors} from './invalid-theme-modal'; import NiceModal from '@ebay/nice-modal-react'; import React from 'react'; import useCustomFonts from '../../../../hooks/use-custom-fonts'; -import {Button, ConfirmationModal, LimitModal, List, ListItem, ModalPage, showToast} from '@tryghost/admin-x-design-system'; +import {ActionList, ActionListItem, ActionListItemActions, ActionListItemContent} from '@tryghost/shade/components'; +import {Button, ConfirmationModal, LimitModal, ModalPage, showToast} from '@tryghost/admin-x-design-system'; import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from '@tryghost/shade/components'; import {JSONError} from '@tryghost/admin-x-framework/errors'; import {type Theme, isActiveTheme, isDefaultTheme, isDeletableTheme, isLegacyTheme, useActivateTheme, useDeleteTheme} from '@tryghost/admin-x-framework/api/themes'; @@ -195,24 +196,25 @@ const ThemeList:React.FC = ({ }); return ( - - {themes.map((theme) => { + <> +

Installed themes

+ + {themes.map((theme) => { const label = getThemeLabel(theme); const detail = getThemeVersion(theme); return ( - } - detail={detail} - id={`theme-${theme.name}`} - separator={false} - testId='theme-list-item' - title={label} - /> + + +
{label}
+ {detail &&
{detail}
} +
+ +
); - })} -
+ })} + + ); }; diff --git a/apps/shade/src/components.ts b/apps/shade/src/components.ts index 8235e7e93ee..ce141dc402d 100644 --- a/apps/shade/src/components.ts +++ b/apps/shade/src/components.ts @@ -1,5 +1,6 @@ // UI components — basic reusable controls export * from './components/ui/alert-dialog'; +export * from './components/ui/action-list'; export * from './components/ui/animated-number'; export * from './components/ui/avatar'; export * from './components/ui/badge'; diff --git a/apps/shade/src/components/ui/action-list.stories.tsx b/apps/shade/src/components/ui/action-list.stories.tsx new file mode 100644 index 00000000000..8a37c91cc17 --- /dev/null +++ b/apps/shade/src/components/ui/action-list.stories.tsx @@ -0,0 +1,52 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {ActionList, ActionListItem, ActionListItemActions, ActionListItemContent} from './action-list'; +import {Avatar} from './avatar'; +import {Button} from './button'; + +const meta = { + title: 'Components / Action List', + component: ActionList, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: 'Rows of related settings or resources with optional leading visuals and actions. Use Table for genuinely tabular data.' + } + } + } +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: ( + <> + + + + + + + + + + + Delete all content + Permanently delete all posts and tags. + + + + + + + ) + } +}; diff --git a/apps/shade/src/components/ui/action-list.tsx b/apps/shade/src/components/ui/action-list.tsx new file mode 100644 index 00000000000..bd2d5344bd3 --- /dev/null +++ b/apps/shade/src/components/ui/action-list.tsx @@ -0,0 +1,72 @@ +import {Slot} from '@radix-ui/react-slot'; +import {type VariantProps, cva} from 'class-variance-authority'; +import * as React from 'react'; + +import {cn} from '@/lib/utils'; + +const actionListItemVariants = cva( + 'group/action-list-item relative flex min-w-0 items-stretch border-b border-border last:border-b-transparent', + { + variants: { + hover: { + true: 'before:pointer-events-none before:absolute before:-inset-x-4 before:inset-y-0 before:z-0 before:rounded-md before:bg-table-row-hover before:opacity-0 before:transition-opacity hover:border-b-transparent hover:before:opacity-100', + false: '' + } + }, + defaultVariants: { + hover: true + } + } +); + +const ActionList = React.forwardRef>(({className, ...props}, ref) => ( +
+)); +ActionList.displayName = 'ActionList'; + +interface ActionListItemProps extends React.HTMLAttributes, VariantProps {} + +const ActionListItem = React.forwardRef(({className, hover, ...props}, ref) => ( +
+)); +ActionListItem.displayName = 'ActionListItem'; + +interface ActionListItemContentProps extends React.HTMLAttributes { + asChild?: boolean; +} + +const ActionListItemContent = React.forwardRef(({asChild = false, className, ...props}, ref) => { + const Component = asChild ? Slot : 'div'; + + return ; +}); +ActionListItemContent.displayName = 'ActionListItemContent'; + +const actionListItemActionsVariants = cva( + 'relative z-10 flex shrink-0 items-center py-3 pl-2', + { + variants: { + visibility: { + always: '', + hover: 'md:opacity-0 md:transition-opacity md:group-focus-within/action-list-item:opacity-100 md:group-hover/action-list-item:opacity-100' + } + }, + defaultVariants: { + visibility: 'always' + } + } +); + +interface ActionListItemActionsProps extends React.HTMLAttributes, VariantProps {} + +const ActionListItemActions = React.forwardRef(({className, visibility, ...props}, ref) => ( +
+)); +ActionListItemActions.displayName = 'ActionListItemActions'; + +export { + ActionList, + ActionListItem, + ActionListItemActions, + ActionListItemContent +}; From d66b6231528bf4f1271ff9122509e34ce7a0566e Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Wed, 22 Jul 2026 10:32:42 -0700 Subject: [PATCH 06/11] Added `email_clicked_count` column to `automation_action_revisions` table (#29532) closes https://linear.app/ghost/issue/NY-1488/add-nullable-email-clicked-count-to-automation-action-revisions ## Summary - Adds a nullable unsigned email_clicked_count column to automation_action_revisions - This column is dormant for now, but will be populated soon --- ...d-email-clicked-count-to-automation-action-revisions.js | 7 +++++++ ghost/core/core/server/data/schema/schema.js | 1 + ghost/core/test/unit/server/data/schema/integrity.test.js | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 ghost/core/core/server/data/migrations/versions/6.54/2026-07-22-15-10-30-add-email-clicked-count-to-automation-action-revisions.js diff --git a/ghost/core/core/server/data/migrations/versions/6.54/2026-07-22-15-10-30-add-email-clicked-count-to-automation-action-revisions.js b/ghost/core/core/server/data/migrations/versions/6.54/2026-07-22-15-10-30-add-email-clicked-count-to-automation-action-revisions.js new file mode 100644 index 00000000000..a2c8ef0da00 --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/6.54/2026-07-22-15-10-30-add-email-clicked-count-to-automation-action-revisions.js @@ -0,0 +1,7 @@ +const {createAddColumnMigration} = require('../../utils'); + +module.exports = createAddColumnMigration('automation_action_revisions', 'email_clicked_count', { + type: 'integer', + nullable: true, + unsigned: true +}, {algorithm: 'auto'}); diff --git a/ghost/core/core/server/data/schema/schema.js b/ghost/core/core/server/data/schema/schema.js index b73a4b7dcf4..383c1058429 100644 --- a/ghost/core/core/server/data/schema/schema.js +++ b/ghost/core/core/server/data/schema/schema.js @@ -1266,6 +1266,7 @@ module.exports = { email_design_setting_id: {type: 'string', maxlength: 24, nullable: true, references: 'email_design_settings.id', setNullDelete: true}, email_sent_count: {type: 'integer', nullable: true, unsigned: true}, email_opened_count: {type: 'integer', nullable: true, unsigned: true}, + email_clicked_count: {type: 'integer', nullable: true, unsigned: true}, '@@UNIQUE_CONSTRAINTS@@': [ ['created_at', 'action_id'] ] diff --git a/ghost/core/test/unit/server/data/schema/integrity.test.js b/ghost/core/test/unit/server/data/schema/integrity.test.js index 723f20655ab..b55636a2fbe 100644 --- a/ghost/core/test/unit/server/data/schema/integrity.test.js +++ b/ghost/core/test/unit/server/data/schema/integrity.test.js @@ -35,7 +35,7 @@ const validateRouteSettings = require('../../../../../core/server/services/route */ describe('DB version integrity', function () { // Only these variables should need updating - const currentSchemaHash = '2f3efab004dbe06fa13f9902af24aa56'; + const currentSchemaHash = '2657a1cc3db66153bd743251a0430401'; const currentFixturesHash = '065b413e1d1f4f95fa7cb7734c5e7934'; const currentSettingsHash = '397be8628c753b1959b8954d5610f83f'; const currentRoutesHash = '3d180d52c663d173a6be791ef411ed01'; From bb1422765b553799148756222b20d4f13cf218d0 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Wed, 22 Jul 2026 12:32:57 -0500 Subject: [PATCH 07/11] Removed legacy settings typography and link components (#29538) no ref - Replaced every admin settings use of the legacy Heading, Link, and IconLabel components with Shade primitives or semantic elements. - Updated legacy design-system internals and shared form labels to render through Shade while the package is being retired. - Removed the legacy components, their stories, and their exports. - Aligned Mailgun controls and shared TextField, HtmlField, and CodeEditor labels with current Settings field styling. - Permitted the one-way admin-x-design-system to Shade migration dependency while continuing to forbid dependencies on admin-x-framework. --- .dependency-cruiser.cjs | 10 +- .../src/global/form/code-editor-view.tsx | 4 +- .../src/global/form/color-indicator.tsx | 4 +- .../src/global/form/form.tsx | 4 +- .../src/global/form/html-field.tsx | 4 +- .../src/global/form/text-field.tsx | 4 +- .../src/global/heading.stories.tsx | 74 ------------ .../src/global/heading.tsx | 106 ------------------ .../src/global/icon-label.stories.tsx | 20 ---- .../src/global/icon-label.tsx | 19 ---- .../src/global/layout/page.stories.tsx | 36 +++--- .../src/global/layout/view-container.tsx | 7 +- .../src/global/link.stories.tsx | 24 ---- .../admin-x-design-system/src/global/link.tsx | 31 ----- .../src/global/modal/modal-page.tsx | 4 +- .../src/global/modal/modal.tsx | 6 +- .../global/modal/preview-modal.stories.tsx | 4 +- .../src/global/modal/preview-modal.tsx | 42 ++++++- .../src/global/sortable-list.tsx | 9 +- apps/admin-x-design-system/src/index.ts | 6 - .../settings/setting-group-header.stories.tsx | 5 +- .../src/settings/setting-group-header.tsx | 4 +- .../src/settings/setting-value.tsx | 5 +- .../settings/advanced/code/code-modal.tsx | 5 +- .../advanced/labs/yaml-file-editor-modal.tsx | 5 +- .../universal-import-modal.tsx | 4 +- .../src/components/settings/email/mailgun.tsx | 53 +++++---- .../newsletters/newsletter-detail-modal.tsx | 6 +- .../settings/general/user-detail-modal.tsx | 5 +- .../settings/general/users/custom-header.tsx | 4 +- .../settings/general/users/staff-token.tsx | 5 +- .../embed-signup/embed-signup-sidebar.tsx | 5 +- .../recommendation-description-form.tsx | 5 +- .../recommendations/recommendation-list.tsx | 6 +- .../membership/portal/look-and-feel.tsx | 6 +- .../membership/portal/transistor-settings.tsx | 5 +- .../stripe/stripe-connect-modal.tsx | 17 +-- .../membership/tiers/tier-detail-modal.tsx | 5 +- .../membership/tiers/tier-detail-preview.tsx | 13 ++- .../components/settings/site/change-theme.tsx | 5 +- .../design-and-branding/theme-setting.tsx | 4 +- .../settings/site/theme/official-themes.tsx | 5 +- apps/admin-x-settings/src/main-content.tsx | 5 +- 43 files changed, 194 insertions(+), 406 deletions(-) delete mode 100644 apps/admin-x-design-system/src/global/heading.stories.tsx delete mode 100644 apps/admin-x-design-system/src/global/heading.tsx delete mode 100644 apps/admin-x-design-system/src/global/icon-label.stories.tsx delete mode 100644 apps/admin-x-design-system/src/global/icon-label.tsx delete mode 100644 apps/admin-x-design-system/src/global/link.stories.tsx delete mode 100644 apps/admin-x-design-system/src/global/link.tsx diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 59c13058cc3..5bbe99c196e 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -103,14 +103,15 @@ module.exports = { to: {path: '^@tryghost/(admin-x-framework|admin-x-design-system)'} }, // ============================================================ - // apps/ — admin-x-design-system/ is a leaf; must not depend on higher layers + // apps/ — admin-x-design-system/ may consume Shade while it is being retired, + // but must not depend on application/framework layers // ============================================================ { - name: 'admin-x-design-system-is-leaf', - comment: 'admin-x-design-system/ must not depend on shade or admin-x-framework.', + name: 'admin-x-design-system-does-not-depend-on-framework', + comment: 'admin-x-design-system/ must not depend on admin-x-framework.', severity: 'error', from: {path: '^apps/admin-x-design-system/'}, - to: {path: '^@tryghost/(shade|admin-x-framework)'} + to: {path: '^@tryghost/admin-x-framework'} }, // ============================================================ // apps/ — admin-x-framework/ must not depend on feature apps @@ -152,4 +153,3 @@ module.exports = { exclude: {path: '(^|/)(node_modules|coverage|coverage-next|test|built|dist)/'} } }; - diff --git a/apps/admin-x-design-system/src/global/form/code-editor-view.tsx b/apps/admin-x-design-system/src/global/form/code-editor-view.tsx index a53fa3663a4..6e15213892b 100644 --- a/apps/admin-x-design-system/src/global/form/code-editor-view.tsx +++ b/apps/admin-x-design-system/src/global/form/code-editor-view.tsx @@ -3,8 +3,8 @@ import CodeMirror, {ReactCodeMirrorProps, ReactCodeMirrorRef, BasicSetupOptions} import clsx from 'clsx'; import React, {FocusEventHandler, forwardRef, useEffect, useId, useRef, useState} from 'react'; import {useFocusContext} from '../../providers/design-system-provider'; -import Heading from '../heading'; import LegacyHint from '../legacy-hint'; +import {FieldLabel} from '@tryghost/shade/components'; export interface CodeEditorProps extends Omit { title?: string; @@ -105,7 +105,7 @@ const CodeEditorView = forwardRef(function onFocus={handleFocus} {...props} /> - {title && {title}} + {title && {title}} {hint && {hint}}
} ; diff --git a/apps/admin-x-design-system/src/global/form/color-indicator.tsx b/apps/admin-x-design-system/src/global/form/color-indicator.tsx index 36770faf856..3aef5547d14 100644 --- a/apps/admin-x-design-system/src/global/form/color-indicator.tsx +++ b/apps/admin-x-design-system/src/global/form/color-indicator.tsx @@ -1,6 +1,6 @@ import clsx from 'clsx'; import {Fragment, MouseEvent, useRef} from 'react'; -import Heading from '../heading'; +import {Text} from '@tryghost/shade/primitives'; type SwatchSizes = 'md' | 'lg'; @@ -82,7 +82,7 @@ const ColorIndicator: React.FC = ({title, value, swatches, return (
- {title && {title}} + {title && {title}}
{showSwatches && (
diff --git a/apps/admin-x-design-system/src/global/form/form.tsx b/apps/admin-x-design-system/src/global/form/form.tsx index 62096b62984..80ed5c3e356 100644 --- a/apps/admin-x-design-system/src/global/form/form.tsx +++ b/apps/admin-x-design-system/src/global/form/form.tsx @@ -1,6 +1,6 @@ import clsx from 'clsx'; import React from 'react'; -import Heading from '../heading'; +import {Text} from '@tryghost/shade/primitives'; export interface FormProps { title?: string; @@ -70,7 +70,7 @@ const Form: React.FC = ({ if (grouped || title) { return (
- {title && {title}} + {title && {title}}
{children}
diff --git a/apps/admin-x-design-system/src/global/form/html-field.tsx b/apps/admin-x-design-system/src/global/form/html-field.tsx index d1c6c4df708..ee4edf09df8 100644 --- a/apps/admin-x-design-system/src/global/form/html-field.tsx +++ b/apps/admin-x-design-system/src/global/form/html-field.tsx @@ -1,8 +1,8 @@ -import Heading from '../heading'; import HtmlEditor, {HtmlEditorProps} from './html-editor'; import React from 'react'; import clsx from 'clsx'; import LegacyHint from '../legacy-hint'; +import {FieldLabel} from '@tryghost/shade/components'; export type HtmlFieldProps = HtmlEditorProps & { title?: string; @@ -43,7 +43,7 @@ const HtmlField: React.FC = ({ return (
- {title && {title}} + {title && {title}}
diff --git a/apps/admin-x-design-system/src/global/form/text-field.tsx b/apps/admin-x-design-system/src/global/form/text-field.tsx index c8386206d58..40633b56d01 100644 --- a/apps/admin-x-design-system/src/global/form/text-field.tsx +++ b/apps/admin-x-design-system/src/global/form/text-field.tsx @@ -1,9 +1,9 @@ -import Heading from '../heading'; import React, {FocusEventHandler, useId} from 'react'; import clsx from 'clsx'; import {useFocusContext} from '../../providers/design-system-provider'; import * as FormPrimitive from '@radix-ui/react-form'; import LegacyHint from '../legacy-hint'; +import {FieldLabel} from '@tryghost/shade/components'; export type TextFieldProps = React.InputHTMLAttributes & { inputRef?: React.RefObject; @@ -132,7 +132,7 @@ const TextField: React.FC = ({
{field} - {title && {title}} + {title && {title}} {hint && {hint}}
diff --git a/apps/admin-x-design-system/src/global/heading.stories.tsx b/apps/admin-x-design-system/src/global/heading.stories.tsx deleted file mode 100644 index 0fb784ba566..00000000000 --- a/apps/admin-x-design-system/src/global/heading.stories.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import type {Meta, StoryObj} from '@storybook/react-vite'; - -import Heading from './heading'; - -const meta = { - title: 'Global / Heading', - component: Heading, - tags: ['autodocs'], - argTypes: { - level: { - control: 'select' - } - } -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const H1: Story = { - args: { - children: 'Heading 1' - } -}; - -export const H2: Story = { - args: { - children: 'Heading 2', - level: 2 - } -}; - -export const H3: Story = { - args: { - children: 'Heading 3', - level: 3 - } -}; - -export const H4: Story = { - args: { - children: 'Heading 4', - level: 4 - } -}; - -export const H5: Story = { - args: { - children: 'Heading 5', - level: 5 - } -}; - -export const H6: Story = { - args: { - children: 'Heading 6', - level: 6 - } -}; - -export const H6Grey: Story = { - args: { - children: 'Grey heading 6', - level: 6, - grey: true - } -}; - -export const H6WithSeparator: Story = { - args: { - children: 'Heading 6 with separator', - level: 6, - separator: true - } -}; diff --git a/apps/admin-x-design-system/src/global/heading.tsx b/apps/admin-x-design-system/src/global/heading.tsx deleted file mode 100644 index 2bbe0b8a3d0..00000000000 --- a/apps/admin-x-design-system/src/global/heading.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import clsx from 'clsx'; -import React from 'react'; -import Separator from './separator'; - -export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6; - -interface HeadingBaseProps { - level?: HeadingLevel; - children?: React.ReactNode; - styles?: string; - - /** - * Only available for Heading 6 - */ - grey?: boolean; - separator?: boolean; - - /** - * Uses <label> tag and standardised styling for form labels - */ - useLabelTag?: boolean; - className?: string; -} - -type Heading1to5Props = { - useLabelTag?: false, - level?: Exclude, - grey?: never -} & HeadingBaseProps & React.HTMLAttributes - -type Heading6Props = { - useLabelTag?: false, - level: 6, - grey?: boolean } & HeadingBaseProps & React.HTMLAttributes - -type HeadingLabelProps = { - useLabelTag: true, - level?: never, - grey?: boolean } & HeadingBaseProps & React.LabelHTMLAttributes - -export const Heading6Styles = clsx('text-base font-semibold tracking-normal'); -export const Heading6StylesGrey = clsx( - Heading6Styles, - 'text-base font-semibold text-grey-900 dark:text-grey-500' -); - -export type HeadingProps = Heading1to5Props | Heading6Props | HeadingLabelProps - -const Heading: React.FC = ({ - level = 1, - children, - styles = '', - grey = true, - separator, - useLabelTag, - className = '', - ...props -}) => { - const newElement = `${useLabelTag ? 'label' : `h${level}`}`; - styles += (level === 6 || useLabelTag) ? (` block ${grey ? Heading6StylesGrey : Heading6Styles}`) : ' '; - - if (!useLabelTag) { - switch (level) { - case 1: - styles += ' md:text-4xl leading-tighter'; - break; - case 2: - styles += ' md:text-3xl'; - break; - case 3: - styles += ' md:text-2xl'; - break; - case 4: - styles += ' md:text-xl'; - break; - case 5: - styles += ' md:text-lg'; - break; - default: - break; - } - } - - className = clsx( - styles, - !grey && 'dark:text-white', - className - ); - - const Element = React.createElement(newElement, {className: className, key: 'heading-elem', ...props}, children); - - if (separator) { - const gap = (!level || level === 1) ? 2 : 1; - const bottomMargin = (level === 6) ? 2 : 3; - return ( -
- {Element} - -
- ); - } else { - return Element; - } -}; - -export default Heading; diff --git a/apps/admin-x-design-system/src/global/icon-label.stories.tsx b/apps/admin-x-design-system/src/global/icon-label.stories.tsx deleted file mode 100644 index 4aa3469980b..00000000000 --- a/apps/admin-x-design-system/src/global/icon-label.stories.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type {Meta, StoryObj} from '@storybook/react-vite'; - -import IconLabel from './icon-label'; - -const meta = { - title: 'Global / Label with icon', - component: IconLabel, - tags: ['autodocs'] -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - icon: 'check-circle', - iconColorClass: 'text-green', - children: 'Here\'s a label with icon' - } -}; diff --git a/apps/admin-x-design-system/src/global/icon-label.tsx b/apps/admin-x-design-system/src/global/icon-label.tsx deleted file mode 100644 index a16745a51ea..00000000000 --- a/apps/admin-x-design-system/src/global/icon-label.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; -import Icon from './icon'; - -export interface IconLabelProps { - icon: string; - iconColorClass?: string; - children?: React.ReactNode; -} - -const IconLabel: React.FC = ({icon, iconColorClass, children}) => { - return ( -
- - {children} -
- ); -}; - -export default IconLabel; diff --git a/apps/admin-x-design-system/src/global/layout/page.stories.tsx b/apps/admin-x-design-system/src/global/layout/page.stories.tsx index d7c2eeef646..ca079c5aa86 100644 --- a/apps/admin-x-design-system/src/global/layout/page.stories.tsx +++ b/apps/admin-x-design-system/src/global/layout/page.stories.tsx @@ -4,8 +4,8 @@ import Page, {CustomGlobalAction} from './page'; import ViewContainer from './view-container'; import {exampleActions as exampleActionButtons} from './view-container.stories'; -import Heading from '../heading'; import Button from '../button'; +import {Text} from '@tryghost/shade/primitives'; const meta = { title: 'Global / Layout / Page', @@ -80,11 +80,11 @@ const mockIdeaCards = () => { for (let i = 0; i < 11; i++) { cards.push(
- + {i % 3 === 0 && 'Sunset drinks cruise eat sleep repeat'} {i % 3 === 1 && 'Elegance Rolls Royce on my private jet'} {i % 3 === 2 && 'Down to the wire Bathurst 5000 Le Tour'} - +
{i % 3 === 0 && 'Numea captain’s table crystal waters paradise island the scenic route great adventure. Pirate speak the road less travelled seas the day '} {i % 3 === 1 && 'Another day in paradise cruise life adventure bound gap year cruise time languid afternoons let the sea set you free'} @@ -134,11 +134,11 @@ const mockPosts = () => {
- + {i % 3 === 0 && 'Sunset drinks cruise eat sleep repeat'} {i % 3 === 1 && 'Elegance Rolls Royce on my private jet'} {i % 3 === 2 && 'Down to the wire Bathurst 5000 Le Tour'} - +
{i % 3 === 0 && 'Numea captain’s table crystal waters paradise island the scenic route great adventure. Pirate speak the road less travelled seas the day '} {i % 3 === 1 && 'Another day in paradise cruise life adventure bound gap year cruise time languid afternoons let the sea set you free'} @@ -201,7 +201,7 @@ export const ExampleDetailScreen: Story = { firstOnPage={false} headerContent={
- Emerson Vaccaro + Emerson Vaccaro
Colombus, OH
} @@ -219,46 +219,46 @@ export const ExampleDetailScreen: Story = { Created on 27 Jan 2021
- Emails received + Emails received 181
- Emails opened + Emails opened 104
- Average open rate + Average open rate 57%
- Member data + Member data
- Name + Name
Emerson Vaccaro
- Email + Email
emerson@vaccaro.com
- Labels + Labels
VIP
Inner Circle
- Notes + Notes
No notes.
- Newsletters + Newsletters
@@ -278,10 +278,10 @@ export const ExampleDetailScreen: Story = {
- Subscriptions + Subscriptions
- $5 + $5 Yearly
@@ -292,7 +292,7 @@ export const ExampleDetailScreen: Story = {
- Activity + Activity
diff --git a/apps/admin-x-design-system/src/global/layout/view-container.tsx b/apps/admin-x-design-system/src/global/layout/view-container.tsx index 757bf991bc0..5d5b15a2d97 100644 --- a/apps/admin-x-design-system/src/global/layout/view-container.tsx +++ b/apps/admin-x-design-system/src/global/layout/view-container.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import Heading from '../heading'; import clsx from 'clsx'; import Button, {ButtonColor, ButtonProps} from '../button'; import {ButtonGroupProps} from '../button-group'; +import {Text} from '@tryghost/shade/primitives'; export interface View { id: string; @@ -162,7 +162,10 @@ const ViewContainer: React.FC = ({
{headerContent} - {title && {title}} + {title && (type === 'page' ? + {title} : + {title} + )} {tabs}
diff --git a/apps/admin-x-design-system/src/global/link.stories.tsx b/apps/admin-x-design-system/src/global/link.stories.tsx deleted file mode 100644 index 2dc32d0f2fb..00000000000 --- a/apps/admin-x-design-system/src/global/link.stories.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type {Meta, StoryObj} from '@storybook/react-vite'; - -import Link from './link'; - -const meta = { - title: 'Global / Link', - component: Link, - tags: ['autodocs'], - argTypes: { - color: { - control: 'text' - } - } -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - href: 'https://ghost.org', - children: 'Click me' - } -}; diff --git a/apps/admin-x-design-system/src/global/link.tsx b/apps/admin-x-design-system/src/global/link.tsx deleted file mode 100644 index 476fa0244ab..00000000000 --- a/apps/admin-x-design-system/src/global/link.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from 'react'; - -export interface LinkProps extends React.ComponentPropsWithoutRef<'a'> { - href: string; - - /** - * Tailwind color name - */ - color?: string; - children?: React.ReactNode; - className?: string; -} - -/** - * Standard link with default styling - */ -const Link: React.FC = ({href, color, className, children, ...props}) => { - if (!color) { - color = 'green'; - } - - let styles = (color === 'black') ? `transition text-black hover:text-black-700` : `text-${color} hover:text-${color}-400`; - - if (className) { - styles = `${styles} ${className}`; - } - - return {children}; -}; - -export default Link; diff --git a/apps/admin-x-design-system/src/global/modal/modal-page.tsx b/apps/admin-x-design-system/src/global/modal/modal-page.tsx index fde0636648d..66cedc539c9 100644 --- a/apps/admin-x-design-system/src/global/modal/modal-page.tsx +++ b/apps/admin-x-design-system/src/global/modal/modal-page.tsx @@ -1,6 +1,6 @@ import clsx from 'clsx'; import React from 'react'; -import Heading from '../heading'; +import {Text} from '@tryghost/shade/primitives'; export interface ModalPageProps { heading?: string; @@ -15,7 +15,7 @@ const ModalPage: React.FC = ({heading, children, className}) => ); return (
- {heading && {heading}} + {heading && {heading}} {children}
); diff --git a/apps/admin-x-design-system/src/global/modal/modal.tsx b/apps/admin-x-design-system/src/global/modal/modal.tsx index 43b3718d765..588266aff88 100644 --- a/apps/admin-x-design-system/src/global/modal/modal.tsx +++ b/apps/admin-x-design-system/src/global/modal/modal.tsx @@ -5,8 +5,8 @@ import useGlobalDirtyState from '../../hooks/use-global-dirty-state'; import {confirmIfDirty} from '../../utils/modals'; import Button, {ButtonColor, ButtonProps} from '../button'; import ButtonGroup from '../button-group'; -import Heading from '../heading'; import StickyFooter from '../sticky-footer'; +import {Text} from '@tryghost/shade/primitives'; export type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'full' | 'bleed'; @@ -444,14 +444,14 @@ const Modal = forwardRef(({ )} data-testid={testId} style={modalStyles}> {header === false ? '' : (!topRightContent || topRightContent === 'close' ? (
- {title && {title}} + {title && {title}}
) : (
- {title && {title}} + {title && {title}} {topRightContent}
))}
diff --git a/apps/admin-x-design-system/src/global/modal/preview-modal.stories.tsx b/apps/admin-x-design-system/src/global/modal/preview-modal.stories.tsx index b36e2cf8399..5aabeb750ce 100644 --- a/apps/admin-x-design-system/src/global/modal/preview-modal.stories.tsx +++ b/apps/admin-x-design-system/src/global/modal/preview-modal.stories.tsx @@ -3,8 +3,8 @@ import {ReactNode} from 'react'; import NiceModal from '@ebay/nice-modal-react'; import Button from '../button'; -import Heading from '../heading'; import PreviewModal, {PreviewModalProps} from './preview-modal'; +import {Text} from '@tryghost/shade/primitives'; const PreviewModalContainer: React.FC = ({...props}) => { return ( @@ -73,7 +73,7 @@ export const CustomSidebarHeader: Story = { ...Default.args, sidebarHeader: (
- A custom header here + A custom header here
) } diff --git a/apps/admin-x-design-system/src/global/modal/preview-modal.tsx b/apps/admin-x-design-system/src/global/modal/preview-modal.tsx index 2c3c3a9e123..d39f4aa776d 100644 --- a/apps/admin-x-design-system/src/global/modal/preview-modal.tsx +++ b/apps/admin-x-design-system/src/global/modal/preview-modal.tsx @@ -5,9 +5,38 @@ import useGlobalDirtyState from '../../hooks/use-global-dirty-state'; import {confirmIfDirty} from '../../utils/modals'; import {ButtonColor, ButtonProps} from '../button'; import ButtonGroup from '../button-group'; -import Heading, {HeadingLevel} from '../heading'; import Icon from '../icon'; import Modal, {ModalSize} from './modal'; +import {Text, type TextElement, type TextLeading, type TextSize} from '@tryghost/shade/primitives'; + +type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6; + +const headingSizes: Record = { + 1: '3xl', + 2: '2xl', + 3: 'xl', + 4: 'lg', + 5: 'md', + 6: 'md' +}; + +const headingClasses: Record = { + 1: 'text-4xl', + 2: 'md:text-3xl', + 3: 'md:text-2xl', + 4: 'md:text-xl', + 5: 'md:text-lg', + 6: 'text-base' +}; + +const headingLeading: Record = { + 1: 'supertight', + 2: 'heading', + 3: 'heading', + 4: 'heading', + 5: 'supertight', + 6: 'body' +}; export interface PreviewModalProps { testId?: string; @@ -198,7 +227,16 @@ export const PreviewModalContent: React.FC = ({
{sidebarHeader ? sidebarHeader : (
- {title} + + {title} + {sidebarButtons ? sidebarButtons : }
)} diff --git a/apps/admin-x-design-system/src/global/sortable-list.tsx b/apps/admin-x-design-system/src/global/sortable-list.tsx index cf5994f030f..ce61dcdf77d 100644 --- a/apps/admin-x-design-system/src/global/sortable-list.tsx +++ b/apps/admin-x-design-system/src/global/sortable-list.tsx @@ -3,10 +3,10 @@ import {SortableContext, useSortable, verticalListSortingStrategy} from '@dnd-ki import {CSS} from '@dnd-kit/utilities'; import clsx from 'clsx'; import React, {ElementType, HTMLProps, ReactNode, useState} from 'react'; -import Heading from './heading'; import Icon from './icon'; import LegacyHint from './legacy-hint'; import Separator from './separator'; +import {Stack, Text} from '@tryghost/shade/primitives'; export interface SortableItemContainerProps { id: string; @@ -135,7 +135,12 @@ const SortableList = ({ return (
- {title && {title}} + {title && (titleSeparator ? ( + + {title} + + + ) : {title})}
- Users + Users Cristofer Vaccaro — Owner cristofer@example.com
diff --git a/apps/admin-x-design-system/src/settings/setting-group-header.tsx b/apps/admin-x-design-system/src/settings/setting-group-header.tsx index c2fde53aac6..67c9df56ce3 100644 --- a/apps/admin-x-design-system/src/settings/setting-group-header.tsx +++ b/apps/admin-x-design-system/src/settings/setting-group-header.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import Heading from '../global/heading'; +import {Text} from '@tryghost/shade/primitives'; export interface SettingGroupHeaderProps { title?: React.ReactNode; @@ -13,7 +13,7 @@ const SettingGroupHeader: React.FC = ({title, descripti
{(title || description) &&
- {title}{beta && Beta} + {title}{beta && Beta} {description &&

{description}

}
} diff --git a/apps/admin-x-design-system/src/settings/setting-value.tsx b/apps/admin-x-design-system/src/settings/setting-value.tsx index af9dda996ef..77231bc2f70 100644 --- a/apps/admin-x-design-system/src/settings/setting-value.tsx +++ b/apps/admin-x-design-system/src/settings/setting-value.tsx @@ -1,6 +1,5 @@ import React, {ReactNode} from 'react'; - -import Heading from '../global/heading'; +import {Text} from '@tryghost/shade/primitives'; export interface SettingValueProps { key: string; @@ -18,7 +17,7 @@ const SettingValue: React.FC = ({heading, value, hint, hideEm return (
- {heading && {heading}} + {heading && {heading}}
{value}
{hint &&

{hint}

}
diff --git a/apps/admin-x-settings/src/components/settings/advanced/code/code-modal.tsx b/apps/admin-x-settings/src/components/settings/advanced/code/code-modal.tsx index 83b40a13ff4..12cf3ee2391 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/code/code-modal.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/code/code-modal.tsx @@ -1,9 +1,10 @@ import NiceModal, {useModal} from '@ebay/nice-modal-react'; import React, {useEffect, useMemo, useRef, useState} from 'react'; import useSettingGroup from '../../../../hooks/use-setting-group'; -import {ButtonGroup, CodeEditor, Heading, Modal} from '@tryghost/admin-x-design-system'; +import {ButtonGroup, CodeEditor, Modal} from '@tryghost/admin-x-design-system'; import {type ReactCodeMirrorRef} from '@uiw/react-codemirror'; import {Tabs, TabsContent, TabsList, TabsTrigger} from '@tryghost/shade/components'; +import {Text} from '@tryghost/shade/primitives'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {useSaveButton} from '../../../../hooks/use-save-button'; @@ -71,7 +72,7 @@ const CodeModal: React.FC = ({afterClose}) => { >
- Code injection + Code injection = ({ >
- {title} + {title} { backDropClick={false} footer={
- Learn about importing + Learn about importing
} diff --git a/apps/admin-x-settings/src/components/settings/email/mailgun.tsx b/apps/admin-x-settings/src/components/settings/email/mailgun.tsx index 40683760bfc..a0e2f4792c6 100644 --- a/apps/admin-x-settings/src/components/settings/email/mailgun.tsx +++ b/apps/admin-x-settings/src/components/settings/email/mailgun.tsx @@ -1,8 +1,10 @@ import React from 'react'; import TopLevelGroup from '../../top-level-group'; import useSettingGroup from '../../../hooks/use-setting-group'; -import {Field, FieldLabel, Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@tryghost/shade/components'; -import {IconLabel, Link, SettingGroupContent, TextField} from '@tryghost/admin-x-design-system'; +import {CheckCircle} from 'lucide-react'; +import {Field, FieldDescription, FieldLabel, Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@tryghost/shade/components'; +import {Inline} from '@tryghost/shade/primitives'; +import {SettingGroupContent} from '@tryghost/admin-x-design-system'; import {getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {withErrorBoundary} from '../../error-boundary'; @@ -35,9 +37,10 @@ const MailGun: React.FC<{ keywords: string[] }> = ({keywords}) => { { key: 'status', value: ( - + + Mailgun is set up - + ) } ] : [ @@ -56,7 +59,7 @@ const MailGun: React.FC<{ keywords: string[] }> = ({keywords}) => { ); const apiKeysHint = ( - <>Find your Mailgun API keys here + <>Find your Mailgun API keys here ); const inputs = ( @@ -64,36 +67,44 @@ const MailGun: React.FC<{ keywords: string[] }> = ({keywords}) => { Mailgun region - { - updateSetting('mailgun_domain', e.target.value); - }} - /> -
- + Mailgun domain + { - updateSetting('mailgun_api_key', e.target.value); + updateSetting('mailgun_domain', e.target.value); }} /> + +
+ + Mailgun private API key + { + updateSetting('mailgun_api_key', e.target.value); + }} + /> + {apiKeysHint} +
); const groupDescription = ( - <>The Mailgun API is used for bulk email newsletter delivery. Why is this required? + <>The Mailgun API is used for bulk email newsletter delivery. Why is this required? ); return ( diff --git a/apps/admin-x-settings/src/components/settings/email/newsletters/newsletter-detail-modal.tsx b/apps/admin-x-settings/src/components/settings/email/newsletters/newsletter-detail-modal.tsx index 7ef16d82dbb..67512966ab4 100644 --- a/apps/admin-x-settings/src/components/settings/email/newsletters/newsletter-detail-modal.tsx +++ b/apps/admin-x-settings/src/components/settings/email/newsletters/newsletter-detail-modal.tsx @@ -4,13 +4,13 @@ import React, {useCallback, useEffect, useState} from 'react'; import useFeatureFlag from '../../../../hooks/use-feature-flag'; import useSettingGroup from '../../../../hooks/use-setting-group'; import validator from 'validator'; -import {Button, ButtonGroup, ColorPickerField, ConfirmationModal, Form, Heading, HtmlField, Icon, ImageUpload, LimitModal, PreviewModalContent, TextField, showToast} from '@tryghost/admin-x-design-system'; +import {Button, ButtonGroup, ColorPickerField, ConfirmationModal, Form, HtmlField, Icon, ImageUpload, LimitModal, PreviewModalContent, TextField, showToast} from '@tryghost/admin-x-design-system'; import {type ErrorMessages, useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; import {Field, FieldContent, FieldDescription, FieldLabel, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Separator, Switch, Tabs, TabsContent, TabsList, TabsTrigger, Textarea} from '@tryghost/shade/components'; import {HostLimitError, useLimiter} from '../../../../hooks/use-limiter'; import {type Newsletter, useBrowseNewsletters, useEditNewsletter} from '@tryghost/admin-x-framework/api/newsletters'; import {type RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing'; -import {Stack} from '@tryghost/shade/primitives'; +import {Stack, Text} from '@tryghost/shade/primitives'; import {formatNumber} from '@tryghost/shade/utils'; import {getImageUrl, useUploadImage} from '@tryghost/admin-x-framework/api/images'; import {getSettingValue, getSettingValues} from '@tryghost/admin-x-framework/api/settings'; @@ -292,7 +292,7 @@ const Sidebar: React.FC<{
- Header image + Header image
= ({user}) => {
- {user.name}{suspendedText} + {user.name}{suspendedText} {user.roles[0].name.toLowerCase()}
diff --git a/apps/admin-x-settings/src/components/settings/general/users/custom-header.tsx b/apps/admin-x-settings/src/components/settings/general/users/custom-header.tsx index 4222e163a8c..7458de51e2d 100644 --- a/apps/admin-x-settings/src/components/settings/general/users/custom-header.tsx +++ b/apps/admin-x-settings/src/components/settings/general/users/custom-header.tsx @@ -1,10 +1,10 @@ -import {Heading} from '@tryghost/admin-x-design-system'; +import {Text} from '@tryghost/shade/primitives'; const CustomHeader: React.FC<{ children?: React.ReactNode; }> = ({children}) => { return ( - {children} + {children} ); }; diff --git a/apps/admin-x-settings/src/components/settings/general/users/staff-token.tsx b/apps/admin-x-settings/src/components/settings/general/users/staff-token.tsx index 9c09c5d48c4..07d42c3ba33 100644 --- a/apps/admin-x-settings/src/components/settings/general/users/staff-token.tsx +++ b/apps/admin-x-settings/src/components/settings/general/users/staff-token.tsx @@ -1,6 +1,7 @@ import APIKeys from '../../advanced/integrations/api-keys'; import NiceModal from '@ebay/nice-modal-react'; -import {ConfirmationModal, Heading} from '@tryghost/admin-x-design-system'; +import {ConfirmationModal} from '@tryghost/admin-x-design-system'; +import {Text} from '@tryghost/shade/primitives'; import {genStaffToken, getStaffToken} from '@tryghost/admin-x-framework/api/staff-token'; import {useEffect, useState} from 'react'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; @@ -42,7 +43,7 @@ const StaffToken: React.FC = () => { }; return (
- Staff access token + Staff access token = ({selectedLayout, return (
- Embed signup form + Embed signup form
Layout
diff --git a/apps/admin-x-settings/src/components/settings/growth/recommendations/recommendation-description-form.tsx b/apps/admin-x-settings/src/components/settings/growth/recommendations/recommendation-description-form.tsx index c3253768967..7988c9f5db8 100644 --- a/apps/admin-x-settings/src/components/settings/growth/recommendations/recommendation-description-form.tsx +++ b/apps/admin-x-settings/src/components/settings/growth/recommendations/recommendation-description-form.tsx @@ -3,7 +3,8 @@ import RecommendationIcon from './recommendation-icon'; import {type EditOrAddRecommendation} from '@tryghost/admin-x-framework/api/recommendations'; import {type ErrorMessages} from '@tryghost/admin-x-framework/hooks'; import {Field, FieldDescription, FieldLabel, Input, Textarea} from '@tryghost/shade/components'; -import {Form, Heading, TextField} from '@tryghost/admin-x-design-system'; +import {Form, TextField} from '@tryghost/admin-x-design-system'; +import {Text} from '@tryghost/shade/primitives'; import {formatNumber} from '@tryghost/shade/utils'; interface Props { @@ -67,7 +68,7 @@ function RecommendationDescriptionForm({showU marginTop >
- Preview + Preview
diff --git a/apps/admin-x-settings/src/components/settings/growth/recommendations/recommendation-list.tsx b/apps/admin-x-settings/src/components/settings/growth/recommendations/recommendation-list.tsx index 181a9c9fe8a..bb8a51f5b47 100644 --- a/apps/admin-x-settings/src/components/settings/growth/recommendations/recommendation-list.tsx +++ b/apps/admin-x-settings/src/components/settings/growth/recommendations/recommendation-list.tsx @@ -4,7 +4,7 @@ import React, {useState} from 'react'; import RecommendationIcon from './recommendation-icon'; import useSettingGroup from '../../../../hooks/use-setting-group'; import {ActionList, ActionListItem, ActionListItemContent, LoadingIndicator, NoValueLabel, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from '@tryghost/shade/components'; -import {Button, Link} from '@tryghost/admin-x-design-system'; +import {Button} from '@tryghost/admin-x-design-system'; import {Inline} from '@tryghost/shade/primitives'; import {type Recommendation} from '@tryghost/admin-x-framework/api/recommendations'; import {formatNumber} from '@tryghost/shade/utils'; @@ -97,7 +97,7 @@ const RecommendationList: React.FC = ({recommendations,
{showMore?.hasMore && }
- Shared with new members after signup, or anytime using this link + Shared with new members after signup, or anytime using this link @@ -123,7 +123,7 @@ const RecommendationList: React.FC = ({recommendations, - Need inspiration? Explore thousands of sites + Need inspiration? Explore thousands of sites ; } }; diff --git a/apps/admin-x-settings/src/components/settings/membership/portal/look-and-feel.tsx b/apps/admin-x-settings/src/components/settings/membership/portal/look-and-feel.tsx index f858737d64f..28a798734c7 100644 --- a/apps/admin-x-settings/src/components/settings/membership/portal/look-and-feel.tsx +++ b/apps/admin-x-settings/src/components/settings/membership/portal/look-and-feel.tsx @@ -2,8 +2,8 @@ import React, {useRef, useState} from 'react'; import clsx from 'clsx'; import {APIError} from '@tryghost/admin-x-framework/errors'; import {Button, Field, FieldLabel, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch, ToggleGroup, ToggleGroupItem} from '@tryghost/shade/components'; -import {Form, Heading, Icon, TextField} from '@tryghost/admin-x-design-system'; -import {Inline} from '@tryghost/shade/primitives'; +import {Form, Icon, TextField} from '@tryghost/admin-x-design-system'; +import {Inline, Text} from '@tryghost/shade/primitives'; import {type Setting, type SettingValue, getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {getImageUrl, useUploadImage} from '@tryghost/admin-x-framework/api/images'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; @@ -113,7 +113,7 @@ const LookAndFeel: React.FC<{ {portalButtonStyle?.toString()?.includes('icon') &&
- Icon + Icon - Transistor + Transistor Enable Transistor integration diff --git a/apps/admin-x-settings/src/components/settings/membership/stripe/stripe-connect-modal.tsx b/apps/admin-x-settings/src/components/settings/membership/stripe/stripe-connect-modal.tsx index ed747c568bd..2905bff5816 100644 --- a/apps/admin-x-settings/src/components/settings/membership/stripe/stripe-connect-modal.tsx +++ b/apps/admin-x-settings/src/components/settings/membership/stripe/stripe-connect-modal.tsx @@ -6,10 +6,11 @@ import React, {useEffect, useState} from 'react'; import StripeLogo from '../../../../assets/images/stripe-emblem.svg'; import StripeVerifiedBadge from '../../../../assets/images/stripe-verified.svg'; import useSettingGroup from '../../../../hooks/use-setting-group'; -import {Button, ConfirmationModal, Form, Heading, LimitModal, Modal, StripeButton, TextField, showToast} from '@tryghost/admin-x-design-system'; +import {Button, ConfirmationModal, Form, LimitModal, Modal, StripeButton, TextField, showToast} from '@tryghost/admin-x-design-system'; import {Field, FieldError, FieldLabel, Switch, Textarea} from '@tryghost/shade/components'; import {HostLimitError, useLimiter} from '../../../../hooks/use-limiter'; import {JSONError} from '@tryghost/admin-x-framework/errors'; +import {Text} from '@tryghost/shade/primitives'; import {checkStripeEnabled, getSettingValue, getSettingValues, useDeleteStripeSettings, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {getGhostPaths} from '@tryghost/admin-x-framework/helpers'; import {toast} from 'react-hot-toast'; @@ -26,7 +27,7 @@ const Start: React.FC<{onNext?: () => void}> = ({onNext}) => { return (
- Getting paid + Getting paid Stripe Verified Partner Badge
@@ -123,18 +124,18 @@ const Connect: React.FC = () => { return (
- Connect with Stripe + Connect with Stripe Test mode
- Step 1 — Generate secure key + Step 1 — Generate secure key
Click on the “Connect with Stripe” button to generate a secure key that connects your Ghost site with Stripe.
- Step 2 — Paste secure key + Step 2 — Paste secure key Secure key