@turtleclub/opportunities 0.1.0-beta.20 → 0.1.0-beta.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,102 +1,169 @@
1
1
  "use client";
2
2
 
3
- import { useState } from "react";
4
- import { Shield, ChevronDown, ChevronUp, CheckCircle, type LucideProps } from "lucide-react";
3
+ import { useState, useMemo } from "react";
4
+ import { Shield, ChevronDown, ChevronUp, Info, type LucideProps } from "lucide-react";
5
5
  import type { ComponentType } from "react";
6
6
  import {
7
7
  Button,
8
8
  Slider,
9
9
  Skeleton,
10
10
  Checkbox,
11
+ Collapsible,
12
+ CollapsibleTrigger,
13
+ CollapsibleContent,
14
+ SwapInput,
11
15
  Tooltip,
12
16
  TooltipTrigger,
13
17
  TooltipContent,
18
+ cn,
14
19
  } from "@turtleclub/ui";
15
- import { formatEther, parseEther } from "viem";
16
- import { useBalance } from "wagmi";
20
+ import { useSwitchChain } from "wagmi";
17
21
  import { useCoverQuote } from "../hooks/useCoverQuote";
18
22
  import { useNexusPurchase } from "../hooks/useNexusPurchase";
19
23
  import { useExistingCovers } from "../hooks/useExistingCovers";
24
+ import { useCheckNexusMembership } from "../hooks/useCheckNexusMembership";
25
+ import { formatAPY } from "../utils";
26
+ import { useDebouncedValue } from "../hooks/useDebouncedValue";
20
27
  import {
21
- getOpportunityAPY,
22
- calculateAdjustedAPY,
23
- formatAPY,
24
- formatEthAmount,
25
- parseCoverTiming,
26
- } from "../utils";
27
- import { NEXUS_KYC_REQUIREMENTS_URL, NEXUS_PROTOCOL_COVER_TERMS_URL } from "../constants";
28
+ NEXUS_KYC_REQUIREMENTS_URL,
29
+ NEXUS_PROTOCOL_COVER_TERMS_URL,
30
+ NEXUS_COVER_ROUTER,
31
+ } from "../constants";
28
32
  import type { CoverOfferCardProps } from "../types";
33
+ import { PurchaseButtonSection } from "./PurchaseButtonSection";
34
+ import { ExistingCoverInfo } from "./ExistingCoverInfo";
35
+ import { CoveredEventsInfo } from "./CoveredEventsInfo";
36
+
37
+ import { useMultichainAccount } from "@turtleclub/multichain";
38
+ import { formatToken } from "@turtleclub/utils";
39
+ import { useTokenApproval } from "../hooks/useTokenApproval";
40
+ import { useCoverTokenSelection } from "../hooks/useCoverTokenSelection";
29
41
 
30
42
  const ShieldIcon = Shield as ComponentType<LucideProps>;
31
43
  const ChevronDownIcon = ChevronDown as ComponentType<LucideProps>;
32
44
  const ChevronUpIcon = ChevronUp as ComponentType<LucideProps>;
33
- const CheckCircleIcon = CheckCircle as ComponentType<LucideProps>;
45
+ const InfoIcon = Info as ComponentType<LucideProps>;
46
+
47
+ const DEBOUNCE_MS = 500;
34
48
 
35
49
  export function CoverOfferCard({
36
50
  productId,
37
51
  opportunity,
38
- amountToCover,
39
52
  buyerAddress,
40
53
  protocolName,
41
54
  coverProductName,
42
55
  onSuccess,
43
56
  onError,
44
57
  startExpanded,
58
+ coverAvailableAssets,
45
59
  }: CoverOfferCardProps) {
46
60
  const [isExpanded, setIsExpanded] = useState(startExpanded ?? false);
47
61
  const [hasAgreedToTerms, setHasAgreedToTerms] = useState(false);
62
+ const [coverPeriod, setCoverPeriod] = useState(30);
63
+ const [amountToCover, setAmountToCover] = useState("");
64
+
65
+ const {
66
+ filteredTokens,
67
+ selectedTokenAddress,
68
+ setSelectedTokenAddress,
69
+ selectedTokenSymbol,
70
+ selectedTokenDecimals,
71
+ selectedTokenBalance,
72
+ handleMaxClick,
73
+ } = useCoverTokenSelection({
74
+ buyerAddress,
75
+ coverAvailableAssets,
76
+ amount: amountToCover,
77
+ setAmount: setAmountToCover,
78
+ });
79
+
80
+ const debouncedAmount = useDebouncedValue(amountToCover, DEBOUNCE_MS);
81
+ const debouncedPeriod = useDebouncedValue(coverPeriod, DEBOUNCE_MS);
48
82
 
49
- const { coverPeriod, setCoverPeriod, isLoading, error, quoteResult, premiumEth, yearlyCostPerc } =
83
+ const { isLoading, error, quoteResult, premium, originalAPY, adjustedAPY, coverCostPerc } =
50
84
  useCoverQuote({
51
85
  productId,
52
86
  buyerAddress,
53
- amountToCover,
87
+ amountToCover: debouncedAmount,
88
+ coverPeriod: debouncedPeriod,
89
+ tokenSymbol: selectedTokenSymbol,
90
+ decimals: selectedTokenDecimals,
91
+ opportunity,
54
92
  });
55
93
 
94
+ // Check if user has insufficient balance for the premium (not the cover amount)
95
+ const hasInsufficientBalance = useMemo(() => {
96
+ if (!quoteResult?.displayInfo?.premiumInAsset || !selectedTokenBalance?.amount) {
97
+ return false;
98
+ }
99
+ const premiumInAsset = BigInt(quoteResult.displayInfo.premiumInAsset);
100
+ const userBalance = BigInt(selectedTokenBalance.amount);
101
+ return userBalance < premiumInAsset;
102
+ }, [quoteResult?.displayInfo?.premiumInAsset, selectedTokenBalance?.amount]);
103
+
104
+ const { chainId } = useMultichainAccount();
105
+ const { switchChainAsync } = useSwitchChain();
106
+ const { isMember, isLoading: isMembershipLoading } = useCheckNexusMembership(
107
+ buyerAddress,
108
+ chainId
109
+ );
56
110
  const { purchase, isPurchasing } = useNexusPurchase({
57
111
  onSuccess,
58
112
  onError,
59
113
  });
60
114
  const { hasCoverForProduct, isLoading: isCoversLoading } = useExistingCovers(buyerAddress);
61
- const { data: balanceData, isLoading: isBalanceLoading } = useBalance({
62
- address: buyerAddress as `0x${string}` | undefined,
63
- query: { enabled: !!buyerAddress },
64
- });
65
- const userBalanceEth = balanceData?.value;
66
- const premiumWei = premiumEth ? parseEther(premiumEth) : 0n;
67
- const insufficientBalance =
68
- isBalanceLoading ||
69
- (userBalanceEth !== undefined && premiumWei > 0n && userBalanceEth < premiumWei);
115
+
116
+ const approvalAmount = quoteResult
117
+ ? BigInt(quoteResult.buyCoverInput.buyCoverParams.maxPremiumInAsset)
118
+ : null;
119
+
120
+ const { needsApproval, isCheckingApproval, approve, isApproving, refetchApprovalStatus } =
121
+ useTokenApproval({
122
+ userAddress: buyerAddress as `0x${string}`,
123
+ tokenAddress: selectedTokenAddress as `0x${string}`,
124
+ spenderAddress: NEXUS_COVER_ROUTER,
125
+ chainId: 1,
126
+ amount: approvalAmount,
127
+ });
70
128
 
71
129
  const existingCover = hasCoverForProduct(productId);
72
130
  const hasExistingCover = !!existingCover;
131
+ const isWrongNetwork = chainId !== 1;
73
132
 
74
- const originalAPY = getOpportunityAPY(opportunity);
75
- const adjustedAPY =
76
- yearlyCostPerc !== null ? calculateAdjustedAPY(originalAPY, yearlyCostPerc) : null;
77
- const coverCostPerc = yearlyCostPerc !== null ? yearlyCostPerc * 100 : null;
133
+ const handleApprove = async () => {
134
+ try {
135
+ await approve();
136
+ await refetchApprovalStatus();
137
+ } catch (error) {
138
+ onError?.("Error approving spending allowance for Nexus cover");
139
+ }
140
+ };
78
141
 
79
142
  const handlePurchase = async () => {
80
143
  if (isPurchasing || !quoteResult) return;
81
144
  await purchase(quoteResult);
82
145
  };
83
146
 
84
- const disabledReason = (() => {
147
+ const disabledReason = useMemo(() => {
85
148
  if (!hasAgreedToTerms) return "Checkbox not marked";
86
149
  if (!quoteResult) return "Quote not ready yet";
87
150
  if (isLoading) return "Fetching quote...";
88
151
  if (error) return error;
89
- if (insufficientBalance) return "Insufficient balance for premium";
152
+ if (hasInsufficientBalance) return "Insufficient balance for premium";
90
153
  if (isPurchasing) return "Purchasing cover...";
91
154
  return null;
92
- })();
93
- const isPurchaseDisabled =
94
- !quoteResult ||
95
- isLoading ||
96
- !!error ||
97
- !hasAgreedToTerms ||
98
- isPurchasing ||
99
- insufficientBalance;
155
+ }, [hasAgreedToTerms, quoteResult, isLoading, error, hasInsufficientBalance, isPurchasing]);
156
+
157
+ const isPurchaseDisabled = useMemo(
158
+ () =>
159
+ !quoteResult ||
160
+ isLoading ||
161
+ !!error ||
162
+ !hasAgreedToTerms ||
163
+ isPurchasing ||
164
+ hasInsufficientBalance,
165
+ [quoteResult, isLoading, error, hasAgreedToTerms, isPurchasing, hasInsufficientBalance]
166
+ );
100
167
 
101
168
  const purchaseButton = (
102
169
  <Button
@@ -110,11 +177,12 @@ export function CoverOfferCard({
110
177
  );
111
178
 
112
179
  return (
113
- <div className="rounded-xl bg-gradient-to-br from-primary/10 to-card border border-primary/20 overflow-hidden">
114
- <button
115
- onClick={() => setIsExpanded(!isExpanded)}
116
- className="w-full p-4 flex items-center justify-between hover:bg-primary/5 transition-colors"
117
- >
180
+ <Collapsible
181
+ open={isExpanded}
182
+ onOpenChange={setIsExpanded}
183
+ className="rounded-xl border overflow-hidden turtle-widget-root"
184
+ >
185
+ <CollapsibleTrigger className="w-full p-4 flex items-center cursor-pointer justify-between hover:bg-primary/5 transition-colors">
118
186
  <div className="flex items-center gap-3">
119
187
  <div className="p-2 rounded-lg bg-primary/10 border border-primary/20">
120
188
  <ShieldIcon className="w-5 h-5 text-primary" />
@@ -122,7 +190,7 @@ export function CoverOfferCard({
122
190
  <div className="text-left">
123
191
  <div className="flex items-center gap-2">
124
192
  <span className="text-sm font-semibold text-foreground">Protect Your Deposit</span>
125
- <span className="px-2 py-0.5 text-[10px] font-medium text-primary bg-primary/10 rounded-full border border-primary/20">
193
+ <span className="px-2 py-0.5 text-xs font-medium text-primary bg-primary/10 rounded-full border border-primary/20">
126
194
  NEW
127
195
  </span>
128
196
  </div>
@@ -136,221 +204,176 @@ export function CoverOfferCard({
136
204
  <ChevronDownIcon className="w-5 h-5 text-muted-foreground" />
137
205
  )}
138
206
  </div>
139
- </button>
207
+ </CollapsibleTrigger>
208
+
209
+ <CollapsibleContent className="px-4 pb-4 space-y-4">
210
+ {/* Protocol & Cover Info */}
211
+ <div className="flex items-center justify-between text-sm">
212
+ <span className="text-muted-foreground">
213
+ Protocol: <span className="font-medium text-foreground">{protocolName}</span>
214
+ </span>
215
+ <span className="text-sm text-muted-foreground px-2 py-1 rounded bg-muted/50 border border-border">
216
+ {coverProductName}
217
+ </span>
218
+ </div>
140
219
 
141
- {/* Expandable Content */}
142
- {isExpanded && (
143
- <div className="px-4 pb-4 space-y-4">
144
- {/* Protocol & Cover Info */}
145
- <div className="flex items-center justify-between text-sm">
146
- <span className="text-muted-foreground">
147
- Protocol: <span className="font-medium text-foreground">{protocolName}</span>
148
- </span>
149
- <span className="text-[10px] text-muted-foreground px-2 py-1 rounded bg-muted/50 border border-border">
150
- {coverProductName}
151
- </span>
220
+ {/* Loading state for existing covers */}
221
+ {isCoversLoading && (
222
+ <div className="p-3 rounded-lg bg-background/50 border border-border">
223
+ <Skeleton className="h-4 w-48 mx-auto bg-neutral-alpha-10" />
152
224
  </div>
225
+ )}
153
226
 
154
- {/* Loading state for existing covers */}
155
- {isCoversLoading && (
156
- <div className="p-3 rounded-lg bg-background/50 border border-border">
157
- <Skeleton className="h-4 w-48 mx-auto bg-neutral-alpha-10" />
158
- </div>
159
- )}
227
+ {/* Existing Cover Info */}
228
+ {hasExistingCover && existingCover && <ExistingCoverInfo cover={existingCover} />}
160
229
 
161
- {/* Existing Cover Info */}
162
- {hasExistingCover && existingCover && (
163
- <>
164
- <div className="p-3 rounded-lg bg-green-500/10 border border-green-500/20">
165
- <div className="flex items-center gap-2 mb-2">
166
- <CheckCircleIcon className="w-4 h-4 text-green-500" />
167
- <span className="text-sm font-medium text-green-500">
168
- You already have cover for this protocol
169
- </span>
170
- </div>
171
- <p className="text-xs text-muted-foreground leading-relaxed">
172
- Your deposit is protected against smart contract bugs, oracle failures, and
173
- governance attacks on this protocol. See{" "}
174
- <a
175
- href={NEXUS_PROTOCOL_COVER_TERMS_URL}
176
- target="_blank"
177
- rel="noopener noreferrer"
178
- className="text-primary hover:text-primary/80 underline underline-offset-2"
179
- >
180
- terms
181
- </a>{" "}
182
- for details.
183
- </p>
184
- </div>
230
+ {/* Purchase Form - only show if no existing cover */}
231
+ {!hasExistingCover && !isCoversLoading && (
232
+ <>
233
+ <CoveredEventsInfo />
185
234
 
186
- <div className="grid grid-cols-2 gap-3">
187
- <div className="p-3 rounded-lg bg-background border border-border text-center">
188
- <p className="text-[10px] uppercase tracking-wide text-muted-foreground mb-1">
189
- Cover Amount
190
- </p>
191
- <p className="text-lg font-bold text-foreground">
192
- {formatEthAmount(formatEther(existingCover.amount))} ETH
193
- </p>
194
- </div>
195
- <div className="p-3 rounded-lg bg-background border border-border text-center">
196
- <p className="text-[10px] uppercase tracking-wide text-muted-foreground mb-1">
197
- Period
198
- </p>
199
- <p className="text-lg font-bold text-foreground">
200
- {
201
- parseCoverTiming(
202
- existingCover.start,
203
- existingCover.period,
204
- existingCover.gracePeriod
205
- ).periodDays
206
- }{" "}
207
- days
208
- </p>
209
- </div>
210
- </div>
235
+ {/* Coverage Amount Input */}
236
+ <div className="space-y-2 ">
237
+ <span className="text-xs text-muted-foreground">Coverage Amount</span>
238
+ <SwapInput
239
+ value={amountToCover}
240
+ onChange={setAmountToCover}
241
+ tokens={filteredTokens}
242
+ placeholder="Enter amount to cover"
243
+ selectedToken={selectedTokenAddress}
244
+ onTokenChange={setSelectedTokenAddress}
245
+ onMaxClick={handleMaxClick}
246
+ className={cn(
247
+ "p-5",
248
+ hasInsufficientBalance ? "border-destructive/50 border" : "border-border border"
249
+ )}
250
+ showTokenSelectorBalance={false}
251
+ showInputBalance={false}
252
+ />
253
+ </div>
211
254
 
212
- <div className="p-3 rounded-lg bg-background border border-border">
213
- <div className="flex items-center justify-between">
214
- <span className="text-xs text-muted-foreground">Cover ID</span>
215
- <span className="text-sm font-medium text-foreground">
216
- #{existingCover.coverId.toString()}
217
- </span>
255
+ {/* APY Comparison */}
256
+ <div className="grid grid-cols-3 gap-3">
257
+ <div className="p-3 rounded-lg bg-background border border-border text-center">
258
+ <div className="flex items-center justify-center gap-1 mb-1">
259
+ <p className="text-xs uppercase tracking-wide text-muted-foreground">Base APR</p>
260
+ <Tooltip>
261
+ <TooltipTrigger asChild>
262
+ <InfoIcon className="w-3 h-3 text-muted-foreground cursor-help" />
263
+ </TooltipTrigger>
264
+ <TooltipContent side="top" className="max-w-[200px]">
265
+ <p className="text-xs">
266
+ The current yield offered by this opportunity before any cover costs.
267
+ </p>
268
+ </TooltipContent>
269
+ </Tooltip>
218
270
  </div>
271
+ <p className="text-lg font-bold text-foreground">{formatAPY(originalAPY)}</p>
219
272
  </div>
220
- </>
221
- )}
222
-
223
- {/* Purchase Form - only show if no existing cover */}
224
- {!hasExistingCover && !isCoversLoading && (
225
- <>
226
- {/* Value Proposition */}
227
- <div className="p-3 rounded-lg bg-background/50 border border-border space-y-3">
228
- <p className="text-xs text-muted-foreground leading-relaxed">
229
- <span className="text-primary font-medium">Safeguard your yield</span> against
230
- protocol-specific risks. Deposit with confidence.
231
- </p>
232
-
233
- {/* Covered Events */}
234
- <div className="space-y-2">
235
- <p className="text-[10px] uppercase tracking-wide text-muted-foreground font-medium">
236
- Covered Events
237
- </p>
238
- <ul className="text-[11px] text-muted-foreground space-y-1.5">
239
- <li className="flex items-start gap-2">
240
- <span className="text-primary mt-0.5">•</span>
241
- <span>Smart contract bugs or exploits in the protocol</span>
242
- </li>
243
- <li className="flex items-start gap-2">
244
- <span className="text-primary mt-0.5">•</span>
245
- <span>Oracle manipulation or failure affecting the protocol</span>
246
- </li>
247
- <li className="flex items-start gap-2">
248
- <span className="text-primary mt-0.5">•</span>
249
- <span>Governance attacks on the protocol</span>
250
- </li>
251
- </ul>
252
- <p className="text-[10px] text-muted-foreground/70 leading-relaxed">
253
- Cover applies only to events directly related to this protocol. See full{" "}
254
- <a
255
- href={NEXUS_PROTOCOL_COVER_TERMS_URL}
256
- target="_blank"
257
- rel="noopener noreferrer"
258
- className="text-primary hover:text-primary/80 underline underline-offset-2"
259
- >
260
- terms and conditions
261
- </a>{" "}
262
- for exclusions.
263
- </p>
264
- </div>
273
+ <div className="p-3 rounded-lg bg-background/80 border border-border text-center">
274
+ <p className="text-xs uppercase tracking-wide text-muted-foreground mb-1"></p>
275
+ {isLoading ? (
276
+ <>
277
+ <Skeleton className="h-6 w-16 mx-auto bg-neutral-alpha-10" />
278
+ <Skeleton className="h-4 w-12 mx-auto mt-1 bg-neutral-alpha-10" />
279
+ </>
280
+ ) : coverCostPerc !== null ? (
281
+ <>
282
+ <p className="text-lg font-bold text-orange-400">
283
+ -{coverCostPerc.toFixed(2)}%
284
+ </p>
285
+ <p className="text-xs text-muted-foreground">per year</p>
286
+ </>
287
+ ) : (
288
+ <p className="text-lg font-bold text-muted-foreground">—</p>
289
+ )}
265
290
  </div>
266
-
267
- {/* APY Comparison */}
268
- <div className="grid grid-cols-3 gap-3">
269
- <div className="p-3 rounded-lg bg-background border border-border text-center">
270
- <p className="text-[10px] uppercase tracking-wide text-muted-foreground mb-1">
271
- Base APR
272
- </p>
273
- <p className="text-lg font-bold text-foreground">{formatAPY(originalAPY)}</p>
274
- </div>
275
- <div className="p-3 rounded-lg bg-background/80 border border-border text-center">
276
- <p className="text-[10px] uppercase tracking-wide text-muted-foreground mb-1"></p>
277
- {isLoading ? (
278
- <>
279
- <Skeleton className="h-6 w-16 mx-auto bg-neutral-alpha-10" />
280
- <Skeleton className="h-4 w-12 mx-auto mt-1 bg-neutral-alpha-10" />
281
- </>
282
- ) : coverCostPerc !== null ? (
283
- <>
284
- <p className="text-lg font-bold text-orange-400">
285
- -{coverCostPerc.toFixed(2)}%
291
+ <div className="p-3 rounded-lg bg-primary/5 border border-primary/20 text-center">
292
+ <div className="flex items-center justify-center gap-1 mb-1">
293
+ <p className="text-xs uppercase tracking-wide text-primary/70">Net APY</p>
294
+ <Tooltip>
295
+ <TooltipTrigger asChild>
296
+ <InfoIcon className="w-3 h-3 text-primary/70 cursor-help" />
297
+ </TooltipTrigger>
298
+ <TooltipContent side="top" className="max-w-[200px]">
299
+ <p className="text-xs">
300
+ Your effective yield after subtracting the annualized cover cost from the
301
+ Base APR.
286
302
  </p>
287
- <p className="text-[10px] text-muted-foreground">per year</p>
288
- </>
289
- ) : (
290
- <p className="text-lg font-bold text-muted-foreground">—</p>
291
- )}
292
- </div>
293
- <div className="p-3 rounded-lg bg-primary/5 border border-primary/20 text-center">
294
- <p className="text-[10px] uppercase tracking-wide text-primary/70 mb-1">
295
- Net APY
296
- </p>
297
- {isLoading ? (
298
- <>
299
- <Skeleton className="h-6 w-14 mx-auto bg-neutral-alpha-10 " />
300
- <Skeleton className="h-3 w-14 mx-auto mt-1 bg-neutral-alpha-10" />
301
- </>
302
- ) : adjustedAPY !== null ? (
303
- <>
304
- <p className="text-lg font-bold text-primary">{formatAPY(adjustedAPY)}</p>
305
- <p className="text-[10px] text-primary/60">protected</p>
306
- </>
307
- ) : (
308
- <p className="text-lg font-bold text-muted-foreground">—</p>
309
- )}
303
+ </TooltipContent>
304
+ </Tooltip>
310
305
  </div>
306
+ {isLoading ? (
307
+ <>
308
+ <Skeleton className="h-6 w-14 mx-auto bg-neutral-alpha-10 " />
309
+ <Skeleton className="h-3 w-14 mx-auto mt-1 bg-neutral-alpha-10" />
310
+ </>
311
+ ) : adjustedAPY !== null ? (
312
+ <>
313
+ <p className="text-lg font-bold text-primary">{formatAPY(adjustedAPY)}</p>
314
+ <p className="text-xs text-primary/60">protected</p>
315
+ </>
316
+ ) : (
317
+ <p className="text-lg font-bold text-muted-foreground">—</p>
318
+ )}
311
319
  </div>
320
+ </div>
312
321
 
313
- {/* Coverage Period Slider */}
314
- <div className="space-y-3">
315
- <div className="flex items-center justify-between">
316
- <span className="text-xs text-muted-foreground">Coverage Period</span>
317
- <div className="flex items-center gap-1">
318
- <span className="text-sm font-medium text-foreground">{coverPeriod} days</span>
319
- {isLoading ? (
320
- <Skeleton className="h-4 w-20 bg-neutral-alpha-10" />
321
- ) : premiumEth ? (
322
- <span className="text-sm text-muted-foreground">
323
- ({formatEthAmount(premiumEth)} ETH)
324
- </span>
325
- ) : null}
326
- </div>
327
- </div>
328
- <div className="w-full">
329
- <Slider
330
- value={[coverPeriod]}
331
- onValueChange={(value) => setCoverPeriod(value[0])}
332
- min={28}
333
- max={365}
334
- step={1}
335
- />
336
- </div>
337
- <div className="flex justify-between text-[10px] text-muted-foreground">
338
- <span>28 days</span>
339
- <span>365 days</span>
322
+ {/* Coverage Period Slider */}
323
+ <div className="space-y-3">
324
+ <div className="flex items-center justify-between">
325
+ <span className="text-xs text-muted-foreground">Coverage Period</span>
326
+ <div className="flex items-center gap-1">
327
+ <span className="text-sm font-medium text-foreground">{coverPeriod} days</span>
340
328
  </div>
341
329
  </div>
330
+ <div className="w-full">
331
+ <Slider
332
+ value={[coverPeriod]}
333
+ onValueChange={(value) => setCoverPeriod(value[0])}
334
+ min={28}
335
+ max={365}
336
+ step={1}
337
+ />
338
+ </div>
339
+ <div className="flex justify-between text-xs text-muted-foreground">
340
+ <span>28 days</span>
341
+ <span>365 days</span>
342
+ </div>
343
+ </div>
342
344
 
343
- {/* Premium Details */}
345
+ {/* Premium Details */}
346
+ <div className="space-y-2">
347
+ <div className="flex items-center justify-between">
348
+ <span className="text-xs text-muted-foreground">Your Balance</span>
349
+ <span className="text-xs text-muted-foreground text-primary">
350
+ {selectedTokenBalance
351
+ ? formatToken(
352
+ selectedTokenBalance.amount,
353
+ selectedTokenBalance.token,
354
+ true,
355
+ false,
356
+ 5
357
+ )
358
+ : "0"}{" "}
359
+ {selectedTokenBalance?.token.symbol}
360
+ </span>
361
+ </div>
344
362
  <div className="flex items-center justify-between p-3 rounded-lg bg-background border border-border">
345
363
  <div className="flex items-center gap-2">
346
364
  <ShieldIcon className="w-4 h-4 text-primary" />
347
- <span className="text-sm text-foreground">Premium</span>
365
+ <span className="text-sm text-foreground">Premium Cost</span>
348
366
  </div>
349
367
  {isLoading ? (
350
368
  <Skeleton className="h-5 w-20 bg-neutral-alpha-10" />
351
- ) : premiumEth ? (
352
- <span className="text-sm font-semibold text-foreground">
353
- {formatEthAmount(premiumEth)} ETH
369
+ ) : premium ? (
370
+ <span
371
+ className={cn(
372
+ "text-sm font-semibold",
373
+ hasInsufficientBalance ? "text-destructive" : "text-foreground"
374
+ )}
375
+ >
376
+ {premium} {selectedTokenSymbol}
354
377
  </span>
355
378
  ) : error ? (
356
379
  <span className="text-sm text-destructive">No cover available</span>
@@ -358,62 +381,65 @@ export function CoverOfferCard({
358
381
  <span className="text-sm text-muted-foreground">—</span>
359
382
  )}
360
383
  </div>
384
+ </div>
385
+
386
+ {/* Terms Agreement */}
387
+ <div className="space-y-3 p-3 rounded-lg bg-muted/20 border border-border ">
388
+ <p className="text-xs text-muted-foreground leading-relaxed">
389
+ By buying Nexus Mutual Bundled Protocol Cover, you agree to the{" "}
390
+ <a
391
+ href={NEXUS_PROTOCOL_COVER_TERMS_URL}
392
+ target="_blank"
393
+ rel="noopener noreferrer"
394
+ className="text-primary hover:text-primary/80 underline underline-offset-2"
395
+ >
396
+ terms and conditions
397
+ </a>
398
+ </p>
361
399
 
362
- {/* Terms Agreement */}
363
- <div className="space-y-3 p-3 rounded-lg bg-muted/20 border border-border ">
364
- <p className="text-[10px] text-muted-foreground leading-relaxed">
365
- By buying Nexus Mutual Bundled Protocol Cover, you agree to the{" "}
400
+ <label className="flex items-start gap-2 group">
401
+ <div className="relative flex-shrink-0 mt-0.5 cursor-pointer">
402
+ <Checkbox
403
+ checked={hasAgreedToTerms}
404
+ onCheckedChange={(hasAgreedToTerms) =>
405
+ setHasAgreedToTerms(hasAgreedToTerms === true)
406
+ }
407
+ className="size-4 cursor-pointer"
408
+ />
409
+ </div>
410
+ <span className="text-xs text-muted-foreground leading-relaxed group-hover:text-foreground/70 transition-colors">
411
+ I confirm that I do not reside in the{" "}
366
412
  <a
367
- href={NEXUS_PROTOCOL_COVER_TERMS_URL}
413
+ href={NEXUS_KYC_REQUIREMENTS_URL}
368
414
  target="_blank"
369
415
  rel="noopener noreferrer"
370
416
  className="text-primary hover:text-primary/80 underline underline-offset-2"
417
+ onClick={(e) => e.stopPropagation()}
371
418
  >
372
- terms and conditions
419
+ countries listed here
373
420
  </a>
374
- </p>
375
-
376
- <label className="flex items-start gap-2 cursor-pointer group">
377
- <div className="relative flex-shrink-0 mt-0.5">
378
- <Checkbox
379
- checked={hasAgreedToTerms}
380
- onCheckedChange={(hasAgreedToTerms) =>
381
- setHasAgreedToTerms(hasAgreedToTerms === true)
382
- }
383
- />
384
- </div>
385
- <span className="text-[10px] text-muted-foreground leading-relaxed group-hover:text-foreground/70 transition-colors">
386
- I confirm that I do not reside in the{" "}
387
- <a
388
- href={NEXUS_KYC_REQUIREMENTS_URL}
389
- target="_blank"
390
- rel="noopener noreferrer"
391
- className="text-primary hover:text-primary/80 underline underline-offset-2"
392
- onClick={(e) => e.stopPropagation()}
393
- >
394
- countries listed here
395
- </a>
396
- , and acknowledge that in the event of a loss, I will be required to join as a
397
- member of Nexus Mutual to file my claim.
398
- </span>
399
- </label>
400
- </div>
421
+ , and agree to the terms and conditions above.
422
+ </span>
423
+ </label>
424
+ </div>
401
425
 
402
- {/* Purchase Button */}
403
- {disabledReason ? (
404
- <Tooltip>
405
- <TooltipTrigger asChild>
406
- <div>{purchaseButton}</div>
407
- </TooltipTrigger>
408
- <TooltipContent side="top">{disabledReason}</TooltipContent>
409
- </Tooltip>
410
- ) : (
411
- purchaseButton
412
- )}
413
- </>
414
- )}
415
- </div>
416
- )}
417
- </div>
426
+ {/* Purchase Button Section */}
427
+ <PurchaseButtonSection
428
+ isWrongNetwork={isWrongNetwork}
429
+ isMembershipLoading={isMembershipLoading}
430
+ isMember={isMember}
431
+ disabledReason={disabledReason}
432
+ purchaseButton={purchaseButton}
433
+ onSwitchNetwork={() => switchChainAsync({ chainId: 1 })}
434
+ needsApproval={needsApproval}
435
+ isCheckingApproval={isCheckingApproval}
436
+ onApprove={handleApprove}
437
+ isApproving={isApproving}
438
+ tokenSymbol={selectedTokenSymbol}
439
+ />
440
+ </>
441
+ )}
442
+ </CollapsibleContent>
443
+ </Collapsible>
418
444
  );
419
445
  }