From ce581a42c8f960b89784474b63e78a655c0fa2db Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:00:19 -0600 Subject: [PATCH] Fix case-study share chrome, hero lockup width, and copy confirmation in the share dialog. Marketing pages omitted create-flow nav copy, so Share rendered as a translation key; the case-study banner squeezed hero text to the thumbnail width; Copy link had no hover or copied state. Co-authored-by: Cursor --- .../hooks/useCompletedRuleShareExport.ts | 14 +++-- .../useUseCaseCompletedRuleActions.ts | 4 +- .../ContentContainer.container.tsx | 34 ++++++++--- .../ContentContainer.types.ts | 8 +-- .../modals/Share/Share.container.tsx | 25 ++++++++- app/components/modals/Share/Share.types.ts | 8 ++- app/components/modals/Share/Share.view.tsx | 56 ++++++++++++++----- .../ContentBanner/ContentBanner.view.tsx | 2 +- lib/assetUtils.ts | 8 ++- lib/propNormalization.ts | 6 +- messages/en/marketing.ts | 27 ++++----- messages/en/modals/share.json | 2 + public/assets/share/check.svg | 3 + tests/components/ContentBanner.test.tsx | 5 +- tests/components/modals/Share.test.tsx | 37 ++++++++++++ tests/pages/use-cases-completed-rule.test.jsx | 27 ++++++++- tests/unit/ContentContainer.test.jsx | 11 ++++ 17 files changed, 225 insertions(+), 52 deletions(-) create mode 100644 public/assets/share/check.svg diff --git a/app/(app)/create/hooks/useCompletedRuleShareExport.ts b/app/(app)/create/hooks/useCompletedRuleShareExport.ts index 5ccf4fa..deee50c 100644 --- a/app/(app)/create/hooks/useCompletedRuleShareExport.ts +++ b/app/(app)/create/hooks/useCompletedRuleShareExport.ts @@ -96,7 +96,7 @@ export function useCompletedRuleShareExport({ }: { setActionBanner: (_: CompletedFlowActionBanner | null) => void; }): { - copyPublishedRuleLink: () => Promise; + copyPublishedRuleLink: () => Promise; mailtoPublishedRule: () => void; sharePublishedRuleViaSignal: () => Promise; sharePublishedRuleViaSlack: () => Promise; @@ -137,32 +137,34 @@ export function useCompletedRuleShareExport({ url: string, banner?: () => void, options?: { suppressFailureWhenDocumentNotFocused?: boolean }, - ) => { + ): Promise => { try { await navigator.clipboard.writeText(url); (banner ?? bannerCopied)(); + return true; } catch { if ( options?.suppressFailureWhenDocumentNotFocused === true && typeof window !== "undefined" && shouldSkipShareClipboardFallback(window) ) { - return; + return true; } bannerCopyFailed(); + return false; } }, [bannerCopied, bannerCopyFailed], ); const copyPublishedRuleLink = useCallback(async () => { - if (typeof window === "undefined") return; + if (typeof window === "undefined") return false; const ctx = resolvePublishedRuleShareContext(window); if (!ctx) { bannerNoRule(); - return; + return false; } - await copyUrlToClipboard(ctx.url); + return copyUrlToClipboard(ctx.url); }, [bannerNoRule, copyUrlToClipboard]); const mailtoPublishedRule = useCallback(() => { diff --git a/app/(marketing-case-study)/use-cases/[slug]/rule/_components/useUseCaseCompletedRuleActions.ts b/app/(marketing-case-study)/use-cases/[slug]/rule/_components/useUseCaseCompletedRuleActions.ts index 0a208e0..150d9b1 100644 --- a/app/(marketing-case-study)/use-cases/[slug]/rule/_components/useUseCaseCompletedRuleActions.ts +++ b/app/(marketing-case-study)/use-cases/[slug]/rule/_components/useUseCaseCompletedRuleActions.ts @@ -34,7 +34,7 @@ export function useUseCaseCompletedRuleActions({ const [duplicateBusy, setDuplicateBusy] = useState(false); const copyPageLink = useCallback(async () => { - if (typeof window === "undefined") return; + if (typeof window === "undefined") return false; try { await navigator.clipboard.writeText(window.location.href); setActionBanner({ @@ -43,6 +43,7 @@ export function useUseCaseCompletedRuleActions({ title: t("shareLinkCopiedTitle"), description: t("shareLinkCopiedDescription"), }); + return true; } catch { setActionBanner({ key: "shareCopyFailed", @@ -50,6 +51,7 @@ export function useUseCaseCompletedRuleActions({ title: t("shareCopyFailedTitle"), description: t("shareCopyFailedDescription"), }); + return false; } }, [setActionBanner, t]); diff --git a/app/components/content/ContentContainer/ContentContainer.container.tsx b/app/components/content/ContentContainer/ContentContainer.container.tsx index 4ee159b..acfbb01 100644 --- a/app/components/content/ContentContainer/ContentContainer.container.tsx +++ b/app/components/content/ContentContainer/ContentContainer.container.tsx @@ -16,7 +16,7 @@ import type { ContentContainerProps } from "./ContentContainer.types"; const ContentContainerContainer = memo( ({ post, - width = "200px", + width: widthProp, size: sizeProp = "responsive", tone: toneProp = "inverse", leadingImageSrc, @@ -26,6 +26,8 @@ const ContentContainerContainer = memo( const size = sizeProp; const tone = toneProp; const showLeadingImage = showLeadingImageProp; + const width = + widthProp ?? (size === "useCase" ? "100%" : "200px"); const onLight = tone === "onLight"; const titleColor = onLight ? "text-[var(--color-content-default-primary)] group-hover:text-[var(--color-content-default-brand-primary)]" @@ -57,40 +59,56 @@ const ContentContainerContainer = memo( }, ); + const isUseCase = size === "useCase"; + const containerClasses = size === "xs" ? "relative z-20 flex h-full flex-col gap-[var(--measures-spacing-012)]" - : "relative z-20 h-full flex flex-col gap-[var(--measures-spacing-012)] sm:gap-[var(--measures-spacing-016)] md:gap-[18px] lg:gap-[var(--measures-spacing-024)]"; + : isUseCase + ? "relative z-20 flex h-full w-full min-w-0 flex-col gap-[var(--measures-spacing-024)]" + : "relative z-20 h-full flex flex-col gap-[var(--measures-spacing-012)] sm:gap-[var(--measures-spacing-016)] md:gap-[18px] lg:gap-[var(--measures-spacing-024)]"; const contentGapClasses = size === "xs" ? "flex flex-col gap-[var(--measures-spacing-008)]" - : "flex flex-col gap-[var(--measures-spacing-008)] sm:gap-[var(--measures-spacing-012)] md:gap-[var(--measures-spacing-008)] lg:gap-[var(--measures-spacing-016)] xl:gap-[var(--measures-spacing-004)]"; + : isUseCase + ? "flex w-full min-w-0 flex-col gap-[var(--measures-spacing-016)]" + : "flex flex-col gap-[var(--measures-spacing-008)] sm:gap-[var(--measures-spacing-012)] md:gap-[var(--measures-spacing-008)] lg:gap-[var(--measures-spacing-016)] xl:gap-[var(--measures-spacing-004)]"; const textGapClasses = size === "xs" ? "flex flex-col gap-[var(--measures-spacing-004)]" - : "flex flex-col gap-[var(--measures-spacing-004)] md:gap-[var(--measures-spacing-002)] lg:gap-[var(--measures-spacing-004)]"; + : isUseCase + ? "flex w-full min-w-0 flex-col gap-[var(--measures-spacing-004)]" + : "flex flex-col gap-[var(--measures-spacing-004)] md:gap-[var(--measures-spacing-002)] lg:gap-[var(--measures-spacing-004)]"; const titleClasses = size === "xs" ? `font-bricolage-grotesque font-medium text-[18px] leading-[22px] transition-colors ${titleColor}` - : `font-bricolage-grotesque font-medium text-xx-small-display sm:text-x-small-display md:text-[32px] md:leading-[110%] lg:text-medium-display xl:text-x-large-display transition-colors ${titleColor}`; + : isUseCase + ? `w-full font-bricolage-grotesque font-medium text-[32px] leading-[110%] lg:text-medium-display transition-colors ${titleColor}` + : `font-bricolage-grotesque font-medium text-xx-small-display sm:text-x-small-display md:text-[32px] md:leading-[110%] lg:text-medium-display xl:text-x-large-display transition-colors ${titleColor}`; const descriptionClasses = size === "xs" ? `text-x-small-paragraph max-w-md ${bodyColor}` - : `text-x-small-paragraph sm:text-small-paragraph md:text-small-paragraph lg:text-large-paragraph xl:text-x-large-paragraph ${bodyColor}`; + : isUseCase + ? `w-full text-small-paragraph lg:text-large-paragraph ${bodyColor}` + : `text-x-small-paragraph sm:text-small-paragraph md:text-small-paragraph lg:text-large-paragraph xl:text-x-large-paragraph ${bodyColor}`; const authorClasses = size === "xs" ? `overflow-hidden text-ellipsis whitespace-nowrap text-xx-small-paragraph ${bodyColor}` - : `text-xx-small-paragraph md:text-x-small-paragraph lg:text-small-paragraph xl:text-large-paragraph ${bodyColor}`; + : isUseCase + ? `overflow-hidden text-ellipsis whitespace-nowrap text-x-small-paragraph lg:text-small-paragraph ${bodyColor}` + : `text-xx-small-paragraph md:text-x-small-paragraph lg:text-small-paragraph xl:text-large-paragraph ${bodyColor}`; const dateClasses = size === "xs" ? `overflow-hidden text-ellipsis whitespace-nowrap text-xx-small-paragraph ${bodyColor}` - : `text-xx-small-paragraph md:text-x-small-paragraph lg:text-small-paragraph xl:text-large-paragraph ${bodyColor}`; + : isUseCase + ? `overflow-hidden text-ellipsis whitespace-nowrap text-x-small-paragraph lg:text-small-paragraph ${bodyColor}` + : `text-xx-small-paragraph md:text-x-small-paragraph lg:text-small-paragraph xl:text-large-paragraph ${bodyColor}`; return ( ((props) => { const overlayRef = useRef(null); const titleId = useId(); const t = useTranslation("modals.share"); + const [linkCopied, setLinkCopied] = useState(false); useCreateModalA11y(props.isOpen, props.onClose, dialogRef); + useEffect(() => { + if (!props.isOpen) { + setLinkCopied(false); + } + }, [props.isOpen]); + + const onCopyLinkClick = useCallback(async () => { + try { + const result = await props.onCopyLink(); + if (result !== false) { + setLinkCopied(true); + } + } catch { + setLinkCopied(false); + } + }, [props.onCopyLink]); + return ( ((props) => { title={t("title")} description={t("description")} copyLinkLabel={t("copyLink")} + copiedLabel={t("copied")} + copiedLive={t("copiedLive")} + linkCopied={linkCopied} + onCopyLinkClick={onCopyLinkClick} signalLabel={t("signal")} slackLabel={t("slack")} discordLabel={t("discord")} diff --git a/app/components/modals/Share/Share.types.ts b/app/components/modals/Share/Share.types.ts index 5618f9d..e8d35c5 100644 --- a/app/components/modals/Share/Share.types.ts +++ b/app/components/modals/Share/Share.types.ts @@ -4,7 +4,8 @@ import type { CreateModalBackdropVariant } from "../Create/CreateModalFrame.view export type ShareProps = { isOpen: boolean; onClose: () => void; - onCopyLink: () => void | Promise; + /** Return `false` when the clipboard write did not succeed. */ + onCopyLink: () => void | boolean | Promise; onEmailShare: () => void; onSignalShare: () => void | Promise; onSlackShare: () => void | Promise; @@ -20,6 +21,10 @@ export type ShareViewProps = ShareProps & { title: string; description: string; copyLinkLabel: string; + copiedLabel: string; + copiedLive: string; + linkCopied: boolean; + onCopyLinkClick: () => void | Promise; signalLabel: string; slackLabel: string; discordLabel: string; @@ -34,4 +39,5 @@ export type ShareChannelTileProps = { onClick: () => void | Promise; circleClassName: string; icon: ReactNode; + copied?: boolean; }; diff --git a/app/components/modals/Share/Share.view.tsx b/app/components/modals/Share/Share.view.tsx index b839a8c..b1e8fc2 100644 --- a/app/components/modals/Share/Share.view.tsx +++ b/app/components/modals/Share/Share.view.tsx @@ -34,19 +34,32 @@ function ShareAssetIcon(props: { ); } -function ShareChannelTile({ label, onClick, circleClassName, icon }: ShareChannelTileProps) { +function ShareChannelTile({ + label, + onClick, + circleClassName, + icon, + copied = false, +}: ShareChannelTileProps) { return ( @@ -56,7 +69,6 @@ function ShareChannelTile({ label, onClick, circleClassName, icon }: ShareChanne export const ShareView = memo(function ShareView({ isOpen, onClose, - onCopyLink, onEmailShare, onSignalShare, onSlackShare, @@ -69,6 +81,10 @@ export const ShareView = memo(function ShareView({ title, description, copyLinkLabel, + copiedLabel, + copiedLive, + linkCopied, + onCopyLinkClick, signalLabel, slackLabel, discordLabel, @@ -109,36 +125,50 @@ export const ShareView = memo(function ShareView({ {/* Channel circle hexes are third-party brand colors (copy/link, Signal, Slack, Discord), not DS tokens. */}
} + label={linkCopied ? copiedLabel : copyLinkLabel} + onClick={onCopyLinkClick} + copied={linkCopied} + circleClassName={ + linkCopied + ? "border-2 border-[#444444] bg-[#333333]" + : "border-[#444444] bg-[#333333] group-hover:border-2 group-hover:border-[var(--color-border-default-positive-primary)] group-hover:bg-[#444444]" + } + icon={ + + } /> } /> } /> } /> } />
+

+ {linkCopied ? copiedLive : ""} +

+ + diff --git a/tests/components/ContentBanner.test.tsx b/tests/components/ContentBanner.test.tsx index bb3f62b..eb63d88 100644 --- a/tests/components/ContentBanner.test.tsx +++ b/tests/components/ContentBanner.test.tsx @@ -157,11 +157,14 @@ describe("ContentBanner", () => { const title = screen.getByRole("heading", { name: "Test Article" }); expect(title).toBeInTheDocument(); - expect(title).toHaveClass("sm:text-x-small-display", "md:text-[32px]"); + expect(title).toHaveClass("text-[32px]", "lg:text-medium-display"); + expect(title).not.toHaveClass("xl:text-x-large-display"); expect(screen.getByText("Sample Operating Manual")).toBeInTheDocument(); const copyColumn = container.querySelector('[data-node-id="19189:9171"]'); expect(copyColumn).toHaveClass("lg:max-w-[365px]"); expect(copyColumn).not.toHaveClass("max-w-[365px]"); + const copyLockup = title.closest("div.relative.z-20"); + expect(copyLockup).toHaveStyle({ width: "100%" }); const bannerRow = container.querySelector( '[data-figma-node="22015:42621"]', ); diff --git a/tests/components/modals/Share.test.tsx b/tests/components/modals/Share.test.tsx index 6262ec9..1da950d 100644 --- a/tests/components/modals/Share.test.tsx +++ b/tests/components/modals/Share.test.tsx @@ -70,6 +70,43 @@ describe("Share modal", () => { expect(onClose).toHaveBeenCalledTimes(1); }); + it("confirms Copy link with Copied! when the handler succeeds", async () => { + const user = userEvent.setup(); + const onCopyLink = vi.fn().mockResolvedValue(true); + render( + , + ); + await user.click(screen.getByRole("button", { name: "Copy link" })); + expect(onCopyLink).toHaveBeenCalledTimes(1); + expect(screen.getByRole("button", { name: "Copied!" })).toHaveAttribute( + "aria-pressed", + "true", + ); + expect(screen.getByText("Link copied to clipboard")).toBeInTheDocument(); + }); + + it("keeps Copy link when the handler reports failure", async () => { + const user = userEvent.setup(); + render( + , + ); + await user.click(screen.getByRole("button", { name: "Copy link" })); + expect(screen.getByRole("button", { name: "Copy link" })).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Copied!" }), + ).not.toBeInTheDocument(); + }); + it("calls onClose when header overflow (more) is activated, matching modal chrome parity", async () => { const user = userEvent.setup(); const onClose = vi.fn(); diff --git a/tests/pages/use-cases-completed-rule.test.jsx b/tests/pages/use-cases-completed-rule.test.jsx index 4b49bec..4796fc4 100644 --- a/tests/pages/use-cases-completed-rule.test.jsx +++ b/tests/pages/use-cases-completed-rule.test.jsx @@ -1,9 +1,12 @@ import { describe, test, expect, vi } from "vitest"; -import { screen } from "@testing-library/react"; +import { screen, render as rtlRender } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { renderWithProviders as render } from "../utils/test-utils"; +import { MessagesProvider } from "../../app/contexts/MessagesContext"; import UseCaseCompletedRulePage from "../../app/(marketing-case-study)/use-cases/[slug]/rule/page"; import messages from "../../messages/en/index"; +import marketingMessages from "../../messages/en/marketing"; +import { getTranslation } from "../../lib/i18n/getTranslation"; import { USE_CASE_DETAIL_SLUGS } from "../../lib/useCaseSyntheticPost"; const mockPush = vi.fn(); @@ -83,9 +86,31 @@ describe("UseCaseCompletedRulePage", () => { name: messages.pages.useCasesCompletedRule.topNav.duplicateAriaLabel, }), ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Share" })).toBeInTheDocument(); }, ); + test("marketing message bundle resolves Share (not the dotted create.topNav key)", () => { + expect(getTranslation(marketingMessages, "create.topNav.share")).toBe( + "Share", + ); + }); + + test("Share button reads Share under the marketing case-study bundle", async () => { + rtlRender( + + {await UseCaseCompletedRulePage({ + params: Promise.resolve({ slug: "mutual-aid-colorado" }), + })} + , + ); + + expect(screen.getByRole("button", { name: "Share" })).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "create.topNav.share" }), + ).not.toBeInTheDocument(); + }); + test("Duplicate opens login when signed out", async () => { const user = userEvent.setup(); mockOpenLogin.mockClear(); diff --git a/tests/unit/ContentContainer.test.jsx b/tests/unit/ContentContainer.test.jsx index d4f87bf..206cf03 100644 --- a/tests/unit/ContentContainer.test.jsx +++ b/tests/unit/ContentContainer.test.jsx @@ -111,6 +111,17 @@ describe("ContentContainer", () => { expect(container).toHaveStyle("width: 200px"); }); + it("applies full width and case-study type scale for useCase size", () => { + render(); + + const container = document.querySelector("div[class*='relative z-20']"); + expect(container).toHaveStyle("width: 100%"); + + const title = screen.getByText("Test Article Title"); + expect(title).toHaveClass("text-[32px]", "lg:text-medium-display"); + expect(title).not.toHaveClass("xl:text-x-large-display"); + }); + it("has proper spacing between icon and text", () => { render();