Skip to content
Open
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
1 change: 1 addition & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type React from "react";
import { Inter, Space_Grotesk } from "next/font/google";
import "./globals.css";
import "./styles/patterns.css";
import "./styles/motion.css";
import { Providers } from "@/components/providers";

const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
Expand Down
102 changes: 102 additions & 0 deletions app/styles/motion.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* Success confetti motion tokens.
* Animated pieces are disabled under prefers-reduced-motion (see globals.css + .success-confetti-static).
*/

@keyframes confetti-fall {
0% {
transform: translate3d(0, -10%, 0) rotate(0deg);
opacity: 1;
}
100% {
transform: translate3d(var(--confetti-drift, 0px), 120%, 0) rotate(var(--confetti-spin, 360deg));
opacity: 0;
}
}

.success-confetti {
pointer-events: none;
position: absolute;
inset: 0;
overflow: hidden;
z-index: 10;
}

.success-confetti-piece {
position: absolute;
top: -8%;
width: 8px;
height: 14px;
border-radius: 2px;
opacity: 0.9;
animation: confetti-fall var(--confetti-duration, 1.4s) ease-out forwards;
will-change: transform, opacity;
}

.success-confetti-piece--cyan {
background: #29fcf3;
left: 12%;
--confetti-drift: -24px;
--confetti-spin: 420deg;
animation-delay: 0ms;
}

.success-confetti-piece--green {
background: #4ade80;
left: 28%;
--confetti-drift: 18px;
--confetti-spin: -300deg;
animation-delay: 80ms;
}

.success-confetti-piece--violet {
background: #a78bfa;
left: 44%;
--confetti-drift: -12px;
--confetti-spin: 540deg;
animation-delay: 40ms;
}

.success-confetti-piece--amber {
background: #fbbf24;
left: 60%;
--confetti-drift: 28px;
--confetti-spin: -420deg;
animation-delay: 120ms;
}

.success-confetti-piece--pink {
background: #f472b6;
left: 76%;
--confetti-drift: -32px;
--confetti-spin: 480deg;
animation-delay: 60ms;
}

.success-confetti-piece--blue {
background: #69daff;
left: 88%;
--confetti-drift: 14px;
--confetti-spin: -360deg;
animation-delay: 100ms;
}

/* Static success badge shown when motion is reduced */
.success-confetti-static {
display: flex;
align-items: center;
justify-content: center;
padding: 0.75rem;
}

.success-confetti-static__icon {
color: #4ade80;
filter: drop-shadow(0 0 8px rgba(74, 222, 128, 0.35));
}

@media (prefers-reduced-motion: reduce) {
.success-confetti-piece {
animation: none;
opacity: 0;
}
}
23 changes: 14 additions & 9 deletions components/patterns/BetConfirmPattern.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
import { Kbd } from "@/components/ui/kbd"
import { playSound } from "@/lib/audio/play-sound"
import { LiveRegion } from "@/components/ui/live-region"
import { SuccessConfetti } from "@/components/success/SuccessConfetti"

/** Each step in the bet placement flow */
type BetStep = "review" | "sign" | "submit" | "confirm"
Expand Down Expand Up @@ -148,14 +149,17 @@ export function BetConfirmPattern() {
</DialogFooter>
</>
) : (
<Receipt
receiptId="TXN-98237498234-XYZ"
amount="$100.00"
partyA="0x1234...5678 (You)"
partyB="Predictify Market Pool"
timestamp={new Date().toISOString()}
type="Bet Placement"
/>
<div className="relative">
<SuccessConfetti ariaLabel="Prediction confirmed" />
<Receipt
receiptId="TXN-98237498234-XYZ"
amount="$100.00"
partyA="0x1234...5678 (You)"
partyB="Predictify Market Pool"
timestamp={new Date().toISOString()}
type="Bet Placement"
/>
</div>
)}
</DialogContent>
</Dialog>
Expand Down Expand Up @@ -200,7 +204,8 @@ export function BetConfirmPattern() {
</DrawerFooter>
</>
) : (
<div className="w-full pt-4 max-h-[90vh] overflow-y-auto">
<div className="relative w-full pt-4 max-h-[90vh] overflow-y-auto">
<SuccessConfetti ariaLabel="Prediction confirmed" />
<DrawerHeader className="text-left">
<DrawerTitle
ref={headingRef}
Expand Down
82 changes: 82 additions & 0 deletions components/success/SuccessConfetti.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"use client"

import { useEffect, useState } from "react"
import { CheckCircle } from "lucide-react"
import { cn } from "@/lib/utils"

const CONFETTI_COLORS = [
"success-confetti-piece--cyan",
"success-confetti-piece--green",
"success-confetti-piece--violet",
"success-confetti-piece--amber",
"success-confetti-piece--pink",
"success-confetti-piece--blue",
] as const

export interface SuccessConfettiProps {
/** When false, nothing is rendered. */
active?: boolean
className?: string
/** Accessible label for the static reduced-motion success state. */
ariaLabel?: string
}

/**
* Decorative success confetti. Under `prefers-reduced-motion: reduce`, renders a
* static check icon instead of falling particles (WCAG 2.3.3).
*/
export function SuccessConfetti({
active = true,
className,
ariaLabel = "Success",
}: SuccessConfettiProps) {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false)

useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)")
setPrefersReducedMotion(mediaQuery.matches)

const onChange = (event: MediaQueryListEvent) => {
setPrefersReducedMotion(event.matches)
}

mediaQuery.addEventListener("change", onChange)
return () => mediaQuery.removeEventListener("change", onChange)
}, [])

if (!active) {
return null
}

if (prefersReducedMotion) {
return (
<div
className={cn("success-confetti-static", className)}
role="img"
aria-label={ariaLabel}
>
<CheckCircle
className="success-confetti-static__icon h-12 w-12"
aria-hidden="true"
/>
</div>
)
}

return (
<div className={cn("success-confetti", className)} aria-hidden="true">
{CONFETTI_COLORS.flatMap((colorClass, index) =>
[0, 1].map((duplicate) => (
<span
key={`${colorClass}-${duplicate}-${index}`}
className={cn("success-confetti-piece", colorClass)}
style={{
left: `${8 + index * 14 + duplicate * 4}%`,
animationDelay: `${index * 70 + duplicate * 35}ms`,
}}
/>
)),
)}
</div>
)
}
41 changes: 41 additions & 0 deletions components/success/__tests__/SuccessConfetti.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import React from "react"
import { render, screen } from "@testing-library/react"
import { SuccessConfetti } from "../SuccessConfetti"

function mockReducedMotion(reduced: boolean) {
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
matches: reduced && query.includes("prefers-reduced-motion"),
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
}))
}

describe("SuccessConfetti", () => {
beforeEach(() => {
mockReducedMotion(false)
})

it("renders animated confetti pieces when motion is allowed", () => {
const { container } = render(<SuccessConfetti />)
expect(container.querySelectorAll(".success-confetti-piece").length).toBeGreaterThan(0)
expect(container.querySelector(".success-confetti-static")).not.toBeInTheDocument()
})

it("renders a static success icon under prefers-reduced-motion", () => {
mockReducedMotion(true)
render(<SuccessConfetti ariaLabel="Prediction confirmed" />)

expect(screen.getByRole("img", { name: "Prediction confirmed" })).toBeInTheDocument()
expect(document.querySelector(".success-confetti-piece")).not.toBeInTheDocument()
})

it("renders nothing when inactive", () => {
const { container } = render(<SuccessConfetti active={false} />)
expect(container).toBeEmptyDOMElement()
})
})