diff --git a/package.json b/package.json index 2a077bb4..40ce6e64 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.2.1", "@stellar/freighter-api": "6.0.1", "@stellar/stellar-sdk": "17.0.1", "@tailwindcss/vite": "^4.1.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2175829e..9a30d084 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,6 +31,9 @@ importers: '@radix-ui/react-slot': specifier: ^1.2.4 version: 1.3.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-tabs': + specifier: ^1.2.1 + version: 1.2.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@stellar/freighter-api': specifier: 6.0.1 version: 6.0.1 @@ -1159,6 +1162,12 @@ packages: integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==, } + '@radix-ui/primitive@1.2.0': + resolution: + { + integrity: sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==, + } + '@radix-ui/react-arrow@1.1.15': resolution: { @@ -1191,6 +1200,22 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-collection@1.2.0': + resolution: + { + integrity: sha512-+yiQzqcc7Sy7/5s0jrMCjpPRFB3538VNrXPWdTLZhE/SCfgEws4aI6lHWN/vvcnyhHndROWBzqmqB4QBrITZ9Q==, + } + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-compose-refs@1.1.5': resolution: { @@ -1215,6 +1240,18 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.3.0': + resolution: + { + integrity: sha512-v5AYEocMISkbJ+1fTXzZpiFv7NRr3D2WiYbLIKf+FkmJ8VkP6d503eV2n34MFussHXY1pcoq3yfJiQH77dc4Mg==, + } + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dialog@1.1.23': resolution: { @@ -1243,6 +1280,18 @@ packages: '@types/react': optional: true + '@radix-ui/react-direction@1.2.0': + resolution: + { + integrity: sha512-FtRA52a/r+LKStFT+neLUm+8vSe7wbzkbQGGeU8ixFiwC8NU14Evoq9ATECqL3eGbcUzJR4fyfA4KkK/q6dfqg==, + } + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dismissable-layer@1.1.19': resolution: { @@ -1411,6 +1460,22 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-presence@1.1.11': + resolution: + { + integrity: sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==, + } + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-primitive@2.1.10': resolution: { @@ -1427,6 +1492,22 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.2.0': + resolution: + { + integrity: sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==, + } + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-roving-focus@1.1.19': resolution: { @@ -1443,6 +1524,22 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-roving-focus@1.2.1': + resolution: + { + integrity: sha512-UwraORzVUeEPMclhO1p0vz0qI+TbdDLO9i+FiOqhJyHKwdpCRCck9yVEscUnAMIzCiuNoQa8D4A9bIpOPXGt6Q==, + } + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-scroll-area@1.2.18': resolution: { @@ -1471,6 +1568,34 @@ packages: '@types/react': optional: true + '@radix-ui/react-slot@1.5.0': + resolution: + { + integrity: sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==, + } + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-tabs@1.2.1': + resolution: + { + integrity: sha512-hRoJlode+hg5ZNlR+3tkfkUliIybdTpD5lkAvHQ9yaXseGf7XVO9Ib2GPEIOuaJJbo7YWYbNpDDRfo8Dhf6/xA==, + } + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-use-callback-ref@1.1.4': resolution: { @@ -1483,6 +1608,18 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-callback-ref@1.1.5': + resolution: + { + integrity: sha512-Y5GBf4NKeADoXI1geiMlMuMoURT3k9MizzzKhGb/m55H94aGigZ01PIpkvsO6lML13BA6XYAXSJ6gza7vTn5nw==, + } + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-controllable-state@1.2.6': resolution: { @@ -1495,6 +1632,18 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-controllable-state@1.3.0': + resolution: + { + integrity: sha512-TAcBI7bw2ljYCbv4+zuTSd6zICu8pRt6SDN2vJ/Z06K2MXh6I9WYFe/A9ovlFDtR5HVHGm5iDxDoABm/++dbKg==, + } + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-effect-event@0.0.5': resolution: { @@ -8221,6 +8370,8 @@ snapshots: '@radix-ui/primitive@1.1.7': {} + '@radix-ui/primitive@1.2.0': {} + '@radix-ui/react-arrow@1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -8242,6 +8393,19 @@ snapshots: '@types/react': 19.2.18 '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@radix-ui/react-collection@1.2.0(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.3.0(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.5.0(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.18)(react@19.2.8)': dependencies: react: 19.2.8 @@ -8254,6 +8418,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 + '@radix-ui/react-context@1.3.0(@types/react@19.2.18)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + '@radix-ui/react-dialog@1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 @@ -8283,6 +8453,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 + '@radix-ui/react-direction@1.2.0(@types/react@19.2.18)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + '@radix-ui/react-dismissable-layer@1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 @@ -8438,6 +8614,15 @@ snapshots: '@types/react': 19.2.18 '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@radix-ui/react-presence@1.1.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@radix-ui/react-primitive@2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) @@ -8447,6 +8632,15 @@ snapshots: '@types/react': 19.2.18 '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@radix-ui/react-primitive@2.2.0(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-slot': 1.5.0(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@radix-ui/react-roving-focus@1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 @@ -8466,6 +8660,25 @@ snapshots: '@types/react': 19.2.18 '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@radix-ui/react-roving-focus@1.2.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.3.0(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.2.0(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@radix-ui/react-scroll-area@1.2.18(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/number': 1.1.3 @@ -8490,12 +8703,42 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 + '@radix-ui/react-slot@1.5.0(@types/react@19.2.18)(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-tabs@1.2.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-context': 1.3.0(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.2.0(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@radix-ui/react-use-callback-ref@1.1.4(@types/react@19.2.18)(react@19.2.8)': dependencies: react: 19.2.8 optionalDependencies: '@types/react': 19.2.18 + '@radix-ui/react-use-callback-ref@1.1.5(@types/react@19.2.18)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + '@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.2.18)(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 @@ -8505,6 +8748,15 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 + '@radix-ui/react-use-controllable-state@1.3.0(@types/react@19.2.18)(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.18)(react@19.2.8)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) diff --git a/src/components/common/SubscriptionInvestmentPanel.tsx b/src/components/common/SubscriptionInvestmentPanel.tsx new file mode 100644 index 00000000..213957c9 --- /dev/null +++ b/src/components/common/SubscriptionInvestmentPanel.tsx @@ -0,0 +1,438 @@ +import { useState, useMemo } from 'react'; +import { Calendar, X, AlertCircle, CheckCircle2, Clock, RotateCcw } from 'lucide-react'; +import { useAccount } from 'wagmi'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from '@/components/ui/card'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { formatNumber } from '@/utils/numberFormat.utils'; +import { + SUBSCRIPTION_INTERVAL_LABELS, + SUBSCRIPTION_INTERVAL_SECONDS, + type SubscriptionInterval, +} from '@/types/keySubscription'; +import { useKeySubscription, useCreateSubscription, useCancelSubscription } from '@/hooks/useKeySubscription'; +import showToast from '@/utils/toast.util'; +import { getSignatureErrorMessage } from '@/utils/errorHandling.utils'; + +interface SubscriptionInvestmentPanelProps { + creatorId: string; + currentPriceStroops?: number | null; + disabled?: boolean; + disabledReason?: string; +} + +const INTERVAL_OPTIONS: { value: SubscriptionInterval; label: string }[] = [ + { value: 'daily', label: 'Daily' }, + { value: 'weekly', label: 'Weekly' }, + { value: 'monthly', label: 'Monthly' }, +]; + +function formatNextExecution(nextExecutionAt: string): string { + const date = new Date(nextExecutionAt); + const now = new Date(); + const diffMs = date.getTime() - now.getTime(); + + if (diffMs <= 0) return 'Executing soon...'; + + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + const diffHours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const diffMinutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); + + if (diffDays > 0) { + return `${diffDays}d ${diffHours}h`; + } + if (diffHours > 0) { + return `${diffHours}h ${diffMinutes}m`; + } + return `${diffMinutes}m`; +} + +function getNextExecutionDate(nextExecutionAt: string): Date { + return new Date(nextExecutionAt); +} + +export default function SubscriptionInvestmentPanel({ + creatorId, + currentPriceStroops, + disabled, + disabledReason, +}: SubscriptionInvestmentPanelProps) { + const { address } = useAccount(); + const isConnected = Boolean(address); + + const { data: subscription, isLoading: isSubscriptionLoading } = useKeySubscription(creatorId); + const createSubscription = useCreateSubscription(); + const cancelSubscription = useCancelSubscription(); + + const [amount, setAmount] = useState(''); + const [interval, setInterval] = useState('monthly'); + const [amountError, setAmountError] = useState(null); + + const activeSubscription = subscription?.status === 'active' ? subscription : null; + + const preview = useMemo(() => { + if (!amount || !currentPriceStroops) return null; + const amountNum = parseFloat(amount); + if (isNaN(amountNum) || amountNum <= 0) return null; + + const intervalSeconds = SUBSCRIPTION_INTERVAL_SECONDS[interval]; + const nextExecutionDate = new Date(Date.now() + intervalSeconds * 1000); + const estimatedCostXlm = amountNum; + + return { + nextExecutionDate, + estimatedCostXlm, + }; + }, [amount, interval, currentPriceStroops]); + + const validateAmount = (value: string): boolean => { + const num = parseFloat(value); + if (!value.trim()) { + setAmountError('Enter an amount'); + return false; + } + if (isNaN(num) || num <= 0) { + setAmountError('Amount must be greater than 0'); + return false; + } + if (num < 0.01) { + setAmountError('Minimum amount is 0.01 XLM'); + return false; + } + setAmountError(null); + return true; + }; + + const handleAmountChange = (value: string) => { + setAmount(value); + if (value.trim()) { + validateAmount(value); + } else { + setAmountError(null); + } + }; + + const handleCreateSubscription = async () => { + if (!validateAmount(amount)) return; + if (!isConnected) { + showToast.error('Connect your wallet to create a subscription'); + return; + } + + try { + showToast.loading('Creating subscription...'); + await createSubscription.mutateAsync({ + creatorId, + amountXlm: parseFloat(amount), + interval, + }); + showToast.transactionSuccess( + 'Subscription created', + `Recurring ${SUBSCRIPTION_INTERVAL_LABELS[interval].toLowerCase()} investment of ${formatNumber(parseFloat(amount))} XLM set up` + ); + setAmount(''); + } catch (error) { + showToast.error(getSignatureErrorMessage(error)); + } + }; + + const handleCancelSubscription = async (subscriptionId: string) => { + if (!isConnected) { + showToast.error('Connect your wallet to cancel subscription'); + return; + } + + try { + showToast.loading('Cancelling subscription...'); + await cancelSubscription.mutateAsync({ subscriptionId }); + showToast.transactionSuccess( + 'Subscription cancelled', + 'Your recurring investment has been cancelled' + ); + } catch (error) { + showToast.error(getSignatureErrorMessage(error)); + } + }; + + const isSubmitting = createSubscription.isPending || cancelSubscription.isPending; + + if (!isConnected) { + return ( + + +
+ +

Connect to Subscribe

+

+ Connect your wallet to set up recurring automatic investments. +

+
+
+
+ ); + } + + return ( + + +
+
+ Subscription Investment + + Set up recurring automatic investments in this key + +
+
+
+ + + + + + New Subscription + + + Active Subscription + + + + + {activeSubscription && ( +
+
+ + + You have an active subscription. Cancel it first to create a new one. + +
+
+ )} + + {!activeSubscription && ( +
+ {/* Amount Input */} +
+ + handleAmountChange(e.target.value)} + onBlur={() => validateAmount(amount)} + placeholder="e.g., 10.00" + disabled={disabled || isSubmitting} + className={`w-full rounded-lg border bg-white/5 px-3 py-2 text-sm text-white placeholder-white/40 outline-none focus:border-amber-400/60 transition-colors ${ + amountError + ? 'border-red-500/60' + : 'border-white/15 focus:border-amber-400/60' + } ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`} + aria-invalid={!!amountError} + aria-describedby={amountError ? 'amount-error' : undefined} + /> + {amountError && ( + + )} + {disabledReason && !amountError && ( +

{disabledReason}

+ )} +
+ + {/* Interval Selector */} +
+ + +
+ + {/* Next Execution Preview */} + {preview && ( +
+
+ + Next Execution Preview +
+
+
+

Estimated Date

+

+ {preview.nextExecutionDate.toLocaleString()} +

+
+
+

Estimated Cost

+

+ {formatNumber(preview.estimatedCostXlm)} XLM +

+
+
+
+ )} + + {/* Current Price Reference */} + {currentPriceStroops && ( +

+ Current key price:{' '} + + {formatNumber(currentPriceStroops / 10_000_000)} XLM + +

+ )} + + {/* Submit Button */} + +
+ )} +
+ + + {isSubscriptionLoading ? ( +
+
+
+ ) : activeSubscription ? ( +
+ {/* Subscription Config */} +
+

+ + Active Subscription Configuration +

+
+
+

Amount

+

+ {formatNumber(activeSubscription.amountXlm)} XLM +

+
+
+

Interval

+

+ {SUBSCRIPTION_INTERVAL_LABELS[activeSubscription.interval]} +

+
+
+

Status

+

+ {activeSubscription.status} +

+
+
+

Total Invested

+

+ {formatNumber(activeSubscription.totalInvestedXlm)} XLM +

+
+
+
+ + {/* Next Execution */} +
+
+ + Next Execution +
+
+
+

Date

+

+ {getNextExecutionDate(activeSubscription.nextExecutionAt).toLocaleString()} +

+
+
+

In

+

+ {formatNextExecution(activeSubscription.nextExecutionAt)} +

+
+
+
+ + {/* Cancel Button */} + +
+ ) : ( +
+ +

No active subscription

+

+ Create a new subscription to get started +

+
+ )} + + + + + ); +} \ No newline at end of file diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx new file mode 100644 index 00000000..7edecbcb --- /dev/null +++ b/src/components/ui/tabs.tsx @@ -0,0 +1,61 @@ +'use client'; + +import * as React from 'react'; +import * as TabsPrimitive from '@radix-ui/react-tabs'; +import { cn } from '@/lib/utils'; + +function Tabs({ + ...props +}: React.ComponentProps) { + return ; +} + +function TabsList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Tabs, TabsList, TabsTrigger, TabsContent }; \ No newline at end of file diff --git a/src/hooks/useKeySubscription.ts b/src/hooks/useKeySubscription.ts new file mode 100644 index 00000000..571c17f1 --- /dev/null +++ b/src/hooks/useKeySubscription.ts @@ -0,0 +1,102 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { keySubscriptionContractService } from '@/services/keySubscriptionContract.service'; +import { queryKeys } from '@/lib/queryKeys'; +import type { CreateSubscriptionInput, CancelSubscriptionInput } from '@/types/keySubscription'; +import { useAccount } from 'wagmi'; + +/** + * Fetch active subscription for a creator by the connected investor + */ +export function useKeySubscription(creatorId: string) { + const { address } = useAccount(); + + return useQuery({ + queryKey: queryKeys.keySubscription.detail(creatorId, address ?? ''), + queryFn: async () => { + if (!address) return null; + const subscriptions = + await keySubscriptionContractService.getSubscriptionsForInvestor( + address + ); + return ( + subscriptions.find( + (s) => s.creatorId === creatorId && s.status === 'active' + ) ?? null + ); + }, + enabled: Boolean(creatorId && address), + staleTime: 30 * 1000, + refetchInterval: 30 * 1000, + }); +} + +/** + * Create a new subscription investment + */ +export function useCreateSubscription() { + const queryClient = useQueryClient(); + const { address } = useAccount(); + + return useMutation({ + mutationFn: (input: CreateSubscriptionInput) => + keySubscriptionContractService.createSubscription(input), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: queryKeys.keySubscription.detail( + variables.creatorId, + address ?? '' + ), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.keySubscription.list(address ?? ''), + }); + }, + }); +} + +/** + * Cancel an active subscription + */ +export function useCancelSubscription() { + const queryClient = useQueryClient(); + const { address } = useAccount(); + + return useMutation({ + mutationFn: (input: CancelSubscriptionInput) => + keySubscriptionContractService.cancelSubscription(input), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.keySubscription.detail( + '', + address ?? '' + ), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.keySubscription.list(address ?? ''), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.keySubscription.all(), + }); + }, + }); +} + +/** + * Fetch all subscriptions for the connected investor + */ +export function useInvestorSubscriptions() { + const { address } = useAccount(); + + return useQuery({ + queryKey: address + ? queryKeys.keySubscription.list(address) + : (['keySubscription', 'list', ''] as const), + queryFn: () => + address + ? keySubscriptionContractService.getSubscriptionsForInvestor(address) + : [], + enabled: Boolean(address), + staleTime: 30 * 1000, + refetchInterval: 30 * 1000, + }); +} \ No newline at end of file diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index 0a23d78c..fd9a6bf3 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -261,4 +261,10 @@ export const queryKeys = { ['auto-invest', 'history', limit ?? 50] as const, settings: () => ['auto-invest', 'settings'] as const, }, + keySubscription: { + all: () => ['keySubscription'] as const, + list: (wallet: string) => ['keySubscription', 'list', wallet] as const, + detail: (creatorId: string, wallet: string) => + ['keySubscription', 'detail', creatorId, wallet] as const, + }, } as const; diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx index 7d708a4a..053afdab 100644 --- a/src/pages/CreatorDetailPage.tsx +++ b/src/pages/CreatorDetailPage.tsx @@ -92,6 +92,7 @@ import KeyStatsPanel from '@/components/common/KeyStatsPanel'; import Skeleton from '@/components/ui/skeleton'; import { Tooltip } from '@/components/ui/tooltip'; import { UnavailableAction } from '@/components/ui/unavailable-action'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import GraduatedCurveMilestoneChart from '@/components/common/GraduatedCurveMilestoneChart'; import KeyDeprecationBanner from '@/components/common/KeyDeprecationBanner'; import KeyTradingPausedBanner from '@/components/common/KeyTradingPausedBanner'; @@ -105,6 +106,7 @@ import { usePerformanceBond } from '@/hooks/usePerformanceBond'; import PerformanceBondPanel from '@/components/common/PerformanceBondPanel'; import WhitelistStatusBadge from '@/components/common/WhitelistStatusBadge'; import ShareModal from '@/components/common/ShareModal'; +import SubscriptionInvestmentPanel from '@/components/common/SubscriptionInvestmentPanel'; import PriceAlertButton from '@/components/common/PriceAlertButton'; import { useTradeCooldownStatus, @@ -791,218 +793,250 @@ function CreatorDetailPageContent() {
)} -
-
-
-

- Key Purchase -

- - {isLockedOut && } -
- -

- {isKeyDeprecated(creator) - ? 'Key is deprecated. New buys are disabled.' - : keyFreeze - ? keyFreeze.reason - : buyDisabledReason - ? buyDisabledReason - : 'Purchase keys for this creator.'} -

- - - - - - {creator.earlyAccessEnabled && ( - - Early access - - )} - - - - {creator.publicLaunchDate && hasValidPublicLaunchDate && ( -

- {isPublicLaunchPending - ? 'Public launch: ' - : 'Public trading is open. '} - - {isPublicLaunchPending && ( - - )} -

- )} -
- -
- - - - - - - {isKeyDeprecated(creator) ? ( - - ) : keyFreeze ? ( - - - - ) : creator.tradingPaused ? ( - - - - ) : buyDisabledReason ? ( - - - - - - ) : ( - { - setTradeSide('buy'); - setBuyDialogOpen(true); - }} - buttonProps={{ - 'data-testid': 'key-detail-buy-button', - }} - /> - )} +
+ + + + Buy + + + Subscribe + + + + +
+
+
+

+ Key Purchase +

+ + {isLockedOut && } +
+ +

+ {isKeyDeprecated(creator) + ? 'Key is deprecated. New buys are disabled.' + : keyFreeze + ? keyFreeze.reason + : buyDisabledReason + ? buyDisabledReason + : 'Purchase keys for this creator.'} +

+ + + + + + {creator.earlyAccessEnabled && ( + + Early access + + )} + + + + {creator.publicLaunchDate && hasValidPublicLaunchDate && ( +

+ {isPublicLaunchPending + ? 'Public launch: ' + : 'Public trading is open. '} + + {isPublicLaunchPending && ( + + )} +

+ )} +
- {holdingsCount > 0 && - (isLockupActive ? ( - - - - ) : isFloorActive ? ( - - - - ) : ( - - ))} - - {holdingsCount > 0 && isLockupActive && ( - + + + + + + + {isKeyDeprecated(creator) ? ( + + ) : keyFreeze ? ( + + + + ) : creator.tradingPaused ? ( + + + + ) : buyDisabledReason ? ( + + + + + + ) : ( + { + setTradeSide('buy'); + setBuyDialogOpen(true); + }} + buttonProps={{ + 'data-testid': 'key-detail-buy-button', + }} + /> + )} + + {holdingsCount > 0 && + (isLockupActive ? ( + + + + ) : isFloorActive ? ( + + + + ) : ( + + ))} + + {holdingsCount > 0 && isLockupActive && ( + + )} +
+
+ + + + - )} -
+ +
{auctionPhase !== 'inactive' && ( diff --git a/src/services/keySubscriptionContract.service.ts b/src/services/keySubscriptionContract.service.ts new file mode 100644 index 00000000..391cef02 --- /dev/null +++ b/src/services/keySubscriptionContract.service.ts @@ -0,0 +1,220 @@ +import { Address, Networks } from '@stellar/stellar-sdk'; +import { Client } from '@stellar/stellar-sdk/contract'; +import { env } from '@/utils/env.utils'; +import type { + KeySubscription, + CreateSubscriptionInput, + CancelSubscriptionInput, + SubscriptionInterval, +} from '@/types/keySubscription'; + +function getNetworkPassphrase(): string { + return env.VITE_STELLAR_NETWORK === 'mainnet' + ? Networks.PUBLIC + : Networks.TESTNET; +} + +const defaultFactory = ( + contractId: string, + rpcUrl: string, + networkPassphrase: string +) => { + return Client.from({ + contractId, + rpcUrl, + networkPassphrase, + }); +}; + +function normalizeSubscription(raw: unknown): KeySubscription | null { + if (!raw || typeof raw !== 'object') return null; + const r = raw as Record; + return { + id: String(r.id ?? ''), + creatorId: String(r.creatorId ?? r.creator_id ?? ''), + investorAddress: String(r.investorAddress ?? r.investor_address ?? ''), + amountXlm: Number(r.amountXlm ?? r.amount_xlm ?? 0), + interval: (r.interval ?? 'monthly') as SubscriptionInterval, + intervalSeconds: Number(r.intervalSeconds ?? r.interval_seconds ?? 0), + nextExecutionAt: String(r.nextExecutionAt ?? r.next_execution_at ?? ''), + totalInvestedXlm: Number(r.totalInvestedXlm ?? r.total_invested_xlm ?? 0), + status: (r.status ?? 'active') as KeySubscription['status'], + createdAt: String(r.createdAt ?? r.created_at ?? ''), + updatedAt: String(r.updatedAt ?? r.updated_at ?? ''), + }; +} + +export class KeySubscriptionContractService { + private readonly factory: typeof defaultFactory; + + constructor(factory: typeof defaultFactory = defaultFactory) { + this.factory = factory; + } + + private getClient() { + if (!env.VITE_STELLAR_RPC_URL || !env.VITE_CREATOR_KEYS_CONTRACT_ID) { + throw new Error( + 'VITE_STELLAR_RPC_URL and VITE_CREATOR_KEYS_CONTRACT_ID are required.' + ); + } + return this.factory( + env.VITE_CREATOR_KEYS_CONTRACT_ID, + env.VITE_STELLAR_RPC_URL, + getNetworkPassphrase() + ) as { + create_subscription?: (args: { + creator_id: Address; + amount: bigint; + interval_seconds: bigint; + }) => Promise<{ result?: unknown; hash?: string }>; + cancel_subscription?: (args: { + subscription_id: Address; + }) => Promise<{ result?: unknown; hash?: string }>; + get_subscription?: (args: { + subscription_id: Address; + }) => Promise<{ result?: unknown }>; + get_subscriptions_for_investor?: (args: { + investor: Address; + }) => Promise<{ result?: unknown }>; + get_subscriptions_for_creator?: (args: { + creator_id: Address; + }) => Promise<{ result?: unknown }>; + }; + } + + async createSubscription(input: CreateSubscriptionInput): Promise<{ + subscription: KeySubscription | null; + hash?: string; + }> { + const client = this.getClient(); + if (typeof client.create_subscription !== 'function') { + throw new Error('create_subscription not available on contract'); + } + + let creatorAddress: Address; + try { + creatorAddress = Address.fromString(input.creatorId); + } catch { + throw new Error('Invalid creator ID address.'); + } + + const intervalSeconds = this.getIntervalSeconds(input.interval); + + const res = await client.create_subscription({ + creator_id: creatorAddress, + amount: BigInt(Math.round(input.amountXlm * 10_000_000)), + interval_seconds: BigInt(intervalSeconds), + }); + + const subscription = normalizeSubscription(res?.result); + return { subscription, hash: res?.hash }; + } + + async cancelSubscription(input: CancelSubscriptionInput): Promise<{ + success: boolean; + hash?: string; + }> { + const client = this.getClient(); + if (typeof client.cancel_subscription !== 'function') { + throw new Error('cancel_subscription not available on contract'); + } + + let subscriptionAddress: Address; + try { + subscriptionAddress = Address.fromString(input.subscriptionId); + } catch { + throw new Error('Invalid subscription ID address.'); + } + + const res = await client.cancel_subscription({ + subscription_id: subscriptionAddress, + }); + + return { success: true, hash: res?.hash }; + } + + async getSubscription(subscriptionId: string): Promise { + const client = this.getClient(); + if (typeof client.get_subscription !== 'function') { + throw new Error('get_subscription not available on contract'); + } + + let subscriptionAddress: Address; + try { + subscriptionAddress = Address.fromString(subscriptionId); + } catch { + throw new Error('Invalid subscription ID address.'); + } + + const res = await client.get_subscription({ + subscription_id: subscriptionAddress, + }); + + return normalizeSubscription(res?.result); + } + + async getSubscriptionsForInvestor( + investorAddress: string + ): Promise { + const client = this.getClient(); + if (typeof client.get_subscriptions_for_investor !== 'function') { + throw new Error( + 'get_subscriptions_for_investor not available on contract' + ); + } + + let investor: Address; + try { + investor = Address.fromString(investorAddress); + } catch { + throw new Error('Invalid investor address.'); + } + + const res = await client.get_subscriptions_for_investor({ + investor, + }); + + const raw = res?.result; + if (!Array.isArray(raw)) return []; + + return raw.map(normalizeSubscription).filter(Boolean) as KeySubscription[]; + } + + async getSubscriptionsForCreator( + creatorId: string + ): Promise { + const client = this.getClient(); + if (typeof client.get_subscriptions_for_creator !== 'function') { + throw new Error( + 'get_subscriptions_for_creator not available on contract' + ); + } + + let creatorAddress: Address; + try { + creatorAddress = Address.fromString(creatorId); + } catch { + throw new Error('Invalid creator ID address.'); + } + + const res = await client.get_subscriptions_for_creator({ + creator_id: creatorAddress, + }); + + const raw = res?.result; + if (!Array.isArray(raw)) return []; + + return raw.map(normalizeSubscription).filter(Boolean) as KeySubscription[]; + } + + private getIntervalSeconds(interval: SubscriptionInterval): number { + const secondsMap: Record = { + daily: 86400, + weekly: 604800, + monthly: 2592000, + }; + return secondsMap[interval]; + } +} + +export const keySubscriptionContractService = new KeySubscriptionContractService(); \ No newline at end of file diff --git a/src/types/keySubscription.ts b/src/types/keySubscription.ts new file mode 100644 index 00000000..72225c68 --- /dev/null +++ b/src/types/keySubscription.ts @@ -0,0 +1,46 @@ +/** + * Key subscription investment types for recurring automatic investments + */ + +export type SubscriptionInterval = 'daily' | 'weekly' | 'monthly'; + +export interface KeySubscription { + id: string; + creatorId: string; + investorAddress: string; + amountXlm: number; + interval: SubscriptionInterval; + intervalSeconds: number; + nextExecutionAt: string; + totalInvestedXlm: number; + status: 'active' | 'cancelled' | 'paused'; + createdAt: string; + updatedAt: string; +} + +export interface CreateSubscriptionInput { + creatorId: string; + amountXlm: number; + interval: SubscriptionInterval; +} + +export interface CancelSubscriptionInput { + subscriptionId: string; +} + +export interface SubscriptionExecutionPreview { + nextExecutionDate: Date; + estimatedCostXlm: number; +} + +export const SUBSCRIPTION_INTERVAL_SECONDS: Record = { + daily: 86400, + weekly: 604800, + monthly: 2592000, +}; + +export const SUBSCRIPTION_INTERVAL_LABELS: Record = { + daily: 'Daily', + weekly: 'Weekly', + monthly: 'Monthly', +}; \ No newline at end of file