diff --git a/packages/ui/src/features/refund/ArchiveRefundDialog.stories.tsx b/packages/ui/src/features/refund/ArchiveRefundDialog.stories.tsx new file mode 100644 index 0000000000..53ddc5306c --- /dev/null +++ b/packages/ui/src/features/refund/ArchiveRefundDialog.stories.tsx @@ -0,0 +1,73 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ArchiveRefundDialog } from "./ArchiveRefundDialog"; + +/** + * Archive and refund, combined into one honest flow. Click Archive, then + * optionally tick "Also refund" to unfurl the gold panel — the confirm button + * turns to gold and confirming celebrates with confetti and a coins-fly-back + * flourish. Refund stays a deliberate opt-in, never confused with the primary + * "use the product" CTAs. + */ +const meta: Meta = { + title: "Components/Refund/ArchiveRefundDialog", + component: ArchiveRefundDialog, + parameters: { layout: "centered" }, + args: { + amountLabel: "$4.20", + // Pretend the network took a beat so the submitting state is visible. + onArchive: () => + new Promise((resolve) => window.setTimeout(resolve, 700)), + }, + argTypes: { + onArchive: { action: "archived" }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const NoAmount: Story = { + args: { amountLabel: undefined }, +}; + +export const Disabled: Story = { + args: { disabled: true }, +}; + +export const ArchiveFails: Story = { + args: { + onArchive: () => + new Promise((_, reject) => + window.setTimeout( + () => reject(new Error("Payment provider declined the refund")), + 700, + ), + ), + }, +}; + +/** + * How it sits next to the other review-bar actions — the point of the thread. + * A single Archive button, not a second confusable Refund button beside it. + */ +export const InAReviewBar: Story = { + render: (args) => ( +
+ + + +
+ ), +}; diff --git a/packages/ui/src/features/refund/ArchiveRefundDialog.tsx b/packages/ui/src/features/refund/ArchiveRefundDialog.tsx new file mode 100644 index 0000000000..122b7231e5 --- /dev/null +++ b/packages/ui/src/features/refund/ArchiveRefundDialog.tsx @@ -0,0 +1,265 @@ +import { CoinsIcon, HeartIcon, InfoIcon } from "@phosphor-icons/react"; +import { fireFrom } from "@posthog/ui/primitives/confetti"; +import { Tooltip } from "@posthog/ui/primitives/Tooltip"; +import { + AlertDialog, + Button, + Checkbox, + Flex, + Spinner, + Text, +} from "@radix-ui/themes"; +import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; +import { useCallback, useRef, useState } from "react"; + +// PostHog gold, the colour money comes back in. +const GOLD = ["#f8be2a", "#f5a623", "#ffd75e", "#f54d00"]; + +interface ArchiveRefundDialogProps { + /** + * Human-readable amount that would be refunded, e.g. "$4.20". Shown next to + * the opt-in and floated up as a coin-return flourish when a refund lands. + */ + amountLabel?: string; + /** + * Archives the PR. `refund` is true when the user opted in to also getting + * their money back. Resolve on success; reject to surface an error. + */ + onArchive: (opts: { refund: boolean }) => void | Promise; + disabled?: boolean; +} + +/** + * One action, two intents. Archiving clears the PR from your view; refunding is + * the psychologically opposite move — spending *less* money, not more. We keep + * them in a single flow (you always archive; refunding is an honest opt-in) + * instead of two confusable top-bar buttons. + * + * The refund opt-in is where it gets fun: tick it and a warm gold panel unfurls + * with PostHog's "only pay for work that helped" philosophy, the confirm button + * turns to gold, and confirming rains confetti + floats your money back up. + * Deliberately distinct from the primary CTAs so "refund" never reads as "use + * the product". Respects `prefers-reduced-motion` throughout. + */ +export function ArchiveRefundDialog({ + amountLabel, + onArchive, + disabled = false, +}: ArchiveRefundDialogProps) { + const [open, setOpen] = useState(false); + const [refund, setRefund] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const [showCoinFloat, setShowCoinFloat] = useState(false); + const triggerRef = useRef(null); + const reduceMotion = useReducedMotion(); + + const resetTransient = useCallback(() => { + setError(null); + setRefund(false); + }, []); + + const handleConfirm = useCallback( + async (event: React.MouseEvent) => { + // Keep the dialog open while we work / show errors. + event.preventDefault(); + if (isSubmitting) return; + setError(null); + setIsSubmitting(true); + try { + await onArchive({ refund }); + setOpen(false); + if (refund) { + // Coins fly back: confetti from the trigger + a "+$X" drift. + if (triggerRef.current) + fireFrom(triggerRef.current, { colors: GOLD }); + setShowCoinFloat(true); + window.setTimeout(() => setShowCoinFloat(false), 1400); + } + resetTransient(); + } catch (err) { + setError( + err instanceof Error + ? err.message + : "Couldn't archive this PR. Try again in a moment.", + ); + } finally { + setIsSubmitting(false); + } + }, + [isSubmitting, onArchive, refund, resetTransient], + ); + + return ( + + {/* Coin-return flourish: amount drifts up and fades once refunded. */} + + {showCoinFloat && !reduceMotion ? ( + + +{amountLabel ?? "refund"} 🪙 + + ) : null} + + + { + if (isSubmitting) return; + setOpen(next); + if (!next) resetTransient(); + }} + > + + + + + + + Archive this PR? + + + It'll move out of your inbox to the archive. You can unarchive it + later. + + + {/* The refund opt-in — Alex's "meaningful, deliberate" choice, made + to feel good rather than like a throwaway checkbox. */} + + + + {refund ? ( + +
+ {/* Gold sheen sweep — the "cool" tell. */} + {!reduceMotion ? ( + + ) : null} + + + + + + Nice — we'll send{" "} + + {amountLabel ?? "your money"} + {" "} + back. No hard feelings.{" "} + + + +
+
+ ) : null} +
+ + {error ? ( + + {error} + + ) : null} + + + + + + + + + +
+
+
+ ); +}