diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 7e81ab178e0..8a0c8a72b32 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -141,6 +141,45 @@ module.exports = { to: { path: '^@tryghost/admin($|/)|^apps/admin/' }, }, // ============================================================ + // apps/admin — domains cross into each other only via api.ts + // ============================================================ + { + name: 'admin-domains-cross-via-api-only', + comment: + "A domain folder in apps/admin/src may import a different domain only through that domain's public surface (its api.ts). Deep imports couple domains to each other's internals. In-app imports use the @/ alias, which the cruiser sees as an unresolved @/-prefixed specifier; both that shape and resolved relative paths are matched. Test files are exempt.", + severity: 'error', + from: { + path: '^apps/admin/src/(members|settings|analytics|posts|tags|comments|automations|onboarding|whats-new)/', + pathNot: ['\\.test\\.(ts|tsx)$'], + }, + to: { + path: '^(?:@/|apps/admin/src/)(?:members|settings|analytics|posts|tags|comments|automations|onboarding|whats-new)($|/)', + pathNot: [ + '^(?:@/|apps/admin/src/)$1($|/)', + '^(?:@/|apps/admin/src/)(?:members|settings|analytics|posts|tags|comments|automations|onboarding|whats-new)/api(\\.ts)?$', + ], + }, + }, + // ============================================================ + // apps/admin — the shell and layout import domains only via api.ts + // ============================================================ + { + name: 'admin-shell-into-domains-via-api-only', + comment: + 'The admin shell (top-level files in apps/admin/src plus its non-domain support folders) may import a domain only through its api.ts. Same matching notes as admin-domains-cross-via-api-only. Test files are exempt.', + severity: 'error', + from: { + path: '^apps/admin/src/(?:(?:layout|hooks|providers|ember-bridge|utils|schemas)/.+|[^/]+\\.(?:ts|tsx))$', + pathNot: ['\\.test\\.(ts|tsx)$'], + }, + to: { + path: '^(?:@/|apps/admin/src/)(?:members|settings|analytics|posts|tags|comments|automations|onboarding|whats-new)($|/)', + pathNot: [ + '^(?:@/|apps/admin/src/)(?:members|settings|analytics|posts|tags|comments|automations|onboarding|whats-new)/api(\\.ts)?$', + ], + }, + }, + // ============================================================ // apps/admin — shared/ must stay domain-free // ============================================================ { diff --git a/apps/admin-x-framework/src/api/member-custom-fields.ts b/apps/admin-x-framework/src/api/member-custom-fields.ts index 36a68ba9480..22741bb9107 100644 --- a/apps/admin-x-framework/src/api/member-custom-fields.ts +++ b/apps/admin-x-framework/src/api/member-custom-fields.ts @@ -191,31 +191,63 @@ const isPartRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); /** - * How each composite type reads as one line. Written per type rather than walked from - * `subFieldsOf`, because where a part sits in the sentence is a fact about how the value - * reads, not one the value schema can supply — an address fuses state and postal code the - * way people write them. A part added upstream stays out of the line until someone decides - * where it belongs. + * Parts that read as one run rather than as separate items — "NY 00001", not "NY, 00001". * - * Total over the field types, the way the presentation catalog above is: a type added - * upstream fails to compile here until someone has decided how its value reads, rather - * than reaching every surface as a blank cell. A scalar declares `undefined`, which is - * how "its value is already a line" is said. + * This is the whole of what a composite's one-line form needs stated. Everything else + * comes from the value schema's declaration order, so a part added to a type upstream + * appears in the line on its own, without anyone knowing to come here. That was the point: + * the previous version wrote each type's line out by hand, and a part left out of it was + * collected, stored, exported and filtered on while being invisible in every summary — + * a silent omission, which is the worst way for this to fail. + * + * Typed against the parts each type declares, so renaming or removing one upstream fails + * the build here. Deliberately not exhaustive: a part nobody mentions is one that reads + * perfectly well on its own, and requiring an entry for each would put the omission + * problem straight back. */ -const compositeValueFormatters: { - [T in FieldType]: [PartsOf] extends [never] - ? undefined - : (value: Record) => string; -} = { - short_text: undefined, - long_text: undefined, - address: (value) => { - const { line1, line2, city, state, postal_code: postalCode, country } = value; - const statePostal = [state, postalCode].filter(Boolean).join(' '); - return [line1, line2, city, statePostal, country].filter(Boolean).join(', '); - }, +export type CompositePartRuns = { [T in FieldType]?: ReadonlyArray[]> }; + +const fusedParts: CompositePartRuns = { + address: [['state', 'postal_code']], }; +/** + * A composite type's parts grouped into the runs its line is built from: declaration + * order, with anything fused above kept together. + * + * A fused pair that is not adjacent in declaration order simply reads as two runs, so + * reordering a type upstream costs a comma rather than a wrong sentence. + */ +function partRunsFor(type: FieldType): string[][] { + const parts: string[] | null = subFieldsOf(type); + if (!parts) { + return []; + } + + const runOf = new Map(); + ((fusedParts[type] ?? []) as ReadonlyArray).forEach((group, index) => { + group.forEach((part) => runOf.set(part, index)); + }); + + const runs: string[][] = []; + let openRun: number | undefined; + for (const part of parts) { + const run = runOf.get(part); + if (run !== undefined && run === openRun) { + runs[runs.length - 1].push(part); + continue; + } + runs.push([part]); + openRun = run; + } + return runs; +} + +// Resolved once: the catalog is static, and this is read for every row of a member list. +const partRuns = Object.fromEntries( + FIELD_TYPE_IDS.map((type) => [type, partRunsFor(type)]), +) as Record; + /** * A member's value for one field as a single readable line: the string itself for a * scalar, and for a composite its parts joined the way that type reads — e.g. @@ -228,13 +260,25 @@ const compositeValueFormatters: { * table cell than in a detail row. */ export const formatMemberCustomFieldValue = (type: FieldType, value: unknown): string => { - const formatComposite = compositeValueFormatters[type]; + // Null for a scalar, and for a type this build has never heard of — both of which read + // as text or as nothing. + if (subFieldsOf(type) === null) { + return typeof value === 'string' ? value : ''; + } - if (formatComposite) { - return isPartRecord(value) ? formatComposite(value) : ''; + if (!isPartRecord(value)) { + return ''; } - return typeof value === 'string' ? value : ''; + return (partRuns[type] ?? []) + .map((run) => + run + .map((part) => value[part]) + .filter((part): part is string => typeof part === 'string' && part !== '') + .join(' '), + ) + .filter(Boolean) + .join(', '); }; export interface MemberCustomFieldsResponseType { diff --git a/apps/admin-x-framework/test/unit/api/member-custom-fields.test.ts b/apps/admin-x-framework/test/unit/api/member-custom-fields.test.ts index 4c1a21db578..bdc22cd0c0f 100644 --- a/apps/admin-x-framework/test/unit/api/member-custom-fields.test.ts +++ b/apps/admin-x-framework/test/unit/api/member-custom-fields.test.ts @@ -1,4 +1,5 @@ import { + type CompositePartRuns, type FieldTypePresentation, type MemberCustomField, formatMemberCustomFieldValue, @@ -41,7 +42,27 @@ const scalarWithParts: FieldTypePresentation<'short_text'> = { subFields: { line1: 'a' }, }; -export { labelled, missingPart, unknownPart, unlabelled, scalarWithParts }; +// A run is how the one-line form is told which parts read together. It names parts rather +// than listing them all, so a part *added* upstream needs no entry — but a part renamed or +// removed upstream has to be caught, or the run would silently stop fusing anything. +const fusedRun: CompositePartRuns = { address: [['state', 'postal_code']] }; + +// @ts-expect-error a run naming a part its value schema does not declare +const unknownFusedPart: CompositePartRuns = { address: [['state', 'postcode']] }; + +// @ts-expect-error a scalar type has no parts to run together +const scalarWithRun: CompositePartRuns = { short_text: [['line1']] }; + +export { + labelled, + missingPart, + unknownPart, + unlabelled, + scalarWithParts, + fusedRun, + unknownFusedPart, + scalarWithRun, +}; const field = (overrides: Partial): MemberCustomField => ({ key: 'nickname', @@ -122,7 +143,9 @@ describe('member custom fields api helpers', () => { field({ key: 'address_home', name: 'Address (Home)', type: 'address' }), ]); - expect(columns[2]).toEqual({ + // Found rather than indexed: what this is about is the label, not the position + // of a part in the address. + expect(columns.find((column) => column.partLabel === 'City')).toEqual({ label: 'Address (Home) (City)', fieldName: 'Address (Home)', partLabel: 'City', @@ -194,6 +217,24 @@ describe('member custom fields api helpers', () => { ).toBe('1 Main St, 12 apt B, New York, NY 00001, US'); }); + // The property this is built for. A part added to a type upstream has to appear in + // the line on its own, because the alternative is a value that is collected, stored, + // exported and filtered on while being invisible in every summary. Asserted through + // the catalog rather than against a hardcoded list, so it keeps holding as the + // catalog grows. + it('includes every part a type declares, in the order it declares them', () => { + const parts = memberCustomFieldParts('address')!; + const value = Object.fromEntries(parts.map(({ key }) => [key, key])); + + const line = formatMemberCustomFieldValue('address', value); + + for (const { key } of parts) { + expect(line, `${key} is missing from the line`).toContain(key); + } + // Separators aside, the parts read in the order the value schema declares them. + expect(line.split(/,\s|\s/)).toEqual(parts.map(({ key }) => key)); + }); + it('pairs state and postal code, and drops missing parts cleanly', () => { expect( formatMemberCustomFieldValue('address', { diff --git a/apps/admin/src/automations/api.ts b/apps/admin/src/automations/api.ts new file mode 100644 index 00000000000..9af4f376dcd --- /dev/null +++ b/apps/admin/src/automations/api.ts @@ -0,0 +1,9 @@ +/** + * Public surface of the automations domain, consumed by the admin shell + * (apps/admin/src/routes.tsx). Everything else in this domain is internal. + */ + +// Lazy entries, not component re-exports: the shell mounts these behind +// `lazy:`, so static re-exports would pull the chunks into the shell bundle. +export const lazyAutomationsScreen = () => import('./automations'); +export const lazyAutomationEditorScreen = () => import('./editor'); diff --git a/apps/admin/src/comments/api.ts b/apps/admin/src/comments/api.ts new file mode 100644 index 00000000000..8666a926c8b --- /dev/null +++ b/apps/admin/src/comments/api.ts @@ -0,0 +1,8 @@ +/** + * Public surface of the comments domain, consumed by the admin shell + * (apps/admin/src/routes.tsx). Everything else in this domain is internal. + */ + +// Lazy entry, not a component re-export: the shell mounts it behind `lazy:`, +// so a static re-export would pull the chunk into the shell bundle. +export const lazyCommentsScreen = () => import('./comments'); diff --git a/apps/admin/src/comments/components/comment-likes-modal.tsx b/apps/admin/src/comments/components/comment-likes-modal.tsx index 77836f7605e..645f083339a 100644 --- a/apps/admin/src/comments/components/comment-likes-modal.tsx +++ b/apps/admin/src/comments/components/comment-likes-modal.tsx @@ -18,7 +18,7 @@ import { useBrowseCommentLikes, } from '@tryghost/admin-x-framework/api/comments'; import { LucideIcon, formatNumber, formatTimestamp } from '@tryghost/shade/utils'; -import { formatMemberName, memberAvatarProps } from '@/members/member-format'; +import { formatMemberName, memberAvatarProps } from '@/members/api'; type DefaultTab = 'likes' | 'dislikes'; diff --git a/apps/admin/src/comments/components/comment-reports-modal.tsx b/apps/admin/src/comments/components/comment-reports-modal.tsx index d63fc37b8fc..ee8e4b0b1fe 100644 --- a/apps/admin/src/comments/components/comment-reports-modal.tsx +++ b/apps/admin/src/comments/components/comment-reports-modal.tsx @@ -10,7 +10,7 @@ import { } from '@tryghost/shade/components'; import { type Comment, useBrowseCommentReports } from '@tryghost/admin-x-framework/api/comments'; import { LucideIcon, formatTimestamp } from '@tryghost/shade/utils'; -import { formatMemberName, memberAvatarProps } from '@/members/member-format'; +import { formatMemberName, memberAvatarProps } from '@/members/api'; interface CommentReportsModalProps { comment: Comment; diff --git a/apps/admin/src/comments/components/comment-thread-list.tsx b/apps/admin/src/comments/components/comment-thread-list.tsx index 07cede98cf2..06cefa86dba 100644 --- a/apps/admin/src/comments/components/comment-thread-list.tsx +++ b/apps/admin/src/comments/components/comment-thread-list.tsx @@ -11,7 +11,7 @@ import { CommentHeader } from './comment-header'; import { CommentMenu } from './comment-menu'; import { CommentMetrics } from './comment-metrics'; import { buildThreadLink } from './thread-link'; -import { memberAvatarProps } from '@/members/member-format'; +import { memberAvatarProps } from '@/members/api'; import { Link, useSearchParams } from '@tryghost/admin-x-framework'; import { LucideIcon, cn } from '@tryghost/shade/utils'; diff --git a/apps/admin/src/comments/components/comments-list.tsx b/apps/admin/src/comments/components/comments-list.tsx index 3c64c75a9a2..cb3bd01b72e 100644 --- a/apps/admin/src/comments/components/comments-list.tsx +++ b/apps/admin/src/comments/components/comments-list.tsx @@ -11,7 +11,7 @@ import { CommentHeader } from './comment-header'; import { CommentMenu } from './comment-menu'; import { CommentMetrics } from './comment-metrics'; import { buildThreadLink } from './thread-link'; -import { memberAvatarProps } from '@/members/member-format'; +import { memberAvatarProps } from '@/members/api'; import { Link, useSearchParams } from '@tryghost/admin-x-framework'; import { LoadMoreButton, diff --git a/apps/admin/src/gift-link-modal-host.tsx b/apps/admin/src/gift-link-modal-host.tsx index 494994d5286..680e3d51863 100644 --- a/apps/admin/src/gift-link-modal-host.tsx +++ b/apps/admin/src/gift-link-modal-host.tsx @@ -1,11 +1,12 @@ import { Suspense, lazy, useEffect, useState } from 'react'; import { EmberFallback, subscribeOpenGiftLinkModal } from './ember-bridge'; import type { OpenGiftLinkModalEvent } from './ember-bridge'; +import { lazyGiftLinkModal } from './posts/api'; // The gift-link modal is React-owned but triggered from the Ember posts/pages // list. It's only needed once someone opens it, so lazy-load it rather than // pulling the posts bundle into every list view. -const GiftLinkModal = lazy(() => import('./posts/analytics/modals/gift-link-modal')); +const GiftLinkModal = lazy(lazyGiftLinkModal); /** * Bridges the Ember posts/pages context menu to the React gift-link modal. diff --git a/apps/admin/src/home-redirect.tsx b/apps/admin/src/home-redirect.tsx index 1b793a7ada2..95d8067372d 100644 --- a/apps/admin/src/home-redirect.tsx +++ b/apps/admin/src/home-redirect.tsx @@ -6,7 +6,7 @@ import { isContributorUser, isOwnerUser, } from '@tryghost/admin-x-framework/api/users'; -import { useOnboarding } from '@/onboarding/hooks/use-onboarding'; +import { useOnboarding } from '@/onboarding/api'; // Hosted signup lands on `/?firstStart=true`; the checklist only starts for // owners but every firstStart visit continues to the onboarding route. diff --git a/apps/admin/src/layout/app-sidebar/app-sidebar-footer.tsx b/apps/admin/src/layout/app-sidebar/app-sidebar-footer.tsx index 1e55698a48e..324d6f760e1 100644 --- a/apps/admin/src/layout/app-sidebar/app-sidebar-footer.tsx +++ b/apps/admin/src/layout/app-sidebar/app-sidebar-footer.tsx @@ -6,7 +6,7 @@ import { SidebarMenu, SidebarMenuItem, } from '@tryghost/shade/components'; -import WhatsNewDialog from '@/whats-new/components/whats-new-dialog'; +import { WhatsNewDialog } from '@/whats-new/api'; import { UserMenu } from './user-menu'; import { useSidebarBannerState } from './hooks/use-sidebar-banner-state'; diff --git a/apps/admin/src/layout/app-sidebar/hooks/use-sidebar-banner-state.tsx b/apps/admin/src/layout/app-sidebar/hooks/use-sidebar-banner-state.tsx index 0bb6f64bc61..6271a618115 100644 --- a/apps/admin/src/layout/app-sidebar/hooks/use-sidebar-banner-state.tsx +++ b/apps/admin/src/layout/app-sidebar/hooks/use-sidebar-banner-state.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from 'react'; import ThemeErrorsBanner from '@/layout/app-sidebar/theme-errors-banner'; import UpgradeBanner from '@/layout/app-sidebar/upgrade-banner'; import { useAdminSidebarVisibility } from '@/layout/sidebar-visibility'; -import WhatsNewBanner from '@/whats-new/components/whats-new-banner'; +import { WhatsNewBanner } from '@/whats-new/api'; import { useUpgradeStatus } from './use-upgrade-status'; import { useWhatsNewStatus } from './use-whats-new-status'; diff --git a/apps/admin/src/layout/app-sidebar/hooks/use-whats-new-status.ts b/apps/admin/src/layout/app-sidebar/hooks/use-whats-new-status.ts index ac41731a68d..12bf1883aba 100644 --- a/apps/admin/src/layout/app-sidebar/hooks/use-whats-new-status.ts +++ b/apps/admin/src/layout/app-sidebar/hooks/use-whats-new-status.ts @@ -1,5 +1,4 @@ -import { useChangelog } from '@/whats-new/hooks/use-changelog'; -import { useWhatsNew } from '@/whats-new/hooks/use-whats-new'; +import { useChangelog, useWhatsNew } from '@/whats-new/api'; export interface WhatsNewStatus { showWhatsNewBanner: boolean; diff --git a/apps/admin/src/layout/app-sidebar/shared-views.ts b/apps/admin/src/layout/app-sidebar/shared-views.ts index 252c9fb7ced..5a5a557c926 100644 --- a/apps/admin/src/layout/app-sidebar/shared-views.ts +++ b/apps/admin/src/layout/app-sidebar/shared-views.ts @@ -1,8 +1,8 @@ import { useMemo } from 'react'; -import { parseAllSharedViewsJSON } from '@/members/shared-views'; +import { parseAllSharedViewsJSON } from '@/members/api'; import { getSettingValue, useBrowseSettings } from '@tryghost/admin-x-framework/api/settings'; -export type { SharedView } from '@/members/shared-views'; +export type { SharedView } from '@/members/api'; export function getColorHex(color: string): string { const colorMap: Record = { diff --git a/apps/admin/src/layout/app-sidebar/user-menu.tsx b/apps/admin/src/layout/app-sidebar/user-menu.tsx index c97ca60e6ed..5ca9307ec8c 100644 --- a/apps/admin/src/layout/app-sidebar/user-menu.tsx +++ b/apps/admin/src/layout/app-sidebar/user-menu.tsx @@ -19,7 +19,7 @@ import { getGhostPaths } from '@tryghost/admin-x-framework/helpers'; import { toast } from 'sonner'; import { type ThemeMode } from '@/hooks/use-theme'; import { useThemeContext } from '@/providers/theme-context'; -import { useWhatsNew } from '@/whats-new/hooks/use-whats-new'; +import { useWhatsNew } from '@/whats-new/api'; import { useUpgradeStatus } from './hooks/use-upgrade-status'; import { useBrowseSite } from '@tryghost/admin-x-framework/api/site'; import { UserMenuItem } from './user-menu-item'; diff --git a/apps/admin/src/members/api.ts b/apps/admin/src/members/api.ts new file mode 100644 index 00000000000..4c51991e835 --- /dev/null +++ b/apps/admin/src/members/api.ts @@ -0,0 +1,9 @@ +/** + * Public surface of the members domain, consumed by the admin shell + * (apps/admin/src/routes.tsx), the layout, and other domains. Everything + * else in this domain is internal. + */ +export { membersRouteChildren } from './routes'; +export { buildMembersUrl } from './member-route'; +export { formatMemberName, getMemberInitials, memberAvatarProps } from './member-format'; +export { type SharedView, parseAllSharedViewsJSON } from './shared-views'; diff --git a/apps/admin/src/members/routes.tsx b/apps/admin/src/members/routes.tsx new file mode 100644 index 00000000000..1d38f5ca2f9 --- /dev/null +++ b/apps/admin/src/members/routes.tsx @@ -0,0 +1,22 @@ +import { type RouteObject, lazyComponent } from '@tryghost/admin-x-framework'; + +// The child routes under `/members`. The shell (apps/admin/src/routes.tsx) +// mounts these under the `/members` route node, which carries the access +// handle. `lazy:` is preserved for per-view code-splitting. +export const membersRouteChildren: RouteObject[] = [ + { + index: true, + lazy: lazyComponent(() => import('./members')), + }, + { + path: 'import', + lazy: lazyComponent(() => import('./members')), + }, + { + // Covers both edit (`:member_id`) and create (the sentinel `new`) + // — real member ids are 24-char hex ObjectIds, so they can't + // collide with the literal "new". + path: ':member_id', + lazy: lazyComponent(() => import('./detail/member-detail')), + }, +]; diff --git a/apps/admin/src/onboarding/api.ts b/apps/admin/src/onboarding/api.ts new file mode 100644 index 00000000000..09e616ba2a8 --- /dev/null +++ b/apps/admin/src/onboarding/api.ts @@ -0,0 +1,11 @@ +/** + * Public surface of the onboarding domain, consumed by the admin shell + * (apps/admin/src/routes.tsx and the home redirect). Everything else in this + * domain is internal. + */ +export { OnboardingRedirect } from './onboarding-redirect'; +export { useOnboarding } from './hooks/use-onboarding'; + +// Lazy entry, not a component re-export: the shell mounts it behind `lazy:`, +// so a static re-export would pull the chunk into the shell bundle. +export const lazyOnboardingScreen = () => import('./onboarding-route'); diff --git a/apps/admin/src/posts/analytics/growth/growth.tsx b/apps/admin/src/posts/analytics/growth/growth.tsx index ca6ee3ffda9..1d71c3b0457 100644 --- a/apps/admin/src/posts/analytics/growth/growth.tsx +++ b/apps/admin/src/posts/analytics/growth/growth.tsx @@ -19,7 +19,7 @@ import { SkeletonTable, } from '@tryghost/shade/components'; import { LucideIcon, formatNumber } from '@tryghost/shade/utils'; -import { buildMembersUrl } from '@/members/member-route'; +import { buildMembersUrl } from '@/members/api'; import { centsToDollars } from '@/shared/analytics/chart-helpers'; import { useAnalyticsData } from '@/shared/analytics/use-analytics-data'; import { useNavigate, useParams } from '@tryghost/admin-x-framework'; diff --git a/apps/admin/src/posts/analytics/newsletter/components/feedback.tsx b/apps/admin/src/posts/analytics/newsletter/components/feedback.tsx index 01b4f8a7c5d..54ec6a714e5 100644 --- a/apps/admin/src/posts/analytics/newsletter/components/feedback.tsx +++ b/apps/admin/src/posts/analytics/newsletter/components/feedback.tsx @@ -26,7 +26,7 @@ import { stringToHslColor, useSimplePagination, } from '@tryghost/shade/utils'; -import { formatMemberName, getMemberInitials } from '@/members/member-format'; +import { formatMemberName, getMemberInitials } from '@/members/api'; import { useNavigate, useParams } from '@tryghost/admin-x-framework'; import { usePostFeedback } from '@/posts/analytics/hooks/use-post-feedback'; import { useState } from 'react'; diff --git a/apps/admin/src/posts/analytics/newsletter/newsletter.tsx b/apps/admin/src/posts/analytics/newsletter/newsletter.tsx index 906f0e0e3ae..d98ee70bd4b 100644 --- a/apps/admin/src/posts/analytics/newsletter/newsletter.tsx +++ b/apps/admin/src/posts/analytics/newsletter/newsletter.tsx @@ -45,7 +45,7 @@ import { type NewsletterRadialChartData, } from './components/newsletter-radial-chart'; import { type Post, usePostAnalytics } from '@/posts/analytics/providers/post-analytics-context'; -import { buildMembersUrl } from '@/members/member-route'; +import { buildMembersUrl } from '@/members/api'; import { getLinkById } from '@/posts/analytics/utils/link-helpers'; import { hasBeenEmailed, useNavigate } from '@tryghost/admin-x-framework'; import { toast } from 'sonner'; diff --git a/apps/admin/src/posts/analytics/routes.tsx b/apps/admin/src/posts/analytics/routes.tsx new file mode 100644 index 00000000000..4a58be3de85 --- /dev/null +++ b/apps/admin/src/posts/analytics/routes.tsx @@ -0,0 +1,29 @@ +import { type RouteObject, lazyComponent } from '@tryghost/admin-x-framework'; + +// The `/posts/analytics/:postId` subtree. The shell (apps/admin/src/routes.tsx) +// mounts a node with `lazy: lazyPostAnalyticsRoot` and these children; the root +// lazy composes the provider around the screen so neither chunk loads before +// the route is visited. `lazy:` is preserved for per-view code-splitting. +export const lazyPostAnalyticsRoot = async () => { + const [{ default: PostAnalyticsProvider }, { default: PostAnalytics }] = await Promise.all([ + import('./providers/post-analytics-provider'), + import('./post-analytics'), + ]); + return { + element: ( + + + + ), + }; +}; + +export const postAnalyticsRouteChildren: RouteObject[] = [ + { path: '', lazy: lazyComponent(() => import('./overview/overview')) }, + { path: 'web', lazy: lazyComponent(() => import('./web/web')) }, + { path: 'growth', lazy: lazyComponent(() => import('./growth/growth')) }, + { + path: 'newsletter', + lazy: lazyComponent(() => import('./newsletter/newsletter')), + }, +]; diff --git a/apps/admin/src/posts/api.ts b/apps/admin/src/posts/api.ts new file mode 100644 index 00000000000..2654323fc6f --- /dev/null +++ b/apps/admin/src/posts/api.ts @@ -0,0 +1,10 @@ +/** + * Public surface of the posts domain, consumed by the admin shell + * (apps/admin/src/routes.tsx and the gift-link modal host). Everything else + * in this domain is internal. + */ +export { lazyPostAnalyticsRoot, postAnalyticsRouteChildren } from './analytics/routes'; + +// Lazy entry, not a component re-export: the shell's host loads the modal on +// demand, so a static re-export would pull the chunk into the shell bundle. +export const lazyGiftLinkModal = () => import('./analytics/modals/gift-link-modal'); diff --git a/apps/admin/src/route-access-guard.tsx b/apps/admin/src/route-access-guard.tsx index 9141b56df00..c6a78c53594 100644 --- a/apps/admin/src/route-access-guard.tsx +++ b/apps/admin/src/route-access-guard.tsx @@ -1,13 +1,6 @@ import { Navigate, Outlet, useLocation, useMatches } from '@tryghost/admin-x-framework'; import { useCurrentUser } from '@tryghost/admin-x-framework/api/current-user'; - -type CurrentUser = NonNullable['data']>; - -export type AccessRule = (user: CurrentUser, location: { pathname: string }) => boolean; - -export interface AccessRouteHandle { - requiresAccess?: AccessRule; -} +import type { AccessRouteHandle, AccessRule } from './route-access'; /** * Guard component that keeps staff users out of routes their role can't use. diff --git a/apps/admin/src/route-access.ts b/apps/admin/src/route-access.ts new file mode 100644 index 00000000000..62dce5f2771 --- /dev/null +++ b/apps/admin/src/route-access.ts @@ -0,0 +1,11 @@ +import type { useCurrentUser } from '@tryghost/admin-x-framework/api/current-user'; + +type CurrentUser = NonNullable['data']>; + +// Route-access contract shared by the shell and the domains: domains type +// their access predicates against this module, not the guard component. +export type AccessRule = (user: CurrentUser, location: { pathname: string }) => boolean; + +export interface AccessRouteHandle { + requiresAccess?: AccessRule; +} diff --git a/apps/admin/src/routes.tsx b/apps/admin/src/routes.tsx index 2f2890495cd..dd33a78b1cf 100644 --- a/apps/admin/src/routes.tsx +++ b/apps/admin/src/routes.tsx @@ -20,17 +20,22 @@ import HomeRedirect from './home-redirect'; import { EmberListWithGiftLinks } from './gift-link-modal-host'; import { TagDetailGate } from './tag-detail-gate'; import { useFlagGatedRouteOwner } from './use-flag-gated-route-owner'; -import { OnboardingRedirect } from './onboarding/onboarding-redirect'; -import { type AccessRouteHandle, RouteAccessGuard } from './route-access-guard'; -import { canAccessSettingsRoute } from './settings/settings-access'; -import { settingsRouteChildren } from './settings/routes'; +import { type AccessRouteHandle } from './route-access'; +import { RouteAccessGuard } from './route-access-guard'; +import { lazyAutomationEditorScreen, lazyAutomationsScreen } from './automations/api'; +import { lazyCommentsScreen } from './comments/api'; +import { membersRouteChildren } from './members/api'; +import { OnboardingRedirect, lazyOnboardingScreen } from './onboarding/api'; +import { lazyPostAnalyticsRoot, postAnalyticsRouteChildren } from './posts/api'; +import { canAccessSettingsRoute, lazySettingsScreen, settingsRouteChildren } from './settings/api'; +import { lazyTagsScreen } from './tags/api'; import { canManageAutomations, canManageMembers, canManageTags, } from '@tryghost/admin-x-framework/api/users'; -import { NotFound } from './not-found'; +import { NotFound } from './shared/not-found'; // Routes handled by the Ember admin app. React delegates these to Ember via // EmberFallback. When migrating a route to React, remove its entry from here. @@ -57,28 +62,6 @@ const emberFallbackRoutes: RouteObject[] = EMBER_ROUTES.map((path) => ({ handle: emberFallbackHandle, })); -const membersRoute: RouteObject = { - path: '/members', - handle: { requiresAccess: canManageMembers } satisfies AccessRouteHandle, - children: [ - { - index: true, - lazy: lazyComponent(() => import('./members/members')), - }, - { - path: 'import', - lazy: lazyComponent(() => import('./members/members')), - }, - { - // Covers both edit (`:member_id`) and create (the sentinel `new`) - // — real member ids are 24-char hex ObjectIds, so they can't - // collide with the literal "new". - path: ':member_id', - lazy: lazyComponent(() => import('./members/detail/member-detail')), - }, - ], -}; - const appRoutes: RouteObject[] = [ { // Role-based landing dispatch, including the hosted-signup @@ -95,17 +78,17 @@ const appRoutes: RouteObject[] = [ { path: '/tags', handle: { requiresAccess: canManageTags } satisfies AccessRouteHandle, - lazy: lazyComponent(() => import('./tags/tags')), + lazy: lazyComponent(lazyTagsScreen), }, { path: '/comments', handle: { requiresAccess: canManageMembers } satisfies AccessRouteHandle, - lazy: lazyComponent(() => import('./comments/comments')), + lazy: lazyComponent(lazyCommentsScreen), }, { path: '/automations', handle: { requiresAccess: canManageAutomations } satisfies AccessRouteHandle, - lazy: lazyComponent(() => import('./automations/automations')), + lazy: lazyComponent(lazyAutomationsScreen), }, { // The automation editor hides the admin sidebar for a focused, @@ -115,7 +98,7 @@ const appRoutes: RouteObject[] = [ hideAdminSidebar: true, requiresAccess: canManageAutomations, } satisfies AdminRouteHandle & AccessRouteHandle, - lazy: lazyComponent(() => import('./automations/editor')), + lazy: lazyComponent(lazyAutomationEditorScreen), }, { // Covers both edit (`:tagSlug`) and create (the sentinel `new`) — @@ -128,31 +111,15 @@ const appRoutes: RouteObject[] = [ Component: TagDetailGate, handle: { requiresAccess: canManageTags } satisfies AccessRouteHandle, }, - membersRoute, + { + path: '/members', + handle: { requiresAccess: canManageMembers } satisfies AccessRouteHandle, + children: membersRouteChildren, + }, { path: '/posts/analytics/:postId', - lazy: async () => { - const [{ default: PostAnalyticsProvider }, { default: PostAnalytics }] = await Promise.all([ - import('./posts/analytics/providers/post-analytics-provider'), - import('./posts/analytics/post-analytics'), - ]); - return { - element: ( - - - - ), - }; - }, - children: [ - { path: '', lazy: lazyComponent(() => import('./posts/analytics/overview/overview')) }, - { path: 'web', lazy: lazyComponent(() => import('./posts/analytics/web/web')) }, - { path: 'growth', lazy: lazyComponent(() => import('./posts/analytics/growth/growth')) }, - { - path: 'newsletter', - lazy: lazyComponent(() => import('./posts/analytics/newsletter/newsletter')), - }, - ], + lazy: lazyPostAnalyticsRoot, + children: postAnalyticsRouteChildren, }, { // Analytics routes folded directly into the shell table. The @@ -171,7 +138,7 @@ const appRoutes: RouteObject[] = [ }, { path: 'setup/onboarding', - lazy: lazyComponent(() => import('./onboarding/onboarding-route')), + lazy: lazyComponent(lazyOnboardingScreen), }, { path: `network`, @@ -195,7 +162,7 @@ const appRoutes: RouteObject[] = [ // hideAdminSidebar lives on the handle, not the lazy module, so the shell // hides at first paint instead of waiting on the settings chunk. path: `settings`, - lazy: lazyComponent(() => import('./settings/settings')), + lazy: lazyComponent(lazySettingsScreen), children: settingsRouteChildren, handle: { allowInForceUpgrade: true, diff --git a/apps/admin/src/settings/advanced/history-modal.tsx b/apps/admin/src/settings/advanced/history-modal.tsx index 3697bb1d7d5..3937b44db05 100644 --- a/apps/admin/src/settings/advanced/history-modal.tsx +++ b/apps/admin/src/settings/advanced/history-modal.tsx @@ -26,7 +26,7 @@ import { inputSurface, } from '@tryghost/shade/components'; import { ChevronDown, History, Pen, Plus, Trash2, X } from 'lucide-react'; -import { memberAvatarProps } from '@/members/member-format'; +import { memberAvatarProps } from '@/members/api'; import { Inline, Stack } from '@tryghost/shade/primitives'; import { useParams } from '@tryghost/admin-x-framework'; import { useSettingsNavigation } from '@/settings/hooks/use-settings-navigation'; diff --git a/apps/admin/src/settings/advanced/integrations.tsx b/apps/admin/src/settings/advanced/integrations.tsx index 2f19d36e089..998df9b135c 100644 --- a/apps/admin/src/settings/advanced/integrations.tsx +++ b/apps/admin/src/settings/advanced/integrations.tsx @@ -1,4 +1,4 @@ -import BrandIcon from '@/settings/components/icons/brand-icon'; +import BrandIcon from '@/shared/brand-icon/brand-icon'; import IntegrationsSettingsImg from '@/settings/assets/images/integrations-settings.png'; import React, { useState } from 'react'; import TopLevelGroup from '@/settings/components/top-level-group'; diff --git a/apps/admin/src/settings/advanced/integrations/first-promoter-modal.tsx b/apps/admin/src/settings/advanced/integrations/first-promoter-modal.tsx index e7be59189af..9bd0fcc9073 100644 --- a/apps/admin/src/settings/advanced/integrations/first-promoter-modal.tsx +++ b/apps/admin/src/settings/advanced/integrations/first-promoter-modal.tsx @@ -1,4 +1,4 @@ -import BrandIcon from '@/settings/components/icons/brand-icon'; +import BrandIcon from '@/shared/brand-icon/brand-icon'; import IntegrationHeader from './integration-header'; import { Field, diff --git a/apps/admin/src/settings/advanced/integrations/pintura-modal.tsx b/apps/admin/src/settings/advanced/integrations/pintura-modal.tsx index a3514e64e5c..0e080599367 100644 --- a/apps/admin/src/settings/advanced/integrations/pintura-modal.tsx +++ b/apps/admin/src/settings/advanced/integrations/pintura-modal.tsx @@ -1,4 +1,4 @@ -import BrandIcon from '@/settings/components/icons/brand-icon'; +import BrandIcon from '@/shared/brand-icon/brand-icon'; import IntegrationHeader from './integration-header'; import pinturaScreenshot from '@/settings/assets/images/pintura-screenshot.png'; import { diff --git a/apps/admin/src/settings/advanced/integrations/slack-modal.tsx b/apps/admin/src/settings/advanced/integrations/slack-modal.tsx index 99aa314e23e..549c4b10bb8 100644 --- a/apps/admin/src/settings/advanced/integrations/slack-modal.tsx +++ b/apps/admin/src/settings/advanced/integrations/slack-modal.tsx @@ -1,4 +1,4 @@ -import BrandIcon from '@/settings/components/icons/brand-icon'; +import BrandIcon from '@/shared/brand-icon/brand-icon'; import IntegrationHeader from './integration-header'; import useSettingGroup from '@/settings/hooks/use-setting-group'; import validator from 'validator'; diff --git a/apps/admin/src/settings/advanced/integrations/transistor-modal.tsx b/apps/admin/src/settings/advanced/integrations/transistor-modal.tsx index 2b1035866f4..f1519ac335b 100644 --- a/apps/admin/src/settings/advanced/integrations/transistor-modal.tsx +++ b/apps/admin/src/settings/advanced/integrations/transistor-modal.tsx @@ -1,6 +1,6 @@ import APIKeys from './api-keys'; import BookmarkThumb from '@/settings/assets/images/integrations/ghost-transistor.png'; -import BrandIcon from '@/settings/components/icons/brand-icon'; +import BrandIcon from '@/shared/brand-icon/brand-icon'; import IntegrationHeader from './integration-header'; import { Field, diff --git a/apps/admin/src/settings/advanced/integrations/unsplash-modal.tsx b/apps/admin/src/settings/advanced/integrations/unsplash-modal.tsx index af7551276e2..6f5846b6439 100644 --- a/apps/admin/src/settings/advanced/integrations/unsplash-modal.tsx +++ b/apps/admin/src/settings/advanced/integrations/unsplash-modal.tsx @@ -1,4 +1,4 @@ -import BrandIcon from '@/settings/components/icons/brand-icon'; +import BrandIcon from '@/shared/brand-icon/brand-icon'; import IntegrationHeader from './integration-header'; import { Field, diff --git a/apps/admin/src/settings/advanced/integrations/zapier-modal.tsx b/apps/admin/src/settings/advanced/integrations/zapier-modal.tsx index 3b18dc91914..393a5a83f03 100644 --- a/apps/admin/src/settings/advanced/integrations/zapier-modal.tsx +++ b/apps/admin/src/settings/advanced/integrations/zapier-modal.tsx @@ -1,5 +1,5 @@ import APIKeys from './api-keys'; -import BrandIcon from '@/settings/components/icons/brand-icon'; +import BrandIcon from '@/shared/brand-icon/brand-icon'; import IntegrationHeader from './integration-header'; import ZapierLogo from '@/settings/assets/images/zapier-logo.svg'; import { diff --git a/apps/admin/src/settings/advanced/migration-tools/content-import/mapping.test.ts b/apps/admin/src/settings/advanced/migration-tools/content-import/mapping.test.ts index a3b2dcecccb..69ad2dd7396 100644 --- a/apps/admin/src/settings/advanced/migration-tools/content-import/mapping.test.ts +++ b/apps/admin/src/settings/advanced/migration-tools/content-import/mapping.test.ts @@ -15,12 +15,24 @@ describe('ContentFieldMapping', () => { expect(mapping.toJSON()).toEqual({ 'First title': '', 'Second title': 'title' }); }); + it('reads and clears individual mappings', () => { + const mapping = ContentFieldMapping.detect(['title', 'Other']); + + expect(mapping.get('title')).toBe('title'); + expect(mapping.get('Other')).toBeNull(); + expect(mapping.update('title', null).get('title')).toBeNull(); + }); + it('detects exact field-name headers', () => { const mapping = ContentFieldMapping.detect([ 'title', 'html', 'markdown', 'published_at', + 'comment_id', + 'authors', + 'author_emails', + 'tags', 'Something else', ]); @@ -29,6 +41,10 @@ describe('ContentFieldMapping', () => { html: 'html', markdown: 'markdown', published_at: 'published_at', + comment_id: 'comment_id', + authors: 'authors', + author_emails: 'author_emails', + tags: 'tags', 'Something else': '', }); }); @@ -51,6 +67,7 @@ describe('ContentFieldMapping', () => { expect(CONTENT_FIELD_GROUPS.map((group) => group.label)).toEqual([ 'Content', 'Publishing', + 'Authors & tags', 'Images', 'SEO', 'Social', @@ -69,6 +86,9 @@ describe('ContentFieldMapping', () => { 'created_at', 'updated_at', 'published_at', + 'authors', + 'author_emails', + 'tags', 'feature_image', 'feature_image_alt', 'feature_image_caption', @@ -82,22 +102,14 @@ describe('ContentFieldMapping', () => { 'twitter_image', 'twitter_title', 'twitter_description', + 'comment_id', 'custom_template', 'codeinjection_head', 'codeinjection_foot', 'frontmatter', ]); expect(CONTENT_FIELD_MAPPINGS.map((field) => field.value)).not.toEqual( - expect.arrayContaining([ - 'authors', - 'tags', - 'comment_id', - 'newsletter_id', - 'email', - 'tiers', - 'id', - 'lexical', - ]), + expect.arrayContaining(['newsletter_id', 'email', 'tiers', 'id', 'lexical']), ); }); }); diff --git a/apps/admin/src/settings/advanced/migration-tools/content-import/mapping.ts b/apps/admin/src/settings/advanced/migration-tools/content-import/mapping.ts index 2917bf05d93..092e5fa8c0d 100644 --- a/apps/admin/src/settings/advanced/migration-tools/content-import/mapping.ts +++ b/apps/admin/src/settings/advanced/migration-tools/content-import/mapping.ts @@ -32,6 +32,14 @@ export const CONTENT_FIELD_GROUPS: readonly ContentFieldGroup[] = [ { label: 'Published at', value: 'published_at', required: false }, ], }, + { + label: 'Authors & tags', + fields: [ + { label: 'Authors', value: 'authors', required: false }, + { label: 'Author emails', value: 'author_emails', required: false }, + { label: 'Tags', value: 'tags', required: false }, + ], + }, { label: 'Images', fields: [ @@ -67,6 +75,7 @@ export const CONTENT_FIELD_GROUPS: readonly ContentFieldGroup[] = [ { label: 'Advanced', fields: [ + { label: 'Source ID', value: 'comment_id', required: false }, { label: 'Custom template', value: 'custom_template', required: false }, { label: 'Code injection head', value: 'codeinjection_head', required: false }, { label: 'Code injection foot', value: 'codeinjection_foot', required: false }, diff --git a/apps/admin/src/settings/advanced/migration-tools/migration-tools-import.tsx b/apps/admin/src/settings/advanced/migration-tools/migration-tools-import.tsx index 86e23501877..c2f13a63bdc 100644 --- a/apps/admin/src/settings/advanced/migration-tools/migration-tools-import.tsx +++ b/apps/admin/src/settings/advanced/migration-tools/migration-tools-import.tsx @@ -1,4 +1,4 @@ -import BrandIcon from '@/settings/components/icons/brand-icon'; +import BrandIcon from '@/shared/brand-icon/brand-icon'; import React, { useState } from 'react'; import UniversalImportModal from './universal-import-modal'; import { Button } from '@tryghost/shade/components'; diff --git a/apps/admin/src/settings/advanced/migration-tools/universal-import-modal.test.tsx b/apps/admin/src/settings/advanced/migration-tools/universal-import-modal.test.tsx index 3ee66a3c286..6b2b520b3db 100644 --- a/apps/admin/src/settings/advanced/migration-tools/universal-import-modal.test.tsx +++ b/apps/admin/src/settings/advanced/migration-tools/universal-import-modal.test.tsx @@ -212,6 +212,52 @@ describe('UniversalImportModal', () => { ); }); + it('maps author names, author emails, and tags from the grouped picker', async () => { + mockUseFeatureFlag.mockReturnValue(true); + showModal(); + + const file = new File( + [ + 'title,Bylines,Emails,Topics\nHello,"Alice, Bob","alice@example.com, bob@example.com","News, Features"', + ], + 'posts.csv', + { type: 'text/csv' }, + ); + await dropFile(file); + + fireEvent.click(await screen.findByRole('combobox', { name: /Field for Bylines/ })); + expect(screen.getByText('Authors & tags')).toBeInTheDocument(); + fireEvent.change(screen.getByPlaceholderText('Search post fields...'), { + target: { value: 'Authors' }, + }); + fireEvent.click(screen.getByText('Authors')); + + fireEvent.click(screen.getByRole('combobox', { name: /Field for Emails/ })); + fireEvent.change(screen.getByPlaceholderText('Search post fields...'), { + target: { value: 'Author emails' }, + }); + fireEvent.click(screen.getByText('Author emails')); + + fireEvent.click(screen.getByRole('combobox', { name: /Field for Topics/ })); + fireEvent.change(screen.getByPlaceholderText('Search post fields...'), { + target: { value: 'Tags' }, + }); + fireEvent.click(screen.getByText('Tags')); + fireEvent.click(screen.getByRole('button', { name: 'Import' })); + + await waitFor(() => + expect(mockImportContentCSV).toHaveBeenCalledWith({ + file, + mapping: { + title: 'title', + Bylines: 'authors', + Emails: 'author_emails', + Topics: 'tags', + }, + }), + ); + }); + it('still sends JSON files to the db import when csvContentImporter is enabled', async () => { mockUseFeatureFlag.mockReturnValue(true); showModal(); diff --git a/apps/admin/src/settings/api.ts b/apps/admin/src/settings/api.ts new file mode 100644 index 00000000000..31d281c8cb5 --- /dev/null +++ b/apps/admin/src/settings/api.ts @@ -0,0 +1,10 @@ +/** + * Public surface of the settings domain, consumed by the admin shell + * (apps/admin/src/routes.tsx). Everything else in this domain is internal. + */ +export { settingsRouteChildren } from './routes'; +export { canAccessSettingsRoute } from './settings-access'; + +// Lazy entry, not a component re-export: the shell mounts it behind `lazy:`, +// so a static re-export would pull the chunk into the shell bundle. +export const lazySettingsScreen = () => import('./settings'); diff --git a/apps/admin/src/settings/general/users.tsx b/apps/admin/src/settings/general/users.tsx index cc69d289a7a..57c8155a978 100644 --- a/apps/admin/src/settings/general/users.tsx +++ b/apps/admin/src/settings/general/users.tsx @@ -33,7 +33,7 @@ import { } from '@tryghost/admin-x-framework/api/invites'; import { UserRoundX } from 'lucide-react'; import { formatNumber } from '@tryghost/shade/utils'; -import { memberAvatarProps } from '@/members/member-format'; +import { memberAvatarProps } from '@/members/api'; import { getSettingValue, useEditSettings } from '@tryghost/admin-x-framework/api/settings'; import { toast } from 'sonner'; import { useGlobalData } from '@/settings/providers/global-data-context'; diff --git a/apps/admin/src/settings/growth/explore/testimonials-modal.tsx b/apps/admin/src/settings/growth/explore/testimonials-modal.tsx index 29c45e05ed7..f403eb8ce3e 100644 --- a/apps/admin/src/settings/growth/explore/testimonials-modal.tsx +++ b/apps/admin/src/settings/growth/explore/testimonials-modal.tsx @@ -17,7 +17,7 @@ import { } from '@tryghost/shade/components'; import { Button, LoadingIndicator } from '@tryghost/shade/components'; import { SettingsModal } from '@tryghost/shade/patterns'; -import { memberAvatarProps } from '@/members/member-format'; +import { memberAvatarProps } from '@/members/api'; import { getSettingValues } from '@tryghost/admin-x-framework/api/settings'; import { toast } from 'sonner'; import { useForm, useHandleError } from '@tryghost/admin-x-framework/hooks'; diff --git a/apps/admin/src/settings/growth/offers/offer-success.tsx b/apps/admin/src/settings/growth/offers/offer-success.tsx index 236670414d9..0d5e00d3d24 100644 --- a/apps/admin/src/settings/growth/offers/offer-success.tsx +++ b/apps/admin/src/settings/growth/offers/offer-success.tsx @@ -1,4 +1,4 @@ -import BrandIcon from '@/settings/components/icons/brand-icon'; +import BrandIcon from '@/shared/brand-icon/brand-icon'; import SettingsBreadcrumbs from '@/settings/components/settings-breadcrumbs'; import { Button, Input } from '@tryghost/shade/components'; import { LucideIcon } from '@tryghost/shade/utils'; diff --git a/apps/admin/src/settings/membership/access.acceptance.test.tsx b/apps/admin/src/settings/membership/access.acceptance.test.tsx index 4a310383b24..63eb3c19ff3 100644 --- a/apps/admin/src/settings/membership/access.acceptance.test.tsx +++ b/apps/admin/src/settings/membership/access.acceptance.test.tsx @@ -234,8 +234,13 @@ describe('Access settings', () => { await choose('default-post-access-select', 'Specific tiers'); await settingsScreen.access().getByTestId('tiers-select').click(); - await settingsScreen.selectOption(basic.name).click(); - await settingsScreen.selectOption(premium.name).click(); + const basicOption = settingsScreen.tierOption(basic.name); + await expect.element(basicOption).toBeVisible(); + await basicOption.click(); + + const premiumOption = settingsScreen.tierOption(premium.name); + await expect.element(premiumOption).toBeVisible(); + await premiumOption.click(); await settingsScreen.access().getByRole('button', { name: 'Save' }).click(); await expect diff --git a/apps/admin/src/settings/membership/portal/look-and-feel.tsx b/apps/admin/src/settings/membership/portal/look-and-feel.tsx index 118651b655e..b234bc991cd 100644 --- a/apps/admin/src/settings/membership/portal/look-and-feel.tsx +++ b/apps/admin/src/settings/membership/portal/look-and-feel.tsx @@ -1,4 +1,4 @@ -import BrandIcon, { type BrandIconName } from '@/settings/components/icons/brand-icon'; +import BrandIcon, { type BrandIconName } from '@/shared/brand-icon/brand-icon'; import React, { useState } from 'react'; import { APIError } from '@tryghost/admin-x-framework/errors'; import { diff --git a/apps/admin/src/settings/settings-access.ts b/apps/admin/src/settings/settings-access.ts index f77bb833dde..40371f55e3f 100644 --- a/apps/admin/src/settings/settings-access.ts +++ b/apps/admin/src/settings/settings-access.ts @@ -1,6 +1,6 @@ import { canAccessSettings } from '@tryghost/admin-x-framework/api/users'; import { getStaffProfileSlug } from '@/settings/providers/routing/staff-profile-paths'; -import type { AccessRule } from '@/route-access-guard'; +import type { AccessRule } from '@/route-access'; const SETTINGS_PREFIX = /^\/settings\/?/; diff --git a/apps/admin/src/settings/settings.screen.ts b/apps/admin/src/settings/settings.screen.ts index 2a612a48f41..67d48d87acd 100644 --- a/apps/admin/src/settings/settings.screen.ts +++ b/apps/admin/src/settings/settings.screen.ts @@ -50,6 +50,8 @@ export const settingsScreen = { page.getByRole('option', { name: new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\s|$)`), }), + tierOption: (name: string) => + page.getByRole('listbox').getByRole('option', { name, exact: true }), errorToast: toast, successToast: toast, inviteUserModal: () => page.getByTestId(sel.inviteUserModal), diff --git a/apps/admin/src/settings/assets/images/brand-icons/beehiiv.svg b/apps/admin/src/shared/brand-icon/assets/beehiiv.svg similarity index 100% rename from apps/admin/src/settings/assets/images/brand-icons/beehiiv.svg rename to apps/admin/src/shared/brand-icon/assets/beehiiv.svg diff --git a/apps/admin/src/settings/assets/images/brand-icons/facebook.svg b/apps/admin/src/shared/brand-icon/assets/facebook.svg similarity index 100% rename from apps/admin/src/settings/assets/images/brand-icons/facebook.svg rename to apps/admin/src/shared/brand-icon/assets/facebook.svg diff --git a/apps/admin/src/settings/assets/images/brand-icons/firstpromoter.svg b/apps/admin/src/shared/brand-icon/assets/firstpromoter.svg similarity index 100% rename from apps/admin/src/settings/assets/images/brand-icons/firstpromoter.svg rename to apps/admin/src/shared/brand-icon/assets/firstpromoter.svg diff --git a/apps/admin/src/settings/assets/images/brand-icons/linkedin.svg b/apps/admin/src/shared/brand-icon/assets/linkedin.svg similarity index 100% rename from apps/admin/src/settings/assets/images/brand-icons/linkedin.svg rename to apps/admin/src/shared/brand-icon/assets/linkedin.svg diff --git a/apps/admin/src/settings/assets/images/brand-icons/mailchimp.svg b/apps/admin/src/shared/brand-icon/assets/mailchimp.svg similarity index 100% rename from apps/admin/src/settings/assets/images/brand-icons/mailchimp.svg rename to apps/admin/src/shared/brand-icon/assets/mailchimp.svg diff --git a/apps/admin/src/settings/assets/images/brand-icons/medium.svg b/apps/admin/src/shared/brand-icon/assets/medium.svg similarity index 100% rename from apps/admin/src/settings/assets/images/brand-icons/medium.svg rename to apps/admin/src/shared/brand-icon/assets/medium.svg diff --git a/apps/admin/src/settings/assets/images/brand-icons/pintura.svg b/apps/admin/src/shared/brand-icon/assets/pintura.svg similarity index 100% rename from apps/admin/src/settings/assets/images/brand-icons/pintura.svg rename to apps/admin/src/shared/brand-icon/assets/pintura.svg diff --git a/apps/admin/src/settings/assets/images/brand-icons/portal-icon-1.svg b/apps/admin/src/shared/brand-icon/assets/portal-icon-1.svg similarity index 100% rename from apps/admin/src/settings/assets/images/brand-icons/portal-icon-1.svg rename to apps/admin/src/shared/brand-icon/assets/portal-icon-1.svg diff --git a/apps/admin/src/settings/assets/images/brand-icons/portal-icon-2.svg b/apps/admin/src/shared/brand-icon/assets/portal-icon-2.svg similarity index 100% rename from apps/admin/src/settings/assets/images/brand-icons/portal-icon-2.svg rename to apps/admin/src/shared/brand-icon/assets/portal-icon-2.svg diff --git a/apps/admin/src/settings/assets/images/brand-icons/portal-icon-3.svg b/apps/admin/src/shared/brand-icon/assets/portal-icon-3.svg similarity index 100% rename from apps/admin/src/settings/assets/images/brand-icons/portal-icon-3.svg rename to apps/admin/src/shared/brand-icon/assets/portal-icon-3.svg diff --git a/apps/admin/src/settings/assets/images/brand-icons/portal-icon-4.svg b/apps/admin/src/shared/brand-icon/assets/portal-icon-4.svg similarity index 100% rename from apps/admin/src/settings/assets/images/brand-icons/portal-icon-4.svg rename to apps/admin/src/shared/brand-icon/assets/portal-icon-4.svg diff --git a/apps/admin/src/settings/assets/images/brand-icons/portal-icon-5.svg b/apps/admin/src/shared/brand-icon/assets/portal-icon-5.svg similarity index 100% rename from apps/admin/src/settings/assets/images/brand-icons/portal-icon-5.svg rename to apps/admin/src/shared/brand-icon/assets/portal-icon-5.svg diff --git a/apps/admin/src/settings/assets/images/brand-icons/slack.svg b/apps/admin/src/shared/brand-icon/assets/slack.svg similarity index 100% rename from apps/admin/src/settings/assets/images/brand-icons/slack.svg rename to apps/admin/src/shared/brand-icon/assets/slack.svg diff --git a/apps/admin/src/settings/assets/images/brand-icons/squarespace.svg b/apps/admin/src/shared/brand-icon/assets/squarespace.svg similarity index 100% rename from apps/admin/src/settings/assets/images/brand-icons/squarespace.svg rename to apps/admin/src/shared/brand-icon/assets/squarespace.svg diff --git a/apps/admin/src/settings/assets/images/brand-icons/substack.svg b/apps/admin/src/shared/brand-icon/assets/substack.svg similarity index 100% rename from apps/admin/src/settings/assets/images/brand-icons/substack.svg rename to apps/admin/src/shared/brand-icon/assets/substack.svg diff --git a/apps/admin/src/settings/assets/images/brand-icons/transistor.svg b/apps/admin/src/shared/brand-icon/assets/transistor.svg similarity index 100% rename from apps/admin/src/settings/assets/images/brand-icons/transistor.svg rename to apps/admin/src/shared/brand-icon/assets/transistor.svg diff --git a/apps/admin/src/settings/assets/images/brand-icons/twitter-x.svg b/apps/admin/src/shared/brand-icon/assets/twitter-x.svg similarity index 100% rename from apps/admin/src/settings/assets/images/brand-icons/twitter-x.svg rename to apps/admin/src/shared/brand-icon/assets/twitter-x.svg diff --git a/apps/admin/src/settings/assets/images/brand-icons/unsplash.svg b/apps/admin/src/shared/brand-icon/assets/unsplash.svg similarity index 100% rename from apps/admin/src/settings/assets/images/brand-icons/unsplash.svg rename to apps/admin/src/shared/brand-icon/assets/unsplash.svg diff --git a/apps/admin/src/settings/assets/images/brand-icons/wordpress.svg b/apps/admin/src/shared/brand-icon/assets/wordpress.svg similarity index 100% rename from apps/admin/src/settings/assets/images/brand-icons/wordpress.svg rename to apps/admin/src/shared/brand-icon/assets/wordpress.svg diff --git a/apps/admin/src/settings/assets/images/brand-icons/zapier.svg b/apps/admin/src/shared/brand-icon/assets/zapier.svg similarity index 100% rename from apps/admin/src/settings/assets/images/brand-icons/zapier.svg rename to apps/admin/src/shared/brand-icon/assets/zapier.svg diff --git a/apps/admin/src/settings/components/icons/brand-icon.tsx b/apps/admin/src/shared/brand-icon/brand-icon.tsx similarity index 54% rename from apps/admin/src/settings/components/icons/brand-icon.tsx rename to apps/admin/src/shared/brand-icon/brand-icon.tsx index 5be835b2e41..b1d3309d4e4 100644 --- a/apps/admin/src/settings/components/icons/brand-icon.tsx +++ b/apps/admin/src/shared/brand-icon/brand-icon.tsx @@ -1,24 +1,24 @@ -import Beehiiv from '@/settings/assets/images/brand-icons/beehiiv.svg'; -import Facebook from '@/settings/assets/images/brand-icons/facebook.svg'; -import FirstPromoter from '@/settings/assets/images/brand-icons/firstpromoter.svg'; -import Linkedin from '@/settings/assets/images/brand-icons/linkedin.svg'; -import Mailchimp from '@/settings/assets/images/brand-icons/mailchimp.svg'; -import Medium from '@/settings/assets/images/brand-icons/medium.svg'; -import Pintura from '@/settings/assets/images/brand-icons/pintura.svg'; -import PortalIcon1 from '@/settings/assets/images/brand-icons/portal-icon-1.svg'; -import PortalIcon2 from '@/settings/assets/images/brand-icons/portal-icon-2.svg'; -import PortalIcon3 from '@/settings/assets/images/brand-icons/portal-icon-3.svg'; -import PortalIcon4 from '@/settings/assets/images/brand-icons/portal-icon-4.svg'; -import PortalIcon5 from '@/settings/assets/images/brand-icons/portal-icon-5.svg'; +import Beehiiv from './assets/beehiiv.svg'; +import Facebook from './assets/facebook.svg'; +import FirstPromoter from './assets/firstpromoter.svg'; +import Linkedin from './assets/linkedin.svg'; +import Mailchimp from './assets/mailchimp.svg'; +import Medium from './assets/medium.svg'; +import Pintura from './assets/pintura.svg'; +import PortalIcon1 from './assets/portal-icon-1.svg'; +import PortalIcon2 from './assets/portal-icon-2.svg'; +import PortalIcon3 from './assets/portal-icon-3.svg'; +import PortalIcon4 from './assets/portal-icon-4.svg'; +import PortalIcon5 from './assets/portal-icon-5.svg'; import React from 'react'; -import Slack from '@/settings/assets/images/brand-icons/slack.svg'; -import Squarespace from '@/settings/assets/images/brand-icons/squarespace.svg'; -import Substack from '@/settings/assets/images/brand-icons/substack.svg'; -import Transistor from '@/settings/assets/images/brand-icons/transistor.svg'; -import TwitterX from '@/settings/assets/images/brand-icons/twitter-x.svg'; -import Unsplash from '@/settings/assets/images/brand-icons/unsplash.svg'; -import Wordpress from '@/settings/assets/images/brand-icons/wordpress.svg'; -import Zapier from '@/settings/assets/images/brand-icons/zapier.svg'; +import Slack from './assets/slack.svg'; +import Squarespace from './assets/squarespace.svg'; +import Substack from './assets/substack.svg'; +import Transistor from './assets/transistor.svg'; +import TwitterX from './assets/twitter-x.svg'; +import Unsplash from './assets/unsplash.svg'; +import Wordpress from './assets/wordpress.svg'; +import Zapier from './assets/zapier.svg'; import clsx from 'clsx'; const icons = { diff --git a/apps/admin/src/not-found.tsx b/apps/admin/src/shared/not-found.tsx similarity index 100% rename from apps/admin/src/not-found.tsx rename to apps/admin/src/shared/not-found.tsx diff --git a/apps/admin/src/tag-detail-gate.tsx b/apps/admin/src/tag-detail-gate.tsx index 331dd77ae1d..08ae806362c 100644 --- a/apps/admin/src/tag-detail-gate.tsx +++ b/apps/admin/src/tag-detail-gate.tsx @@ -1,5 +1,6 @@ import { FlagGatedRoute } from './flag-gated-route'; import { lazy } from 'react'; +import { lazyTagDetailScreen } from './tags/api'; /** * Serves `/tags/:tagSlug` — covering both edit (`:tagSlug`) and create (the @@ -7,7 +8,7 @@ import { lazy } from 'react'; * `tagDetailsReact` Labs flag is on, and from Ember otherwise. The gating * semantics (loading, error, and flag branching) live in FlagGatedRoute. */ -const TagDetailReact = lazy(() => import('./tags/detail/tag-detail')); +const TagDetailReact = lazy(lazyTagDetailScreen); export function TagDetailGate() { return ; diff --git a/apps/admin/src/tags/api.ts b/apps/admin/src/tags/api.ts new file mode 100644 index 00000000000..fd349cf0707 --- /dev/null +++ b/apps/admin/src/tags/api.ts @@ -0,0 +1,11 @@ +/** + * Public surface of the tags domain, consumed by the admin shell + * (apps/admin/src/routes.tsx and the tag detail gate). Everything else in + * this domain is internal. + */ + +// Lazy entries, not component re-exports: the shell mounts these behind +// `lazy:`/`lazy()`, so static re-exports would pull the chunks into the +// shell bundle. +export const lazyTagsScreen = () => import('./tags'); +export const lazyTagDetailScreen = () => import('./detail/tag-detail'); diff --git a/apps/admin/src/tags/detail/tag-detail.tsx b/apps/admin/src/tags/detail/tag-detail.tsx index 15d04e7ab06..8d82ad27be1 100644 --- a/apps/admin/src/tags/detail/tag-detail.tsx +++ b/apps/admin/src/tags/detail/tag-detail.tsx @@ -24,7 +24,7 @@ import { DetailPage } from '@tryghost/shade/page-templates'; import { DirtyConfirmDialog, PageHeader } from '@tryghost/shade/patterns'; import { Link, useHandleError, useNavigate, useParams } from '@tryghost/admin-x-framework'; import { LucideIcon } from '@tryghost/shade/utils'; -import { NotFound } from '@/not-found'; +import { NotFound } from '@/shared/not-found'; import { buildTagSavePayload, generateSlugFromName, diff --git a/apps/admin/src/tags/detail/tag-image-field.tsx b/apps/admin/src/tags/detail/tag-image-field.tsx index e9283880079..cabc28f1672 100644 --- a/apps/admin/src/tags/detail/tag-image-field.tsx +++ b/apps/admin/src/tags/detail/tag-image-field.tsx @@ -15,7 +15,7 @@ import { createPortal } from 'react-dom'; import { getImageUrl, useUploadImage } from '@tryghost/admin-x-framework/api/images'; import { useFramework } from '@tryghost/admin-x-framework'; import { usePinturaEditor } from '@/hooks/use-pintura-editor'; -import BrandIcon from '@/settings/components/icons/brand-icon'; +import BrandIcon from '@/shared/brand-icon/brand-icon'; import { JSONError, RequestEntityTooLargeError, diff --git a/apps/admin/src/whats-new/api.ts b/apps/admin/src/whats-new/api.ts new file mode 100644 index 00000000000..9f0f4fcffb4 --- /dev/null +++ b/apps/admin/src/whats-new/api.ts @@ -0,0 +1,8 @@ +/** + * Public surface of the whats-new domain, consumed by the admin layout + * (apps/admin/src/layout). Everything else in this domain is internal. + */ +export { default as WhatsNewBanner } from './components/whats-new-banner'; +export { default as WhatsNewDialog } from './components/whats-new-dialog'; +export { useChangelog } from './hooks/use-changelog'; +export { useWhatsNew } from './hooks/use-whats-new'; diff --git a/apps/portal/src/components/frame.styles.js b/apps/portal/src/components/frame.styles.js index f393edda0c2..584f7530147 100644 --- a/apps/portal/src/components/frame.styles.js +++ b/apps/portal/src/components/frame.styles.js @@ -25,7 +25,7 @@ import { TipsAndDonationsSuccessStyle } from './pages/support-success'; import { GiftRedemptionStyles } from './pages/gift-redemption-page'; import { BetaGiftRedemptionStyles } from './pages/beta-gift-redemption-page'; import { GiftPageStyles } from './pages/gift-page'; -import { BetaGiftPageStyles } from './pages/beta-gift-page'; +import { BetaGiftPageStyles } from './pages/beta-gift-page.styles'; import { GiftSuccessStyle } from './pages/gift-success-page'; import { BetaGiftSuccessStyle } from './pages/beta-gift-success-page'; import { TipsAndDonationsErrorStyle } from './pages/support-error'; diff --git a/apps/portal/src/components/pages/beta-gift-page.jsx b/apps/portal/src/components/pages/beta-gift-page.styles.js similarity index 55% rename from apps/portal/src/components/pages/beta-gift-page.jsx rename to apps/portal/src/components/pages/beta-gift-page.styles.js index 375626f6a68..28696ff6d12 100644 --- a/apps/portal/src/components/pages/beta-gift-page.jsx +++ b/apps/portal/src/components/pages/beta-gift-page.styles.js @@ -1,32 +1,5 @@ -import { useContext, useEffect, useRef, useState } from 'react'; -import AppContext from '../../app-context'; -import CloseButton from '../common/close-button'; -import DatePicker from '../common/date-picker'; -import SiteTitleBackButton from '../common/site-title-back-button'; -import ActionButton from '../common/action-button'; -import GiftCard from '../common/gift-card'; -import GiftEmailPreview from '../common/gift-email-preview'; -import InputField from '../common/input-field'; -import LoadingPage from './loading-page'; -import CheckmarkIcon from '../../images/icons/checkmark.svg?react'; import giftCardNoiseUrl from '../../images/gift-card-noise.webp'; import giftCardOrbUrl from '../../images/gift-card-orb.webp'; -import { isCookiesDisabled } from '../../utils/helpers'; -import { addCalendarDays, getDateInputValue } from '../../utils/date-time'; -import { - getActiveGiftDuration, - getAvailableGiftDurations, - getGiftPrice, - getGiftProducts, -} from '../../utils/gift-subscriptions'; -import { - getGiftDurationAttributiveLabel, - getGiftDurationLabel, -} from '../../utils/gift-redemption-notification'; -import { ValidateInputForm } from '../../utils/form'; -import { t } from '../../utils/i18n'; -import useCardTilt from '../../utils/use-card-tilt'; -import { formatGiftValue } from './gift-page'; export const BetaGiftPageStyles = ` @property --shine-angle { @@ -1331,681 +1304,3 @@ html[dir="rtl"] .gh-portal-content.gift .gh-portal-btn-site-title-back { } } `; - -function GiftDurationSwitch({ offeredDurations, activeDuration, setSelectedDuration }) { - if (offeredDurations.length < 2) { - return null; - } - - return ( -
- {offeredDurations.map((months) => { - const isActive = months === activeDuration; - return ( - - ); - })} -
- ); -} - -const GIFT_EMAIL_MAX_LENGTH = 191; -const GIFT_NAME_MAX_LENGTH = 191; -const GIFT_MESSAGE_MAX_LENGTH = 250; -// Mirrors GIFT_MAX_SCHEDULE_DAYS in ghost/core's gifts constants — change -// them together. -const GIFT_MAX_SCHEDULE_DAYS = 365; - -function getTierPriceLabel(product, months) { - return formatGiftValue(getGiftPrice(product, months)); -} - -const BetaGiftPage = () => { - const { site, member, brandColor, action, doAction, lastPage } = useContext(AppContext); - const [step, setStep] = useState('plan'); - const [selectedDuration, setSelectedDuration] = useState(null); - const [selectedProductId, setSelectedProductId] = useState(null); - const [email, setEmail] = useState(''); - const [recipientEmail, setRecipientEmail] = useState(''); - const [recipientName, setRecipientName] = useState(''); - const [buyerName, setBuyerName] = useState(member?.name || ''); - const [giftMessage, setGiftMessage] = useState(''); - const [deliveryMethod, setDeliveryMethod] = useState('email'); - // null means untouched: the effective date then tracks "today" in the - // site's timezone on every render, so an untouched form still means - // "send now" after the page sits open across site-midnight. - const [deliveryDate, setDeliveryDate] = useState(null); - const [errors, setErrors] = useState({}); - const { cardRef, containerProps: cardTiltProps } = useCardTilt(); - - // Prefill the "from" name once the logged-in member loads, without - // clobbering anything the buyer has already typed - useEffect(() => { - setBuyerName((current) => current || member?.name || ''); - }, [member?.name]); - - // Anchors us to the popup's real (iframe) document for scroll control. - const contentRef = useRef(null); - - // Moving between the plan and delivery steps swaps a full screen of content, - // so reset the popup scroll to the top — otherwise the buyer can land partway - // down the next step. Portal renders inside a react-frame-component iframe, so - // the global `document` here is the parent; reach the popup via the rendered - // node's own document instead. Depending on the embed the actual scroller is - // the wrapper (live portal) or the container, so reset both — and any other - // scrollable ancestor — since only the one that overflows will move. Deferred - // a frame so it runs after the browser's scroll anchoring. - useEffect(() => { - const raf = requestAnimationFrame(() => { - const node = contentRef.current; - const doc = node?.ownerDocument; - if (!doc) { - return; - } - const view = doc.defaultView; - doc.querySelectorAll('.gh-portal-popup-wrapper, .gh-portal-popup-container').forEach((el) => { - el.scrollTop = 0; - }); - // Fallback: walk ancestors and reset whichever one actually scrolls. - for (let el = node.parentElement; el; el = el.parentElement) { - const overflowY = view?.getComputedStyle(el).overflowY; - if ((overflowY === 'auto' || overflowY === 'scroll') && el.scrollHeight > el.clientHeight) { - el.scrollTop = 0; - } - } - }); - return () => cancelAnimationFrame(raf); - }, [step]); - - if (!site) { - return ; - } - - const { portal_default_plan: portalDefaultPlan } = site; - const offeredDurations = getAvailableGiftDurations({ site }); - const activeDuration = getActiveGiftDuration({ - availableDurations: offeredDurations, - portalDefaultPlan, - selectedDuration, - }); - const products = getGiftProducts({ site, duration: activeDuration }); - - const siteIcon = site.icon; - const siteTitle = site.title || ''; - if (products.length === 0) { - return ( - <> -
- -
-
- - -
- - ); - } - - const activeProduct = products.find((p) => p.id === selectedProductId) || products[0]; - const isSingleTier = products.length === 1; - const emailDuration = - activeDuration === 12 - ? { cadence: 'year', duration: 1 } - : { cadence: 'month', duration: activeDuration }; - // This use sits in front of a noun ("6 month membership"), so this is the - // attributive form. The picker and the gift card face use the standalone - // one ("6 months"). - const activeDurationLabel = getGiftDurationAttributiveLabel(emailDuration); - const isPurchasing = action === 'checkoutGift:running'; - const hasErrors = - step === 'plan' - ? !!(errors.email || errors.buyerName) - : !!(errors.recipientEmail || errors.deliveryDate); - const isDisabled = isCookiesDisabled() || isPurchasing || hasErrors; - const isLoggedIn = !!member; - const showBuyerName = !(member?.name || '').trim(); - const showBuyerEmail = !isLoggedIn; - // On the delivery step the email being composed is the more useful thing to - // show than the gift card — it's what the recipient actually opens. The card - // stays for the plan step and for "I'll share it myself", where no email is - // sent and the card is what the buyer passes on. - const showEmailPreview = step === 'delivery' && deliveryMethod === 'email'; - const minDeliveryDate = getDateInputValue(new Date(), site.timezone); - const maxDeliveryDate = addCalendarDays(minDeliveryDate, GIFT_MAX_SCHEDULE_DAYS); - const effectiveDeliveryDate = deliveryDate ?? minDeliveryDate; - - const emailField = { - type: 'email', - value: email, - placeholder: t('jamie@example.com'), - label: t('Your email'), - name: 'email', - required: true, - maxLength: GIFT_EMAIL_MAX_LENGTH, - errorMessage: errors.email || '', - }; - - const recipientEmailField = { - type: 'email', - value: recipientEmail, - placeholder: t('taylor@example.com'), - label: t("Recipient's email"), - name: 'recipientEmail', - required: false, - maxLength: GIFT_EMAIL_MAX_LENGTH, - errorMessage: errors.recipientEmail || '', - }; - - const buyerNameField = { - type: 'text', - value: buyerName, - placeholder: t('Jamie Larson'), - label: t('Your name'), - name: 'buyerName', - required: false, - maxLength: GIFT_NAME_MAX_LENGTH, - errorMessage: errors.buyerName || '', - }; - - const recipientNameField = { - type: 'text', - value: recipientName, - placeholder: t('Taylor Reid'), - label: t("Recipient's name"), - name: 'recipientName', - required: false, - maxLength: GIFT_NAME_MAX_LENGTH, - errorMessage: '', - }; - - const handleEmailChange = (event) => { - setErrors((currentErrors) => ({ - ...currentErrors, - email: '', - })); - setEmail(event.target.value); - }; - - const handleRecipientEmailChange = (event) => { - setErrors((currentErrors) => ({ - ...currentErrors, - recipientEmail: '', - deliveryDate: '', - })); - setRecipientEmail(event.target.value); - }; - - const handleDeliveryMethodChange = (method) => { - setErrors((currentErrors) => ({ - ...currentErrors, - recipientEmail: '', - deliveryDate: '', - })); - setDeliveryMethod(method); - }; - - const handleDeliveryDateChange = (nextDate) => { - setErrors((currentErrors) => ({ - ...currentErrors, - deliveryDate: '', - })); - // Store today as null so "send now" keeps tracking the site day across - // midnight; a typed past date stays put for validation to call out. - setDeliveryDate(nextDate === minDeliveryDate ? null : nextDate); - }; - - const handleContinueToDelivery = (e) => { - e.preventDefault(); - if (!isLoggedIn) { - const formErrors = ValidateInputForm({ fields: [{ ...emailField, value: email.trim() }] }); - const formHasErrors = Object.values(formErrors).some((errorMessage) => !!errorMessage); - - setErrors(formErrors); - - if (formHasErrors) { - return; - } - } - setStep('delivery'); - }; - - const handleBackToPlan = () => { - setErrors({}); - setStep('plan'); - }; - - const handlePurchase = (e) => { - e.preventDefault(); - - if (isPurchasing) { - return; - } - - const customerEmail = email.trim(); - const trimmedRecipientEmail = recipientEmail.trim(); - const trimmedRecipientName = recipientName.trim(); - const trimmedBuyerName = buyerName.trim(); - const trimmedGiftMessage = giftMessage.trim(); - const isEmailDelivery = deliveryMethod === 'email'; - const isScheduled = isEmailDelivery && effectiveDeliveryDate > minDeliveryDate; - - const fieldsToValidate = []; - if (!isLoggedIn) { - fieldsToValidate.push({ ...emailField, value: customerEmail }); - } - if (isEmailDelivery && trimmedRecipientEmail) { - fieldsToValidate.push({ ...recipientEmailField, value: trimmedRecipientEmail }); - } - - const formErrors = ValidateInputForm({ fields: fieldsToValidate }); - - if (isEmailDelivery && !trimmedBuyerName) { - formErrors.buyerName = t('Enter your name'); - } - - // No confirm-email field: the buyer gets a confirmation copy, which - // covers the (unlikely) mistyped-recipient case. - if (isEmailDelivery && !trimmedRecipientEmail) { - formErrors.recipientEmail = t("Enter the recipient's email address"); - } - - if (isEmailDelivery) { - if (!effectiveDeliveryDate) { - formErrors.deliveryDate = t('Choose a delivery date'); - } else if (effectiveDeliveryDate < minDeliveryDate) { - formErrors.deliveryDate = t('Choose a date from today onwards'); - } else if (effectiveDeliveryDate > maxDeliveryDate) { - formErrors.deliveryDate = t('Choose a date within the next year'); - } - } - - const formHasErrors = Object.values(formErrors).some((errorMessage) => !!errorMessage); - - setErrors(formErrors); - - if (formHasErrors) { - if (formErrors.buyerName) { - setStep('plan'); - } - return; - } - - doAction('checkoutGift', { - tierId: activeProduct.id, - duration: activeDuration, - ...(!isLoggedIn ? { email: customerEmail } : {}), - deliveryMethod, - ...(isEmailDelivery ? { recipientEmail: trimmedRecipientEmail } : {}), - ...(isEmailDelivery && trimmedRecipientName ? { recipientName: trimmedRecipientName } : {}), - ...(trimmedBuyerName ? { buyerName: trimmedBuyerName } : {}), - ...(isEmailDelivery && trimmedGiftMessage ? { personalMessage: trimmedGiftMessage } : {}), - ...(isScheduled ? { deliveryDate: effectiveDeliveryDate } : {}), - }); - }; - - return ( - <> -
- -
-
-