From dbd0ff3a097697a8ac5a839f09a2d8683f6c9bee Mon Sep 17 00:00:00 2001 From: Michael Matloka Date: Thu, 16 Jul 2026 13:10:45 +0200 Subject: [PATCH 1/3] Add a cool RefundButton for the review bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a self-contained, delightful Refund action as a UI component plus a Storybook story. A refund is psychologically the opposite of every other review-bar action — it spends less money, not more — so it's styled apart from the primary CTAs (warm gold, coin-return iconography) and made to feel good rather than punitive. Hover shows a gold sheen sweep and a coin flip; confirm leans into PostHog's "only pay for work that helped" philosophy, then celebrates with confetti fired from the button and a "+amount" coin-return flourish. Respects prefers-reduced-motion throughout. Ships as a component + story (no live wiring) since there's no refund backend in this repo yet; drop it into the review bar once the endpoint exists. Generated-By: PostHog Code Task-Id: 1d16eb2b-0862-4770-88c8-888603e42de0 --- .../features/refund/RefundButton.stories.tsx | 72 ++++++ .../ui/src/features/refund/RefundButton.tsx | 216 ++++++++++++++++++ 2 files changed, 288 insertions(+) create mode 100644 packages/ui/src/features/refund/RefundButton.stories.tsx create mode 100644 packages/ui/src/features/refund/RefundButton.tsx diff --git a/packages/ui/src/features/refund/RefundButton.stories.tsx b/packages/ui/src/features/refund/RefundButton.stories.tsx new file mode 100644 index 0000000000..ec97cb626b --- /dev/null +++ b/packages/ui/src/features/refund/RefundButton.stories.tsx @@ -0,0 +1,72 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { RefundButton } from "./RefundButton"; + +/** + * The refund action, made to feel good rather than punitive. Hover to catch the + * gold sheen and coin flip; confirm to celebrate an honest refund with a + * coins-flying-back flourish. Styled apart from the primary CTAs on purpose so + * "refund" never reads as "use the product". + */ +const meta: Meta = { + title: "Components/Refund/RefundButton", + component: RefundButton, + parameters: { layout: "centered" }, + args: { + amountLabel: "$4.20", + // Pretend the network took a beat so the "Refunding…" state is visible. + onRefund: () => + new Promise((resolve) => window.setTimeout(resolve, 700)), + }, + argTypes: { + onRefund: { action: "refunded" }, + }, +}; + +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 RefundFails: Story = { + args: { + onRefund: () => + 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. + * Refund reads as its own, warmer thing beside the neutral/primary buttons. + */ +export const InAReviewBar: Story = { + render: (args) => ( +
+ + + +
+ ), +}; diff --git a/packages/ui/src/features/refund/RefundButton.tsx b/packages/ui/src/features/refund/RefundButton.tsx new file mode 100644 index 0000000000..abe16d40d7 --- /dev/null +++ b/packages/ui/src/features/refund/RefundButton.tsx @@ -0,0 +1,216 @@ +import { + CheckCircleIcon, + CoinsIcon, + HeartIcon, +} from "@phosphor-icons/react"; +import { Button } from "@posthog/quill"; +import { fireFrom } from "@posthog/ui/primitives/confetti"; +import { Flex, Popover, Spinner, Text } from "@radix-ui/themes"; +import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; +import { useCallback, useRef, useState } from "react"; + +type RefundState = "idle" | "refunding" | "refunded"; + +// PostHog gold, the colour money comes back in. +const GOLD = ["#f8be2a", "#f5a623", "#ffd75e", "#f54d00"]; + +interface RefundButtonProps { + /** + * Human-readable amount to refund, e.g. "$4.20". Shown on the confirm step + * and floated up as a coin-return flourish once the refund lands. + */ + amountLabel?: string; + /** + * Runs the actual refund. Resolve to confirm success; reject to surface an + * error. Kept as a plain callback so the button stays host-agnostic. + */ + onRefund: () => void | Promise; + disabled?: boolean; +} + +/** + * A refund is a different animal from every other action in the review bar: + * it's the one that spends *less* money, not more. Rather than bury it or dress + * it up as a scary destructive action, we lean into PostHog's philosophy — if + * the work wasn't useful, take your money back, no hard feelings — and make the + * moment feel *good*. Confirm honestly, then celebrate: coins fly back to you. + * + * Deliberately styled apart from the primary CTAs (warm gold, coin-return + * iconography) so nobody confuses "refund" with "use the product". Respects + * `prefers-reduced-motion` throughout. + */ +export function RefundButton({ + amountLabel, + onRefund, + disabled = false, +}: RefundButtonProps) { + const [open, setOpen] = useState(false); + const [state, setState] = useState("idle"); + const [error, setError] = useState(null); + const [showCoinFloat, setShowCoinFloat] = useState(false); + const buttonRef = useRef(null); + const reduceMotion = useReducedMotion(); + + const isRefunding = state === "refunding"; + const isRefunded = state === "refunded"; + + const handleConfirm = useCallback(async () => { + setError(null); + setState("refunding"); + try { + await onRefund(); + setState("refunded"); + setOpen(false); + // Coins fly back to the wallet: confetti from the button + a "+$X" drift. + if (buttonRef.current) fireFrom(buttonRef.current, { colors: GOLD }); + setShowCoinFloat(true); + window.setTimeout(() => setShowCoinFloat(false), 1400); + } catch (err) { + setState("idle"); + setError(err instanceof Error ? err.message : "Refund failed"); + } + }, [onRefund]); + + return ( + + {/* The coin-return flourish: amount drifts up and fades once refunded. */} + + {showCoinFloat && !reduceMotion ? ( + + +{amountLabel ?? "refund"} 🪙 + + ) : null} + + + { + // Lock the popover shut while a refund is in flight or already done. + if (isRefunding || isRefunded) return; + setOpen(next); + if (!next) setError(null); + }} + > + + + + + + + + + + + + Refund{amountLabel ? ` ${amountLabel}` : ""}? + + + + + Yes, really. If PostHog's work here wasn't actually useful, take + your money back — no hard feelings, no questions. That's our + philosophy: you should only pay for work that helped.{" "} + + + + {error ? ( + + {error} + + ) : null} + + + + + + + + + + ); +} From 56185ad94adb2960c21ef071314f9734014bbced Mon Sep 17 00:00:00 2001 From: Michael Matloka Date: Thu, 16 Jul 2026 13:27:34 +0200 Subject: [PATCH 2/3] Rework into a combined archive + optional refund flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the standalone RefundButton with ArchiveRefundDialog: one Archive action with an honest "Also refund" opt-in, instead of two confusable top-bar buttons. Ticking the opt-in unfurls a warm gold panel with PostHog's "only pay for work that helped" philosophy (and a "What?"-style tooltip on the checkbox), turns the confirm button to gold, and — on confirm — celebrates with confetti fired from the trigger plus a coins-fly-back flourish. Refund stays a deliberate opt-in, never confused with the primary "use the product" CTAs. Respects prefers-reduced-motion throughout. Still a component + Storybook story, no live wiring, until a refund backend exists in this repo. Generated-By: PostHog Code Task-Id: 1d16eb2b-0862-4770-88c8-888603e42de0 --- ...es.tsx => ArchiveRefundDialog.stories.tsx} | 33 +-- .../features/refund/ArchiveRefundDialog.tsx | 261 ++++++++++++++++++ .../ui/src/features/refund/RefundButton.tsx | 216 --------------- 3 files changed, 278 insertions(+), 232 deletions(-) rename packages/ui/src/features/refund/{RefundButton.stories.tsx => ArchiveRefundDialog.stories.tsx} (57%) create mode 100644 packages/ui/src/features/refund/ArchiveRefundDialog.tsx delete mode 100644 packages/ui/src/features/refund/RefundButton.tsx diff --git a/packages/ui/src/features/refund/RefundButton.stories.tsx b/packages/ui/src/features/refund/ArchiveRefundDialog.stories.tsx similarity index 57% rename from packages/ui/src/features/refund/RefundButton.stories.tsx rename to packages/ui/src/features/refund/ArchiveRefundDialog.stories.tsx index ec97cb626b..53ddc5306c 100644 --- a/packages/ui/src/features/refund/RefundButton.stories.tsx +++ b/packages/ui/src/features/refund/ArchiveRefundDialog.stories.tsx @@ -1,29 +1,30 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { RefundButton } from "./RefundButton"; +import { ArchiveRefundDialog } from "./ArchiveRefundDialog"; /** - * The refund action, made to feel good rather than punitive. Hover to catch the - * gold sheen and coin flip; confirm to celebrate an honest refund with a - * coins-flying-back flourish. Styled apart from the primary CTAs on purpose so - * "refund" never reads as "use the product". + * 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/RefundButton", - component: RefundButton, +const meta: Meta = { + title: "Components/Refund/ArchiveRefundDialog", + component: ArchiveRefundDialog, parameters: { layout: "centered" }, args: { amountLabel: "$4.20", - // Pretend the network took a beat so the "Refunding…" state is visible. - onRefund: () => + // Pretend the network took a beat so the submitting state is visible. + onArchive: () => new Promise((resolve) => window.setTimeout(resolve, 700)), }, argTypes: { - onRefund: { action: "refunded" }, + onArchive: { action: "archived" }, }, }; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = {}; @@ -35,9 +36,9 @@ export const Disabled: Story = { args: { disabled: true }, }; -export const RefundFails: Story = { +export const ArchiveFails: Story = { args: { - onRefund: () => + onArchive: () => new Promise((_, reject) => window.setTimeout( () => reject(new Error("Payment provider declined the refund")), @@ -49,7 +50,7 @@ export const RefundFails: Story = { /** * How it sits next to the other review-bar actions — the point of the thread. - * Refund reads as its own, warmer thing beside the neutral/primary buttons. + * A single Archive button, not a second confusable Refund button beside it. */ export const InAReviewBar: Story = { render: (args) => ( @@ -60,7 +61,7 @@ export const InAReviewBar: Story = { > Discuss - + + + + + + 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} + + + + + + + + + +
+ + + ); +} diff --git a/packages/ui/src/features/refund/RefundButton.tsx b/packages/ui/src/features/refund/RefundButton.tsx deleted file mode 100644 index abe16d40d7..0000000000 --- a/packages/ui/src/features/refund/RefundButton.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import { - CheckCircleIcon, - CoinsIcon, - HeartIcon, -} from "@phosphor-icons/react"; -import { Button } from "@posthog/quill"; -import { fireFrom } from "@posthog/ui/primitives/confetti"; -import { Flex, Popover, Spinner, Text } from "@radix-ui/themes"; -import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; -import { useCallback, useRef, useState } from "react"; - -type RefundState = "idle" | "refunding" | "refunded"; - -// PostHog gold, the colour money comes back in. -const GOLD = ["#f8be2a", "#f5a623", "#ffd75e", "#f54d00"]; - -interface RefundButtonProps { - /** - * Human-readable amount to refund, e.g. "$4.20". Shown on the confirm step - * and floated up as a coin-return flourish once the refund lands. - */ - amountLabel?: string; - /** - * Runs the actual refund. Resolve to confirm success; reject to surface an - * error. Kept as a plain callback so the button stays host-agnostic. - */ - onRefund: () => void | Promise; - disabled?: boolean; -} - -/** - * A refund is a different animal from every other action in the review bar: - * it's the one that spends *less* money, not more. Rather than bury it or dress - * it up as a scary destructive action, we lean into PostHog's philosophy — if - * the work wasn't useful, take your money back, no hard feelings — and make the - * moment feel *good*. Confirm honestly, then celebrate: coins fly back to you. - * - * Deliberately styled apart from the primary CTAs (warm gold, coin-return - * iconography) so nobody confuses "refund" with "use the product". Respects - * `prefers-reduced-motion` throughout. - */ -export function RefundButton({ - amountLabel, - onRefund, - disabled = false, -}: RefundButtonProps) { - const [open, setOpen] = useState(false); - const [state, setState] = useState("idle"); - const [error, setError] = useState(null); - const [showCoinFloat, setShowCoinFloat] = useState(false); - const buttonRef = useRef(null); - const reduceMotion = useReducedMotion(); - - const isRefunding = state === "refunding"; - const isRefunded = state === "refunded"; - - const handleConfirm = useCallback(async () => { - setError(null); - setState("refunding"); - try { - await onRefund(); - setState("refunded"); - setOpen(false); - // Coins fly back to the wallet: confetti from the button + a "+$X" drift. - if (buttonRef.current) fireFrom(buttonRef.current, { colors: GOLD }); - setShowCoinFloat(true); - window.setTimeout(() => setShowCoinFloat(false), 1400); - } catch (err) { - setState("idle"); - setError(err instanceof Error ? err.message : "Refund failed"); - } - }, [onRefund]); - - return ( - - {/* The coin-return flourish: amount drifts up and fades once refunded. */} - - {showCoinFloat && !reduceMotion ? ( - - +{amountLabel ?? "refund"} 🪙 - - ) : null} - - - { - // Lock the popover shut while a refund is in flight or already done. - if (isRefunding || isRefunded) return; - setOpen(next); - if (!next) setError(null); - }} - > - - - - - - - - - - - - Refund{amountLabel ? ` ${amountLabel}` : ""}? - - - - - Yes, really. If PostHog's work here wasn't actually useful, take - your money back — no hard feelings, no questions. That's our - philosophy: you should only pay for work that helped.{" "} - - - - {error ? ( - - {error} - - ) : null} - - - - - - - - - - ); -} From b3b68b94f620756ac927ae0873895ff0bf5b2e83 Mon Sep 17 00:00:00 2001 From: Michael Matloka Date: Thu, 16 Jul 2026 13:44:48 +0200 Subject: [PATCH 3/3] Fix Biome lint errors in ArchiveRefundDialog Associate the refund opt-in label with its checkbox via htmlFor/id (noLabelWithoutControl) and sort the label's Tailwind classes (useSortedClasses). Generated-By: PostHog Code Task-Id: 1d16eb2b-0862-4770-88c8-888603e42de0 --- packages/ui/src/features/refund/ArchiveRefundDialog.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/features/refund/ArchiveRefundDialog.tsx b/packages/ui/src/features/refund/ArchiveRefundDialog.tsx index adf3cf16bb..122b7231e5 100644 --- a/packages/ui/src/features/refund/ArchiveRefundDialog.tsx +++ b/packages/ui/src/features/refund/ArchiveRefundDialog.tsx @@ -140,8 +140,12 @@ export function ArchiveRefundDialog({ {/* The refund opt-in — Alex's "meaningful, deliberate" choice, made to feel good rather than like a throwaway checkbox. */} -