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 && ( +
+
+ + + + {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.