Skip to content

Commit dbd0ff3

Browse files
authored
Add a cool RefundButton for the review bar
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
1 parent ed6cdfb commit dbd0ff3

2 files changed

Lines changed: 288 additions & 0 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type { Meta, StoryObj } from "@storybook/react-vite";
2+
import { RefundButton } from "./RefundButton";
3+
4+
/**
5+
* The refund action, made to feel good rather than punitive. Hover to catch the
6+
* gold sheen and coin flip; confirm to celebrate an honest refund with a
7+
* coins-flying-back flourish. Styled apart from the primary CTAs on purpose so
8+
* "refund" never reads as "use the product".
9+
*/
10+
const meta: Meta<typeof RefundButton> = {
11+
title: "Components/Refund/RefundButton",
12+
component: RefundButton,
13+
parameters: { layout: "centered" },
14+
args: {
15+
amountLabel: "$4.20",
16+
// Pretend the network took a beat so the "Refunding…" state is visible.
17+
onRefund: () =>
18+
new Promise<void>((resolve) => window.setTimeout(resolve, 700)),
19+
},
20+
argTypes: {
21+
onRefund: { action: "refunded" },
22+
},
23+
};
24+
25+
export default meta;
26+
type Story = StoryObj<typeof RefundButton>;
27+
28+
export const Default: Story = {};
29+
30+
export const NoAmount: Story = {
31+
args: { amountLabel: undefined },
32+
};
33+
34+
export const Disabled: Story = {
35+
args: { disabled: true },
36+
};
37+
38+
export const RefundFails: Story = {
39+
args: {
40+
onRefund: () =>
41+
new Promise<void>((_, reject) =>
42+
window.setTimeout(
43+
() => reject(new Error("Payment provider declined the refund")),
44+
700,
45+
),
46+
),
47+
},
48+
};
49+
50+
/**
51+
* How it sits next to the other review-bar actions — the point of the thread.
52+
* Refund reads as its own, warmer thing beside the neutral/primary buttons.
53+
*/
54+
export const InAReviewBar: Story = {
55+
render: (args) => (
56+
<div className="flex items-center gap-1 rounded-md border border-(--gray-6) bg-(--color-panel-solid) p-2">
57+
<button
58+
type="button"
59+
className="rounded px-2 py-1 text-(--gray-11) text-sm hover:bg-(--gray-3)"
60+
>
61+
Discuss
62+
</button>
63+
<RefundButton {...args} />
64+
<button
65+
type="button"
66+
className="rounded bg-(--accent-9) px-2 py-1 text-sm text-white"
67+
>
68+
Open in GitHub
69+
</button>
70+
</div>
71+
),
72+
};
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import {
2+
CheckCircleIcon,
3+
CoinsIcon,
4+
HeartIcon,
5+
} from "@phosphor-icons/react";
6+
import { Button } from "@posthog/quill";
7+
import { fireFrom } from "@posthog/ui/primitives/confetti";
8+
import { Flex, Popover, Spinner, Text } from "@radix-ui/themes";
9+
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
10+
import { useCallback, useRef, useState } from "react";
11+
12+
type RefundState = "idle" | "refunding" | "refunded";
13+
14+
// PostHog gold, the colour money comes back in.
15+
const GOLD = ["#f8be2a", "#f5a623", "#ffd75e", "#f54d00"];
16+
17+
interface RefundButtonProps {
18+
/**
19+
* Human-readable amount to refund, e.g. "$4.20". Shown on the confirm step
20+
* and floated up as a coin-return flourish once the refund lands.
21+
*/
22+
amountLabel?: string;
23+
/**
24+
* Runs the actual refund. Resolve to confirm success; reject to surface an
25+
* error. Kept as a plain callback so the button stays host-agnostic.
26+
*/
27+
onRefund: () => void | Promise<void>;
28+
disabled?: boolean;
29+
}
30+
31+
/**
32+
* A refund is a different animal from every other action in the review bar:
33+
* it's the one that spends *less* money, not more. Rather than bury it or dress
34+
* it up as a scary destructive action, we lean into PostHog's philosophy — if
35+
* the work wasn't useful, take your money back, no hard feelings — and make the
36+
* moment feel *good*. Confirm honestly, then celebrate: coins fly back to you.
37+
*
38+
* Deliberately styled apart from the primary CTAs (warm gold, coin-return
39+
* iconography) so nobody confuses "refund" with "use the product". Respects
40+
* `prefers-reduced-motion` throughout.
41+
*/
42+
export function RefundButton({
43+
amountLabel,
44+
onRefund,
45+
disabled = false,
46+
}: RefundButtonProps) {
47+
const [open, setOpen] = useState(false);
48+
const [state, setState] = useState<RefundState>("idle");
49+
const [error, setError] = useState<string | null>(null);
50+
const [showCoinFloat, setShowCoinFloat] = useState(false);
51+
const buttonRef = useRef<HTMLButtonElement>(null);
52+
const reduceMotion = useReducedMotion();
53+
54+
const isRefunding = state === "refunding";
55+
const isRefunded = state === "refunded";
56+
57+
const handleConfirm = useCallback(async () => {
58+
setError(null);
59+
setState("refunding");
60+
try {
61+
await onRefund();
62+
setState("refunded");
63+
setOpen(false);
64+
// Coins fly back to the wallet: confetti from the button + a "+$X" drift.
65+
if (buttonRef.current) fireFrom(buttonRef.current, { colors: GOLD });
66+
setShowCoinFloat(true);
67+
window.setTimeout(() => setShowCoinFloat(false), 1400);
68+
} catch (err) {
69+
setState("idle");
70+
setError(err instanceof Error ? err.message : "Refund failed");
71+
}
72+
}, [onRefund]);
73+
74+
return (
75+
<span className="relative inline-flex">
76+
{/* The coin-return flourish: amount drifts up and fades once refunded. */}
77+
<AnimatePresence>
78+
{showCoinFloat && !reduceMotion ? (
79+
<motion.span
80+
key="coin-float"
81+
aria-hidden
82+
initial={{ opacity: 0, y: 4, scale: 0.8 }}
83+
animate={{ opacity: 1, y: -22, scale: 1 }}
84+
exit={{ opacity: 0, y: -34 }}
85+
transition={{ duration: 1.2, ease: "easeOut" }}
86+
className="-top-1 -translate-x-1/2 pointer-events-none absolute left-1/2 z-20 whitespace-nowrap font-semibold text-(--yellow-11) text-xs"
87+
>
88+
+{amountLabel ?? "refund"} 🪙
89+
</motion.span>
90+
) : null}
91+
</AnimatePresence>
92+
93+
<Popover.Root
94+
open={open}
95+
onOpenChange={(next) => {
96+
// Lock the popover shut while a refund is in flight or already done.
97+
if (isRefunding || isRefunded) return;
98+
setOpen(next);
99+
if (!next) setError(null);
100+
}}
101+
>
102+
<Popover.Trigger>
103+
<Button
104+
ref={buttonRef}
105+
type="button"
106+
variant="outline"
107+
size="sm"
108+
disabled={disabled || isRefunding}
109+
className="group relative gap-1 overflow-hidden border-transparent transition-colors hover:border-(--yellow-8) hover:text-(--yellow-11)"
110+
// CSS variables / runtime colour: inline style is the sanctioned path.
111+
style={
112+
isRefunded
113+
? {
114+
backgroundColor: "var(--yellow-9)",
115+
borderColor: "transparent",
116+
color: "var(--gray-12)",
117+
}
118+
: undefined
119+
}
120+
title={
121+
isRefunded ? "Refunded — thanks for the honesty" : "Refund this work"
122+
}
123+
>
124+
{/* Gold sheen that sweeps across on hover — the "cool" tell. */}
125+
{!reduceMotion && !isRefunded ? (
126+
<span
127+
aria-hidden
128+
className="-translate-x-full pointer-events-none absolute inset-0 bg-gradient-to-r from-transparent via-(--yellow-5) to-transparent opacity-0 transition-all duration-700 ease-out group-hover:translate-x-full group-hover:opacity-100"
129+
/>
130+
) : null}
131+
132+
<span className="relative z-10 flex items-center gap-1">
133+
{isRefunding ? (
134+
<Spinner size="1" />
135+
) : isRefunded ? (
136+
<CheckCircleIcon size={13} weight="fill" />
137+
) : (
138+
// Coin flips on hover — a little wink of money coming back.
139+
<motion.span
140+
className="inline-flex"
141+
whileHover={reduceMotion ? undefined : { rotateY: 180 }}
142+
transition={{ duration: 0.4 }}
143+
style={{ transformStyle: "preserve-3d" }}
144+
>
145+
<CoinsIcon size={13} weight="duotone" />
146+
</motion.span>
147+
)}
148+
{isRefunding ? "Refunding…" : isRefunded ? "Refunded" : "Refund"}
149+
</span>
150+
</Button>
151+
</Popover.Trigger>
152+
153+
<Popover.Content
154+
align="end"
155+
side="bottom"
156+
sideOffset={6}
157+
className="w-[340px] border border-(--gray-6) bg-(--color-panel-solid) p-4 shadow-6"
158+
>
159+
<Flex direction="column" gap="3">
160+
<Flex align="center" gap="2">
161+
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-(--yellow-4) text-(--yellow-11)">
162+
<CoinsIcon size={18} weight="duotone" />
163+
</span>
164+
<Text size="2" weight="bold">
165+
Refund{amountLabel ? ` ${amountLabel}` : ""}?
166+
</Text>
167+
</Flex>
168+
169+
<Text size="1" color="gray">
170+
Yes, really. If PostHog's work here wasn't actually useful, take
171+
your money back — no hard feelings, no questions. That's our
172+
philosophy: you should only pay for work that helped.{" "}
173+
<HeartIcon
174+
size={11}
175+
weight="fill"
176+
className="-mt-0.5 inline text-(--red-9)"
177+
/>
178+
</Text>
179+
180+
{error ? (
181+
<Text size="1" className="text-(--red-11)">
182+
{error}
183+
</Text>
184+
) : null}
185+
186+
<Flex justify="end" gap="2" align="center">
187+
<Button
188+
type="button"
189+
variant="outline"
190+
size="sm"
191+
onClick={() => setOpen(false)}
192+
>
193+
Keep it
194+
</Button>
195+
<Button
196+
type="button"
197+
variant="primary"
198+
size="sm"
199+
disabled={isRefunding}
200+
onClick={handleConfirm}
201+
className="gap-1"
202+
style={{
203+
backgroundColor: "var(--yellow-9)",
204+
color: "var(--gray-12)",
205+
}}
206+
>
207+
{isRefunding ? <Spinner size="1" /> : <CoinsIcon size={13} />}
208+
Refund it
209+
</Button>
210+
</Flex>
211+
</Flex>
212+
</Popover.Content>
213+
</Popover.Root>
214+
</span>
215+
);
216+
}

0 commit comments

Comments
 (0)