From 29c346e868dce1836c7f0aca383064ae8ad3d091 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Thu, 28 Aug 2025 11:29:37 +0400 Subject: [PATCH] Alerts: backup-contact phrasing; correct backup countdown; include per-position pay link; deep-link route rendering in-place; single-position view via deep-link; ARB alias; backup test message alignment --- app/dapp/page.tsx | 104 ++++++++++++++---- .../[network]/[preset]/[tokenId]/page.tsx | 26 +++++ components/SettingsModal.tsx | 62 +++++++++-- types/notifications.ts | 4 + utils/scheduler.ts | 33 ++++++ 5 files changed, 199 insertions(+), 30 deletions(-) create mode 100644 app/dapp/position/[network]/[preset]/[tokenId]/page.tsx diff --git a/app/dapp/page.tsx b/app/dapp/page.tsx index 176d87e..d88cae3 100644 --- a/app/dapp/page.tsx +++ b/app/dapp/page.tsx @@ -55,6 +55,7 @@ export default function DappPage() { const [presetKey, setPresetKey] = useState('cbBTC-USDC'); const [manualWallet, setManualWallet] = useState(''); const [manualTokenId, setManualTokenId] = useState(''); + const [deeplinkTokenId, setDeeplinkTokenId] = useState(''); const [detectedTokenIds, setDetectedTokenIds] = useState([]); const [scanBusy, setScanBusy] = useState(false); const [scanComplete, setScanComplete] = useState(false); @@ -86,6 +87,8 @@ export default function DappPage() { schedyBaseUrl: ENV_SCHEDY, schedyApiKey: ENV_SCHEDY_API_KEY, email: '', + backupEmail: '', + backupDelayDays: 1, daysBefore: 10, }); useEffect(() => { @@ -143,6 +146,32 @@ export default function DappPage() { complete?: boolean; // when true, stop further ownerOf scans (gap limit reached) }; + // Consume deep-link payload from /dapp/position/[network]/[preset]/[tokenId] + useEffect(() => { + try { + if (typeof window === 'undefined') return; + const raw = localStorage.getItem('dapp:deeplink:v1'); + if (!raw) return; + localStorage.removeItem('dapp:deeplink:v1'); + const parsed = JSON.parse(raw || '{}') as { network?: string; preset?: number; tokenId?: string; ts?: number }; + const age = Math.floor(Date.now() / 1000) - (parsed.ts || 0); + if (age > 600) return; // ignore stale links >10min + const net = String(parsed.network || '').toUpperCase(); + const presetIdx = Math.max(1, Number(parsed.preset || 1)); + const nextChainId = net === 'BASE' ? base.id : ((net === 'ARBITRUM' || net === 'ARB') ? arbitrum.id : selectedChainId); + if (nextChainId !== selectedChainId) { + setSelectedChainId(nextChainId); + loadChainDefaults(nextChainId); + } + const list = PRESETS[nextChainId] || []; + const item = list[presetIdx - 1]; + if (item?.key) setPresetKey(item.key); + // Force single-position view for deep link + if (parsed.tokenId) setDeeplinkTokenId(String(parsed.tokenId)); + } catch {} + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // Utility: sleep for backoff @@ -397,9 +426,12 @@ export default function DappPage() { // Resolve tokenIds: either detected or manual entry const tokenIds = useMemo(() => { + if (deeplinkTokenId.trim()) { + try { return [BigInt(deeplinkTokenId.trim())]; } catch { return []; } + } const manual = manualTokenId.trim() ? [BigInt(manualTokenId.trim())] : []; return [...detectedTokenIds, ...manual]; - }, [detectedTokenIds, manualTokenId]); + }, [detectedTokenIds, manualTokenId, deeplinkTokenId]); // Build reads for debt contract const debtReads = useMemo(() => { @@ -622,14 +654,15 @@ export default function DappPage() { }; // Helpers to construct provider webhook URL/body for cronhost - const buildNotificationRequest = (row: { tokenId: bigint; secondsTillLiq?: bigint }, message: string) => { + const buildNotificationRequest = (row: { tokenId: bigint; secondsTillLiq?: bigint }, message: string, opts?: { email?: string }) => { const provider = notifSettings.provider; if (!provider) throw new Error('Notification provider not configured'); if (provider === 'ntfy') { const baseUrl = (notifSettings.ntfyServer || 'https://ntfy.sh').replace(/\/$/, ''); const topic = notifSettings.ntfyTopic || ''; const headers: Record = { 'Content-Type': 'text/plain' }; - if ((notifSettings.email || '').trim()) headers['X-Email'] = String(notifSettings.email); + const targetEmail = (opts?.email || notifSettings.email || '').trim(); + if (targetEmail) headers['X-Email'] = String(targetEmail); return { url: `${baseUrl}/${encodeURIComponent(topic)}`, method: 'POST' as const, @@ -685,7 +718,11 @@ export default function DappPage() { const monthly = (row as any)?.monthlyPaymentSize as bigint | undefined; const paymentPending = (row as any)?.currentPaymentPending as bigint | undefined; const debtRemaining = (row as any)?.debtAtThisSize as bigint | undefined; - const dappUrl = (typeof window !== 'undefined' && window.location?.origin) ? `${window.location.origin}/dapp` : 'https://markets.mortgagefi.app/dashboard'; + const origin = (typeof window !== 'undefined' && window.location?.origin) ? window.location.origin : 'https://markets.mortgagefi.app'; + const dappUrl = `${origin}/dapp`; + const networkSlug = selectedChainId === base.id ? 'BASE' : (selectedChainId === arbitrum.id ? 'ARBITRUM' : 'UNKNOWN'); + const presetIdx = Math.max(0, (PRESETS[selectedChainId]?.findIndex(p => p.key === presetKey) ?? 0)) + 1; // 1-based + const positionUrl = `${origin}/dapp/position/${networkSlug}/${presetIdx}/${row.tokenId.toString()}`; const humanLeft = human(offset); const mul15 = (v: bigint) => (v * BigInt(3)) / BigInt(2); @@ -705,13 +742,38 @@ export default function DappPage() { const collateralStr = fmt((row as any)?.coinSize as bigint | undefined, Number(coinDecimals ?? 8), 8); const collateralSym = String(coinSymbol ?? ''); - const msg = `Your position ${row.tokenId.toString()} is approaching liquidation in ~${humanLeft}. ${payClause} Collateral at risk: ${collateralStr ?? 'unknown'} ${collateralSym}. Visit ${dappUrl} or https://markets.mortgagefi.app/dashboard.`; + const msg = `Your position ${row.tokenId.toString()} is approaching liquidation in ~${humanLeft}. ${payClause} Collateral at risk: ${collateralStr ?? 'unknown'} ${collateralSym}. Pay link: ${positionUrl}. Visit ${dappUrl} or https://markets.mortgagefi.app/dashboard.`; - const runAt = nowSec + Math.max(0, seconds - offset); - if (runAt <= nowSec) throw new Error('Computed run time is in the past'); - const req = buildNotificationRequest(row, msg); - const res = await scheduleJob(notifSettings, { runAtEpoch: runAt, method: req.method, url: req.url, body: req.body, headers: req.headers }); - return { jobId: res.jobId, runAt }; + const liqAt = nowSec + Math.max(0, seconds); + const runAt1 = liqAt - offset; + if (runAt1 <= nowSec) throw new Error('Computed run time is in the past'); + // Primary job (to main email) + const req1 = buildNotificationRequest(row, msg); + const res1 = await scheduleJob(notifSettings, { runAtEpoch: runAt1, method: req1.method, url: req1.url, body: req1.body, headers: req1.headers }); + + // Optional backup job + const jobs: Array<{ id: string; at: number; label: 'lead' | 'half' | 'last' }> = [ + { id: res1.jobId, at: runAt1, label: 'lead' }, + ]; + const bEmail = (notifSettings.backupEmail || '').trim(); + const bDelayDays = Math.max(0, Number(notifSettings.backupDelayDays ?? 0)); + if (bEmail && bDelayDays >= 1) { + const requested = runAt1 + Math.floor(bDelayDays * 86400); + const min1dBeforeLiq = liqAt - 86400; // must be >= 1 day before liq + const runAt2 = Math.min(requested, min1dBeforeLiq); + if (runAt2 > nowSec && runAt2 > runAt1) { + const primaryEmail = (notifSettings.email || '').trim(); + const prefix = `You are the backup contact for this position. Please contact the primary contact${primaryEmail ? ' ' + primaryEmail : ''} immediately. If they do not respond, please pay down the debt to avoid liquidation in a timely manner.`; + const humanLeftBackup = human(Math.max(0, liqAt - runAt2)); + const msgBackupCore = `Your position ${row.tokenId.toString()} is approaching liquidation in ~${humanLeftBackup}. ${payClause} Collateral at risk: ${collateralStr ?? 'unknown'} ${collateralSym}. Pay link: ${positionUrl}. Visit ${dappUrl} or https://markets.mortgagefi.app/dashboard.`; + const backupMsg = `${prefix}\n\n${msgBackupCore}`; + const req2 = buildNotificationRequest(row, backupMsg, { email: bEmail }); + const res2 = await scheduleJob(notifSettings, { runAtEpoch: runAt2, method: req2.method, url: req2.url, body: req2.body, headers: req2.headers }); + jobs.push({ id: res2.jobId, at: runAt2, label: 'last' }); + } + } + + return { jobs }; }; const cancelNotification = async (p: PositionNotification) => { @@ -800,13 +862,15 @@ export default function DappPage() { if (!Number.isFinite(seconds) || seconds <= 0) continue; const lead = Math.max(0, Number(notifSettings.daysBefore ?? 0) * 86400); const targetRunAt = Math.floor(Date.now() / 1000) + Math.max(0, seconds - (lead > 0 ? lead : 86400)); - const drift = Math.abs((p.scheduledAt || 0) - targetRunAt); - if (!p.jobId || drift > 60) { - // reschedule single + const scheduledAtLead = (p.jobs?.find(j => j.label === 'lead')?.at) || (p.scheduledAt || 0); + const drift = Math.abs(scheduledAtLead - targetRunAt); + const hasAnyJob = (p.jobs && p.jobs.length > 0) || Boolean(p.jobId); + if (!hasAnyJob || drift > 60) { + // reschedule if (p) try { await cancelNotification(p); } catch {} try { - const { jobId, runAt } = await scheduleNotification(row); - setPositionsNotif((prev) => ({ ...prev, [key]: { ...(prev[key] || {}), chainId: selectedChainId, debtAddress, nftAddress, tokenId: key, enabled: true, jobId, scheduledAt: runAt } })); + const { jobs } = await scheduleNotification(row); + setPositionsNotif((prev) => ({ ...prev, [key]: { ...(prev[key] || {}), chainId: selectedChainId, debtAddress, nftAddress, tokenId: key, enabled: true, jobs } })); } catch (e) { console.warn('[notif] reschedule failed', e); } @@ -983,7 +1047,7 @@ export default function DappPage() {
About

Enter your NFT contract and token ID(s), then fetch debt details from the mortgage contract.

-

Base network is required. The app will attempt to detect your token IDs via on-chain queries.

+

{selectedChainId === base.id ? 'Base' : selectedChainId === arbitrum.id ? 'Arbitrum' : 'Selected'} network is required. The app will attempt to detect your token IDs via on-chain queries when supported.

@@ -1168,10 +1232,10 @@ export default function DappPage() { return; } try { - const { jobId, runAt } = await scheduleNotification(row); + const { jobs } = await scheduleNotification(row); setPositionsNotif((prev) => ({ ...prev, - [key]: { chainId: selectedChainId, debtAddress, nftAddress, tokenId: key, enabled: true, jobId, scheduledAt: runAt }, + [key]: { chainId: selectedChainId, debtAddress, nftAddress, tokenId: key, enabled: true, jobs }, })); } catch (e) { console.warn('[notif] schedule failed', e); @@ -1243,9 +1307,7 @@ export default function DappPage() { })}
-
- If there's a specific "start date" function in the ABI, let me know its exact name so I can add it to the reads. -
+ {/* Footer note removed per requirements */} {/* Settings Modal */} (); + + useEffect(() => { + try { + const network = String(params?.network || '').toUpperCase(); + const preset = Number(params?.preset || '1'); + const tokenId = String(params?.tokenId || ''); + if (!network || !preset || !tokenId) return; + // Persist for Dapp page to consume on mount; keep URL unchanged + const payload = { network, preset, tokenId, ts: Math.floor(Date.now() / 1000) }; + if (typeof window !== 'undefined') { + localStorage.setItem('dapp:deeplink:v1', JSON.stringify(payload)); + } + } catch {} + }, [params]); + + // Render the main Dapp UI without redirect; it will consume the deep-link + return ; +} diff --git a/components/SettingsModal.tsx b/components/SettingsModal.tsx index a16feaa..f1f96dd 100644 --- a/components/SettingsModal.tsx +++ b/components/SettingsModal.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useState } from "react"; import { NotificationSettings, NotificationProvider, SchedulerProvider } from "@/types/notifications"; -import { scheduleTestNotification } from "@/utils/scheduler"; +import { scheduleTestNotification, scheduleTestBackupNotification } from "@/utils/scheduler"; export interface SettingsModalProps { open: boolean; @@ -28,6 +28,8 @@ const defaultSettings: NotificationSettings = { schedyBaseUrl: ENV_SCHEDY, schedyApiKey: ENV_SCHEDY_API_KEY, email: "", + backupEmail: "", + backupDelayDays: 1, daysBefore: 10, }; @@ -37,13 +39,12 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin useEffect(() => { if (open) { - const base: any = initial || defaultSettings; - const next: any = { ...base }; - if (next.scheduler !== 'schedy') next.scheduler = 'schedy'; - for (const k of ['cronhostApiKey', 'kronosBaseUrl', 'schedifyBaseUrl', 'schedifyApiKey', 'schedifyWebhookId']) { - if (k in next) delete next[k]; - } - setForm(next); + // Merge defaults to ensure newly added fields (e.g., backupDelayDays) are populated + const merged = { ...defaultSettings, ...(initial || {}) } as NotificationSettings; + // Re-apply env defaults if user hasn't set values + if (!merged.ntfyServer) merged.ntfyServer = ENV_NTFY; + if (!merged.schedyBaseUrl) merged.schedyBaseUrl = ENV_SCHEDY; + setForm(merged); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, initial]); @@ -55,6 +56,12 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin if (!form.email) return true; const db = form.daysBefore ?? 10; if (Number(db) < 0) return true; + // if backupEmail provided, enforce min backupDelayDays >= 1 (treat empty/NaN as invalid) + const hasBackup = Boolean((form.backupEmail || '').trim()); + if (hasBackup) { + const bdd = Number(form.backupDelayDays); + if (!Number.isFinite(bdd) || bdd < 1) return true; + } if (!provider) return true; if (!scheduler) return true; if (provider === 'ntfy') return !(form.ntfyServer && form.ntfyTopic); @@ -80,6 +87,16 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin } }; + const onTestBackup = async () => { + setTestStatus('Scheduling backup test…'); + try { + const { jobId } = await scheduleTestBackupNotification(form); + setTestStatus(`Backup test scheduled (job ${jobId}). You should receive a backup email shortly.`); + } catch (e: any) { + setTestStatus(`Backup test failed: ${e?.message || String(e)}`); + } + }; + if (!open) return null; return ( @@ -146,6 +163,16 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin /> + + -
+ {provider === 'ntfy' && ( <> @@ -260,6 +296,14 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin > Send test alert +