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 <cursoragent@cursor.com>
This commit is contained in:
@@ -96,7 +96,7 @@ export function useCompletedRuleShareExport({
|
|||||||
}: {
|
}: {
|
||||||
setActionBanner: (_: CompletedFlowActionBanner | null) => void;
|
setActionBanner: (_: CompletedFlowActionBanner | null) => void;
|
||||||
}): {
|
}): {
|
||||||
copyPublishedRuleLink: () => Promise<void>;
|
copyPublishedRuleLink: () => Promise<boolean>;
|
||||||
mailtoPublishedRule: () => void;
|
mailtoPublishedRule: () => void;
|
||||||
sharePublishedRuleViaSignal: () => Promise<void>;
|
sharePublishedRuleViaSignal: () => Promise<void>;
|
||||||
sharePublishedRuleViaSlack: () => Promise<void>;
|
sharePublishedRuleViaSlack: () => Promise<void>;
|
||||||
@@ -137,32 +137,34 @@ export function useCompletedRuleShareExport({
|
|||||||
url: string,
|
url: string,
|
||||||
banner?: () => void,
|
banner?: () => void,
|
||||||
options?: { suppressFailureWhenDocumentNotFocused?: boolean },
|
options?: { suppressFailureWhenDocumentNotFocused?: boolean },
|
||||||
) => {
|
): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(url);
|
await navigator.clipboard.writeText(url);
|
||||||
(banner ?? bannerCopied)();
|
(banner ?? bannerCopied)();
|
||||||
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
if (
|
if (
|
||||||
options?.suppressFailureWhenDocumentNotFocused === true &&
|
options?.suppressFailureWhenDocumentNotFocused === true &&
|
||||||
typeof window !== "undefined" &&
|
typeof window !== "undefined" &&
|
||||||
shouldSkipShareClipboardFallback(window)
|
shouldSkipShareClipboardFallback(window)
|
||||||
) {
|
) {
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
bannerCopyFailed();
|
bannerCopyFailed();
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[bannerCopied, bannerCopyFailed],
|
[bannerCopied, bannerCopyFailed],
|
||||||
);
|
);
|
||||||
|
|
||||||
const copyPublishedRuleLink = useCallback(async () => {
|
const copyPublishedRuleLink = useCallback(async () => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return false;
|
||||||
const ctx = resolvePublishedRuleShareContext(window);
|
const ctx = resolvePublishedRuleShareContext(window);
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
bannerNoRule();
|
bannerNoRule();
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
await copyUrlToClipboard(ctx.url);
|
return copyUrlToClipboard(ctx.url);
|
||||||
}, [bannerNoRule, copyUrlToClipboard]);
|
}, [bannerNoRule, copyUrlToClipboard]);
|
||||||
|
|
||||||
const mailtoPublishedRule = useCallback(() => {
|
const mailtoPublishedRule = useCallback(() => {
|
||||||
|
|||||||
+3
-1
@@ -34,7 +34,7 @@ export function useUseCaseCompletedRuleActions({
|
|||||||
const [duplicateBusy, setDuplicateBusy] = useState(false);
|
const [duplicateBusy, setDuplicateBusy] = useState(false);
|
||||||
|
|
||||||
const copyPageLink = useCallback(async () => {
|
const copyPageLink = useCallback(async () => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return false;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(window.location.href);
|
await navigator.clipboard.writeText(window.location.href);
|
||||||
setActionBanner({
|
setActionBanner({
|
||||||
@@ -43,6 +43,7 @@ export function useUseCaseCompletedRuleActions({
|
|||||||
title: t("shareLinkCopiedTitle"),
|
title: t("shareLinkCopiedTitle"),
|
||||||
description: t("shareLinkCopiedDescription"),
|
description: t("shareLinkCopiedDescription"),
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
setActionBanner({
|
setActionBanner({
|
||||||
key: "shareCopyFailed",
|
key: "shareCopyFailed",
|
||||||
@@ -50,6 +51,7 @@ export function useUseCaseCompletedRuleActions({
|
|||||||
title: t("shareCopyFailedTitle"),
|
title: t("shareCopyFailedTitle"),
|
||||||
description: t("shareCopyFailedDescription"),
|
description: t("shareCopyFailedDescription"),
|
||||||
});
|
});
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}, [setActionBanner, t]);
|
}, [setActionBanner, t]);
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import type { ContentContainerProps } from "./ContentContainer.types";
|
|||||||
const ContentContainerContainer = memo<ContentContainerProps>(
|
const ContentContainerContainer = memo<ContentContainerProps>(
|
||||||
({
|
({
|
||||||
post,
|
post,
|
||||||
width = "200px",
|
width: widthProp,
|
||||||
size: sizeProp = "responsive",
|
size: sizeProp = "responsive",
|
||||||
tone: toneProp = "inverse",
|
tone: toneProp = "inverse",
|
||||||
leadingImageSrc,
|
leadingImageSrc,
|
||||||
@@ -26,6 +26,8 @@ const ContentContainerContainer = memo<ContentContainerProps>(
|
|||||||
const size = sizeProp;
|
const size = sizeProp;
|
||||||
const tone = toneProp;
|
const tone = toneProp;
|
||||||
const showLeadingImage = showLeadingImageProp;
|
const showLeadingImage = showLeadingImageProp;
|
||||||
|
const width =
|
||||||
|
widthProp ?? (size === "useCase" ? "100%" : "200px");
|
||||||
const onLight = tone === "onLight";
|
const onLight = tone === "onLight";
|
||||||
const titleColor = onLight
|
const titleColor = onLight
|
||||||
? "text-[var(--color-content-default-primary)] group-hover:text-[var(--color-content-default-brand-primary)]"
|
? "text-[var(--color-content-default-primary)] group-hover:text-[var(--color-content-default-brand-primary)]"
|
||||||
@@ -57,39 +59,55 @@ const ContentContainerContainer = memo<ContentContainerProps>(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isUseCase = size === "useCase";
|
||||||
|
|
||||||
const containerClasses =
|
const containerClasses =
|
||||||
size === "xs"
|
size === "xs"
|
||||||
? "relative z-20 flex h-full flex-col gap-[var(--measures-spacing-012)]"
|
? "relative z-20 flex h-full flex-col gap-[var(--measures-spacing-012)]"
|
||||||
|
: 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)]";
|
: "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 =
|
const contentGapClasses =
|
||||||
size === "xs"
|
size === "xs"
|
||||||
? "flex flex-col gap-[var(--measures-spacing-008)]"
|
? "flex flex-col gap-[var(--measures-spacing-008)]"
|
||||||
|
: 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)]";
|
: "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 =
|
const textGapClasses =
|
||||||
size === "xs"
|
size === "xs"
|
||||||
? "flex flex-col gap-[var(--measures-spacing-004)]"
|
? "flex flex-col 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)]";
|
: "flex flex-col gap-[var(--measures-spacing-004)] md:gap-[var(--measures-spacing-002)] lg:gap-[var(--measures-spacing-004)]";
|
||||||
|
|
||||||
const titleClasses =
|
const titleClasses =
|
||||||
size === "xs"
|
size === "xs"
|
||||||
? `font-bricolage-grotesque font-medium text-[18px] leading-[22px] transition-colors ${titleColor}`
|
? `font-bricolage-grotesque font-medium text-[18px] leading-[22px] 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}`;
|
: `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 =
|
const descriptionClasses =
|
||||||
size === "xs"
|
size === "xs"
|
||||||
? `text-x-small-paragraph max-w-md ${bodyColor}`
|
? `text-x-small-paragraph max-w-md ${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}`;
|
: `text-x-small-paragraph sm:text-small-paragraph md:text-small-paragraph lg:text-large-paragraph xl:text-x-large-paragraph ${bodyColor}`;
|
||||||
|
|
||||||
const authorClasses =
|
const authorClasses =
|
||||||
size === "xs"
|
size === "xs"
|
||||||
? `overflow-hidden text-ellipsis whitespace-nowrap text-xx-small-paragraph ${bodyColor}`
|
? `overflow-hidden text-ellipsis whitespace-nowrap text-xx-small-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}`;
|
: `text-xx-small-paragraph md:text-x-small-paragraph lg:text-small-paragraph xl:text-large-paragraph ${bodyColor}`;
|
||||||
|
|
||||||
const dateClasses =
|
const dateClasses =
|
||||||
size === "xs"
|
size === "xs"
|
||||||
? `overflow-hidden text-ellipsis whitespace-nowrap text-xx-small-paragraph ${bodyColor}`
|
? `overflow-hidden text-ellipsis whitespace-nowrap text-xx-small-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}`;
|
: `text-xx-small-paragraph md:text-x-small-paragraph lg:text-small-paragraph xl:text-large-paragraph ${bodyColor}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { BlogPost } from "../../../../lib/content";
|
import type { BlogPost } from "../../../../lib/content";
|
||||||
|
import type { ContentContainerSizeValue } from "../../../../lib/propNormalization";
|
||||||
export type ContentContainerSizeValue = "xs" | "responsive";
|
|
||||||
|
|
||||||
/** `inverse` — blog hero on imagery; `onLight` — marketing pages on default surface. */
|
/** `inverse` — blog hero on imagery; `onLight` — marketing pages on default surface. */
|
||||||
export type ContentContainerToneValue = "inverse" | "onLight";
|
export type ContentContainerToneValue = "inverse" | "onLight";
|
||||||
@@ -9,7 +8,8 @@ export interface ContentContainerProps {
|
|||||||
post: BlogPost;
|
post: BlogPost;
|
||||||
width?: string;
|
width?: string;
|
||||||
/**
|
/**
|
||||||
* Content container size.
|
* `xs` — catalog thumbnail. `responsive` — article banner (scales through xl).
|
||||||
|
* `useCase` — case-study ContentBanner lockup (Figma 365px / medium-display).
|
||||||
*/
|
*/
|
||||||
size?: ContentContainerSizeValue;
|
size?: ContentContainerSizeValue;
|
||||||
/**
|
/**
|
||||||
@@ -27,7 +27,7 @@ export interface ContentContainerProps {
|
|||||||
export interface ContentContainerViewProps {
|
export interface ContentContainerViewProps {
|
||||||
post: BlogPost;
|
post: BlogPost;
|
||||||
width: string;
|
width: string;
|
||||||
size: "xs" | "responsive";
|
size: ContentContainerSizeValue;
|
||||||
tone: ContentContainerToneValue;
|
tone: ContentContainerToneValue;
|
||||||
iconImage: string;
|
iconImage: string;
|
||||||
iconAlt: string;
|
iconAlt: string;
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
/**
|
/**
|
||||||
* Figma: Community Rule System — "Modal / Share"
|
* Figma: Community Rule System — "Modal / Share"
|
||||||
* https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22073-30884
|
* https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22073-30884
|
||||||
|
* Copy-link hover / Copied! : 23769:27494 / 23769:27443
|
||||||
*/
|
*/
|
||||||
import { memo, useId, useRef } from "react";
|
import { memo, useCallback, useEffect, useId, useRef, useState } from "react";
|
||||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||||
import { useCreateModalA11y } from "../Create/useCreateModalA11y";
|
import { useCreateModalA11y } from "../Create/useCreateModalA11y";
|
||||||
import { ShareView } from "./Share.view";
|
import { ShareView } from "./Share.view";
|
||||||
@@ -15,9 +16,27 @@ const ShareContainer = memo<ShareProps>((props) => {
|
|||||||
const overlayRef = useRef<HTMLDivElement>(null);
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
const titleId = useId();
|
const titleId = useId();
|
||||||
const t = useTranslation("modals.share");
|
const t = useTranslation("modals.share");
|
||||||
|
const [linkCopied, setLinkCopied] = useState(false);
|
||||||
|
|
||||||
useCreateModalA11y(props.isOpen, props.onClose, dialogRef);
|
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 (
|
return (
|
||||||
<ShareView
|
<ShareView
|
||||||
{...props}
|
{...props}
|
||||||
@@ -27,6 +46,10 @@ const ShareContainer = memo<ShareProps>((props) => {
|
|||||||
title={t("title")}
|
title={t("title")}
|
||||||
description={t("description")}
|
description={t("description")}
|
||||||
copyLinkLabel={t("copyLink")}
|
copyLinkLabel={t("copyLink")}
|
||||||
|
copiedLabel={t("copied")}
|
||||||
|
copiedLive={t("copiedLive")}
|
||||||
|
linkCopied={linkCopied}
|
||||||
|
onCopyLinkClick={onCopyLinkClick}
|
||||||
signalLabel={t("signal")}
|
signalLabel={t("signal")}
|
||||||
slackLabel={t("slack")}
|
slackLabel={t("slack")}
|
||||||
discordLabel={t("discord")}
|
discordLabel={t("discord")}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import type { CreateModalBackdropVariant } from "../Create/CreateModalFrame.view
|
|||||||
export type ShareProps = {
|
export type ShareProps = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCopyLink: () => void | Promise<void>;
|
/** Return `false` when the clipboard write did not succeed. */
|
||||||
|
onCopyLink: () => void | boolean | Promise<void | boolean>;
|
||||||
onEmailShare: () => void;
|
onEmailShare: () => void;
|
||||||
onSignalShare: () => void | Promise<void>;
|
onSignalShare: () => void | Promise<void>;
|
||||||
onSlackShare: () => void | Promise<void>;
|
onSlackShare: () => void | Promise<void>;
|
||||||
@@ -20,6 +21,10 @@ export type ShareViewProps = ShareProps & {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
copyLinkLabel: string;
|
copyLinkLabel: string;
|
||||||
|
copiedLabel: string;
|
||||||
|
copiedLive: string;
|
||||||
|
linkCopied: boolean;
|
||||||
|
onCopyLinkClick: () => void | Promise<void>;
|
||||||
signalLabel: string;
|
signalLabel: string;
|
||||||
slackLabel: string;
|
slackLabel: string;
|
||||||
discordLabel: string;
|
discordLabel: string;
|
||||||
@@ -34,4 +39,5 @@ export type ShareChannelTileProps = {
|
|||||||
onClick: () => void | Promise<void>;
|
onClick: () => void | Promise<void>;
|
||||||
circleClassName: string;
|
circleClassName: string;
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
|
copied?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,19 +34,32 @@ function ShareAssetIcon(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ShareChannelTile({ label, onClick, circleClassName, icon }: ShareChannelTileProps) {
|
function ShareChannelTile({
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
circleClassName,
|
||||||
|
icon,
|
||||||
|
copied = false,
|
||||||
|
}: ShareChannelTileProps) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void onClick()}
|
onClick={() => void onClick()}
|
||||||
className="flex w-16 shrink-0 flex-col items-center gap-2 rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]"
|
aria-pressed={copied || undefined}
|
||||||
|
className="group flex w-16 shrink-0 flex-col items-center gap-2 rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`flex h-[60px] w-[60px] items-center justify-center rounded-full border border-solid ${circleClassName}`}
|
className={`flex h-[60px] w-[60px] items-center justify-center rounded-full border border-solid transition-[transform,background-color,border-color,filter] duration-150 ease-out group-active:scale-90 ${circleClassName}`}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
<span className="max-w-[4.5rem] text-center text-x-small-label text-[var(--color-content-default-tertiary)]">
|
<span
|
||||||
|
className={`max-w-[4.5rem] text-center text-x-small-label ${
|
||||||
|
copied
|
||||||
|
? "text-[var(--color-border-default-positive-primary)]"
|
||||||
|
: "text-[var(--color-content-default-tertiary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -56,7 +69,6 @@ function ShareChannelTile({ label, onClick, circleClassName, icon }: ShareChanne
|
|||||||
export const ShareView = memo(function ShareView({
|
export const ShareView = memo(function ShareView({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onCopyLink,
|
|
||||||
onEmailShare,
|
onEmailShare,
|
||||||
onSignalShare,
|
onSignalShare,
|
||||||
onSlackShare,
|
onSlackShare,
|
||||||
@@ -69,6 +81,10 @@ export const ShareView = memo(function ShareView({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
copyLinkLabel,
|
copyLinkLabel,
|
||||||
|
copiedLabel,
|
||||||
|
copiedLive,
|
||||||
|
linkCopied,
|
||||||
|
onCopyLinkClick,
|
||||||
signalLabel,
|
signalLabel,
|
||||||
slackLabel,
|
slackLabel,
|
||||||
discordLabel,
|
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. */}
|
{/* Channel circle hexes are third-party brand colors (copy/link, Signal, Slack, Discord), not DS tokens. */}
|
||||||
<div className="flex flex-wrap gap-4">
|
<div className="flex flex-wrap gap-4">
|
||||||
<ShareChannelTile
|
<ShareChannelTile
|
||||||
label={copyLinkLabel}
|
label={linkCopied ? copiedLabel : copyLinkLabel}
|
||||||
onClick={onCopyLink}
|
onClick={onCopyLinkClick}
|
||||||
circleClassName="border-[#444444] bg-[#333333]"
|
copied={linkCopied}
|
||||||
icon={<ShareAssetIcon name="link" width={24} height={24} />}
|
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={
|
||||||
|
<ShareAssetIcon
|
||||||
|
name={linkCopied ? "check" : "link"}
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<ShareChannelTile
|
<ShareChannelTile
|
||||||
label={signalLabel}
|
label={signalLabel}
|
||||||
onClick={onSignalShare}
|
onClick={onSignalShare}
|
||||||
circleClassName="border-[#3a76f0] bg-[#3a76f0]"
|
circleClassName="border-[#3a76f0] bg-[#3a76f0] group-hover:brightness-110"
|
||||||
icon={<ShareAssetIcon name="signal" width={26} height={26} />}
|
icon={<ShareAssetIcon name="signal" width={26} height={26} />}
|
||||||
/>
|
/>
|
||||||
<ShareChannelTile
|
<ShareChannelTile
|
||||||
label={slackLabel}
|
label={slackLabel}
|
||||||
onClick={onSlackShare}
|
onClick={onSlackShare}
|
||||||
circleClassName="border-[#4a154b] bg-[#4a154b]"
|
circleClassName="border-[#4a154b] bg-[#4a154b] group-hover:brightness-110"
|
||||||
icon={<ShareAssetIcon name="slack" width={26} height={26} />}
|
icon={<ShareAssetIcon name="slack" width={26} height={26} />}
|
||||||
/>
|
/>
|
||||||
<ShareChannelTile
|
<ShareChannelTile
|
||||||
label={discordLabel}
|
label={discordLabel}
|
||||||
onClick={onDiscordShare}
|
onClick={onDiscordShare}
|
||||||
circleClassName="border-[#5865f2] bg-[#5865f2]"
|
circleClassName="border-[#5865f2] bg-[#5865f2] group-hover:brightness-110"
|
||||||
icon={<ShareAssetIcon name="discord" width={30} height={30} />}
|
icon={<ShareAssetIcon name="discord" width={30} height={30} />}
|
||||||
/>
|
/>
|
||||||
<ShareChannelTile
|
<ShareChannelTile
|
||||||
label={emailLabel}
|
label={emailLabel}
|
||||||
onClick={onEmailShare}
|
onClick={onEmailShare}
|
||||||
circleClassName="border-[var(--color-surface-default-brand-kiwi)] bg-[var(--color-surface-default-brand-kiwi)]"
|
circleClassName="border-[var(--color-surface-default-brand-kiwi)] bg-[var(--color-surface-default-brand-kiwi)] group-hover:brightness-110"
|
||||||
icon={<ShareAssetIcon name="mail" width={24} height={24} />}
|
icon={<ShareAssetIcon name="mail" width={24} height={24} />}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="sr-only" aria-live="polite">
|
||||||
|
{linkCopied ? copiedLive : ""}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ModalFooter
|
<ModalFooter
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ function ContentBannerUseCaseView({
|
|||||||
>
|
>
|
||||||
<ContentContainer
|
<ContentContainer
|
||||||
post={post}
|
post={post}
|
||||||
size="responsive"
|
size="useCase"
|
||||||
tone={contentTone}
|
tone={contentTone}
|
||||||
showLeadingImage={false}
|
showLeadingImage={false}
|
||||||
leadingImageSrc={leadingImageSrc}
|
leadingImageSrc={leadingImageSrc}
|
||||||
|
|||||||
+7
-1
@@ -86,7 +86,13 @@ export function partnerLogoPath(slug: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Share modal glyphs in `public/assets/share/`. */
|
/** Share modal glyphs in `public/assets/share/`. */
|
||||||
export type ShareIconName = "discord" | "link" | "mail" | "signal" | "slack";
|
export type ShareIconName =
|
||||||
|
| "check"
|
||||||
|
| "discord"
|
||||||
|
| "link"
|
||||||
|
| "mail"
|
||||||
|
| "signal"
|
||||||
|
| "slack";
|
||||||
|
|
||||||
export function shareIconPath(name: ShareIconName): string {
|
export function shareIconPath(name: ShareIconName): string {
|
||||||
return `assets/share/${name}.svg`;
|
return `assets/share/${name}.svg`;
|
||||||
|
|||||||
@@ -119,7 +119,11 @@ export type HeaderLockupPaletteValue =
|
|||||||
export const TEXT_INPUT_SIZE_OPTIONS = ["small", "medium"] as const;
|
export const TEXT_INPUT_SIZE_OPTIONS = ["small", "medium"] as const;
|
||||||
export type TextInputSizeValue = (typeof TEXT_INPUT_SIZE_OPTIONS)[number];
|
export type TextInputSizeValue = (typeof TEXT_INPUT_SIZE_OPTIONS)[number];
|
||||||
|
|
||||||
export const CONTENT_CONTAINER_SIZE_OPTIONS = ["xs", "responsive"] as const;
|
export const CONTENT_CONTAINER_SIZE_OPTIONS = [
|
||||||
|
"xs",
|
||||||
|
"responsive",
|
||||||
|
"useCase",
|
||||||
|
] as const;
|
||||||
export type ContentContainerSizeValue =
|
export type ContentContainerSizeValue =
|
||||||
(typeof CONTENT_CONTAINER_SIZE_OPTIONS)[number];
|
(typeof CONTENT_CONTAINER_SIZE_OPTIONS)[number];
|
||||||
|
|
||||||
|
|||||||
+14
-13
@@ -1,17 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Marketing-scoped message bundle: every namespace from `./index` EXCEPT the
|
* Marketing-scoped message bundle: every namespace from `./index` EXCEPT the
|
||||||
* `create.*` subtree. The `create` namespace is ~41 KB gzipped — the largest
|
* bulk of the `create.*` subtree. That subtree is ~41 KB gzipped — the
|
||||||
* single contributor to per-route HTML size — and is only used inside
|
* largest single contributor to per-route HTML size — and wizard copy is
|
||||||
* `(app)/create/*`. Excluding it from the `(marketing)` group's
|
* only used inside `(app)/create/*`.
|
||||||
* `MessagesProvider` removes the embed from every marketing HTML response.
|
|
||||||
*
|
*
|
||||||
* The type stays compatible with `typeof import("./index").default` because
|
* Case-study completed-rule chrome reuses `CreateFlowTopNav`, so this bundle
|
||||||
* we satisfy the same key shape (modulo `create`); marketing client
|
* includes `create.topNav` only. Other `create.*` keys still fall back to the
|
||||||
* components only read keys that exist here. If a future change reaches into
|
* dotted path via `getTranslation` if a marketing surface reaches them.
|
||||||
* `messages.create.*` from a marketing surface, `getTranslation` will return
|
|
||||||
* the dotted key as the fallback — visible immediately at runtime.
|
|
||||||
*
|
*
|
||||||
* Keep this in sync with new entries added to `./index` (excluding `create/`).
|
* Keep this in sync with new entries added to `./index` (excluding wizard
|
||||||
|
* `create/` files other than `create/topNav.json`).
|
||||||
* See `docs/perf/next16-eval.md` for measurement context.
|
* See `docs/perf/next16-eval.md` for measurement context.
|
||||||
*/
|
*/
|
||||||
import common from "./common.json";
|
import common from "./common.json";
|
||||||
@@ -53,6 +51,7 @@ import metadata from "./metadata.json";
|
|||||||
import modalsShare from "./modals/share.json";
|
import modalsShare from "./modals/share.json";
|
||||||
import modalsPopoverExport from "./modals/popoverExport.json";
|
import modalsPopoverExport from "./modals/popoverExport.json";
|
||||||
import modalsAskOrganizerInquiry from "./modals/askOrganizerInquiry.json";
|
import modalsAskOrganizerInquiry from "./modals/askOrganizerInquiry.json";
|
||||||
|
import createTopNav from "./create/topNav.json";
|
||||||
import type messages from "./index";
|
import type messages from "./index";
|
||||||
|
|
||||||
const marketingMessages = {
|
const marketingMessages = {
|
||||||
@@ -99,11 +98,13 @@ const marketingMessages = {
|
|||||||
popoverExport: modalsPopoverExport,
|
popoverExport: modalsPopoverExport,
|
||||||
askOrganizerInquiry: modalsAskOrganizerInquiry,
|
askOrganizerInquiry: modalsAskOrganizerInquiry,
|
||||||
},
|
},
|
||||||
|
create: {
|
||||||
|
topNav: createTopNav,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cast to the full shape so it satisfies `typeof import("./index").default`
|
// Cast to the full shape so it satisfies `typeof import("./index").default`
|
||||||
// at the MessagesProvider boundary. Reads of `messages.create.*` from a
|
// at the MessagesProvider boundary. Reads of wizard `create.*` keys other
|
||||||
// marketing surface are a code smell and will return the dotted key (the
|
// than `create.topNav` still return the dotted key at runtime.
|
||||||
// runtime `getTranslation` fallback) — visible immediately.
|
|
||||||
export default marketingMessages as typeof messages;
|
export default marketingMessages as typeof messages;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
"title": "Share this CommunityRule",
|
"title": "Share this CommunityRule",
|
||||||
"description": "Anyone with the link can view this rule.",
|
"description": "Anyone with the link can view this rule.",
|
||||||
"copyLink": "Copy link",
|
"copyLink": "Copy link",
|
||||||
|
"copied": "Copied!",
|
||||||
|
"copiedLive": "Link copied to clipboard",
|
||||||
"signal": "Signal",
|
"signal": "Signal",
|
||||||
"slack": "Slack",
|
"slack": "Slack",
|
||||||
"discord": "Discord",
|
"discord": "Discord",
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M20 6L9 17L4 12" stroke="#45EA97" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 188 B |
@@ -157,11 +157,14 @@ describe("ContentBanner", () => {
|
|||||||
|
|
||||||
const title = screen.getByRole("heading", { name: "Test Article" });
|
const title = screen.getByRole("heading", { name: "Test Article" });
|
||||||
expect(title).toBeInTheDocument();
|
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();
|
expect(screen.getByText("Sample Operating Manual")).toBeInTheDocument();
|
||||||
const copyColumn = container.querySelector('[data-node-id="19189:9171"]');
|
const copyColumn = container.querySelector('[data-node-id="19189:9171"]');
|
||||||
expect(copyColumn).toHaveClass("lg:max-w-[365px]");
|
expect(copyColumn).toHaveClass("lg:max-w-[365px]");
|
||||||
expect(copyColumn).not.toHaveClass("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(
|
const bannerRow = container.querySelector(
|
||||||
'[data-figma-node="22015:42621"]',
|
'[data-figma-node="22015:42621"]',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -70,6 +70,43 @@ describe("Share modal", () => {
|
|||||||
expect(onClose).toHaveBeenCalledTimes(1);
|
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(
|
||||||
|
<Share
|
||||||
|
isOpen={true}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
{...noopHandlers}
|
||||||
|
onCopyLink={onCopyLink}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<Share
|
||||||
|
isOpen={true}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
{...noopHandlers}
|
||||||
|
onCopyLink={vi.fn().mockResolvedValue(false)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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 () => {
|
it("calls onClose when header overflow (more) is activated, matching modal chrome parity", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { describe, test, expect, vi } from "vitest";
|
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 userEvent from "@testing-library/user-event";
|
||||||
import { renderWithProviders as render } from "../utils/test-utils";
|
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 UseCaseCompletedRulePage from "../../app/(marketing-case-study)/use-cases/[slug]/rule/page";
|
||||||
import messages from "../../messages/en/index";
|
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";
|
import { USE_CASE_DETAIL_SLUGS } from "../../lib/useCaseSyntheticPost";
|
||||||
|
|
||||||
const mockPush = vi.fn();
|
const mockPush = vi.fn();
|
||||||
@@ -83,9 +86,31 @@ describe("UseCaseCompletedRulePage", () => {
|
|||||||
name: messages.pages.useCasesCompletedRule.topNav.duplicateAriaLabel,
|
name: messages.pages.useCasesCompletedRule.topNav.duplicateAriaLabel,
|
||||||
}),
|
}),
|
||||||
).toBeInTheDocument();
|
).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(
|
||||||
|
<MessagesProvider messages={marketingMessages}>
|
||||||
|
{await UseCaseCompletedRulePage({
|
||||||
|
params: Promise.resolve({ slug: "mutual-aid-colorado" }),
|
||||||
|
})}
|
||||||
|
</MessagesProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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 () => {
|
test("Duplicate opens login when signed out", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
mockOpenLogin.mockClear();
|
mockOpenLogin.mockClear();
|
||||||
|
|||||||
@@ -111,6 +111,17 @@ describe("ContentContainer", () => {
|
|||||||
expect(container).toHaveStyle("width: 200px");
|
expect(container).toHaveStyle("width: 200px");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies full width and case-study type scale for useCase size", () => {
|
||||||
|
render(<ContentContainer post={mockPost} size="useCase" />);
|
||||||
|
|
||||||
|
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", () => {
|
it("has proper spacing between icon and text", () => {
|
||||||
render(<ContentContainer post={mockPost} />);
|
render(<ContentContainer post={mockPost} />);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user