@turtleclub/opportunities 0.1.0-beta.14 → 0.1.0-beta.16
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.
- package/CHANGELOG.md +10 -0
- package/package.json +6 -6
- package/src/cover-offer/components/CoverOfferCard.tsx +419 -291
- package/src/cover-offer/components/CoverRequestForm.tsx +188 -73
- package/src/cover-offer/components/MembershipRequestCard.tsx +77 -0
- package/src/cover-offer/components/NexusCoverSection.tsx +87 -65
- package/src/cover-offer/constants.ts +8 -6
- package/src/cover-offer/hooks/useCheckNexusMembership.ts +54 -0
- package/src/cover-offer/hooks/useCoverQuote.ts +4 -6
- package/src/cover-offer/hooks/useExistingCovers.ts +126 -0
- package/src/cover-offer/hooks/useNexusPurchase.ts +65 -94
- package/src/cover-offer/hooks/useUserCoverNfts.ts +83 -0
- package/src/cover-offer/index.ts +0 -2
- package/src/cover-offer/types/index.ts +25 -9
- package/src/cover-offer/utils/index.ts +46 -9
|
@@ -1,31 +1,44 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useMemo, useState } from "react";
|
|
4
|
-
import { Button } from "@turtleclub/ui";
|
|
5
|
-
import { ShieldAlert, Send, ChevronDown, ChevronUp, type LucideProps } from "lucide-react";
|
|
3
|
+
import { ChangeEvent, FormEvent, useMemo, useState } from "react";
|
|
4
|
+
import { Button, Checkbox, Input } from "@turtleclub/ui";
|
|
5
|
+
import { ShieldAlert, Send, ChevronDown, ChevronUp, type LucideProps, Shield } from "lucide-react";
|
|
6
6
|
import type { ComponentType } from "react";
|
|
7
|
-
|
|
7
|
+
import type { CoverRequestData, CoverRequestFormProps } from "../types";
|
|
8
8
|
const ShieldAlertIcon = ShieldAlert as ComponentType<LucideProps>;
|
|
9
9
|
const SendIcon = Send as ComponentType<LucideProps>;
|
|
10
10
|
const ChevronUpIcon = ChevronUp as ComponentType<LucideProps>;
|
|
11
11
|
const ChevronDownIcon = ChevronDown as ComponentType<LucideProps>;
|
|
12
|
+
const ShieldIcon = Shield as ComponentType<LucideProps>;
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
const PERIOD_OPTIONS = [
|
|
15
|
+
{ label: "28d", days: 28 },
|
|
16
|
+
{ label: "3m", days: 90 },
|
|
17
|
+
{ label: "6m", days: 180 },
|
|
18
|
+
{ label: "1y", days: 365 },
|
|
19
|
+
] as const;
|
|
19
20
|
|
|
20
21
|
export function CoverRequestForm({
|
|
21
22
|
protocolName,
|
|
23
|
+
depositedAmount,
|
|
22
24
|
baseApyLabel,
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
onSuccess,
|
|
26
|
+
onError,
|
|
27
|
+
startExpanded,
|
|
25
28
|
}: CoverRequestFormProps) {
|
|
29
|
+
const [coverageAmount, setCoverageAmount] = useState("");
|
|
30
|
+
const [useDepositedAmount, setUseDepositedAmount] = useState(false);
|
|
26
31
|
const [desiredApySacrifice, setDesiredApySacrifice] = useState("");
|
|
32
|
+
const [selectedPeriodDays, setSelectedPeriodDays] = useState(90);
|
|
27
33
|
const [inputError, setInputError] = useState<string | null>(null);
|
|
28
|
-
const [isExpanded, setIsExpanded] = useState(
|
|
34
|
+
const [isExpanded, setIsExpanded] = useState(startExpanded ?? false);
|
|
35
|
+
|
|
36
|
+
const parsedCoverageAmount = useMemo(() => {
|
|
37
|
+
const amountStr = useDepositedAmount ? (depositedAmount ?? "") : coverageAmount;
|
|
38
|
+
if (!amountStr.trim()) return null;
|
|
39
|
+
const value = Number(amountStr);
|
|
40
|
+
return Number.isFinite(value) && value > 0 ? value : null;
|
|
41
|
+
}, [coverageAmount, useDepositedAmount, depositedAmount]);
|
|
29
42
|
|
|
30
43
|
const parsedSacrifice = useMemo(() => {
|
|
31
44
|
if (!desiredApySacrifice.trim()) return null;
|
|
@@ -33,32 +46,74 @@ export function CoverRequestForm({
|
|
|
33
46
|
return Number.isFinite(value) ? value : null;
|
|
34
47
|
}, [desiredApySacrifice]);
|
|
35
48
|
|
|
36
|
-
const
|
|
37
|
-
if (
|
|
38
|
-
|
|
49
|
+
const parsedBaseApy = useMemo(() => {
|
|
50
|
+
if (!baseApyLabel) return null;
|
|
51
|
+
const value = Number(baseApyLabel.replace(/[^0-9.]/g, ""));
|
|
52
|
+
return Number.isFinite(value) ? value : null;
|
|
53
|
+
}, [baseApyLabel]);
|
|
54
|
+
|
|
55
|
+
const netApy = useMemo(() => {
|
|
56
|
+
if (parsedBaseApy === null || parsedSacrifice === null) return null;
|
|
57
|
+
return parsedBaseApy - parsedSacrifice;
|
|
58
|
+
}, [parsedBaseApy, parsedSacrifice]);
|
|
59
|
+
|
|
60
|
+
const estimatedPremium = useMemo(() => {
|
|
61
|
+
if (parsedCoverageAmount === null || parsedSacrifice === null) return null;
|
|
62
|
+
// Premium = Amount × (APY/100) × (Days/365)
|
|
63
|
+
return parsedCoverageAmount * (parsedSacrifice / 100) * (selectedPeriodDays / 365);
|
|
64
|
+
}, [parsedCoverageAmount, parsedSacrifice, selectedPeriodDays]);
|
|
65
|
+
|
|
66
|
+
const resetForm = () => {
|
|
67
|
+
setCoverageAmount("");
|
|
68
|
+
setUseDepositedAmount(false);
|
|
69
|
+
setDesiredApySacrifice("");
|
|
70
|
+
setSelectedPeriodDays(90);
|
|
71
|
+
setInputError(null);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const handleSubmit = (): CoverRequestData | undefined => {
|
|
75
|
+
if (parsedCoverageAmount === null) {
|
|
76
|
+
setInputError("Enter a valid coverage amount");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (parsedSacrifice === null) {
|
|
80
|
+
setInputError("Enter a valid APY sacrifice");
|
|
39
81
|
return;
|
|
40
82
|
}
|
|
41
|
-
if (parsedSacrifice
|
|
83
|
+
if (parsedSacrifice < 0) {
|
|
42
84
|
setInputError("APY sacrifice cannot be negative");
|
|
43
85
|
return;
|
|
44
86
|
}
|
|
45
87
|
setInputError(null);
|
|
46
88
|
|
|
47
|
-
|
|
89
|
+
const requestData: CoverRequestData = {
|
|
90
|
+
protocolName,
|
|
91
|
+
coverageAmount: parsedCoverageAmount,
|
|
92
|
+
periodDays: selectedPeriodDays,
|
|
93
|
+
desiredApySacrifice: parsedSacrifice,
|
|
94
|
+
estimatedPremium: estimatedPremium ?? 0,
|
|
95
|
+
};
|
|
48
96
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
97
|
+
onSuccess?.("Cover request submitted");
|
|
98
|
+
console.info("Cover request submitted", requestData);
|
|
99
|
+
|
|
100
|
+
return requestData;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const handleFormSubmit = (event: FormEvent<HTMLFormElement>) => {
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
const requestData = handleSubmit();
|
|
106
|
+
if (requestData) {
|
|
107
|
+
console.log("Submitted data successfully", requestData);
|
|
108
|
+
resetForm();
|
|
55
109
|
}
|
|
56
110
|
};
|
|
57
111
|
|
|
58
112
|
return (
|
|
59
|
-
<div className="rounded-xl bg-gradient-to-br from-primary/5 to-card border border-primary/20">
|
|
113
|
+
<div className="rounded-xl bg-gradient-to-br from-primary/5 to-card overflow-hidden border border-primary/20">
|
|
60
114
|
<button
|
|
61
115
|
onClick={() => setIsExpanded(!isExpanded)}
|
|
116
|
+
type="button"
|
|
62
117
|
className="w-full p-4 flex items-center justify-between hover:bg-primary/5 transition-colors"
|
|
63
118
|
>
|
|
64
119
|
<div className="flex items-center gap-3 text-left">
|
|
@@ -76,26 +131,6 @@ export function CoverRequestForm({
|
|
|
76
131
|
</div>
|
|
77
132
|
</div>
|
|
78
133
|
<div className="flex items-center gap-2">
|
|
79
|
-
{onDismiss && (
|
|
80
|
-
<Button
|
|
81
|
-
variant="ghost"
|
|
82
|
-
size="icon"
|
|
83
|
-
onClick={(e) => {
|
|
84
|
-
e.stopPropagation();
|
|
85
|
-
onDismiss();
|
|
86
|
-
}}
|
|
87
|
-
className="h-6 w-6 text-muted-foreground hover:text-foreground"
|
|
88
|
-
>
|
|
89
|
-
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
90
|
-
<path
|
|
91
|
-
strokeLinecap="round"
|
|
92
|
-
strokeLinejoin="round"
|
|
93
|
-
strokeWidth={2}
|
|
94
|
-
d="M6 18L18 6M6 6l12 12"
|
|
95
|
-
/>
|
|
96
|
-
</svg>
|
|
97
|
-
</Button>
|
|
98
|
-
)}
|
|
99
134
|
{isExpanded ? (
|
|
100
135
|
<ChevronUpIcon className="w-5 h-5 text-muted-foreground" />
|
|
101
136
|
) : (
|
|
@@ -105,47 +140,127 @@ export function CoverRequestForm({
|
|
|
105
140
|
</button>
|
|
106
141
|
|
|
107
142
|
{isExpanded && (
|
|
108
|
-
<
|
|
143
|
+
<form onSubmit={handleFormSubmit} className="p-4 space-y-4">
|
|
144
|
+
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
|
145
|
+
<span className="font-medium text-foreground">{protocolName}</span>
|
|
146
|
+
{baseApyLabel && <span>Current APR: {baseApyLabel}</span>}
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
{/* Coverage Amount */}
|
|
109
150
|
<div className="space-y-2">
|
|
110
|
-
<
|
|
111
|
-
|
|
112
|
-
|
|
151
|
+
<label className="text-xs text-foreground">Coverage Amount</label>
|
|
152
|
+
<Input
|
|
153
|
+
variant=""
|
|
154
|
+
value={useDepositedAmount ? (depositedAmount ?? "") : coverageAmount}
|
|
155
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
156
|
+
const value = e.target.value;
|
|
157
|
+
if (value === "" || /^\d*\.?\d*$/.test(value)) {
|
|
158
|
+
setCoverageAmount(value);
|
|
159
|
+
}
|
|
160
|
+
}}
|
|
161
|
+
placeholder="e.g. 1"
|
|
162
|
+
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"
|
|
163
|
+
disabled={useDepositedAmount}
|
|
164
|
+
/>
|
|
165
|
+
{depositedAmount && (
|
|
166
|
+
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
|
167
|
+
<Checkbox
|
|
168
|
+
checked={useDepositedAmount}
|
|
169
|
+
onCheckedChange={(checked) => setUseDepositedAmount(!useDepositedAmount)}
|
|
170
|
+
className="rounded border-border"
|
|
171
|
+
/>
|
|
172
|
+
Use my deposited amount ({depositedAmount})
|
|
173
|
+
</label>
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
{/* Period Selector */}
|
|
178
|
+
<div className="space-y-3">
|
|
179
|
+
<label className="text-xs text-foreground text-center block">Coverage Period</label>
|
|
180
|
+
<div className="flex gap-2">
|
|
181
|
+
{PERIOD_OPTIONS.map((option) => (
|
|
182
|
+
<Button
|
|
183
|
+
key={option.days}
|
|
184
|
+
type="button"
|
|
185
|
+
onClick={() => setSelectedPeriodDays(option.days)}
|
|
186
|
+
variant="none"
|
|
187
|
+
className={`flex-1 py-2.5 rounded-lg text-sm font-medium transition-colors ${
|
|
188
|
+
selectedPeriodDays === option.days
|
|
189
|
+
? "bg-primary text-primary-foreground border-primary hover:border-primary"
|
|
190
|
+
: "bg-secondary text-foreground border-border hover:border-primary/50"
|
|
191
|
+
}`}
|
|
192
|
+
>
|
|
193
|
+
{option.label}
|
|
194
|
+
</Button>
|
|
195
|
+
))}
|
|
113
196
|
</div>
|
|
114
|
-
|
|
115
|
-
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
{/* APY Sacrifice */}
|
|
200
|
+
<div className="space-y-2">
|
|
201
|
+
<label className="text-xs text-foreground">
|
|
202
|
+
APY you're willing to sacrifice for cover (%)
|
|
116
203
|
</label>
|
|
117
|
-
<
|
|
204
|
+
<Input
|
|
118
205
|
type="text"
|
|
206
|
+
variant="bordered"
|
|
119
207
|
value={desiredApySacrifice}
|
|
120
|
-
onChange={(e) => {
|
|
121
|
-
const
|
|
122
|
-
|
|
208
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
209
|
+
const value = e.target.value;
|
|
210
|
+
if (value === "" || /^\d*\.?\d*$/.test(value)) {
|
|
211
|
+
setDesiredApySacrifice(e.target.value);
|
|
212
|
+
}
|
|
123
213
|
}}
|
|
124
|
-
placeholder="e.g.
|
|
125
|
-
className="w-full
|
|
214
|
+
placeholder="e.g. 2.5"
|
|
215
|
+
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"
|
|
126
216
|
/>
|
|
127
|
-
|
|
128
|
-
<
|
|
129
|
-
|
|
217
|
+
|
|
218
|
+
<div className="flex items-center justify-between text-xs p-2 rounded-md bg-muted/50">
|
|
219
|
+
<span className="text-muted-foreground">
|
|
220
|
+
{parsedBaseApy ?? "—"}% − {parsedSacrifice ?? "—"}% =
|
|
221
|
+
</span>
|
|
222
|
+
<span
|
|
223
|
+
className={`font-medium ${netApy !== null && netApy >= 0 ? "text-green-500" : "text-muted-foreground"}`}
|
|
224
|
+
>
|
|
225
|
+
{netApy !== null ? `${netApy.toFixed(2)}% Net APY` : "— Net APY"}
|
|
226
|
+
</span>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
{/* Estimated Premium */}
|
|
231
|
+
<div className="p-3 rounded-sm bg-primary/5 border border-primary/20 space-y-1">
|
|
232
|
+
<p className="text-xs text-muted-foreground">Estimated Premium</p>
|
|
233
|
+
<p className="text-lg font-semibold text-foreground">
|
|
234
|
+
{estimatedPremium !== null
|
|
235
|
+
? estimatedPremium.toLocaleString(undefined, {
|
|
236
|
+
minimumFractionDigits: 2,
|
|
237
|
+
maximumFractionDigits: 2,
|
|
238
|
+
})
|
|
239
|
+
: "—"}
|
|
240
|
+
</p>
|
|
241
|
+
<p className="text-[10px] text-muted-foreground">
|
|
242
|
+
{parsedCoverageAmount?.toLocaleString() ?? "—"} × {parsedSacrifice ?? "—"}% ×{" "}
|
|
243
|
+
{selectedPeriodDays} days
|
|
130
244
|
</p>
|
|
131
245
|
</div>
|
|
132
246
|
|
|
133
|
-
<
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
247
|
+
{inputError && <p className="text-[11px] text-destructive">{inputError}</p>}
|
|
248
|
+
|
|
249
|
+
<p className="text-base text-muted-foreground">
|
|
250
|
+
We'll use this to gauge interest and bring cover to the protocols you care about.
|
|
251
|
+
</p>
|
|
252
|
+
|
|
253
|
+
<div className="flex justify-center gap-2 w-full ">
|
|
254
|
+
<Button
|
|
255
|
+
className=" flex w-full text-lg font-semibold rounded-sm"
|
|
256
|
+
variant="green"
|
|
257
|
+
type="submit"
|
|
258
|
+
>
|
|
259
|
+
<ShieldIcon className="w-5 h-5 text-primary" />
|
|
145
260
|
Request Cover
|
|
146
261
|
</Button>
|
|
147
262
|
</div>
|
|
148
|
-
</
|
|
263
|
+
</form>
|
|
149
264
|
)}
|
|
150
265
|
</div>
|
|
151
266
|
);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ComponentType } from "react";
|
|
4
|
+
import { Button } from "@turtleclub/ui";
|
|
5
|
+
|
|
6
|
+
interface MembershipRequestCardProps {
|
|
7
|
+
isExpanded: boolean;
|
|
8
|
+
onToggle: () => void;
|
|
9
|
+
membershipUrl: string;
|
|
10
|
+
ShieldIcon: ComponentType<any>;
|
|
11
|
+
ExternalLinkIcon: ComponentType<any>;
|
|
12
|
+
ChevronUpIcon: ComponentType<any>;
|
|
13
|
+
ChevronDownIcon: ComponentType<any>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function MembershipRequestCard({
|
|
17
|
+
isExpanded,
|
|
18
|
+
onToggle,
|
|
19
|
+
membershipUrl,
|
|
20
|
+
ShieldIcon,
|
|
21
|
+
ExternalLinkIcon,
|
|
22
|
+
ChevronUpIcon,
|
|
23
|
+
ChevronDownIcon,
|
|
24
|
+
}: MembershipRequestCardProps) {
|
|
25
|
+
return (
|
|
26
|
+
<div className="rounded-xl bg-gradient-to-br from-primary/10 to-card border border-primary/20 overflow-hidden">
|
|
27
|
+
<button
|
|
28
|
+
onClick={onToggle}
|
|
29
|
+
className="w-full p-4 flex items-center justify-between hover:bg-primary/5 transition-colors"
|
|
30
|
+
>
|
|
31
|
+
<div className="flex items-center gap-3">
|
|
32
|
+
<div className="p-2 rounded-lg bg-primary/10 border border-primary/20">
|
|
33
|
+
<ShieldIcon className="w-5 h-5 text-primary" />
|
|
34
|
+
</div>
|
|
35
|
+
<div className="text-left">
|
|
36
|
+
<div className="flex items-center gap-2">
|
|
37
|
+
<span className="text-sm font-semibold text-foreground">Protect Your Deposit</span>
|
|
38
|
+
<span className="px-2 py-0.5 text-[10px] font-medium text-primary bg-primary/10 rounded-full border border-primary/20">
|
|
39
|
+
NEW
|
|
40
|
+
</span>
|
|
41
|
+
</div>
|
|
42
|
+
<p className="text-xs text-muted-foreground">Nexus Mutual Single Protocol Cover</p>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
{isExpanded ? (
|
|
46
|
+
<ChevronUpIcon className="w-5 h-5 text-muted-foreground" />
|
|
47
|
+
) : (
|
|
48
|
+
<ChevronDownIcon className="w-5 h-5 text-muted-foreground" />
|
|
49
|
+
)}
|
|
50
|
+
</button>
|
|
51
|
+
|
|
52
|
+
{isExpanded && (
|
|
53
|
+
<div className="px-4 pb-4 space-y-4">
|
|
54
|
+
<div className="p-3 rounded-lg bg-background/50 border border-border">
|
|
55
|
+
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
56
|
+
<span className="text-primary font-medium">Membership required.</span> Join Nexus
|
|
57
|
+
Mutual to protect your deposit against smart contract exploits, oracle failures, and
|
|
58
|
+
protocol-specific risks.
|
|
59
|
+
</p>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<Button asChild className="w-full" variant="default">
|
|
63
|
+
<a
|
|
64
|
+
href={membershipUrl}
|
|
65
|
+
target="_blank"
|
|
66
|
+
rel="noopener noreferrer"
|
|
67
|
+
className="flex items-center justify-center gap-2"
|
|
68
|
+
>
|
|
69
|
+
Become a Member
|
|
70
|
+
<ExternalLinkIcon className="w-4 h-4" />
|
|
71
|
+
</a>
|
|
72
|
+
</Button>
|
|
73
|
+
</div>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -1,65 +1,87 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { Shield, ExternalLink, ChevronDown, ChevronUp, type LucideProps } from "lucide-react";
|
|
5
|
+
import type { ComponentType } from "react";
|
|
6
|
+
import { Button } from "@turtleclub/ui";
|
|
7
|
+
import type { NexusCoverSectionProps } from "../types";
|
|
8
|
+
import { getOpportunityAPY, formatAPY } from "../utils";
|
|
9
|
+
import { useNexusProduct } from "../hooks/useNexusProduct";
|
|
10
|
+
import { useCheckNexusMembership } from "../hooks/useCheckNexusMembership";
|
|
11
|
+
import { CoverOfferCard } from "./CoverOfferCard";
|
|
12
|
+
import { CoverRequestForm } from "./CoverRequestForm";
|
|
13
|
+
import { NEXUS_MEMBERSHIP_URL } from "../constants";
|
|
14
|
+
import { MembershipRequestCard } from "./MembershipRequestCard";
|
|
15
|
+
|
|
16
|
+
const ShieldIcon = Shield as ComponentType<LucideProps>;
|
|
17
|
+
const ExternalLinkIcon = ExternalLink as ComponentType<LucideProps>;
|
|
18
|
+
const ChevronDownIcon = ChevronDown as ComponentType<LucideProps>;
|
|
19
|
+
const ChevronUpIcon = ChevronUp as ComponentType<LucideProps>;
|
|
20
|
+
|
|
21
|
+
export function NexusCoverSection({
|
|
22
|
+
opportunity,
|
|
23
|
+
amountToCover,
|
|
24
|
+
buyerAddress,
|
|
25
|
+
onSuccess,
|
|
26
|
+
onError,
|
|
27
|
+
startExpanded = false,
|
|
28
|
+
className,
|
|
29
|
+
}: NexusCoverSectionProps) {
|
|
30
|
+
const [isExpanded, setIsExpanded] = useState(startExpanded);
|
|
31
|
+
const { isCoverable, productId, protocolName, coverProductName } = useNexusProduct(opportunity);
|
|
32
|
+
const baseApy = getOpportunityAPY(opportunity);
|
|
33
|
+
const { isMember, isLoading: isMembershipLoading } = useCheckNexusMembership(buyerAddress);
|
|
34
|
+
|
|
35
|
+
if (isCoverable && isMembershipLoading) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
//If the user is not a member but the opportunity is coverable, show the membership url.
|
|
40
|
+
// if the user is not a member and the opportunity is not coverable, show nothing
|
|
41
|
+
if (protocolName === "Unknown" || !buyerAddress || (!isCoverable && !isMember)) return null;
|
|
42
|
+
|
|
43
|
+
if (isCoverable && !isMember) {
|
|
44
|
+
return (
|
|
45
|
+
<MembershipRequestCard
|
|
46
|
+
isExpanded={isExpanded}
|
|
47
|
+
onToggle={() => setIsExpanded(!isExpanded)}
|
|
48
|
+
membershipUrl={NEXUS_MEMBERSHIP_URL}
|
|
49
|
+
ShieldIcon={ShieldIcon}
|
|
50
|
+
ExternalLinkIcon={ExternalLinkIcon}
|
|
51
|
+
ChevronUpIcon={ChevronUpIcon}
|
|
52
|
+
ChevronDownIcon={ChevronDownIcon}
|
|
53
|
+
/>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (isCoverable && productId !== null && isMember) {
|
|
58
|
+
return (
|
|
59
|
+
<div className={className}>
|
|
60
|
+
<CoverOfferCard
|
|
61
|
+
productId={productId}
|
|
62
|
+
opportunity={opportunity}
|
|
63
|
+
amountToCover={amountToCover}
|
|
64
|
+
buyerAddress={buyerAddress}
|
|
65
|
+
protocolName={protocolName}
|
|
66
|
+
coverProductName={coverProductName ?? "Protocol Cover"}
|
|
67
|
+
onSuccess={onSuccess}
|
|
68
|
+
onError={onError}
|
|
69
|
+
startExpanded={startExpanded}
|
|
70
|
+
/>
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div className={className}>
|
|
77
|
+
<CoverRequestForm
|
|
78
|
+
protocolName={protocolName}
|
|
79
|
+
baseApyLabel={formatAPY(baseApy)}
|
|
80
|
+
depositedAmount={amountToCover}
|
|
81
|
+
onSuccess={onSuccess}
|
|
82
|
+
onError={onError}
|
|
83
|
+
startExpanded={startExpanded}
|
|
84
|
+
/>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
export const NEXUS_PROTOCOL_COVER_TERMS_URL =
|
|
2
|
-
"https://nexusmutual.io/
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
export const NEXUS_PROTOCOL_COVER_TERMS_URL =
|
|
2
|
+
"https://api.nexusmutual.io/ipfs/QmQz38DSo6DyrHkRj8uvtGFyx842izVvnx8a3qqF99dctG";
|
|
3
|
+
export const NEXUS_KYC_REQUIREMENTS_URL =
|
|
4
|
+
"https://docs.nexusmutual.io/overview/membership/#kyc-requirements";
|
|
5
|
+
export const NEXUS_MEMBERSHIP_URL = "https://app.nexusmutual.io/become-member";
|
|
6
|
+
export const NEXUS_COVER_NFT_ADDRESS = "0xcafeaca76be547f14d0220482667b42d8e7bc3eb";
|
|
7
|
+
export const USER_NFTS_API_URL = "https://lumon.turtle.xyz/query/token/erc721_portfolio";
|
|
8
|
+
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { createPublicClient, http, getAddress } from 'viem';
|
|
3
|
+
import { mainnet } from 'viem/chains';
|
|
4
|
+
import nexusSdk from "@nexusmutual/sdk";
|
|
5
|
+
|
|
6
|
+
const publicClient = createPublicClient({
|
|
7
|
+
chain: mainnet,
|
|
8
|
+
transport: http(),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export function useCheckNexusMembership(userAddress: string | undefined) {
|
|
12
|
+
const [isMember, setIsMember] = useState<boolean>(false);
|
|
13
|
+
const [isLoading, setIsLoading] = useState<boolean>(true);
|
|
14
|
+
const [error, setError] = useState<Error | null>(null);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!userAddress) {
|
|
18
|
+
setIsMember(false);
|
|
19
|
+
setIsLoading(false);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const checkMembership = async () => {
|
|
24
|
+
setIsLoading(true);
|
|
25
|
+
setError(null);
|
|
26
|
+
try {
|
|
27
|
+
const masterAddress = nexusSdk.addresses?.NXMaster;
|
|
28
|
+
const masterAbi = nexusSdk.abis?.NXMaster;
|
|
29
|
+
|
|
30
|
+
if (!masterAddress || !masterAbi) {
|
|
31
|
+
throw new Error('Nexus SDK not configured correctly for NXMaster.');
|
|
32
|
+
}
|
|
33
|
+
const checksummedAddress = getAddress(userAddress);
|
|
34
|
+
const result = await publicClient.readContract({
|
|
35
|
+
address: getAddress(masterAddress),
|
|
36
|
+
abi: masterAbi,
|
|
37
|
+
functionName: 'isMember',
|
|
38
|
+
args: [checksummedAddress],
|
|
39
|
+
});
|
|
40
|
+
setIsMember(result as boolean);
|
|
41
|
+
} catch (err: any) {
|
|
42
|
+
console.error("Error checking membership:", err.message);
|
|
43
|
+
setError(err);
|
|
44
|
+
setIsMember(false);
|
|
45
|
+
} finally {
|
|
46
|
+
setIsLoading(false);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
checkMembership();
|
|
51
|
+
}, [userAddress]);
|
|
52
|
+
|
|
53
|
+
return { isMember, isLoading, error };
|
|
54
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback } from "react";
|
|
2
|
-
import
|
|
2
|
+
import { parseEther, formatEther } from "viem";
|
|
3
3
|
import type { NexusQuoteResult, CoverQuoteState } from "../types";
|
|
4
4
|
import { CoverAsset, NexusSDK } from "@nexusmutual/sdk";
|
|
5
5
|
|
|
@@ -39,9 +39,8 @@ async function fetchNexusQuote(
|
|
|
39
39
|
period: number
|
|
40
40
|
): Promise<NexusQuoteResult> {
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
const amountWei = ethers.parseEther(amount).toString();
|
|
42
|
+
const { nexusSdk } = await getNexusSdk();
|
|
43
|
+
const amountWei = parseEther(amount).toString();
|
|
45
44
|
|
|
46
45
|
const { result, error } = await nexusSdk.quote.getQuoteAndBuyCoverInputs({
|
|
47
46
|
productId,
|
|
@@ -74,7 +73,6 @@ export function useCoverQuote({
|
|
|
74
73
|
const [yearlyCostPerc, setYearlyCostPerc] = useState<number | null>(null);
|
|
75
74
|
|
|
76
75
|
const fetchQuote = useCallback(async () => {
|
|
77
|
-
console.log("fetchQuote", productId, buyerAddress, amountToCover, coverPeriod);
|
|
78
76
|
if (!productId || !buyerAddress || !amountToCover || parseFloat(amountToCover) <= 0) {
|
|
79
77
|
console.error("Validation failed", productId, buyerAddress, amountToCover, coverPeriod);
|
|
80
78
|
return;
|
|
@@ -87,7 +85,7 @@ export function useCoverQuote({
|
|
|
87
85
|
const result = await fetchNexusQuote(productId, buyerAddress, amountToCover, coverPeriod);
|
|
88
86
|
|
|
89
87
|
setQuoteResult(result);
|
|
90
|
-
setPremiumEth(
|
|
88
|
+
setPremiumEth(formatEther(BigInt(result.displayInfo.premiumInAsset)));
|
|
91
89
|
setYearlyCostPerc(result.displayInfo.yearlyCostPerc);
|
|
92
90
|
} catch (err: unknown) {
|
|
93
91
|
const errorMessage = err instanceof Error ? err.message : "Failed to fetch quote";
|