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
49 changes: 47 additions & 2 deletions app/(marketing)/_components/__tests__/markets-widget.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import React from "react";
import { render, screen, act } from "@testing-library/react";
import { useFollowsStore } from "@/app/state/follows";
import { useUserLimitsStore } from "@/app/state/userLimits";

// ── minimal stub for the Card component ─────────────────────────────────────
jest.mock("@/components/ui/card", () => ({
Expand All @@ -24,12 +25,36 @@ function resetFollows() {
});
}

function resetUserLimits() {
act(() => {
useUserLimitsStore.setState({ limitsByMarket: {} });
});
}

// We import the file under test AFTER mocks are in place.
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { MarketsWidget } = require("../markets-widget");

describe("MarketsWidget – following indicator", () => {
beforeEach(resetFollows);
beforeAll(() => {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
});

beforeEach(() => {
resetFollows();
resetUserLimits();
});

it("does not show any following indicator when no markets are followed", () => {
render(<MarketsWidget />);
Expand Down Expand Up @@ -64,4 +89,24 @@ describe("MarketsWidget – following indicator", () => {
const indicators = screen.getAllByTestId("following-indicator");
expect(indicators).toHaveLength(1);
});

it("shows remaining daily betting allowance on market cards", () => {
act(() => {
useUserLimitsStore.getState().setLimit("btc-price", {
dailyLimit: 500,
usedToday: 120,
currency: "USDC",
});
});

render(<MarketsWidget />);

expect(screen.getByText("Daily allowance")).toBeInTheDocument();
expect(screen.getByText("380 USDC left today")).toBeInTheDocument();
expect(
screen.getByRole("progressbar", {
name: "Daily betting allowance remaining for Bitcoin Price",
})
).toHaveAttribute("aria-valuenow", "76");
});
});
39 changes: 36 additions & 3 deletions app/(marketing)/_components/markets-widget.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
"use client";

import { ArrowRight, TrendingUp, Globe, BarChart3, CheckCircle2, Coins, Bell } from "lucide-react";
import LanguageBadge from "@/components/LanguageBadge";
import { TrendingUp, Globe, BarChart3, CheckCircle2, Coins, Bell, Wallet } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { sampleMarkets, winNotifications, type Market } from "@/content/markets.sample";
import { useState, useEffect } from "react";
import { useFollowsStore } from "@/app/state/follows";
import { useUserLimitsStore } from "@/app/state/userLimits";

interface MarketsWidgetProps {
className?: string;
Expand Down Expand Up @@ -129,6 +129,9 @@ interface MarketCardProps {
*/
function MarketCard({ market, IconComponent, colors, index, reducedMotion }: MarketCardProps) {
const isFollowing = useFollowsStore((s) => s.isFollowing(market.id));
const limit = useUserLimitsStore((s) => s.getLimit(market.id));
const remaining = useUserLimitsStore((s) => s.getRemaining(market.id));
const remainingPercent = useUserLimitsStore((s) => s.getRemainingPercent(market.id));

return (
<Card
Expand Down Expand Up @@ -179,10 +182,40 @@ function MarketCard({ market, IconComponent, colors, index, reducedMotion }: Mar
/>
</div>

{limit && remaining !== null && remainingPercent !== null && (
<div
className="mb-3 rounded-lg border border-cyan-300/20 bg-cyan-400/10 p-2 text-white"
data-testid="daily-allowance-nudge"
>
<div className="mb-1 flex items-center justify-between gap-2 text-xs">
<span className="inline-flex items-center gap-1 font-medium text-cyan-100">
<Wallet className="h-3.5 w-3.5" aria-hidden="true" />
Daily allowance
</span>
<span className="text-white/80">
{remaining.toLocaleString()} {limit.currency} left today
</span>
</div>
<div
className="h-1.5 overflow-hidden rounded-full bg-white/15"
role="progressbar"
aria-label={`Daily betting allowance remaining for ${market.title}`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={remainingPercent}
>
<div
className="h-full rounded-full bg-cyan-300 transition-all duration-300"
style={{ width: `${remainingPercent}%` }}
/>
</div>
</div>
)}

<div className="flex justify-between text-xs text-white/60">
<span>Pool: {market.poolAmount.toLocaleString()} USDC</span>
<span>Ends in {market.endsIn}</span>
</div>
</Card>
);
}
}
38 changes: 38 additions & 0 deletions app/state/__tests__/userLimits.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { act } from "@testing-library/react";
import { useUserLimitsStore } from "../userLimits";

function resetStore() {
act(() => {
useUserLimitsStore.setState({ limitsByMarket: {} });
});
}

describe("useUserLimitsStore", () => {
beforeEach(resetStore);

it("calculates remaining allowance for a market", () => {
act(() => {
useUserLimitsStore.getState().setLimit("btc-price", {
dailyLimit: 500,
usedToday: 125,
currency: "USDC",
});
});

expect(useUserLimitsStore.getState().getRemaining("btc-price")).toBe(375);
expect(useUserLimitsStore.getState().getUsagePercent("btc-price")).toBe(25);
});

it("clamps exhausted allowance to zero remaining and full usage", () => {
act(() => {
useUserLimitsStore.getState().setLimit("market-1", {
dailyLimit: 100,
usedToday: 140,
currency: "USDC",
});
});

expect(useUserLimitsStore.getState().getRemaining("market-1")).toBe(0);
expect(useUserLimitsStore.getState().getUsagePercent("market-1")).toBe(100);
});
});
87 changes: 87 additions & 0 deletions app/state/userLimits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";

export interface UserMarketLimit {
dailyLimit: number;
usedToday: number;
currency: string;
}

interface UserLimitsState {
limitsByMarket: Record<string, UserMarketLimit>;
setLimit: (marketId: string, limit: UserMarketLimit) => void;
getLimit: (marketId: string) => UserMarketLimit | null;
getRemaining: (marketId: string) => number | null;
getUsagePercent: (marketId: string) => number | null;
getRemainingPercent: (marketId: string) => number | null;
}

const defaultLimitsByMarket: Record<string, UserMarketLimit> = {
"btc-price": {
dailyLimit: 500,
usedToday: 120,
currency: "USDC",
},
"us-election": {
dailyLimit: 350,
usedToday: 90,
currency: "USDC",
},
"tesla-earnings": {
dailyLimit: 250,
usedToday: 30,
currency: "USDC",
},
};

function normalizeLimit(limit: UserMarketLimit): UserMarketLimit {
return {
dailyLimit: Math.max(0, limit.dailyLimit),
usedToday: Math.max(0, limit.usedToday),
currency: limit.currency,
};
}

function getPercent(part: number, whole: number): number {
if (whole <= 0) return 0;
return Math.min(100, Math.max(0, Math.round((part / whole) * 100)));
}

export const useUserLimitsStore = create<UserLimitsState>()(
persist(
(set, get) => ({
limitsByMarket: defaultLimitsByMarket,

setLimit: (marketId, limit) =>
set((state) => ({
limitsByMarket: {
...state.limitsByMarket,
[marketId]: normalizeLimit(limit),
},
})),

getLimit: (marketId) => get().limitsByMarket[marketId] ?? null,

getRemaining: (marketId) => {
const limit = get().getLimit(marketId);
if (!limit) return null;
return Math.max(0, limit.dailyLimit - limit.usedToday);
},

getUsagePercent: (marketId) => {
const limit = get().getLimit(marketId);
if (!limit) return null;
return getPercent(limit.usedToday, limit.dailyLimit);
},

getRemainingPercent: (marketId) => {
const limit = get().getLimit(marketId);
if (!limit) return null;
return getPercent(Math.max(0, limit.dailyLimit - limit.usedToday), limit.dailyLimit);
},
}),
{
name: "predictify-user-limits",
}
)
);
20 changes: 20 additions & 0 deletions docs/user-limit-nudge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# User Limit Nudge

The market card now surfaces the user's remaining daily betting allowance before the confirmation flow.

## Implementation

- `app/state/userLimits.ts` stores per-market daily limits, used amount, currency, remaining allowance, usage percentage, and remaining percentage.
- `app/(marketing)/_components/markets-widget.tsx` reads the store for each market card and renders a compact allowance nudge when limit data is available.
- The nudge uses a labelled `progressbar` so assistive technology can announce the remaining allowance percentage.

## Behavior

- Remaining allowance is clamped at `0` when usage exceeds the daily limit.
- Percentages are clamped between `0` and `100`.
- Sample local values are provided for the marketing widget until live account-limit data is wired in.

## Verification

- `app/state/__tests__/userLimits.test.ts` covers store calculations and clamping.
- `app/(marketing)/_components/__tests__/markets-widget.test.tsx` covers visible copy and progressbar accessibility.