From d0032c6b0656b92e2112a855a6c478e96f8f3775 Mon Sep 17 00:00:00 2001 From: whitezaddy Date: Sat, 26 Sep 2026 19:24:28 +0100 Subject: [PATCH 1/3] feat(#978): add key subscription status and content access gate --- .../__tests__/SubscriptionAccessGate.test.tsx | 87 ++++++++++++ .../common/SubscriptionAccessGate.tsx | 129 ++++++++++++++++++ src/pages/CreatorDetailPage.tsx | 20 +++ 3 files changed, 236 insertions(+) create mode 100644 src/components/__tests__/SubscriptionAccessGate.test.tsx create mode 100644 src/components/common/SubscriptionAccessGate.tsx diff --git a/src/components/__tests__/SubscriptionAccessGate.test.tsx b/src/components/__tests__/SubscriptionAccessGate.test.tsx new file mode 100644 index 00000000..9130bfed --- /dev/null +++ b/src/components/__tests__/SubscriptionAccessGate.test.tsx @@ -0,0 +1,87 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import SubscriptionAccessGate from '../common/SubscriptionAccessGate'; +import { useWalletHoldings } from '@/hooks/useWallet'; +import { useProfileStore } from '@/hooks/useProfileStore'; + +vi.mock('@/hooks/useWallet', () => ({ + useWalletHoldings: vi.fn(), +})); + +vi.mock('@/hooks/useProfileStore', () => ({ + useProfileStore: vi.fn(), +})); + +describe('SubscriptionAccessGate', () => { + const mockOnBuyClick = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows non-connected state when no wallet is connected', () => { + (useProfileStore as unknown as ReturnType).mockImplementation((selector: unknown) => (selector as (state: unknown) => unknown)({ profile: null })); + (useWalletHoldings as unknown as ReturnType).mockReturnValue({ data: [], isLoading: false }); + + render( + +
Exclusive Content
+
+ ); + + expect(screen.getByTestId('subscription-gate-unconnected')).toBeInTheDocument(); + expect(screen.getByText(/Wallet Not Connected/i)).toBeInTheDocument(); + }); + + it('shows loading state while fetching holdings', () => { + (useProfileStore as unknown as ReturnType).mockImplementation((selector: unknown) => (selector as (state: unknown) => unknown)({ profile: { id: 'wallet1' } })); + (useWalletHoldings as unknown as ReturnType).mockReturnValue({ data: [], isLoading: true }); + + render( + +
Exclusive Content
+
+ ); + + expect(screen.getByTestId('subscription-gate-loading')).toBeInTheDocument(); + }); + + it('shows locked state when holding is below minimum threshold', () => { + (useProfileStore as unknown as ReturnType).mockImplementation((selector: unknown) => (selector as (state: unknown) => unknown)({ profile: { id: 'wallet1' } })); + (useWalletHoldings as unknown as ReturnType).mockReturnValue({ + data: [{ creatorId: 'creator1', quantity: 0 }], + isLoading: false, + }); + + render( + +
Exclusive Content
+
+ ); + + expect(screen.getByTestId('subscription-gate-locked')).toBeInTheDocument(); + expect(screen.getByText(/Content Locked/i)).toBeInTheDocument(); + + const buyButton = screen.getByRole('button', { name: /Buy Keys to Unlock/i }); + fireEvent.click(buyButton); + expect(mockOnBuyClick).toHaveBeenCalled(); + }); + + it('shows unlocked state and content when holding meets minimum threshold', () => { + (useProfileStore as unknown as ReturnType).mockImplementation((selector: unknown) => (selector as (state: unknown) => unknown)({ profile: { id: 'wallet1' } })); + (useWalletHoldings as unknown as ReturnType).mockReturnValue({ + data: [{ creatorId: 'creator1', quantity: 1, last_buy_timestamp: Date.now() }], + isLoading: false, + }); + + render( + +
Exclusive Content
+
+ ); + + expect(screen.getByTestId('subscription-gate-unlocked')).toBeInTheDocument(); + expect(screen.getByText(/Active Subscription/i)).toBeInTheDocument(); + expect(screen.getByText('Exclusive Content')).toBeInTheDocument(); + }); +}); diff --git a/src/components/common/SubscriptionAccessGate.tsx b/src/components/common/SubscriptionAccessGate.tsx new file mode 100644 index 00000000..be7df38c --- /dev/null +++ b/src/components/common/SubscriptionAccessGate.tsx @@ -0,0 +1,129 @@ +import type { ReactNode } from 'react'; +import { useWalletHoldings } from '@/hooks/useWallet'; +import { useProfileStore } from '@/hooks/useProfileStore'; +import { Button } from '@/components/ui/button'; +import { formatDistanceToNow } from 'date-fns'; + +interface SubscriptionAccessGateProps { + creatorId: string; + minimumHolding?: number; + children: ReactNode; + onBuyClick?: () => void; +} + +export default function SubscriptionAccessGate({ + creatorId, + minimumHolding = 1, + children, + onBuyClick, +}: SubscriptionAccessGateProps) { + const profile = useProfileStore(state => state.profile); + const userAddress = profile?.id; + const { data: holdings = [], isLoading } = useWalletHoldings( + userAddress ?? '' + ); + + const userPosition = holdings.find(h => h.creatorId === creatorId); + const quantity = userPosition?.quantity ?? 0; + const isUnlocked = quantity >= minimumHolding; + + // Mock expiry countdown for now (e.g. 30 days from last buy, defaulting to 30 days from now if not present) + const expiryDate = userPosition?.last_buy_timestamp + ? new Date( + new Date(userPosition.last_buy_timestamp).getTime() + + 30 * 24 * 60 * 60 * 1000 + ) + : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + + if (!userAddress) { + return ( +
+

Wallet Not Connected

+

+ Connect your wallet to verify your key holdings and access this content. +

+
+ ); + } + + if (isLoading) { + return ( +
+ Checking subscription status... +
+ ); + } + + if (!isUnlocked) { + return ( +
+
+ + + +
+

Content Locked

+

+ You need at least {minimumHolding} key{minimumHolding === 1 ? '' : 's'}{' '} + to access this creator's gated content. You currently hold {quantity}. +

+ {onBuyClick && ( + + )} +
+ ); + } + + return ( +
+
+
+
+ + + +
+
+

Active Subscription

+

+ You hold {quantity} key{quantity === 1 ? '' : 's'} +

+
+
+ {expiryDate && ( +
+

+ Expires +

+

+ {formatDistanceToNow(expiryDate, { addSuffix: true })} +

+
+ )} +
+ {children} +
+ ); +} diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx index 81dbb91e..c6e115bf 100644 --- a/src/pages/CreatorDetailPage.tsx +++ b/src/pages/CreatorDetailPage.tsx @@ -16,6 +16,7 @@ import KeyHolderList from '@/components/common/KeyHolderList'; import HolderConcentrationChart from '@/components/common/HolderConcentrationChart'; import StakingRewardsSection from '@/components/common/StakingRewardsSection'; import DeprecationNotice from '@/components/common/DeprecationNotice'; +import SubscriptionAccessGate from '@/components/common/SubscriptionAccessGate'; import { isKeyDeprecated } from '@/utils/keyDeprecation.utils'; import { Button } from '@/components/ui/button'; import { CreatorDashboardSkeleton } from '@/components/common/CreatorSkeleton'; @@ -523,6 +524,25 @@ function CreatorDetailPageContent() { }} /> + + {/* Gated Content */} +
+

+ Exclusive Content +

+ setBuyDialogOpen(true)} + > +
+

+ 🎉 Welcome to the exclusive content section! Here you can access premium videos, articles, and perks from {creator.title || creator.name || 'this creator'}. +

+
+
+
+

Activity From a8dee9e94bd1fbee6ba7892b9773a2cc1cf53b69 Mon Sep 17 00:00:00 2001 From: whitezaddy Date: Tue, 29 Sep 2026 08:19:30 +0100 Subject: [PATCH 2/3] Fix lint errors in BatchTradePanel and CreatorDetailPage --- src/components/common/BatchTradePanel.tsx | 16 +++++++------- src/pages/CreatorDetailPage.tsx | 27 ++++------------------- 2 files changed, 12 insertions(+), 31 deletions(-) diff --git a/src/components/common/BatchTradePanel.tsx b/src/components/common/BatchTradePanel.tsx index 9c905351..de159fa3 100644 --- a/src/components/common/BatchTradePanel.tsx +++ b/src/components/common/BatchTradePanel.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; @@ -21,21 +21,21 @@ export default function BatchTradePanel({ open, onOpenChange }: { open: boolean; const [quantity, setQuantity] = useState(1); const [slippageBps, setSlippageBps] = useState(500); const [results, setResults] = useState>['results'] | null>(null); - const courses = coursesQuery.data ?? []; - const holdings = holdingsQuery.data ?? []; - const getCourse = (id: string) => courses.find(course => course.id === id); - const orderCost = (order: DraftOrder) => { + const courses = useMemo(() => coursesQuery.data ?? [], [coursesQuery.data]); + const holdings = useMemo(() => holdingsQuery.data ?? [], [holdingsQuery.data]); + const getCourse = useCallback((id: string) => courses.find(course => course.id === id), [courses]); + const orderCost = useCallback((order: DraftOrder) => { const course = getCourse(order.creatorId); const price = Number(order.side === 'buy' ? course?.priceStroops : course?.priceStroops) || 0; return order.side === 'buy' ? calculateFeeBreakdown({ quantity: order.quantity, keyPriceStroops: price, currentSupply: course?.creatorShareSupply ?? 0, protocolFeeBps: course?.protocolFeeBps ?? 500, creatorFeeBps: course?.creatorFeeBps ?? 500 }).totalCostStroops : 0; - }; - const totalCost = useMemo(() => orders.reduce((sum, order) => sum + orderCost(order), 0), [orders, courses]); + }, [getCourse]); + const totalCost = useMemo(() => orders.reduce((sum, order) => sum + orderCost(order), 0), [orders, orderCost]); const totalFees = useMemo(() => orders.reduce((sum, order) => { if (order.side !== 'buy') return sum; const course = getCourse(order.creatorId); const gross = (Number(course?.priceStroops) || 0) * order.quantity; return sum + Math.round(gross * ((course?.protocolFeeBps ?? 500) + (course?.creatorFeeBps ?? 500)) / 10_000); - }, 0), [orders, courses]); + }, 0), [orders, getCourse]); const sellExceeded = orders.some(order => order.side === 'sell' && (holdings.find(item => item.creatorId === order.creatorId)?.quantity ?? 0) < order.quantity); const addOrder = () => { if (!creatorId || quantity < 1 || !Number.isFinite(quantity)) return; diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx index 63f5e3a0..2a1cd345 100644 --- a/src/pages/CreatorDetailPage.tsx +++ b/src/pages/CreatorDetailPage.tsx @@ -6,22 +6,17 @@ import { useRecentlyViewed } from '@/hooks/useRecentlyViewed'; import { useCreatorProfileStaleIndicator } from '@/hooks/useCreatorProfileStaleIndicator'; import CreatorBreadcrumb from '@/components/common/CreatorBreadcrumb'; import CreatorProfileHeader from '@/components/common/CreatorProfileHeader'; -import CreatorProfileInfoGrid from '@/components/common/CreatorProfileInfoGrid'; import CreatorActivityFeed from '@/components/common/CreatorActivityFeed'; -import CreatorProfileStaleIndicator from '@/components/common/CreatorProfileStaleIndicator'; import CreatorProfileStatRow from '@/components/common/CreatorProfileStatRow'; import { BondingCurveChart } from '@/components/common/BondingCurveChart'; -import KeySimulationTool from '@/components/common/KeySimulationTool'; import BuyCooldownCountdown from '@/components/common/BuyCooldownCountdown'; -import KeyHolderList from '@/components/common/KeyHolderList'; -import HolderConcentrationChart from '@/components/common/HolderConcentrationChart'; import StakingRewardsSection from '@/components/common/StakingRewardsSection'; import DeprecationNotice from '@/components/common/DeprecationNotice'; import SubscriptionAccessGate from '@/components/common/SubscriptionAccessGate'; import { isKeyDeprecated } from '@/utils/keyDeprecation.utils'; import { Button } from '@/components/ui/button'; import { CreatorDashboardSkeleton } from '@/components/common/CreatorSkeleton'; -import { bpsToPercent, formatNumber } from '@/utils/numberFormat.utils'; +import { formatNumber } from '@/utils/numberFormat.utils'; import { resolveCreatorKeyPriceStroops, formatDisplayKeyPrice, @@ -33,7 +28,6 @@ import { useNavigationTiming } from '@/hooks/useNavigationTiming'; import { useKeyHolders } from '@/hooks/useKeyHolders'; import { useProfileStore } from '@/hooks/useProfileStore'; import { useWalletHoldings, useTradeMutation } from '@/hooks/useWallet'; -import CoCreatorSection from '@/components/creator/CoCreatorSection'; import ShareTwitterButton from '@/components/common/ShareTwitterButton'; import { PriceHistoryChart } from '@/components/common/PriceHistoryChart'; import type { PriceHistoryInterval } from '@/services/course.service'; @@ -52,7 +46,6 @@ import { useKeyConfig } from '@/hooks/useKeyConfig'; import KeyStatsPanel from '@/components/common/KeyStatsPanel'; import Skeleton from '@/components/ui/skeleton'; import { Tooltip } from '@/components/ui/tooltip'; -import GraduatedCurveMilestoneChart from '@/components/common/GraduatedCurveMilestoneChart'; import KeyDeprecationBanner from '@/components/common/KeyDeprecationBanner'; import MergeProposalBanner from '@/components/common/MergeProposalBanner'; import KeyBuybackModal from '@/components/common/KeyBuybackModal'; @@ -107,8 +100,7 @@ function CreatorDetailPageContent() { }); }, [creator, recordVisit]); - const { holders, hasNextPage, isFetchingNextPage, fetchNextPage } = - useKeyHolders(id || ''); + useKeyHolders(id || ''); // User holdings for Share to X button const profile = useProfileStore(state => state.profile); @@ -171,7 +163,7 @@ function CreatorDetailPageContent() { const isLockedOut = isWhitelistGateActive && !isUserWhitelisted; // Track stale data indicator - const { shouldShowBadge, handleRefetch } = useCreatorProfileStaleIndicator( + useCreatorProfileStaleIndicator( id || '', isFetching, () => refetch() @@ -261,18 +253,7 @@ function CreatorDetailPageContent() { throw error; } - const feeItems = [ - { - label: 'Creator fee', - value: bpsToPercent(creator.creatorFeeBps), - helperText: 'Fee paid directly to the creator on each trade.', - }, - { - label: 'Protocol fee', - value: bpsToPercent(creator.protocolFeeBps), - helperText: 'Fee paid to the platform for protocol maintenance.', - }, - ]; + const statItems = [ { From b094d31f64e7a49f645f509c5dc04fce5a715cf5 Mon Sep 17 00:00:00 2001 From: whitezaddy Date: Tue, 29 Sep 2026 08:24:50 +0100 Subject: [PATCH 3/3] Fix typescript compilation errors --- src/pages/CreatorDetailPage.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx index 2a1cd345..b38d8181 100644 --- a/src/pages/CreatorDetailPage.tsx +++ b/src/pages/CreatorDetailPage.tsx @@ -8,7 +8,7 @@ import CreatorBreadcrumb from '@/components/common/CreatorBreadcrumb'; import CreatorProfileHeader from '@/components/common/CreatorProfileHeader'; import CreatorActivityFeed from '@/components/common/CreatorActivityFeed'; import CreatorProfileStatRow from '@/components/common/CreatorProfileStatRow'; -import { BondingCurveChart } from '@/components/common/BondingCurveChart'; +import BondingCurveChart from '@/components/common/BondingCurveChart'; import BuyCooldownCountdown from '@/components/common/BuyCooldownCountdown'; import StakingRewardsSection from '@/components/common/StakingRewardsSection'; import DeprecationNotice from '@/components/common/DeprecationNotice'; @@ -53,7 +53,9 @@ import type { KeyBuybackReceipt } from '@/hooks/useKeyBuyback'; import { usePerformanceBond } from '@/hooks/usePerformanceBond'; import PerformanceBondPanel from '@/components/common/PerformanceBondPanel'; import WhitelistStatusBadge from '@/components/common/WhitelistStatusBadge'; - +import { useTradeCooldownStatus, invalidateTradeCooldownStatus, resolveActiveTradeCooldown } from '@/hooks/useTradeCooldownStatus'; +import { isActiveCooldown, type ActiveTradeCooldown } from '@/utils/tradeCooldown.utils'; +import TradeCooldownButton from '@/components/common/TradeCooldownButton'; function CreatorDetailPageContent() { usePurchaseConfetti();