@turtleclub/opportunities 0.1.0-beta.2 → 0.1.0-beta.21

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.
@@ -0,0 +1,267 @@
1
+ "use client";
2
+
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
+ import type { ComponentType } from "react";
7
+ import type { CoverRequestData, CoverRequestFormProps } from "../types";
8
+ const ShieldAlertIcon = ShieldAlert as ComponentType<LucideProps>;
9
+ const SendIcon = Send as ComponentType<LucideProps>;
10
+ const ChevronUpIcon = ChevronUp as ComponentType<LucideProps>;
11
+ const ChevronDownIcon = ChevronDown as ComponentType<LucideProps>;
12
+ const ShieldIcon = Shield as ComponentType<LucideProps>;
13
+
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;
20
+
21
+ export function CoverRequestForm({
22
+ protocolName,
23
+ depositedAmount,
24
+ baseApyLabel,
25
+ onSuccess,
26
+ onError,
27
+ startExpanded,
28
+ }: CoverRequestFormProps) {
29
+ const [coverageAmount, setCoverageAmount] = useState("");
30
+ const [useDepositedAmount, setUseDepositedAmount] = useState(false);
31
+ const [desiredApySacrifice, setDesiredApySacrifice] = useState("");
32
+ const [selectedPeriodDays, setSelectedPeriodDays] = useState(90);
33
+ const [inputError, setInputError] = useState<string | null>(null);
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]);
42
+
43
+ const parsedSacrifice = useMemo(() => {
44
+ if (!desiredApySacrifice.trim()) return null;
45
+ const value = Number(desiredApySacrifice);
46
+ return Number.isFinite(value) ? value : null;
47
+ }, [desiredApySacrifice]);
48
+
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");
81
+ return;
82
+ }
83
+ if (parsedSacrifice < 0) {
84
+ setInputError("APY sacrifice cannot be negative");
85
+ return;
86
+ }
87
+ setInputError(null);
88
+
89
+ const requestData: CoverRequestData = {
90
+ protocolName,
91
+ coverageAmount: parsedCoverageAmount,
92
+ periodDays: selectedPeriodDays,
93
+ desiredApySacrifice: parsedSacrifice,
94
+ estimatedPremium: estimatedPremium ?? 0,
95
+ };
96
+
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();
109
+ }
110
+ };
111
+
112
+ return (
113
+ <div className="rounded-xl bg-gradient-to-br from-primary/5 to-card overflow-hidden border border-primary/20">
114
+ <button
115
+ onClick={() => setIsExpanded(!isExpanded)}
116
+ type="button"
117
+ className="w-full p-4 flex items-center justify-between hover:bg-primary/5 transition-colors"
118
+ >
119
+ <div className="flex items-center gap-3 text-left">
120
+ <div className="p-2 rounded-lg bg-primary/10 border border-primary/20">
121
+ <ShieldAlertIcon className="w-5 h-5 text-primary" />
122
+ </div>
123
+ <div className="space-y-1">
124
+ <p className="text-sm font-semibold text-foreground">
125
+ Request coverage for this opportunity
126
+ </p>
127
+ <p className="text-xs text-muted-foreground leading-relaxed">
128
+ We don&apos;t yet have Nexus Mutual cover available for{" "}
129
+ <span className="font-medium">{protocolName}</span>.
130
+ </p>
131
+ </div>
132
+ </div>
133
+ <div className="flex items-center gap-2">
134
+ {isExpanded ? (
135
+ <ChevronUpIcon className="w-5 h-5 text-muted-foreground" />
136
+ ) : (
137
+ <ChevronDownIcon className="w-5 h-5 text-muted-foreground" />
138
+ )}
139
+ </div>
140
+ </button>
141
+
142
+ {isExpanded && (
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 */}
150
+ <div className="space-y-2">
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
+ ))}
196
+ </div>
197
+ </div>
198
+
199
+ {/* APY Sacrifice */}
200
+ <div className="space-y-2">
201
+ <label className="text-xs text-foreground">
202
+ APY you&apos;re willing to sacrifice for cover (%)
203
+ </label>
204
+ <Input
205
+ type="text"
206
+ variant="bordered"
207
+ value={desiredApySacrifice}
208
+ onChange={(e: ChangeEvent<HTMLInputElement>) => {
209
+ const value = e.target.value;
210
+ if (value === "" || /^\d*\.?\d*$/.test(value)) {
211
+ setDesiredApySacrifice(e.target.value);
212
+ }
213
+ }}
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"
216
+ />
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
244
+ </p>
245
+ </div>
246
+
247
+ {inputError && <p className="text-[11px] text-destructive">{inputError}</p>}
248
+
249
+ <p className="text-base text-muted-foreground">
250
+ We&apos;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" />
260
+ Request Cover
261
+ </Button>
262
+ </div>
263
+ </form>
264
+ )}
265
+ </div>
266
+ );
267
+ }
@@ -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
+ }
@@ -0,0 +1,87 @@
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
+ }
@@ -0,0 +1,8 @@
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
+ }
@@ -0,0 +1,122 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import { parseEther, formatEther } from "viem";
3
+ import type { NexusQuoteResult, CoverQuoteState } from "../types";
4
+ import { CoverAsset, NexusSDK } from "@nexusmutual/sdk";
5
+
6
+ interface UseCoverQuoteOptions {
7
+ productId: number;
8
+ buyerAddress: string;
9
+ amountToCover: string;
10
+ /** Debounce delay in ms */
11
+ debounceMs?: number;
12
+ }
13
+
14
+ interface UseCoverQuoteReturn extends CoverQuoteState {
15
+ setCoverPeriod: (period: number) => void;
16
+ refetch: () => void;
17
+ }
18
+
19
+ let nexusSdkInstance: import("@nexusmutual/sdk").NexusSDK | null = null;
20
+ let coverAssetEnum: typeof import("@nexusmutual/sdk").CoverAsset | null = null;
21
+
22
+ const TERMS_IPFS_CID = "QmXUzXDMbeKSCewUie34vPD7mCAGnshi4ULRy4h7DLmoRS";
23
+
24
+ async function getNexusSdk() {
25
+ if (!nexusSdkInstance || !coverAssetEnum) {
26
+ const { NexusSDK, CoverAsset } = await import("@nexusmutual/sdk");
27
+ nexusSdkInstance = new NexusSDK();
28
+ coverAssetEnum = CoverAsset;
29
+ }
30
+
31
+ return { nexusSdk: nexusSdkInstance, CoverAsset: coverAssetEnum };
32
+ }
33
+
34
+
35
+ async function fetchNexusQuote(
36
+ productId: number,
37
+ buyerAddress: string,
38
+ amount: string,
39
+ period: number
40
+ ): Promise<NexusQuoteResult> {
41
+
42
+ const { nexusSdk } = await getNexusSdk();
43
+ const amountWei = parseEther(amount).toString();
44
+
45
+ const { result, error } = await nexusSdk.quote.getQuoteAndBuyCoverInputs({
46
+ productId,
47
+ amount: amountWei,
48
+ period,
49
+ coverAsset: CoverAsset.ETH,
50
+ paymentAsset: CoverAsset.ETH,
51
+ buyerAddress,
52
+ ipfsCidOrContent: TERMS_IPFS_CID,
53
+ });
54
+
55
+ if (error) {
56
+ throw new Error(error.message || "Failed to fetch Nexus quote");
57
+ }
58
+
59
+ return result as NexusQuoteResult;
60
+ }
61
+
62
+ export function useCoverQuote({
63
+ productId,
64
+ buyerAddress,
65
+ amountToCover,
66
+ debounceMs = 500,
67
+ }: UseCoverQuoteOptions): UseCoverQuoteReturn {
68
+ const [coverPeriod, setCoverPeriod] = useState(30);
69
+ const [isLoading, setIsLoading] = useState(false);
70
+ const [error, setError] = useState<string | null>(null);
71
+ const [quoteResult, setQuoteResult] = useState<NexusQuoteResult | null>(null);
72
+ const [premiumEth, setPremiumEth] = useState<string | null>(null);
73
+ const [yearlyCostPerc, setYearlyCostPerc] = useState<number | null>(null);
74
+
75
+ const fetchQuote = useCallback(async () => {
76
+ if (!productId || !buyerAddress || !amountToCover || parseFloat(amountToCover) <= 0) {
77
+ console.error("Validation failed", productId, buyerAddress, amountToCover, coverPeriod);
78
+ return;
79
+ }
80
+
81
+ setIsLoading(true);
82
+ setError(null);
83
+
84
+ try {
85
+ const result = await fetchNexusQuote(productId, buyerAddress, amountToCover, coverPeriod);
86
+
87
+ setQuoteResult(result);
88
+ setPremiumEth(formatEther(BigInt(result.displayInfo.premiumInAsset)));
89
+ setYearlyCostPerc(result.displayInfo.yearlyCostPerc);
90
+ } catch (err: unknown) {
91
+ const errorMessage = err instanceof Error ? err.message : "Failed to fetch quote";
92
+ console.error("Cover quote error:", err);
93
+ setError(errorMessage);
94
+ setQuoteResult(null);
95
+ setPremiumEth(null);
96
+ setYearlyCostPerc(null);
97
+ } finally {
98
+ setIsLoading(false);
99
+ }
100
+ }, [productId, buyerAddress, amountToCover, coverPeriod]);
101
+
102
+ // Debounced fetch on input changes
103
+ useEffect(() => {
104
+ const timeoutId = setTimeout(() => {
105
+ fetchQuote();
106
+ }, debounceMs);
107
+
108
+ return () => clearTimeout(timeoutId);
109
+ }, [fetchQuote, debounceMs]);
110
+
111
+ return {
112
+ coverPeriod,
113
+ setCoverPeriod,
114
+ isLoading,
115
+ error,
116
+ quoteResult,
117
+ premiumEth,
118
+ yearlyCostPerc,
119
+ refetch: fetchQuote,
120
+ };
121
+ }
122
+