From 48d7f86b292696a9a6e4483bd5d1634294edaf30 Mon Sep 17 00:00:00 2001
From: pq198363-ops <246611021+pq198363-ops@users.noreply.github.com>
Date: Sat, 4 Jul 2026 00:22:10 +0800
Subject: [PATCH] feat: add user limit nudge to market cards
---
.../__tests__/markets-widget.test.tsx | 49 ++++++++++-
.../_components/markets-widget.tsx | 39 ++++++++-
app/state/__tests__/userLimits.test.ts | 38 ++++++++
app/state/userLimits.ts | 87 +++++++++++++++++++
docs/user-limit-nudge.md | 20 +++++
5 files changed, 228 insertions(+), 5 deletions(-)
create mode 100644 app/state/__tests__/userLimits.test.ts
create mode 100644 app/state/userLimits.ts
create mode 100644 docs/user-limit-nudge.md
diff --git a/app/(marketing)/_components/__tests__/markets-widget.test.tsx b/app/(marketing)/_components/__tests__/markets-widget.test.tsx
index 770b5ade..f98c2306 100644
--- a/app/(marketing)/_components/__tests__/markets-widget.test.tsx
+++ b/app/(marketing)/_components/__tests__/markets-widget.test.tsx
@@ -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", () => ({
@@ -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();
@@ -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();
+
+ 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");
+ });
});
diff --git a/app/(marketing)/_components/markets-widget.tsx b/app/(marketing)/_components/markets-widget.tsx
index 90ad334f..bc8d7211 100644
--- a/app/(marketing)/_components/markets-widget.tsx
+++ b/app/(marketing)/_components/markets-widget.tsx
@@ -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;
@@ -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 (
+ {limit && remaining !== null && remainingPercent !== null && (
+
+
+
+
+ Daily allowance
+
+
+ {remaining.toLocaleString()} {limit.currency} left today
+
+
+
+
+ )}
+
Pool: {market.poolAmount.toLocaleString()} USDC
Ends in {market.endsIn}
);
-}
\ No newline at end of file
+}
diff --git a/app/state/__tests__/userLimits.test.ts b/app/state/__tests__/userLimits.test.ts
new file mode 100644
index 00000000..12b31202
--- /dev/null
+++ b/app/state/__tests__/userLimits.test.ts
@@ -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);
+ });
+});
diff --git a/app/state/userLimits.ts b/app/state/userLimits.ts
new file mode 100644
index 00000000..866fcc03
--- /dev/null
+++ b/app/state/userLimits.ts
@@ -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;
+ 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 = {
+ "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()(
+ 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",
+ }
+ )
+);
diff --git a/docs/user-limit-nudge.md b/docs/user-limit-nudge.md
new file mode 100644
index 00000000..549dfc19
--- /dev/null
+++ b/docs/user-limit-nudge.md
@@ -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.