@turtleclub/covers 0.1.0-beta.1

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 (27) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +51 -0
  3. package/package.json +38 -0
  4. package/src/index.ts +1 -0
  5. package/src/nexus-mutual-cover/components/CoverOfferCard.tsx +495 -0
  6. package/src/nexus-mutual-cover/components/CoverRequestForm.tsx +362 -0
  7. package/src/nexus-mutual-cover/components/CoveredEventsInfo.tsx +120 -0
  8. package/src/nexus-mutual-cover/components/ExistingCoverInfo.tsx +74 -0
  9. package/src/nexus-mutual-cover/components/ExistingCoverRequest.tsx +65 -0
  10. package/src/nexus-mutual-cover/components/NexusCoverSection.tsx +49 -0
  11. package/src/nexus-mutual-cover/components/PurchaseButtonSection.tsx +106 -0
  12. package/src/nexus-mutual-cover/components/index.ts +7 -0
  13. package/src/nexus-mutual-cover/constants.ts +39 -0
  14. package/src/nexus-mutual-cover/hooks/index.ts +9 -0
  15. package/src/nexus-mutual-cover/hooks/useCheckNexusMembership.ts +32 -0
  16. package/src/nexus-mutual-cover/hooks/useCoverQuote.ts +126 -0
  17. package/src/nexus-mutual-cover/hooks/useCoverTokenSelection.ts +84 -0
  18. package/src/nexus-mutual-cover/hooks/useDebouncedValue.ts +12 -0
  19. package/src/nexus-mutual-cover/hooks/useExistingCovers.ts +110 -0
  20. package/src/nexus-mutual-cover/hooks/useNexusProduct.ts +79 -0
  21. package/src/nexus-mutual-cover/hooks/useNexusPurchase.ts +67 -0
  22. package/src/nexus-mutual-cover/hooks/useTokenApproval.ts +118 -0
  23. package/src/nexus-mutual-cover/hooks/useUserCoverNfts.ts +31 -0
  24. package/src/nexus-mutual-cover/index.ts +4 -0
  25. package/src/nexus-mutual-cover/types/index.ts +41 -0
  26. package/src/nexus-mutual-cover/utils/index.ts +95 -0
  27. package/tsconfig.json +22 -0
@@ -0,0 +1,362 @@
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
+ Skeleton,
14
+ cn,
15
+ } from "@turtleclub/ui";
16
+ import { ShieldAlert, ChevronDown, ChevronUp, type LucideProps, Shield } from "lucide-react";
17
+ import type { ComponentType } from "react";
18
+ import type { CoverRequestFormProps } from "../types";
19
+ import { useSubmitCoverRequest, useCheckCoverRequest } from "@turtleclub/hooks";
20
+ import { useQueryClient } from "@tanstack/react-query";
21
+ import { ExistingCoverRequest } from "./ExistingCoverRequest";
22
+ import { PERIOD_OPTIONS } from "../constants";
23
+
24
+ const ShieldAlertIcon = ShieldAlert as ComponentType<LucideProps>;
25
+ const ChevronUpIcon = ChevronUp as ComponentType<LucideProps>;
26
+ const ChevronDownIcon = ChevronDown as ComponentType<LucideProps>;
27
+ const ShieldIcon = Shield as ComponentType<LucideProps>;
28
+
29
+ type PeriodDays = 28 | 90 | 180 | 365;
30
+
31
+ const NUMERIC_INPUT_REGEX = /^\d*\.?\d*$/;
32
+
33
+ function parsePositiveNumber(value: string): number | null {
34
+ if (!value) return null;
35
+ const num = Number(value);
36
+ return Number.isFinite(num) && num > 0 ? num : null;
37
+ }
38
+
39
+ function handleNumericInputChange(
40
+ e: React.ChangeEvent<HTMLInputElement>,
41
+ onChange: (value: string) => void
42
+ ) {
43
+ const value = e.target.value;
44
+ if (value === "" || NUMERIC_INPUT_REGEX.test(value)) {
45
+ onChange(value);
46
+ }
47
+ }
48
+
49
+ const USDC_TOKEN = {
50
+ symbol: "USDC",
51
+ logoUrl: "https://storage.googleapis.com/turtle-assets/tokens/usdc.png",
52
+ };
53
+
54
+ export function CoverRequestForm({
55
+ protocolName,
56
+ baseApyLabel,
57
+ onSuccess,
58
+ onError,
59
+ startExpanded,
60
+ }: CoverRequestFormProps) {
61
+ const [isExpanded, setIsExpanded] = useState(startExpanded ?? false);
62
+ const queryClient = useQueryClient();
63
+
64
+ const { data: coverRequestCheck, isLoading: isCheckingRequest } = useCheckCoverRequest({
65
+ protocolName,
66
+ });
67
+
68
+ const canSubmit = coverRequestCheck?.canSubmit ?? true;
69
+ const existingRequest = coverRequestCheck?.existingRequest ?? null;
70
+
71
+ const form = useForm({
72
+ defaultValues: {
73
+ coverageAmount: "",
74
+ periodDays: 90 as PeriodDays,
75
+ desiredApySacrifice: "",
76
+ tokenSymbol: "USDC",
77
+ },
78
+ onSubmit: async ({ value }) => {
79
+ const coverageAmount = Number(value.coverageAmount);
80
+ const desiredApySacrifice = Number(value.desiredApySacrifice);
81
+ const periodDays = value.periodDays;
82
+
83
+ const calculatedEstimatedPremium =
84
+ coverageAmount * (desiredApySacrifice / 100) * (periodDays / 365);
85
+
86
+ submitCoverRequest({
87
+ protocolName,
88
+ coverageAmount: String(coverageAmount),
89
+ periodDays,
90
+ desiredApySacrifice: String(desiredApySacrifice),
91
+ calculatedEstimatedPremium: String(calculatedEstimatedPremium),
92
+ tokenSymbol: value.tokenSymbol,
93
+ });
94
+ },
95
+ });
96
+
97
+ const { mutate: submitCoverRequest, isPending } = useSubmitCoverRequest({
98
+ onSuccess: (msg) => {
99
+ form.reset();
100
+ queryClient.invalidateQueries({ queryKey: ["covers"] });
101
+ onSuccess?.(msg);
102
+ setIsExpanded(false);
103
+ },
104
+ onError,
105
+ });
106
+
107
+ const parsedBaseApy = useMemo(() => {
108
+ if (!baseApyLabel) return null;
109
+ const value = Number(baseApyLabel.replace(/[^0-9.]/g, ""));
110
+ return Number.isFinite(value) ? value : null;
111
+ }, [baseApyLabel]);
112
+
113
+ const coverageAmountValue = useStore(form.store, (state) => state.values.coverageAmount);
114
+ const sacrificeValue = useStore(form.store, (state) => state.values.desiredApySacrifice);
115
+ const periodDaysValue = useStore(form.store, (state) => state.values.periodDays);
116
+
117
+ const parsedCoverageAmount = useMemo(() => {
118
+ return parsePositiveNumber(coverageAmountValue);
119
+ }, [coverageAmountValue]);
120
+
121
+ const parsedSacrifice = useMemo(() => {
122
+ if (!sacrificeValue) return null;
123
+ const num = Number(sacrificeValue);
124
+ return Number.isFinite(num) ? num : null;
125
+ }, [sacrificeValue]);
126
+
127
+ const netApy = useMemo(() => {
128
+ if (parsedBaseApy === null || parsedSacrifice === null) return null;
129
+ return parsedBaseApy - parsedSacrifice;
130
+ }, [parsedBaseApy, parsedSacrifice]);
131
+
132
+ const calculatedEstimatedPremium = useMemo(() => {
133
+ if (parsedCoverageAmount === null || parsedSacrifice === null) return null;
134
+ return parsedCoverageAmount * (parsedSacrifice / 100) * (periodDaysValue / 365);
135
+ }, [parsedCoverageAmount, parsedSacrifice, periodDaysValue]);
136
+
137
+ return (
138
+ <Collapsible
139
+ open={isExpanded}
140
+ onOpenChange={setIsExpanded}
141
+ className="rounded-xl overflow-hidden border"
142
+ >
143
+ <CollapsibleTrigger className="w-full p-4 flex items-center cursor-pointer justify-between hover:bg-primary/5 transition-colors">
144
+ <div className="flex items-center gap-3 text-left">
145
+ <div className="p-2 rounded-lg bg-primary/10 border border-primary/20">
146
+ <ShieldAlertIcon className="w-5 h-5 text-primary" />
147
+ </div>
148
+ <div className="space-y-1">
149
+ <p className="text-sm font-semibold text-foreground">
150
+ Request coverage for this opportunity
151
+ </p>
152
+ <p className="text-sm text-muted-foreground leading-relaxed">
153
+ We don&apos;t yet have Nexus Mutual cover available for{" "}
154
+ <span className="font-medium">{protocolName}</span>.
155
+ </p>
156
+ </div>
157
+ </div>
158
+ <div className="flex items-center gap-2">
159
+ {isExpanded ? (
160
+ <ChevronUpIcon className="w-5 h-5 text-muted-foreground" />
161
+ ) : (
162
+ <ChevronDownIcon className="w-5 h-5 text-muted-foreground" />
163
+ )}
164
+ </div>
165
+ </CollapsibleTrigger>
166
+
167
+ <CollapsibleContent>
168
+ {isCheckingRequest ? (
169
+ <div className="p-4 space-y-4">
170
+ <Skeleton className="h-4 w-48 bg-neutral-alpha-10" />
171
+ <Skeleton className="h-10 w-full bg-neutral-alpha-10" />
172
+ <Skeleton className="h-10 w-full bg-neutral-alpha-10" />
173
+ <Skeleton className="h-10 w-full bg-neutral-alpha-10" />
174
+ </div>
175
+ ) : !canSubmit && existingRequest ? (
176
+ <ExistingCoverRequest
177
+ protocolName={protocolName}
178
+ existingRequest={existingRequest}
179
+ />
180
+ ) : (
181
+ <form
182
+ onSubmit={(e) => {
183
+ e.preventDefault();
184
+ e.stopPropagation();
185
+ form.handleSubmit();
186
+ }}
187
+ className="p-4 space-y-4"
188
+ >
189
+ <div className="flex items-center justify-between text-sm text-muted-foreground">
190
+ <span className="font-medium text-foreground">{protocolName}</span>
191
+ {baseApyLabel && <span>Current APR: {baseApyLabel}</span>}
192
+ </div>
193
+
194
+ {/* Coverage Amount */}
195
+ <form.Field
196
+ name="coverageAmount"
197
+ validators={{
198
+ onSubmit: ({ value }) => {
199
+ if (!parsePositiveNumber(value)) {
200
+ return "Enter a valid coverage amount";
201
+ }
202
+ return undefined;
203
+ },
204
+ }}
205
+ >
206
+ {(field) => (
207
+ <div className="space-y-2">
208
+ <Label className="text-xs text-foreground">Coverage Amount</Label>
209
+ <div className="flex items-center gap-6">
210
+ {/* Amount input */}
211
+ <div className="flex-1">
212
+ <Input
213
+ value={field.state.value}
214
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
215
+ handleNumericInputChange(e, field.handleChange)
216
+ }
217
+ onBlur={field.handleBlur}
218
+ placeholder="e.g. 1000"
219
+ 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"
220
+ />
221
+ </div>
222
+ <Badge
223
+ className={cn(
224
+ "bg-muted hover:bg-muted/80 w-auto min-w-[80px] rounded-md font-medium",
225
+ "focus:ring-primary/20 focus:ring-2 focus:outline-none",
226
+ "min-w-[80px] gap-1.5"
227
+ )}
228
+ >
229
+ <img
230
+ src={USDC_TOKEN.logoUrl}
231
+ alt={USDC_TOKEN.symbol}
232
+ className="w-5 h-5 rounded-full"
233
+ />
234
+ <span className="text-sm font-medium text-foreground">{USDC_TOKEN.symbol}</span>
235
+ </Badge>
236
+ </div>
237
+ {field.state.meta.errors.length > 0 && (
238
+ <p className="text-[11px] text-destructive">{field.state.meta.errors[0]}</p>
239
+ )}
240
+ </div>
241
+ )}
242
+ </form.Field>
243
+
244
+ {/* Period Selector */}
245
+ <form.Field name="periodDays">
246
+ {(field) => (
247
+ <div className="space-y-3">
248
+ <Label className="text-sm text-foreground block">Coverage Period</Label>
249
+ <div className="flex gap-2">
250
+ {PERIOD_OPTIONS.map((option) => (
251
+ <Button
252
+ key={option.days}
253
+ type="button"
254
+ onClick={() => field.handleChange(option.days as PeriodDays)}
255
+ variant="none"
256
+ className={`flex-1 py-2.5 rounded-lg text-sm font-medium transition-colors ${
257
+ field.state.value === option.days
258
+ ? "bg-primary text-primary-foreground border-primary hover:border-primary"
259
+ : "bg-secondary text-foreground border-border hover:border-primary/50"
260
+ }`}
261
+ >
262
+ {option.label}
263
+ </Button>
264
+ ))}
265
+ </div>
266
+ </div>
267
+ )}
268
+ </form.Field>
269
+
270
+ {/* APY Sacrifice */}
271
+ <form.Field
272
+ name="desiredApySacrifice"
273
+ validators={{
274
+ onSubmit: ({ value }) => {
275
+ const num = parsePositiveNumber(value);
276
+ if (num === null) {
277
+ return "Enter a valid APY sacrifice percentage";
278
+ }
279
+ if (num > 100) {
280
+ return "Sacrifice cannot exceed 100%";
281
+ }
282
+ return undefined;
283
+ },
284
+ }}
285
+ >
286
+ {(field) => (
287
+ <div className="space-y-2">
288
+ <Label className="text-sm text-foreground">
289
+ APY you&apos;re willing to sacrifice for cover (%)
290
+ </Label>
291
+ <Input
292
+ type="text"
293
+ value={field.state.value}
294
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
295
+ handleNumericInputChange(e, field.handleChange)
296
+ }
297
+ onBlur={field.handleBlur}
298
+ placeholder="e.g. 2.5"
299
+ 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"
300
+ />
301
+ {field.state.meta.errors.length > 0 && (
302
+ <p className="text-[11px] text-destructive">{field.state.meta.errors[0]}</p>
303
+ )}
304
+
305
+ <div className="flex items-center justify-between text-xs p-2 rounded-md bg-muted/50">
306
+ <span className="text-muted-foreground">
307
+ {parsedBaseApy ?? "—"}% − {parsedSacrifice ?? "—"}% =
308
+ </span>
309
+ <span
310
+ className={`font-medium ${
311
+ netApy !== null
312
+ ? netApy >= 0
313
+ ? "text-green-500"
314
+ : "text-destructive"
315
+ : "text-muted-foreground"
316
+ }`}
317
+ >
318
+ {netApy !== null ? `${netApy.toFixed(2)}% Net APY` : "— Net APY"}
319
+ </span>
320
+ </div>
321
+ </div>
322
+ )}
323
+ </form.Field>
324
+
325
+ {/* Estimated Premium */}
326
+ <div className="p-3 rounded-sm bg-primary/5 border border-primary/20 space-y-1">
327
+ <p className="text-sm text-muted-foreground">Estimated Premium</p>
328
+ <p className="text-lg font-semibold text-foreground">
329
+ {calculatedEstimatedPremium !== null
330
+ ? calculatedEstimatedPremium.toLocaleString(undefined, {
331
+ minimumFractionDigits: 2,
332
+ maximumFractionDigits: 6,
333
+ })
334
+ : "—"}
335
+ </p>
336
+ <p className="text-[10px] text-muted-foreground">
337
+ {parsedCoverageAmount?.toLocaleString() ?? "—"} × {parsedSacrifice ?? "—"}% ×{" "}
338
+ {periodDaysValue} days
339
+ </p>
340
+ </div>
341
+
342
+ <p className="text-base text-muted-foreground">
343
+ We&apos;ll use this to gauge interest and bring cover to the protocols you care about.
344
+ </p>
345
+
346
+ <div className="flex justify-center gap-2 w-full">
347
+ <Button
348
+ className="flex w-full text-lg font-semibold rounded-sm"
349
+ variant="green"
350
+ type="submit"
351
+ disabled={isPending}
352
+ >
353
+ <ShieldIcon className="w-5 h-5 text-primary" />
354
+ {isPending ? "Submitting..." : "Request Cover"}
355
+ </Button>
356
+ </div>
357
+ </form>
358
+ )}
359
+ </CollapsibleContent>
360
+ </Collapsible>
361
+ );
362
+ }
@@ -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">Remaining</p>
60
+ <p className="text-lg font-bold text-foreground">
61
+ {parseCoverTiming(cover.start, cover.period, cover.gracePeriod).remainingDays} 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,65 @@
1
+ import { ShieldCheck, type LucideProps } from "lucide-react";
2
+ import type { ComponentType } from "react";
3
+ import type { CoverRequestData } from "@turtleclub/hooks";
4
+ import { PERIOD_OPTIONS } from "../constants";
5
+
6
+ const ShieldCheckIcon = ShieldCheck as ComponentType<LucideProps>;
7
+
8
+ function formatPeriodDays(days: number): string {
9
+ const option = PERIOD_OPTIONS.find((o) => o.days === days);
10
+ return option ? option.label : `${days}d`;
11
+ }
12
+
13
+ interface ExistingCoverRequestProps {
14
+ protocolName: string;
15
+ existingRequest: CoverRequestData;
16
+ }
17
+
18
+ export function ExistingCoverRequest({
19
+ protocolName,
20
+ existingRequest,
21
+ }: ExistingCoverRequestProps) {
22
+ return (
23
+ <div className="p-4 space-y-4">
24
+ <div className="flex items-center gap-3">
25
+ <div className="p-2 rounded-lg bg-green-500/10 border border-green-500/20">
26
+ <ShieldCheckIcon className="w-5 h-5 text-green-500" />
27
+ </div>
28
+ <div className="space-y-1">
29
+ <p className="text-sm font-semibold text-foreground">
30
+ You&apos;ve already requested cover for {protocolName}
31
+ </p>
32
+ <p className="text-sm text-muted-foreground">
33
+ Your feedback has been shared with Nexus Mutual.
34
+ </p>
35
+ </div>
36
+ </div>
37
+
38
+ <div className="p-3 rounded-sm bg-muted/50 border border-border space-y-2">
39
+ <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
40
+ Your Request
41
+ </p>
42
+ <div className="grid grid-cols-3 gap-2 text-sm">
43
+ <div>
44
+ <p className="text-muted-foreground text-xs">Coverage Amount</p>
45
+ <p className="font-medium text-foreground">
46
+ {Number(existingRequest.coverageAmount).toLocaleString()} {existingRequest.tokenSymbol}
47
+ </p>
48
+ </div>
49
+ <div>
50
+ <p className="text-muted-foreground text-xs">Period</p>
51
+ <p className="font-medium text-foreground">
52
+ {formatPeriodDays(existingRequest.periodDays)}
53
+ </p>
54
+ </div>
55
+ <div>
56
+ <p className="text-muted-foreground text-xs">APY Sacrifice</p>
57
+ <p className="font-medium text-foreground">
58
+ {existingRequest.desiredApySacrifice}%
59
+ </p>
60
+ </div>
61
+ </div>
62
+ </div>
63
+ </div>
64
+ );
65
+ }
@@ -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
+ }