Skip to content
Draft
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
73 changes: 73 additions & 0 deletions packages/ui/src/features/refund/ArchiveRefundDialog.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof ArchiveRefundDialog> = {
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<void>((resolve) => window.setTimeout(resolve, 700)),
},
argTypes: {
onArchive: { action: "archived" },
},
};

export default meta;
type Story = StoryObj<typeof ArchiveRefundDialog>;

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<void>((_, 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) => (
<div className="flex items-center gap-1 rounded-md border border-(--gray-6) bg-(--color-panel-solid) p-2">
<button
type="button"
className="rounded px-2 py-1 text-(--gray-11) text-sm hover:bg-(--gray-3)"
>
Discuss
</button>
<ArchiveRefundDialog {...args} />
<button
type="button"
className="rounded bg-(--accent-9) px-2 py-1 text-sm text-white"
>
Open in GitHub
</button>
</div>
),
};
265 changes: 265 additions & 0 deletions packages/ui/src/features/refund/ArchiveRefundDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<void>;
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<string | null>(null);
const [showCoinFloat, setShowCoinFloat] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(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 (
<span className="relative inline-flex">
{/* Coin-return flourish: amount drifts up and fades once refunded. */}
<AnimatePresence>
{showCoinFloat && !reduceMotion ? (
<motion.span
key="coin-float"
aria-hidden
initial={{ opacity: 0, y: 4, scale: 0.8 }}
animate={{ opacity: 1, y: -22, scale: 1 }}
exit={{ opacity: 0, y: -34 }}
transition={{ duration: 1.2, ease: "easeOut" }}
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"
>
+{amountLabel ?? "refund"} 🪙
</motion.span>
) : null}
</AnimatePresence>

<AlertDialog.Root
open={open}
onOpenChange={(next) => {
if (isSubmitting) return;
setOpen(next);
if (!next) resetTransient();
}}
>
<AlertDialog.Trigger>
<Button
ref={triggerRef}
type="button"
variant="outline"
size="1"
disabled={disabled}
>
Archive
</Button>
</AlertDialog.Trigger>

<AlertDialog.Content maxWidth="420px" size="2">
<AlertDialog.Title className="text-base">
Archive this PR?
</AlertDialog.Title>
<AlertDialog.Description className="text-sm">
It'll move out of your inbox to the archive. You can unarchive it
later.
</AlertDialog.Description>

{/* The refund opt-in — Alex's "meaningful, deliberate" choice, made
to feel good rather than like a throwaway checkbox. */}
<label
htmlFor="archive-refund-optin"
className="mt-3 flex cursor-pointer select-none items-center gap-2"
>
<Checkbox
id="archive-refund-optin"
checked={refund}
onCheckedChange={(next) => setRefund(next === true)}
disabled={isSubmitting}
/>
<Text size="2">
Also refund{amountLabel ? ` ${amountLabel}` : " this PR"}
</Text>
<Tooltip
content="Yes, really. Feel free to refund work by PostHog that wasn't actually useful — that's our philosophy. You should only pay for work that helped."
side="top"
>
<span className="inline-flex text-(--gray-9) hover:text-(--gray-11)">
<InfoIcon size={14} />
</span>
</Tooltip>
</label>

<AnimatePresence initial={false}>
{refund ? (
<motion.div
key="refund-panel"
initial={reduceMotion ? false : { opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={reduceMotion ? undefined : { opacity: 0, height: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="overflow-hidden"
>
<div className="relative mt-3 overflow-hidden rounded-md border border-(--yellow-6) bg-(--yellow-2) p-3">
{/* Gold sheen sweep — the "cool" tell. */}
{!reduceMotion ? (
<motion.span
aria-hidden
initial={{ x: "-120%" }}
animate={{ x: "120%" }}
transition={{
duration: 1.1,
ease: "easeInOut",
repeat: Number.POSITIVE_INFINITY,
repeatDelay: 1.4,
}}
className="pointer-events-none absolute inset-y-0 w-1/2 bg-gradient-to-r from-transparent via-(--yellow-5) to-transparent"
/>
) : null}
<Flex align="center" gap="2" className="relative">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-(--yellow-4) text-(--yellow-11)">
<CoinsIcon size={16} weight="duotone" />
</span>
<Text size="1" className="text-(--yellow-12)">
Nice — we'll send{" "}
<span className="font-semibold">
{amountLabel ?? "your money"}
</span>{" "}
back. No hard feelings.{" "}
<HeartIcon
size={11}
weight="fill"
className="-mt-0.5 inline text-(--red-9)"
/>
</Text>
</Flex>
</div>
</motion.div>
) : null}
</AnimatePresence>

{error ? (
<Text color="red" size="2" mt="2" as="div">
{error}
</Text>
) : null}

<Flex justify="end" gap="2" mt="4" align="center">
<AlertDialog.Cancel>
<Button
variant="soft"
color="gray"
size="1"
disabled={isSubmitting}
>
Cancel
</Button>
</AlertDialog.Cancel>
<AlertDialog.Action>
<Button
type="button"
variant="solid"
size="1"
disabled={isSubmitting}
onClick={handleConfirm}
className="gap-1"
// Runtime gold when refunding: inline style is the sanctioned
// path for CSS-variable colours.
style={
refund
? {
backgroundColor: "var(--yellow-9)",
color: "var(--gray-12)",
}
: undefined
}
>
{isSubmitting ? (
<Spinner size="1" />
) : refund ? (
<CoinsIcon size={13} />
) : null}
{refund
? `Archive & refund${amountLabel ? ` ${amountLabel}` : ""}`
: "Archive"}
</Button>
</AlertDialog.Action>
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
</span>
);
}
Loading