Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@ import { withSnapshot } from "@/lib/storybook-utils/parameters"

import { UpsellingAlert } from "."

/**
* Example dismiss handler: persist the dismissal so the alert stays hidden for
* a number of days and reappears afterwards.
*
* Takes a per-alert `storageKey` so multiple upselling alerts on the same page
* don't overwrite each other's dismissal state. Namespace it by the module /
* feature the alert promotes.
*/
const dismissForDays = (storageKey: string) => () => {
const HIDE_FOR_DAYS = 7
const hideUntil = Date.now() + HIDE_FOR_DAYS * 24 * 60 * 60 * 1000
window.localStorage.setItem(storageKey, String(hideUntil))
alert(`Dismissed — hidden for ${HIDE_FOR_DAYS} days.`)
}

const meta: Meta<typeof UpsellingAlert> = {
title: "UpsellingAlert",
component: UpsellingAlert,
Expand All @@ -20,7 +35,22 @@ const meta: Meta<typeof UpsellingAlert> = {
mapping: icons,
options: [undefined, ...Object.keys(icons)],
},
onDismiss: {
description:
"When enabled, shows a dismiss (close) button to the right of the action button.",
control: "boolean",
},
},
render: ({ onDismiss, ...args }) => (
<UpsellingAlert
{...args}
onDismiss={
onDismiss
? dismissForDays("upselling-alert-hidden-until:demo")
: undefined
}
/>
),
}

export default meta
Expand Down Expand Up @@ -70,6 +100,41 @@ export const WithIcon: Story = {
},
}

/**
* When `onDismiss` is provided, a close button is rendered to the right of the
* upselling action button. The consumer decides what happens on dismiss — here
* we persist the dismissal under a per-alert key so the alert stays hidden for
* a number of days and reappears afterwards. Use a distinct key per alert so
* multiple upselling alerts don't overwrite each other. Toggle the `onDismiss`
* control to show/hide the button.
*/
export const Dismissible: Story = {
args: {
...WithIcon.args,
onDismiss: dismissForDays("upselling-alert-hidden-until:performance"),
},
}

/**
* When the action only opens a modal or navigates instead of creating an
* upselling request, set `showConfirmation: false` so the success dialog
* ("request sent") isn't shown for a request that was never made.
*/
export const WithoutConfirmation: Story = {
args: {
...Default.args,
title: "Open the product details",
description:
"Use showConfirmation: false when the action only opens a modal or navigates, so no success dialog is shown for a request that was never sent.",
action: {
...Default.args!.action!,
label: "Learn more",
showConfirmation: false,
onRequest: () => alert("Open product modal"),
},
},
}

export const Snapshot: Story = {
parameters: withSnapshot({}),
args: Default.args,
Expand Down
124 changes: 91 additions & 33 deletions packages/react/src/sds/UpsellingKit/UpsellingAlert/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { F0Button } from "@/components/F0Button"
import { F0Icon, type IconType } from "@/components/F0Icon"
import { Cross } from "@/icons/app"
import { withDataTestId } from "@/lib/data-testid"
import { useI18n } from "@/lib/providers/i18n/i18n-provider"
import { cn } from "@/lib/utils"

import { UpsellingButton, type UpsellingButtonProps } from "../UpsellingButton"
Expand All @@ -12,6 +15,13 @@ type AlertAction = {
loadingState: UpsellingButtonProps["loadingState"]
nextSteps: UpsellingButtonProps["nextSteps"]
closeLabel: UpsellingButtonProps["closeLabel"]
/**
* Whether to show the confirmation dialog after the request resolves.
* Defaults to `true`. Set to `false` when `onRequest` only opens a modal or
* navigates instead of creating an upselling request, so the success dialog
* ("request sent") is not shown for an action that sent nothing.
*/
showConfirmation?: UpsellingButtonProps["showConfirmation"]
}

export interface UpsellingAlertProps {
Expand All @@ -31,59 +41,107 @@ export interface UpsellingAlertProps {
* The upselling action button configuration.
*/
action: AlertAction
/**
* Called when the user dismisses the alert. When provided, a close button is
* shown just to the right of the upselling action button.
*
* The consumer is responsible for deciding what happens on dismiss — for
* example, hiding the alert for a number of days and showing it again later
* by persisting the dismissal (e.g. in a cookie or local storage) and
* unmounting the component while it should stay hidden.
*/
onDismiss?: () => void
}

/**
* Leaf component wrapping the close button so `useI18n()` is only read when
* an `onDismiss` handler is actually provided. Keeps UpsellingAlert renderable
* without an `I18nProvider` for consumers (and tests) that don't use the
* dismiss affordance.
*/
const DismissButton = ({ onDismiss }: { onDismiss: () => void }) => {
const { actions } = useI18n()
return (
<F0Button
icon={Cross}
label={actions.close}
hideLabel
variant="outline"
size="sm"
onClick={onDismiss}
type="button"
/>
)
}

function _UpsellingAlert({
icon,
title,
description,
action,
onDismiss,
}: UpsellingAlertProps) {
return (
<div className="@container">
<div
role="status"
className="w-full rounded-md p-3 text-f1-foreground [background:hsl(var(--promote-50)/0.1)]"
className={cn(
"w-full rounded-md p-3 text-f1-foreground [background:hsl(var(--promote-50)/0.1)]",
onDismiss && "pr-2"
)}
>
<div
className={cn(
"flex flex-1 flex-col items-start gap-3 @xs:flex-row @xs:justify-between",
description ? "@xs:items-start" : "@xs:items-center"
)}
>
<div className="flex flex-row gap-2">
<div
className={cn(
"flex flex-row gap-2",
description ? "items-start" : "items-center"
"flex flex-1 flex-col items-start gap-3 @xs:flex-row @xs:justify-between",
description ? "@xs:items-start" : "@xs:items-center"
)}
>
{icon && (
<div className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-sm border border-solid text-f1-icon-promote [background:hsl(var(--promote-50)/0.1)] [border-color:hsl(var(--promote-50)/0.1)]">
<F0Icon icon={icon} size="sm" />
</div>
)}
<div className="flex flex-col gap-0.5">
<p className="font-medium text-f1-foreground">{title}</p>
{description && (
<p className="text-base text-f1-foreground-secondary">
{description}
</p>
<div
className={cn(
"flex flex-row gap-2",
description ? "items-start" : "items-center"
)}
>
{icon && (
<div className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-sm border border-solid text-f1-icon-promote [background:hsl(var(--promote-50)/0.1)] [border-color:hsl(var(--promote-50)/0.1)]">
<F0Icon icon={icon} size="sm" />
</div>
)}
<div className="flex flex-col gap-0.5">
<p className="font-medium text-f1-foreground">{title}</p>
{description && (
<p className="text-base text-f1-foreground-secondary">
{description}
</p>
)}
</div>
</div>
<div className={cn("flex flex-shrink-0 @xs:pl-0", icon && "pl-8")}>
<UpsellingButton
label={action.label}
onRequest={action.onRequest}
errorMessage={action.errorMessage}
successMessage={action.successMessage}
loadingState={action.loadingState}
nextSteps={action.nextSteps}
closeLabel={action.closeLabel}
showConfirmation={action.showConfirmation}
variant="outlinePromote"
size="sm"
/>
</div>
</div>
<div className={cn("flex flex-shrink-0 @xs:pl-0", icon && "pl-8")}>
<UpsellingButton
label={action.label}
onRequest={action.onRequest}
errorMessage={action.errorMessage}
successMessage={action.successMessage}
loadingState={action.loadingState}
nextSteps={action.nextSteps}
closeLabel={action.closeLabel}
variant="outlinePromote"
size="sm"
/>
</div>
{onDismiss && (
<div
className={cn(
"flex-shrink-0 self-start",
!description && "@xs:self-center"
)}
>
<DismissButton onDismiss={onDismiss} />
</div>
)}
</div>
</div>
</div>
Expand Down
Loading