@turtleclub/opportunities 0.1.0-beta.6 → 0.1.0-beta.61

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +230 -0
  2. package/README.md +179 -0
  3. package/package.json +13 -6
  4. package/src/components/index.ts +1 -1
  5. package/src/cover-offer/README.md +46 -0
  6. package/src/cover-offer/components/CoverOfferCard.tsx +445 -0
  7. package/src/cover-offer/components/CoverRequestForm.tsx +342 -0
  8. package/src/cover-offer/components/CoveredEventsInfo.tsx +120 -0
  9. package/src/cover-offer/components/ExistingCoverInfo.tsx +74 -0
  10. package/src/cover-offer/components/NexusCoverSection.tsx +49 -0
  11. package/src/cover-offer/components/PurchaseButtonSection.tsx +106 -0
  12. package/src/cover-offer/constants.ts +32 -0
  13. package/src/cover-offer/hooks/useCheckNexusMembership.ts +32 -0
  14. package/src/cover-offer/hooks/useCoverQuote.ts +126 -0
  15. package/src/cover-offer/hooks/useCoverTokenSelection.ts +84 -0
  16. package/src/cover-offer/hooks/useDebouncedValue.ts +12 -0
  17. package/src/cover-offer/hooks/useExistingCovers.ts +101 -0
  18. package/src/cover-offer/hooks/useNexusProduct.ts +79 -0
  19. package/src/cover-offer/hooks/useNexusPurchase.ts +67 -0
  20. package/src/cover-offer/hooks/useTokenApproval.ts +118 -0
  21. package/src/cover-offer/hooks/useUserCoverNfts.ts +26 -0
  22. package/src/cover-offer/index.ts +4 -0
  23. package/src/cover-offer/types/index.ts +41 -0
  24. package/src/cover-offer/utils/index.ts +90 -0
  25. package/src/deposit/NativeDepositSection.tsx +220 -0
  26. package/src/deposit/TemporalWrapper.tsx +82 -0
  27. package/src/{components → deposit/components}/balances-data-table.tsx +6 -0
  28. package/src/deposit/components/index.ts +3 -0
  29. package/src/deposit/components/swap-input-v3.tsx +194 -0
  30. package/src/deposit/components/token-selector-v3.tsx +122 -0
  31. package/src/deposit/index.ts +4 -0
  32. package/src/index.ts +9 -0
  33. package/src/opportunity-actions/OpportunityActions.tsx +191 -0
  34. package/src/opportunity-actions/index.ts +1 -0
  35. package/src/opportunity-table/components/opportunities-table.tsx +6 -5
  36. package/src/route-details/index.ts +6 -0
  37. package/src/route-details/route-details-v2.tsx +137 -0
  38. package/src/route-details/route-details.tsx +5 -4
  39. package/src/route-details/types.ts +7 -0
  40. package/src/transaction-status/hooks/useTransactionQueue.ts +1 -1
  41. package/src/withdraw/NativeWithdrawSection.tsx +45 -0
  42. package/src/withdraw/index.ts +1 -0
@@ -0,0 +1,342 @@
1
+ "use client";
2
+
3
+ import { useMemo, useState } from "react";
4
+ import { useForm, useStore } from "@tanstack/react-form";
5
+ import {
6
+ Button,
7
+ Input,
8
+ Collapsible,
9
+ CollapsibleTrigger,
10
+ CollapsibleContent,
11
+ Label,
12
+ Badge,
13
+ cn,
14
+ } from "@turtleclub/ui";
15
+ import { ShieldAlert, ChevronDown, ChevronUp, type LucideProps, Shield } from "lucide-react";
16
+ import type { ComponentType } from "react";
17
+ import type { CoverRequestFormProps } from "../types";
18
+ import { useSubmitCoverRequest } from "@turtleclub/hooks";
19
+
20
+ const ShieldAlertIcon = ShieldAlert as ComponentType<LucideProps>;
21
+ const ChevronUpIcon = ChevronUp as ComponentType<LucideProps>;
22
+ const ChevronDownIcon = ChevronDown as ComponentType<LucideProps>;
23
+ const ShieldIcon = Shield as ComponentType<LucideProps>;
24
+
25
+ type PeriodDays = 28 | 90 | 180 | 365;
26
+
27
+ const PERIOD_OPTIONS = [
28
+ { label: "28d", days: 28 },
29
+ { label: "3m", days: 90 },
30
+ { label: "6m", days: 180 },
31
+ { label: "1y", days: 365 },
32
+ ] as const;
33
+
34
+ const NUMERIC_INPUT_REGEX = /^\d*\.?\d*$/;
35
+
36
+ function parsePositiveNumber(value: string): number | null {
37
+ if (!value) return null;
38
+ const num = Number(value);
39
+ return Number.isFinite(num) && num > 0 ? num : null;
40
+ }
41
+
42
+ function handleNumericInputChange(
43
+ e: React.ChangeEvent<HTMLInputElement>,
44
+ onChange: (value: string) => void
45
+ ) {
46
+ const value = e.target.value;
47
+ if (value === "" || NUMERIC_INPUT_REGEX.test(value)) {
48
+ onChange(value);
49
+ }
50
+ }
51
+
52
+ const USDC_TOKEN = {
53
+ symbol: "USDC",
54
+ logoUrl: "https://storage.googleapis.com/turtle-assets/tokens/usdc.png",
55
+ };
56
+
57
+ export function CoverRequestForm({
58
+ protocolName,
59
+ baseApyLabel,
60
+ onSuccess,
61
+ onError,
62
+ startExpanded,
63
+ }: CoverRequestFormProps) {
64
+ const [isExpanded, setIsExpanded] = useState(startExpanded ?? false);
65
+
66
+ const form = useForm({
67
+ defaultValues: {
68
+ coverageAmount: "",
69
+ periodDays: 90 as PeriodDays,
70
+ desiredApySacrifice: "",
71
+ tokenSymbol: "USDC",
72
+ },
73
+ onSubmit: async ({ value }) => {
74
+ const coverageAmount = Number(value.coverageAmount);
75
+ const desiredApySacrifice = Number(value.desiredApySacrifice);
76
+ const periodDays = value.periodDays;
77
+
78
+ const calculatedEstimatedPremium =
79
+ coverageAmount * (desiredApySacrifice / 100) * (periodDays / 365);
80
+
81
+ submitCoverRequest({
82
+ protocolName,
83
+ coverageAmount: String(coverageAmount),
84
+ periodDays,
85
+ desiredApySacrifice: String(desiredApySacrifice),
86
+ calculatedEstimatedPremium: String(calculatedEstimatedPremium),
87
+ tokenSymbol: value.tokenSymbol,
88
+ });
89
+ },
90
+ });
91
+
92
+ const { mutate: submitCoverRequest, isPending } = useSubmitCoverRequest({
93
+ onSuccess: (msg) => {
94
+ form.reset();
95
+ onSuccess?.(msg);
96
+ setIsExpanded(false);
97
+ },
98
+ onError,
99
+ });
100
+
101
+ const parsedBaseApy = useMemo(() => {
102
+ if (!baseApyLabel) return null;
103
+ const value = Number(baseApyLabel.replace(/[^0-9.]/g, ""));
104
+ return Number.isFinite(value) ? value : null;
105
+ }, [baseApyLabel]);
106
+
107
+ const coverageAmountValue = useStore(form.store, (state) => state.values.coverageAmount);
108
+ const sacrificeValue = useStore(form.store, (state) => state.values.desiredApySacrifice);
109
+ const periodDaysValue = useStore(form.store, (state) => state.values.periodDays);
110
+
111
+ const parsedCoverageAmount = useMemo(() => {
112
+ return parsePositiveNumber(coverageAmountValue);
113
+ }, [coverageAmountValue]);
114
+
115
+ const parsedSacrifice = useMemo(() => {
116
+ if (!sacrificeValue) return null;
117
+ const num = Number(sacrificeValue);
118
+ return Number.isFinite(num) ? num : null;
119
+ }, [sacrificeValue]);
120
+
121
+ const netApy = useMemo(() => {
122
+ if (parsedBaseApy === null || parsedSacrifice === null) return null;
123
+ return parsedBaseApy - parsedSacrifice;
124
+ }, [parsedBaseApy, parsedSacrifice]);
125
+
126
+ const calculatedEstimatedPremium = useMemo(() => {
127
+ if (parsedCoverageAmount === null || parsedSacrifice === null) return null;
128
+ return parsedCoverageAmount * (parsedSacrifice / 100) * (periodDaysValue / 365);
129
+ }, [parsedCoverageAmount, parsedSacrifice, periodDaysValue]);
130
+
131
+ return (
132
+ <Collapsible
133
+ open={isExpanded}
134
+ onOpenChange={setIsExpanded}
135
+ className="rounded-xl overflow-hidden border"
136
+ >
137
+ <CollapsibleTrigger className="w-full p-4 flex items-center cursor-pointer justify-between hover:bg-primary/5 transition-colors">
138
+ <div className="flex items-center gap-3 text-left">
139
+ <div className="p-2 rounded-lg bg-primary/10 border border-primary/20">
140
+ <ShieldAlertIcon className="w-5 h-5 text-primary" />
141
+ </div>
142
+ <div className="space-y-1">
143
+ <p className="text-sm font-semibold text-foreground">
144
+ Request coverage for this opportunity
145
+ </p>
146
+ <p className="text-sm text-muted-foreground leading-relaxed">
147
+ We don&apos;t yet have Nexus Mutual cover available for{" "}
148
+ <span className="font-medium">{protocolName}</span>.
149
+ </p>
150
+ </div>
151
+ </div>
152
+ <div className="flex items-center gap-2">
153
+ {isExpanded ? (
154
+ <ChevronUpIcon className="w-5 h-5 text-muted-foreground" />
155
+ ) : (
156
+ <ChevronDownIcon className="w-5 h-5 text-muted-foreground" />
157
+ )}
158
+ </div>
159
+ </CollapsibleTrigger>
160
+
161
+ <CollapsibleContent>
162
+ <form
163
+ onSubmit={(e) => {
164
+ e.preventDefault();
165
+ e.stopPropagation();
166
+ form.handleSubmit();
167
+ }}
168
+ className="p-4 space-y-4"
169
+ >
170
+ <div className="flex items-center justify-between text-sm text-muted-foreground">
171
+ <span className="font-medium text-foreground">{protocolName}</span>
172
+ {baseApyLabel && <span>Current APR: {baseApyLabel}</span>}
173
+ </div>
174
+
175
+ {/* Coverage Amount */}
176
+ <form.Field
177
+ name="coverageAmount"
178
+ validators={{
179
+ onSubmit: ({ value }) => {
180
+ if (!parsePositiveNumber(value)) {
181
+ return "Enter a valid coverage amount";
182
+ }
183
+ return undefined;
184
+ },
185
+ }}
186
+ >
187
+ {(field) => (
188
+ <div className="space-y-2">
189
+ <Label className="text-xs text-foreground">Coverage Amount</Label>
190
+ <div className="flex items-center gap-6">
191
+ {/* Amount input */}
192
+ <div className="flex-1">
193
+ <Input
194
+ value={field.state.value}
195
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
196
+ handleNumericInputChange(e, field.handleChange)
197
+ }
198
+ onBlur={field.handleBlur}
199
+ placeholder="e.g. 1000"
200
+ className="w-full bg-secondary border border-border rounded-sm px-4 py-3 pr-24 text-foreground placeholder-muted-foreground focus:outline-none focus:border-primary/50 transition-colors"
201
+ />
202
+ </div>
203
+ <Badge
204
+ className={cn(
205
+ "bg-muted hover:bg-muted/80 w-auto min-w-[80px] rounded-md font-medium",
206
+ "focus:ring-primary/20 focus:ring-2 focus:outline-none",
207
+ "min-w-[80px] gap-1.5"
208
+ )}
209
+ >
210
+ <img
211
+ src={USDC_TOKEN.logoUrl}
212
+ alt={USDC_TOKEN.symbol}
213
+ className="w-5 h-5 rounded-full"
214
+ />
215
+ <span className="text-sm font-medium text-foreground">{USDC_TOKEN.symbol}</span>
216
+ </Badge>
217
+ </div>
218
+ {field.state.meta.errors.length > 0 && (
219
+ <p className="text-[11px] text-destructive">{field.state.meta.errors[0]}</p>
220
+ )}
221
+ </div>
222
+ )}
223
+ </form.Field>
224
+
225
+ {/* Period Selector */}
226
+ <form.Field name="periodDays">
227
+ {(field) => (
228
+ <div className="space-y-3">
229
+ <Label className="text-sm text-foreground block">Coverage Period</Label>
230
+ <div className="flex gap-2">
231
+ {PERIOD_OPTIONS.map((option) => (
232
+ <Button
233
+ key={option.days}
234
+ type="button"
235
+ onClick={() => field.handleChange(option.days as PeriodDays)}
236
+ variant="none"
237
+ className={`flex-1 py-2.5 rounded-lg text-sm font-medium transition-colors ${
238
+ field.state.value === option.days
239
+ ? "bg-primary text-primary-foreground border-primary hover:border-primary"
240
+ : "bg-secondary text-foreground border-border hover:border-primary/50"
241
+ }`}
242
+ >
243
+ {option.label}
244
+ </Button>
245
+ ))}
246
+ </div>
247
+ </div>
248
+ )}
249
+ </form.Field>
250
+
251
+ {/* APY Sacrifice */}
252
+ <form.Field
253
+ name="desiredApySacrifice"
254
+ validators={{
255
+ onSubmit: ({ value }) => {
256
+ const num = parsePositiveNumber(value);
257
+ if (num === null) {
258
+ return "Enter a valid APY sacrifice percentage";
259
+ }
260
+ if (num > 100) {
261
+ return "Sacrifice cannot exceed 100%";
262
+ }
263
+ return undefined;
264
+ },
265
+ }}
266
+ >
267
+ {(field) => (
268
+ <div className="space-y-2">
269
+ <Label className="text-sm text-foreground">
270
+ APY you&apos;re willing to sacrifice for cover (%)
271
+ </Label>
272
+ <Input
273
+ type="text"
274
+ value={field.state.value}
275
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
276
+ handleNumericInputChange(e, field.handleChange)
277
+ }
278
+ onBlur={field.handleBlur}
279
+ placeholder="e.g. 2.5"
280
+ className="w-full bg-secondary border border-border rounded-sm px-4 py-3 pr-12 text-foreground placeholder-muted-foreground focus:outline-none focus:border-primary/50 transition-colors"
281
+ />
282
+ {field.state.meta.errors.length > 0 && (
283
+ <p className="text-[11px] text-destructive">{field.state.meta.errors[0]}</p>
284
+ )}
285
+
286
+ <div className="flex items-center justify-between text-xs p-2 rounded-md bg-muted/50">
287
+ <span className="text-muted-foreground">
288
+ {parsedBaseApy ?? "—"}% − {parsedSacrifice ?? "—"}% =
289
+ </span>
290
+ <span
291
+ className={`font-medium ${
292
+ netApy !== null
293
+ ? netApy >= 0
294
+ ? "text-green-500"
295
+ : "text-destructive"
296
+ : "text-muted-foreground"
297
+ }`}
298
+ >
299
+ {netApy !== null ? `${netApy.toFixed(2)}% Net APY` : "— Net APY"}
300
+ </span>
301
+ </div>
302
+ </div>
303
+ )}
304
+ </form.Field>
305
+
306
+ {/* Estimated Premium */}
307
+ <div className="p-3 rounded-sm bg-primary/5 border border-primary/20 space-y-1">
308
+ <p className="text-sm text-muted-foreground">Estimated Premium</p>
309
+ <p className="text-lg font-semibold text-foreground">
310
+ {calculatedEstimatedPremium !== null
311
+ ? calculatedEstimatedPremium.toLocaleString(undefined, {
312
+ minimumFractionDigits: 2,
313
+ maximumFractionDigits: 6,
314
+ })
315
+ : "—"}
316
+ </p>
317
+ <p className="text-[10px] text-muted-foreground">
318
+ {parsedCoverageAmount?.toLocaleString() ?? "—"} × {parsedSacrifice ?? "—"}% ×{" "}
319
+ {periodDaysValue} days
320
+ </p>
321
+ </div>
322
+
323
+ <p className="text-base text-muted-foreground">
324
+ We&apos;ll use this to gauge interest and bring cover to the protocols you care about.
325
+ </p>
326
+
327
+ <div className="flex justify-center gap-2 w-full">
328
+ <Button
329
+ className="flex w-full text-lg font-semibold rounded-sm"
330
+ variant="green"
331
+ type="submit"
332
+ disabled={isPending}
333
+ >
334
+ <ShieldIcon className="w-5 h-5 text-primary" />
335
+ {isPending ? "Submitting..." : "Request Cover"}
336
+ </Button>
337
+ </div>
338
+ </form>
339
+ </CollapsibleContent>
340
+ </Collapsible>
341
+ );
342
+ }
@@ -0,0 +1,120 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { ChevronDown, ChevronUp, type LucideProps } from "lucide-react";
5
+ import type { ComponentType } from "react";
6
+ import { NEXUS_PROTOCOL_COVER_TERMS_URL } from "../constants";
7
+
8
+ const ChevronDownIcon = ChevronDown as ComponentType<LucideProps>;
9
+ const ChevronUpIcon = ChevronUp as ComponentType<LucideProps>;
10
+
11
+ const COVERED_EVENTS = [
12
+ "Smart contract exploits/hacks",
13
+ "Oracle failure or manipulation",
14
+ "Liquidation failure",
15
+ "Governance takeovers",
16
+ ];
17
+
18
+ const EXCLUSIONS = [
19
+ "Loss of value of any asset (i.e., depeg event)",
20
+ "Losses due to phishing, private key breaches, malware",
21
+ "Losses due to frontend attacks where the protocol is unaffected",
22
+ ];
23
+
24
+ export function CoveredEventsInfo() {
25
+ const [isExpanded, setIsExpanded] = useState(false);
26
+
27
+ return (
28
+ <div className="p-3 rounded-lg bg-background/50 border border-border space-y-2">
29
+ {/* Summary - Always visible */}
30
+ <p className="text-xs text-muted-foreground leading-relaxed">
31
+ <span className="text-primary font-medium">Single Protocol Cover</span> protects against
32
+ loss of funds on all EVM-compatible chains.
33
+ </p>
34
+
35
+ {/* Covered Events - Compact */}
36
+ <div className="flex flex-wrap gap-1.5">
37
+ {COVERED_EVENTS.map((event) => (
38
+ <span
39
+ key={event}
40
+ className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary border border-primary/20"
41
+ >
42
+ {event}
43
+ </span>
44
+ ))}
45
+ </div>
46
+
47
+ {/* Expand/Collapse Button */}
48
+ <button
49
+ onClick={() => setIsExpanded(!isExpanded)}
50
+ className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
51
+ >
52
+ {isExpanded ? (
53
+ <>
54
+ <ChevronUpIcon className="w-3 h-3" />
55
+ <span>Show less</span>
56
+ </>
57
+ ) : (
58
+ <>
59
+ <ChevronDownIcon className="w-3 h-3" />
60
+ <span>View exclusions & claim details</span>
61
+ </>
62
+ )}
63
+ </button>
64
+
65
+ {/* Expanded Details */}
66
+ {isExpanded && (
67
+ <div className="space-y-3 pt-2 border-t border-border">
68
+ {/* Exclusions */}
69
+ <div className="space-y-1.5">
70
+ <p className="text-xs uppercase tracking-wide text-muted-foreground font-medium">
71
+ Not Covered
72
+ </p>
73
+ <ul className="text-xs text-muted-foreground space-y-1">
74
+ {EXCLUSIONS.map((exclusion) => (
75
+ <li key={exclusion} className="flex items-start gap-1.5">
76
+ <span className="text-destructive mt-0.5">✕</span>
77
+ <span>{exclusion}</span>
78
+ </li>
79
+ ))}
80
+ </ul>
81
+ </div>
82
+
83
+ {/* Deductible & Claims */}
84
+ <div className="space-y-1.5">
85
+ <p className="text-xs uppercase tracking-wide text-muted-foreground font-medium">
86
+ Claim Process
87
+ </p>
88
+ <ul className="text-xs text-muted-foreground space-y-1">
89
+ <li className="flex items-start gap-1.5">
90
+ <span className="text-primary mt-0.5">•</span>
91
+ <span>5% deductible of the Cover Amount</span>
92
+ </li>
93
+ <li className="flex items-start gap-1.5">
94
+ <span className="text-primary mt-0.5">•</span>
95
+ <span>14-day waiting period after loss event</span>
96
+ </li>
97
+ <li className="flex items-start gap-1.5">
98
+ <span className="text-primary mt-0.5">•</span>
99
+ <span>Claims accepted up to 35 days after cover ends</span>
100
+ </li>
101
+ </ul>
102
+ </div>
103
+
104
+ {/* Disclaimer */}
105
+ <p className="text-xs text-muted-foreground/70 leading-relaxed">
106
+ This is not a contract of insurance. Cover is provided on a discretionary basis.{" "}
107
+ <a
108
+ href={NEXUS_PROTOCOL_COVER_TERMS_URL}
109
+ target="_blank"
110
+ rel="noopener noreferrer"
111
+ className="text-primary hover:text-primary/80 underline underline-offset-2"
112
+ >
113
+ Read complete terms
114
+ </a>
115
+ </p>
116
+ </div>
117
+ )}
118
+ </div>
119
+ );
120
+ }
@@ -0,0 +1,74 @@
1
+ "use client";
2
+
3
+ import type { ComponentType } from "react";
4
+ import { CheckCircle, type LucideProps } from "lucide-react";
5
+
6
+ import { parseCoverTiming } from "../utils";
7
+ import { NEXUS_AVAILABLE_TOKENS, NEXUS_PROTOCOL_COVER_TERMS_URL } from "../constants";
8
+ import type { CoverData } from "../hooks/useExistingCovers";
9
+ import { CoverAsset } from "@nexusmutual/sdk";
10
+ import { formatToken } from "@turtleclub/utils";
11
+ import { formatUnits } from "viem";
12
+
13
+ const CheckCircleIcon = CheckCircle as ComponentType<LucideProps>;
14
+
15
+ export interface ExistingCoverInfoProps {
16
+ cover: CoverData;
17
+ }
18
+
19
+ export function ExistingCoverInfo({ cover }: ExistingCoverInfoProps) {
20
+ const asset = CoverAsset[cover.coverAsset];
21
+ const token = NEXUS_AVAILABLE_TOKENS.find((token) => token.symbol === asset);
22
+
23
+ return (
24
+ <>
25
+ <div className="p-3 rounded-lg bg-green-500/10 border border-green-500/20">
26
+ <div className="flex items-center gap-2 mb-2">
27
+ <CheckCircleIcon className="w-4 h-4 text-green-500" />
28
+ <span className="text-sm font-medium text-green-500">
29
+ You already have cover for this protocol
30
+ </span>
31
+ </div>
32
+ <p className="text-xs text-muted-foreground leading-relaxed">
33
+ Your deposit is protected against smart contract bugs, oracle failures, and governance
34
+ attacks on this protocol. See{" "}
35
+ <a
36
+ href={NEXUS_PROTOCOL_COVER_TERMS_URL}
37
+ target="_blank"
38
+ rel="noopener noreferrer"
39
+ className="text-primary hover:text-primary/80 underline underline-offset-2"
40
+ >
41
+ terms
42
+ </a>{" "}
43
+ for details.
44
+ </p>
45
+ </div>
46
+
47
+ <div className="grid grid-cols-2 gap-3">
48
+ <div className="p-3 rounded-lg bg-background border border-border text-center">
49
+ <p className="text-[10px] uppercase tracking-wide text-muted-foreground mb-1">
50
+ Cover Amount
51
+ </p>
52
+ {token?.decimals && (
53
+ <p className="text-lg font-bold text-foreground">
54
+ {Number(formatUnits(cover.amount, token?.decimals)).toLocaleString('en-US', { maximumFractionDigits: 6 })} {token?.symbol}
55
+ </p>
56
+ )}
57
+ </div>
58
+ <div className="p-3 rounded-lg bg-background border border-border text-center">
59
+ <p className="text-[10px] uppercase tracking-wide text-muted-foreground mb-1">Period</p>
60
+ <p className="text-lg font-bold text-foreground">
61
+ {parseCoverTiming(cover.start, cover.period, cover.gracePeriod).periodDays} days
62
+ </p>
63
+ </div>
64
+ </div>
65
+
66
+ <div className="p-3 rounded-lg bg-background border border-border">
67
+ <div className="flex items-center justify-between">
68
+ <span className="text-xs text-muted-foreground">Cover ID</span>
69
+ <span className="text-sm font-medium text-foreground">#{cover.coverId.toString()}</span>
70
+ </div>
71
+ </div>
72
+ </>
73
+ );
74
+ }
@@ -0,0 +1,49 @@
1
+ "use client";
2
+
3
+ import type { NexusCoverSectionProps } from "../types";
4
+ import { getOpportunityAPY, formatAPY } from "../utils";
5
+ import { useNexusProduct } from "../hooks/useNexusProduct";
6
+ import { CoverOfferCard } from "./CoverOfferCard";
7
+ import { CoverRequestForm } from "./CoverRequestForm";
8
+ import { TurtleHooksProvider } from "@turtleclub/hooks";
9
+
10
+ export function NexusCoverSection({
11
+ opportunity,
12
+ buyerAddress,
13
+ onSuccess,
14
+ onError,
15
+ startExpanded = false,
16
+ className,
17
+ }: NexusCoverSectionProps) {
18
+ const { isCoverable, productId, protocolName, coverProductName, coverAssets } =
19
+ useNexusProduct(opportunity);
20
+ const baseApy = getOpportunityAPY(opportunity);
21
+
22
+ if (protocolName === "Unknown" || !buyerAddress) return null;
23
+
24
+ return (
25
+ <div className={className}>
26
+ {isCoverable && productId !== null && coverAssets && coverAssets.length > 0 ? (
27
+ <CoverOfferCard
28
+ productId={productId}
29
+ opportunity={opportunity}
30
+ buyerAddress={buyerAddress}
31
+ protocolName={protocolName}
32
+ coverProductName={coverProductName ?? "Protocol Cover"}
33
+ onSuccess={onSuccess}
34
+ onError={onError}
35
+ startExpanded={startExpanded}
36
+ coverAvailableAssets={coverAssets || []}
37
+ />
38
+ ) : (
39
+ <CoverRequestForm
40
+ protocolName={protocolName}
41
+ baseApyLabel={formatAPY(baseApy)}
42
+ onSuccess={onSuccess}
43
+ onError={onError}
44
+ startExpanded={startExpanded}
45
+ />
46
+ )}
47
+ </div>
48
+ );
49
+ }
@@ -0,0 +1,106 @@
1
+ "use client";
2
+
3
+ import type { ComponentType } from "react";
4
+ import { ExternalLink, type LucideProps } from "lucide-react";
5
+ import { Button, Skeleton, Tooltip, TooltipTrigger, TooltipContent, Card } from "@turtleclub/ui";
6
+ import { NEXUS_MEMBERSHIP_URL } from "../constants";
7
+
8
+ const ExternalLinkIcon = ExternalLink as ComponentType<LucideProps>;
9
+
10
+ export interface PurchaseButtonSectionProps {
11
+ isWrongNetwork: boolean;
12
+ isMembershipLoading: boolean;
13
+ isMember: boolean;
14
+ disabledReason: string | null;
15
+ purchaseButton: React.ReactNode;
16
+ onSwitchNetwork: () => void;
17
+ needsApproval?: boolean;
18
+ isCheckingApproval?: boolean;
19
+ onApprove?: () => Promise<void>;
20
+ isApproving?: boolean;
21
+ tokenSymbol?: string;
22
+ }
23
+
24
+ export function PurchaseButtonSection({
25
+ isWrongNetwork,
26
+ isMembershipLoading,
27
+ isMember,
28
+ disabledReason,
29
+ purchaseButton,
30
+ onSwitchNetwork,
31
+ needsApproval = false,
32
+ isCheckingApproval = false,
33
+ onApprove,
34
+ isApproving = false,
35
+ tokenSymbol,
36
+ }: PurchaseButtonSectionProps) {
37
+ if (isWrongNetwork) {
38
+ return (
39
+ <Card variant="border" className="h-fit">
40
+ <Button
41
+ onClick={onSwitchNetwork}
42
+ className="text-primary hover:text-primary/80 w-full cursor-pointer font-medium underline transition-colors"
43
+ >
44
+ Switch to Ethereum network to Buy Cover
45
+ </Button>
46
+ </Card>
47
+ );
48
+ }
49
+
50
+ if (isMembershipLoading) {
51
+ return <Skeleton className="h-10 w-full bg-neutral-alpha-10" />;
52
+ }
53
+
54
+ if (!isMember) {
55
+ return (
56
+ <div className="space-y-3">
57
+ <div className="p-3 rounded-lg bg-background/50 border border-border">
58
+ <p className="text-xs text-muted-foreground leading-relaxed">
59
+ <span className="text-primary font-medium">Membership required.</span> Join Nexus Mutual
60
+ to protect your deposit against smart contract exploits, oracle failures, and
61
+ protocol-specific risks.
62
+ </p>
63
+ </div>
64
+ <Button asChild className="w-full" variant="green">
65
+ <a
66
+ href={NEXUS_MEMBERSHIP_URL}
67
+ target="_blank"
68
+ rel="noopener noreferrer"
69
+ className="flex items-center justify-center gap-2"
70
+ >
71
+ Become a Member
72
+ <ExternalLinkIcon className="w-4 h-4" />
73
+ </a>
74
+ </Button>
75
+ </div>
76
+ );
77
+ }
78
+
79
+ if (needsApproval && tokenSymbol && onApprove) {
80
+ return (
81
+ <div className="space-y-3">
82
+ <Button
83
+ onClick={onApprove}
84
+ disabled={isApproving || isCheckingApproval}
85
+ className="w-full"
86
+ variant="green"
87
+ >
88
+ {isApproving || isCheckingApproval ? "Approving..." : `Approve ${tokenSymbol} spending`}
89
+ </Button>
90
+ </div>
91
+ );
92
+ }
93
+
94
+ if (disabledReason) {
95
+ return (
96
+ <Tooltip>
97
+ <TooltipTrigger asChild>
98
+ <div>{purchaseButton}</div>
99
+ </TooltipTrigger>
100
+ <TooltipContent side="top">{disabledReason}</TooltipContent>
101
+ </Tooltip>
102
+ );
103
+ }
104
+
105
+ return <>{purchaseButton}</>;
106
+ }