Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf76322008 | ||
|
|
4c67bf3a15 | ||
|
|
62653437b5 | ||
|
|
f60e4ca3a5 | ||
|
|
29c346e868 | ||
|
|
6c4a8dfe83 | ||
|
|
0d06090865 |
14
.dockerignore
Normal file
14
.dockerignore
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
Dockerfile*
|
||||||
|
README.md
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.env*
|
||||||
|
.dockerignore
|
||||||
|
**/*.local.*
|
||||||
|
**/*.test.*
|
||||||
|
**/__tests__/**
|
||||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "submodules/schedy"]
|
||||||
|
path = submodules/schedy
|
||||||
|
url = ssh://git@git.manko.yoga:222/manawenuz/schedy.git
|
||||||
41
Dockerfile
Normal file
41
Dockerfile
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# 1) Install dependencies
|
||||||
|
FROM --platform=linux/amd64 node:20-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
ENV CI=true
|
||||||
|
# Install system deps commonly needed by Next.js
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --legacy-peer-deps --no-audit --no-fund
|
||||||
|
|
||||||
|
# 2) Build the app
|
||||||
|
FROM --platform=linux/amd64 node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
# Allow passing public env vars at build time
|
||||||
|
ARG NEXT_PUBLIC_NTFY_URL
|
||||||
|
ARG NEXT_PUBLIC_SCHEDY_URL
|
||||||
|
ENV NEXT_PUBLIC_NTFY_URL=${NEXT_PUBLIC_NTFY_URL:-/ntfy}
|
||||||
|
ENV NEXT_PUBLIC_SCHEDY_URL=${NEXT_PUBLIC_SCHEDY_URL:-/schedy}
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# 3) Run with standalone output
|
||||||
|
FROM --platform=linux/amd64 node:20-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
# Create non-root user
|
||||||
|
RUN adduser -D nextjs
|
||||||
|
# Copy the minimal standalone output
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder /app/.next/BUILD_ID ./.next/BUILD_ID
|
||||||
|
# Expose and run
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000 HOST=0.0.0.0
|
||||||
|
USER nextjs
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -2,10 +2,15 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useAccount, useChainId, useReadContract, useReadContracts, useSwitchChain, usePublicClient, useWriteContract, useSendTransaction } from 'wagmi';
|
import { useAccount, useChainId, useReadContract, useReadContracts, useSwitchChain, usePublicClient, useWriteContract, useSendTransaction } from 'wagmi';
|
||||||
import { base, arbitrum } from 'wagmi/chains';
|
import { base, arbitrum, mainnet } from 'wagmi/chains';
|
||||||
import { Abi, parseUnits } from 'viem';
|
import { Abi, parseUnits } from 'viem';
|
||||||
import debtAbi from '@/ABIs/mortgagefiusdccbbtcupgraded.json';
|
import debtAbi from '@/ABIs/mortgagefiusdccbbtcupgraded.json';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import SettingsModal from '@/components/SettingsModal';
|
||||||
|
import { useLocalStorage } from '@/utils/useLocalStorage';
|
||||||
|
import type { NotificationSettings, PositionNotification } from '@/types/notifications';
|
||||||
|
import { scheduleJob, cancelScheduledJob, purgeNtfyTopicSchedules } from '@/utils/scheduler';
|
||||||
|
|
||||||
// Minimal ERC-721 ABI for balance/owner/enumeration
|
// Minimal ERC-721 ABI for balance/owner/enumeration
|
||||||
const erc721Abi = [
|
const erc721Abi = [
|
||||||
@@ -16,6 +21,8 @@ const erc721Abi = [
|
|||||||
{ type: 'function', name: 'tokenOfOwnerByIndex', stateMutability: 'view', inputs: [{ name: 'owner', type: 'address' }, { name: 'index', type: 'uint256' }], outputs: [{ type: 'uint256' }] },
|
{ type: 'function', name: 'tokenOfOwnerByIndex', stateMutability: 'view', inputs: [{ name: 'owner', type: 'address' }, { name: 'index', type: 'uint256' }], outputs: [{ type: 'uint256' }] },
|
||||||
] as const satisfies Abi;
|
] as const satisfies Abi;
|
||||||
|
|
||||||
|
const ENABLE_MAINNET = process.env.NEXT_PUBLIC_ENABLE_MAINNET === 'true';
|
||||||
|
|
||||||
const DEFAULTS = {
|
const DEFAULTS = {
|
||||||
[base.id]: {
|
[base.id]: {
|
||||||
nft: '0xcc9a350c5b1e1c9ecd23d376e6618cdfd6bbbdbe',
|
nft: '0xcc9a350c5b1e1c9ecd23d376e6618cdfd6bbbdbe',
|
||||||
@@ -25,10 +32,17 @@ const DEFAULTS = {
|
|||||||
nft: '0xedE6F5F8A9D6B90b1392Dcc9E7FD8A5B0192Bfe1',
|
nft: '0xedE6F5F8A9D6B90b1392Dcc9E7FD8A5B0192Bfe1',
|
||||||
debt: '0x9Be2Cf73E62DD3b5dF4334D9A36888394822A33F',
|
debt: '0x9Be2Cf73E62DD3b5dF4334D9A36888394822A33F',
|
||||||
},
|
},
|
||||||
|
...(ENABLE_MAINNET ? {
|
||||||
|
[mainnet.id]: {
|
||||||
|
// Dummy placeholders for future USDC-WETH mainnet vault
|
||||||
|
nft: '0x0000000000000000000000000000000000000001',
|
||||||
|
debt: '0x0000000000000000000000000000000000000002',
|
||||||
|
},
|
||||||
|
} : {}),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Presets per chain (selectable pairs)
|
// Presets per chain (selectable pairs)
|
||||||
const PRESETS: Record<number, { key: string; label: string; nft: string; debt: string }[]> = {
|
const PRESETS: Partial<Record<number, { key: string; label: string; nft: string; debt: string }[]>> = {
|
||||||
[base.id]: [
|
[base.id]: [
|
||||||
{ key: 'cbBTC-USDC', label: 'cbBTC-USDC', nft: '0xcc9a350c5b1e1c9ecd23d376e6618cdfd6bbbdbe', debt: '0xe93131620945a1273b48f57f453983d270b62dc7' },
|
{ key: 'cbBTC-USDC', label: 'cbBTC-USDC', nft: '0xcc9a350c5b1e1c9ecd23d376e6618cdfd6bbbdbe', debt: '0xe93131620945a1273b48f57f453983d270b62dc7' },
|
||||||
{ key: 'WETH-USDC', label: 'WETH-USDC', nft: '0xab825f45e9e5d2459fb7a1527a8d0ca082c582f4', debt: '0x1be87d273d47c3832ab7853812e9a995a4de9eea' },
|
{ key: 'WETH-USDC', label: 'WETH-USDC', nft: '0xab825f45e9e5d2459fb7a1527a8d0ca082c582f4', debt: '0x1be87d273d47c3832ab7853812e9a995a4de9eea' },
|
||||||
@@ -36,12 +50,18 @@ const PRESETS: Record<number, { key: string; label: string; nft: string; debt: s
|
|||||||
[arbitrum.id]: [
|
[arbitrum.id]: [
|
||||||
{ key: 'USDTO-WBTC', label: 'USDTO-WBTC', nft: '0xedE6F5F8A9D6B90b1392Dcc9E7FD8A5B0192Bfe1', debt: '0x9Be2Cf73E62DD3b5dF4334D9A36888394822A33F' },
|
{ key: 'USDTO-WBTC', label: 'USDTO-WBTC', nft: '0xedE6F5F8A9D6B90b1392Dcc9E7FD8A5B0192Bfe1', debt: '0x9Be2Cf73E62DD3b5dF4334D9A36888394822A33F' },
|
||||||
],
|
],
|
||||||
|
...(ENABLE_MAINNET ? {
|
||||||
|
[mainnet.id]: [
|
||||||
|
{ key: 'USDC-WETH', label: 'USDC-WETH (mainnet dummy)', nft: '0x0000000000000000000000000000000000000001', debt: '0x0000000000000000000000000000000000000002' },
|
||||||
|
],
|
||||||
|
} : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DappPage() {
|
export default function DappPage() {
|
||||||
const { address, isConnected } = useAccount();
|
const { address, isConnected } = useAccount();
|
||||||
const chainId = useChainId();
|
const chainId = useChainId();
|
||||||
const { switchChain } = useSwitchChain();
|
const { switchChain } = useSwitchChain();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const [selectedChainId, setSelectedChainId] = useState<number>(base.id);
|
const [selectedChainId, setSelectedChainId] = useState<number>(base.id);
|
||||||
const [nftAddress, setNftAddress] = useState<string>(DEFAULTS[base.id].nft);
|
const [nftAddress, setNftAddress] = useState<string>(DEFAULTS[base.id].nft);
|
||||||
@@ -49,6 +69,7 @@ export default function DappPage() {
|
|||||||
const [presetKey, setPresetKey] = useState<string>('cbBTC-USDC');
|
const [presetKey, setPresetKey] = useState<string>('cbBTC-USDC');
|
||||||
const [manualWallet, setManualWallet] = useState<string>('');
|
const [manualWallet, setManualWallet] = useState<string>('');
|
||||||
const [manualTokenId, setManualTokenId] = useState('');
|
const [manualTokenId, setManualTokenId] = useState('');
|
||||||
|
const [deeplinkTokenId, setDeeplinkTokenId] = useState('');
|
||||||
const [detectedTokenIds, setDetectedTokenIds] = useState<bigint[]>([]);
|
const [detectedTokenIds, setDetectedTokenIds] = useState<bigint[]>([]);
|
||||||
const [scanBusy, setScanBusy] = useState(false);
|
const [scanBusy, setScanBusy] = useState(false);
|
||||||
const [scanComplete, setScanComplete] = useState(false);
|
const [scanComplete, setScanComplete] = useState(false);
|
||||||
@@ -57,6 +78,96 @@ export default function DappPage() {
|
|||||||
const { writeContractAsync, isPending: writePending } = useWriteContract();
|
const { writeContractAsync, isPending: writePending } = useWriteContract();
|
||||||
const { sendTransactionAsync, isPending: txPending } = useSendTransaction();
|
const { sendTransactionAsync, isPending: txPending } = useSendTransaction();
|
||||||
|
|
||||||
|
// NFTCache settings (from localStorage; defaults to env or '/nftcache')
|
||||||
|
const [nftcacheEnabled, setNftcacheEnabled] = useState<boolean>(false);
|
||||||
|
const [nftcacheBaseUrl, setNftcacheBaseUrl] = useState<string>((process.env.NEXT_PUBLIC_NFTCACHE_URL || process.env.NFTCACHE_URL || '/nftcache').replace(/\/$/, ''));
|
||||||
|
const [nftcacheApiKey, setNftcacheApiKey] = useState<string>((process.env.NEXT_PUBLIC_NFTCACHE_API_KEY || process.env.NFTCACHE_API_KEY || '').trim());
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
if (typeof window !== 'undefined' && window.localStorage) {
|
||||||
|
const ls = window.localStorage;
|
||||||
|
setNftcacheEnabled(ls.getItem('nftcache:enabled') === '1');
|
||||||
|
const nurl = (ls.getItem('nftcache:baseUrl') || '').trim();
|
||||||
|
if (nurl) setNftcacheBaseUrl(nurl.replace(/\/$/, ''));
|
||||||
|
const nkey = (ls.getItem('nftcache:apiKey') || '').trim();
|
||||||
|
if (nkey) setNftcacheApiKey(nkey);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Notification settings (global) and per-position preferences
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams?.get('settings') === '1') setSettingsOpen(true);
|
||||||
|
}, [searchParams]);
|
||||||
|
const ENV_NTFY = (process.env.NEXT_PUBLIC_NTFY_URL || process.env.NTFY_URL || 'https://ntfy.sh').replace(/\/$/, '');
|
||||||
|
const ENV_SCHEDY = (process.env.NEXT_PUBLIC_SCHEDY_URL || process.env.SCHEDY_URL || 'http://localhost:8080').replace(/\/$/, '');
|
||||||
|
const ENV_SCHEDY_API_KEY = (process.env.NEXT_PUBLIC_SCHEDY_API_KEY || process.env.SCHEDY_API_KEY || '').trim();
|
||||||
|
const [notifSettings, setNotifSettings] = useLocalStorage<NotificationSettings>('notif:settings', {
|
||||||
|
provider: '',
|
||||||
|
scheduler: 'schedy',
|
||||||
|
ntfyServer: ENV_NTFY,
|
||||||
|
ntfyTopic: '',
|
||||||
|
gotifyServer: '',
|
||||||
|
gotifyToken: '',
|
||||||
|
snsRegion: '',
|
||||||
|
snsTopicArn: '',
|
||||||
|
snsAccessKeyId: '',
|
||||||
|
snsSecretAccessKey: '',
|
||||||
|
schedyBaseUrl: ENV_SCHEDY,
|
||||||
|
schedyApiKey: ENV_SCHEDY_API_KEY,
|
||||||
|
email: '',
|
||||||
|
backupEmail: '',
|
||||||
|
backupDelayDays: 1,
|
||||||
|
daysBefore: 10,
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('[Settings] Current', notifSettings);
|
||||||
|
}, [notifSettings]);
|
||||||
|
|
||||||
|
// One-time migration: minutesBefore -> daysBefore
|
||||||
|
useEffect(() => {
|
||||||
|
const anySettings: any = notifSettings as any;
|
||||||
|
if (anySettings && anySettings.minutesBefore !== undefined && anySettings.daysBefore === undefined) {
|
||||||
|
const mins = Number(anySettings.minutesBefore) || 0;
|
||||||
|
const days = Math.max(0, Math.round(mins / 1440));
|
||||||
|
const next = { ...notifSettings, daysBefore: days } as NotificationSettings;
|
||||||
|
delete (next as any).minutesBefore;
|
||||||
|
console.log('[Settings] Migrating minutesBefore -> daysBefore', { mins, days });
|
||||||
|
setNotifSettings(next);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// One-time migration: legacy scheduler providers -> schedy; remove legacy fields
|
||||||
|
useEffect(() => {
|
||||||
|
const s: any = notifSettings as any;
|
||||||
|
if (!s) return;
|
||||||
|
let changed = false;
|
||||||
|
const next: any = { ...notifSettings };
|
||||||
|
// Normalize scheduler
|
||||||
|
if (!s.scheduler || s.scheduler !== 'schedy') {
|
||||||
|
next.scheduler = 'schedy';
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
// Remove legacy keys if present
|
||||||
|
const legacyKeys = ['cronhostApiKey', 'kronosBaseUrl', 'schedifyBaseUrl', 'schedifyApiKey', 'schedifyWebhookId'];
|
||||||
|
for (const k of legacyKeys) {
|
||||||
|
if (k in next) {
|
||||||
|
delete next[k];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
console.log('[Settings] Migrating legacy scheduler -> schedy and pruning legacy fields');
|
||||||
|
setNotifSettings(next as NotificationSettings);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
const positionsStoreKey = useMemo(() => `notif:positions:v1:${selectedChainId}:${debtAddress?.toLowerCase()}`, [selectedChainId, debtAddress]);
|
||||||
|
const [positionsNotif, setPositionsNotif] = useLocalStorage<Record<string, PositionNotification>>(positionsStoreKey, {});
|
||||||
|
|
||||||
// Cache helpers
|
// Cache helpers
|
||||||
type WalletCache = {
|
type WalletCache = {
|
||||||
lastScannedIndex: number;
|
lastScannedIndex: number;
|
||||||
@@ -66,6 +177,34 @@ export default function DappPage() {
|
|||||||
complete?: boolean; // when true, stop further ownerOf scans (gap limit reached)
|
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
|
||||||
|
: (ENABLE_MAINNET && net === 'MAINNET' ? mainnet.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
|
// Utility: sleep for backoff
|
||||||
@@ -81,6 +220,26 @@ export default function DappPage() {
|
|||||||
return `nftScan:v1:${selectedChainId}:${nftAddress.toLowerCase()}`;
|
return `nftScan:v1:${selectedChainId}:${nftAddress.toLowerCase()}`;
|
||||||
}, [nftAddress, selectedChainId]);
|
}, [nftAddress, selectedChainId]);
|
||||||
|
|
||||||
|
// Resolve network key used by NFTCache API
|
||||||
|
const nftcacheNetworkKey = useMemo(() => {
|
||||||
|
if (selectedChainId === base.id) return 'base';
|
||||||
|
if (selectedChainId === arbitrum.id) return 'arb';
|
||||||
|
if (selectedChainId === mainnet.id) return 'eth';
|
||||||
|
return String(selectedChainId);
|
||||||
|
}, [selectedChainId]);
|
||||||
|
|
||||||
|
// Map known contracts to nftcache slug keys
|
||||||
|
const nftcacheContractSlug = useMemo(() => {
|
||||||
|
const addr = (nftAddress || '').toLowerCase();
|
||||||
|
// base
|
||||||
|
if (addr === '0xcc9a350c5b1e1c9ecd23d376e6618cdfd6bbbdbe') return 'cbbtc';
|
||||||
|
if (addr === '0xab825f45e9e5d2459fb7a1527a8d0ca082c582f4') return 'weth';
|
||||||
|
// arbitrum
|
||||||
|
if (addr === '0xede6f5f8a9d6b90b1392dcc9e7fd8a5b0192bfe1') return 'wbbtc';
|
||||||
|
// fallback: return empty to use raw address
|
||||||
|
return '';
|
||||||
|
}, [nftAddress]);
|
||||||
|
|
||||||
const loadCache = (): ContractCache | null => {
|
const loadCache = (): ContractCache | null => {
|
||||||
try {
|
try {
|
||||||
if (typeof window === 'undefined' || !cacheKey) return null;
|
if (typeof window === 'undefined' || !cacheKey) return null;
|
||||||
@@ -120,6 +279,41 @@ export default function DappPage() {
|
|||||||
}
|
}
|
||||||
}, [effectiveWallet, cacheKey]);
|
}, [effectiveWallet, cacheKey]);
|
||||||
|
|
||||||
|
// Try NFTCache fetch first when enabled; fallback to local cache/scan
|
||||||
|
useEffect(() => {
|
||||||
|
const run = async () => {
|
||||||
|
if (!nftcacheEnabled) return; // disabled => keep current behavior
|
||||||
|
if (!effectiveWallet || !nftAddress) return;
|
||||||
|
try {
|
||||||
|
setScanBusy(true);
|
||||||
|
const contractParam = nftcacheContractSlug || nftAddress;
|
||||||
|
const url = `${nftcacheBaseUrl}/nfts?network=${encodeURIComponent(nftcacheNetworkKey)}&nft_contract=${encodeURIComponent(contractParam)}&user_wallet=${encodeURIComponent(effectiveWallet)}`;
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const t = setTimeout(() => ctrl.abort(), 12000);
|
||||||
|
const headers: Record<string, string> = { 'Accept': 'application/json' };
|
||||||
|
if (nftcacheApiKey) headers['X-API-Key'] = nftcacheApiKey;
|
||||||
|
const res = await fetch(url, { signal: ctrl.signal, headers });
|
||||||
|
clearTimeout(t);
|
||||||
|
if (!res.ok) throw new Error(`nftcache status ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
let ids: string[] = [];
|
||||||
|
if (Array.isArray(data)) ids = data.map(String);
|
||||||
|
else if (data && Array.isArray((data as any).tokenIds)) ids = (data as any).tokenIds.map(String);
|
||||||
|
else if (data && Array.isArray((data as any).token_ids)) ids = (data as any).token_ids.map(String);
|
||||||
|
const bigs = ids.map((s) => BigInt(s)).filter((x, i, arr) => (arr.indexOf(x) === i));
|
||||||
|
setDetectedTokenIds(bigs);
|
||||||
|
setScanComplete(true);
|
||||||
|
console.log('[NFTCache] Loaded owner tokens', { count: bigs.length });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[NFTCache] Fetch failed; falling back to local scan', e);
|
||||||
|
} finally {
|
||||||
|
setScanBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
run();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [nftcacheEnabled, nftcacheBaseUrl, nftcacheApiKey, nftcacheNetworkKey, effectiveWallet, nftAddress, nftcacheContractSlug]);
|
||||||
|
|
||||||
// OwnerOf scan (batch of 12)
|
// OwnerOf scan (batch of 12)
|
||||||
const scanMore = async () => {
|
const scanMore = async () => {
|
||||||
if (!publicClient || !effectiveWallet || !nftAddress) return;
|
if (!publicClient || !effectiveWallet || !nftAddress) return;
|
||||||
@@ -320,9 +514,17 @@ export default function DappPage() {
|
|||||||
|
|
||||||
// Resolve tokenIds: either detected or manual entry
|
// Resolve tokenIds: either detected or manual entry
|
||||||
const tokenIds = useMemo(() => {
|
const tokenIds = useMemo(() => {
|
||||||
const manual = manualTokenId.trim() ? [BigInt(manualTokenId.trim())] : [];
|
// Show all sources together: detected, manual, and deep-linked (deduped)
|
||||||
return [...detectedTokenIds, ...manual];
|
const set = new Set<string>();
|
||||||
}, [detectedTokenIds, manualTokenId]);
|
for (const id of detectedTokenIds) set.add(id.toString());
|
||||||
|
if (manualTokenId.trim()) {
|
||||||
|
try { set.add(BigInt(manualTokenId.trim()).toString()); } catch {}
|
||||||
|
}
|
||||||
|
if (deeplinkTokenId.trim()) {
|
||||||
|
try { set.add(BigInt(deeplinkTokenId.trim()).toString()); } catch {}
|
||||||
|
}
|
||||||
|
return Array.from(set).map((s) => BigInt(s));
|
||||||
|
}, [detectedTokenIds, manualTokenId, deeplinkTokenId]);
|
||||||
|
|
||||||
// Build reads for debt contract
|
// Build reads for debt contract
|
||||||
const debtReads = useMemo(() => {
|
const debtReads = useMemo(() => {
|
||||||
@@ -544,6 +746,165 @@ export default function DappPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helpers to construct provider webhook URL/body for cronhost
|
||||||
|
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<string, string> = { 'Content-Type': 'text/plain' };
|
||||||
|
const targetEmail = (opts?.email || notifSettings.email || '').trim();
|
||||||
|
if (targetEmail) headers['X-Email'] = String(targetEmail);
|
||||||
|
return {
|
||||||
|
url: `${baseUrl}/${encodeURIComponent(topic)}`,
|
||||||
|
method: 'POST' as const,
|
||||||
|
body: message,
|
||||||
|
headers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (provider === 'gotify') {
|
||||||
|
const baseUrl = (notifSettings.gotifyServer || '').replace(/\/$/, '');
|
||||||
|
const token = notifSettings.gotifyToken || '';
|
||||||
|
return {
|
||||||
|
url: `${baseUrl}/message?token=${encodeURIComponent(token)}`,
|
||||||
|
method: 'POST' as const,
|
||||||
|
body: { title: 'MortgageFi Alert', message, priority: 5 },
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (provider === 'sns') {
|
||||||
|
// Note: Direct SNS publish requires AWS SigV4; recommend server-side relay. Here we simply target a placeholder you control.
|
||||||
|
// User should set up a relay endpoint that publishes to SNS.
|
||||||
|
const relay = '/api/sns-relay';
|
||||||
|
return {
|
||||||
|
url: relay,
|
||||||
|
method: 'POST' as const,
|
||||||
|
body: { topicArn: notifSettings.snsTopicArn, region: notifSettings.snsRegion, message, email: notifSettings.email },
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error('Unsupported provider');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Single schedule at configured lead time (or 1 day by default)
|
||||||
|
const scheduleNotification = async (row: { tokenId: bigint; secondsTillLiq?: bigint }) => {
|
||||||
|
const seconds = Number(row.secondsTillLiq ?? 0);
|
||||||
|
if (!Number.isFinite(seconds) || seconds <= 0) throw new Error('Invalid liquidation timer');
|
||||||
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
|
const leadSecsCfg = Math.max(0, Math.floor(Number(notifSettings.daysBefore ?? 0) * 86400));
|
||||||
|
const offset = leadSecsCfg > 0 ? leadSecsCfg : 86400; // default to 1 day
|
||||||
|
|
||||||
|
// Helper: humanize a duration in seconds
|
||||||
|
const human = (rem: number) => {
|
||||||
|
if (rem <= 0) return '0m';
|
||||||
|
const days = Math.floor(rem / 86400);
|
||||||
|
const hours = Math.floor((rem % 86400) / 3600);
|
||||||
|
const mins = Math.floor((rem % 3600) / 60);
|
||||||
|
if (days >= 2) return `${days}d ${hours}h`;
|
||||||
|
if (days === 1) return `1d ${hours}h`;
|
||||||
|
if (hours >= 1) return `${hours}h ${mins}m`;
|
||||||
|
return `${mins}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Amount suggestion: prefer total debt remaining; fallback to 1.5x monthly payment; else current payment pending
|
||||||
|
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 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' : (selectedChainId === mainnet.id ? 'MAINNET' : '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);
|
||||||
|
const formatToken = (v: bigint | undefined) => {
|
||||||
|
if (v === undefined) return null;
|
||||||
|
const dec = Number(stableDecimals ?? 6);
|
||||||
|
const factor = BigInt(10) ** BigInt(dec);
|
||||||
|
const int = v / factor;
|
||||||
|
const frac = (v % factor).toString().padStart(dec, '0').slice(0, 6);
|
||||||
|
return `${int.toString()}.${frac}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const payAmt15 = monthly !== undefined ? mul15(monthly) : undefined;
|
||||||
|
const payAmtStr = formatToken(debtRemaining ?? payAmt15 ?? paymentPending ?? BigInt(0));
|
||||||
|
const payClause = payAmtStr ? `Pay at least ${payAmtStr} USDC to avoid liquidation.` : `Make a payment to avoid liquidation.`;
|
||||||
|
// Collateral at risk: use same formatting as UI Loan Collateral
|
||||||
|
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}. Pay link: ${positionUrl}. Visit ${dappUrl} or https://markets.mortgagefi.app/dashboard.`;
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
// Cancel new multi-jobs first
|
||||||
|
if (p.jobs && p.jobs.length) {
|
||||||
|
for (const j of p.jobs) {
|
||||||
|
try { await cancelScheduledJob(notifSettings, j.id); } catch (e) { console.warn('[scheduler] cancel failed', e); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Back-compat: single jobId
|
||||||
|
if (p.jobId) {
|
||||||
|
try { await cancelScheduledJob(notifSettings, p.jobId); } catch (e) { console.warn('[scheduler] cancel failed', e); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scheduled notifications helpers
|
||||||
|
const scheduledList = useMemo(() => {
|
||||||
|
const entries = Object.entries(positionsNotif || {});
|
||||||
|
return entries
|
||||||
|
.filter(([, v]) => v && v.enabled && ((v.jobs && v.jobs.length) || v.jobId || v.scheduledAt))
|
||||||
|
.map(([k, v]) => {
|
||||||
|
const earliest = v.jobs && v.jobs.length ? Math.min(...v.jobs.map(j => j.at)) : (v.scheduledAt || 0);
|
||||||
|
return { key: k, ...v, scheduledAt: earliest };
|
||||||
|
})
|
||||||
|
.sort((a, b) => (a.scheduledAt || 0) - (b.scheduledAt || 0));
|
||||||
|
}, [positionsNotif]);
|
||||||
|
|
||||||
|
const deleteSchedule = async (key: string) => {
|
||||||
|
const p = positionsNotif[key];
|
||||||
|
if (!p) return;
|
||||||
|
try { await cancelNotification(p); } catch {}
|
||||||
|
setPositionsNotif((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[key];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const parsed = useMemo(() => {
|
const parsed = useMemo(() => {
|
||||||
if (!debtResults || !tokenIds.length) return [] as Array<{
|
if (!debtResults || !tokenIds.length) return [] as Array<{
|
||||||
tokenId: bigint;
|
tokenId: bigint;
|
||||||
@@ -583,6 +944,35 @@ export default function DappPage() {
|
|||||||
return out;
|
return out;
|
||||||
}, [debtResults, tokenIds]);
|
}, [debtResults, tokenIds]);
|
||||||
|
|
||||||
|
// Auto-reschedule when secondsTillLiq changes for enabled positions
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
for (const row of parsed) {
|
||||||
|
const key = row.tokenId.toString();
|
||||||
|
const p = positionsNotif[key];
|
||||||
|
if (!p || !p.enabled) continue;
|
||||||
|
const seconds = Number(row.secondsTillLiq ?? 0);
|
||||||
|
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 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 { 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [parsed.map(r => String(r.tokenId)+':'+String(r.secondsTillLiq)).join(','), notifSettings.daysBefore, positionsStoreKey]);
|
||||||
|
|
||||||
// Map tokenId -> max payable (Amount to reset timer)
|
// Map tokenId -> max payable (Amount to reset timer)
|
||||||
const payMaxByTokenId = useMemo(() => {
|
const payMaxByTokenId = useMemo(() => {
|
||||||
const m: Record<string, bigint> = {};
|
const m: Record<string, bigint> = {};
|
||||||
@@ -750,7 +1140,51 @@ export default function DappPage() {
|
|||||||
<div className="rounded border p-4 bg-gray-800 border-gray-700">
|
<div className="rounded border p-4 bg-gray-800 border-gray-700">
|
||||||
<div className="font-medium text-gray-100">About</div>
|
<div className="font-medium text-gray-100">About</div>
|
||||||
<p className="text-sm text-gray-300 mt-1">Enter your NFT contract and token ID(s), then fetch debt details from the mortgage contract.</p>
|
<p className="text-sm text-gray-300 mt-1">Enter your NFT contract and token ID(s), then fetch debt details from the mortgage contract.</p>
|
||||||
<p className="text-xs text-gray-400 mt-2">Base network is required. The app will attempt to detect your token IDs via on-chain queries.</p>
|
<p className="text-xs text-gray-400 mt-2">{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.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded border p-4 bg-gray-800 border-gray-700 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="font-medium text-gray-100">Scheduled notifications</div>
|
||||||
|
<button
|
||||||
|
className="text-xs px-2 py-1 border border-red-700 text-red-300 hover:bg-red-900/40 rounded"
|
||||||
|
onClick={async () => {
|
||||||
|
// Purge remotely from Schedy by ntfy topic URL
|
||||||
|
try { await purgeNtfyTopicSchedules(notifSettings); } catch (e) { console.warn('[schedy] purge failed', e); }
|
||||||
|
// Also clear any local state we track
|
||||||
|
if (scheduledList.length) {
|
||||||
|
for (const it of scheduledList) {
|
||||||
|
try { await deleteSchedule(it.key); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{scheduledList.length === 0 ? (
|
||||||
|
<div className="text-sm text-gray-400">No schedules yet.</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-gray-700">
|
||||||
|
{scheduledList.map((it) => (
|
||||||
|
<li key={it.key} className="py-2 flex items-center justify-between text-sm">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="text-gray-200">Token #{it.tokenId}</div>
|
||||||
|
<div className="text-gray-400">
|
||||||
|
{it.scheduledAt ? `Runs at ${new Date(it.scheduledAt * 1000).toLocaleString()}` : 'Scheduled'}
|
||||||
|
{it.jobId ? ` · Job ${it.jobId}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="px-2 py-1 border border-gray-600 hover:bg-gray-700 rounded text-gray-200"
|
||||||
|
onClick={() => deleteSchedule(it.key)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -860,7 +1294,55 @@ export default function DappPage() {
|
|||||||
<div className="text-gray-900">{row.startDate ? new Date(Number(row.startDate) * 1000).toLocaleDateString() : '-'}</div>
|
<div className="text-gray-900">{row.startDate ? new Date(Number(row.startDate) * 1000).toLocaleDateString() : '-'}</div>
|
||||||
|
|
||||||
<div className="text-gray-800">Seconds Till Liquidation</div>
|
<div className="text-gray-800">Seconds Till Liquidation</div>
|
||||||
<div className="text-gray-900">{fmtDuration(row.secondsTillLiq)}</div>
|
<div className="text-gray-900 flex items-center gap-3">
|
||||||
|
<span>{fmtDuration(row.secondsTillLiq)}</span>
|
||||||
|
{!isClosed && (
|
||||||
|
(() => {
|
||||||
|
const key = row.tokenId.toString();
|
||||||
|
const p = positionsNotif[key];
|
||||||
|
const checked = Boolean(p?.enabled);
|
||||||
|
const disabled = row.secondsTillLiq === undefined || Number(row.secondsTillLiq) <= 0;
|
||||||
|
const onToggle = async (next: boolean) => {
|
||||||
|
// Ensure settings
|
||||||
|
const haveProvider = (() => {
|
||||||
|
if (!notifSettings.provider) return false;
|
||||||
|
if (notifSettings.provider === 'ntfy') return Boolean(notifSettings.ntfyServer && notifSettings.ntfyTopic);
|
||||||
|
if (notifSettings.provider === 'gotify') return Boolean(notifSettings.gotifyServer && notifSettings.gotifyToken);
|
||||||
|
if (notifSettings.provider === 'sns') return Boolean(notifSettings.snsRegion && notifSettings.snsTopicArn && notifSettings.snsAccessKeyId && notifSettings.snsSecretAccessKey);
|
||||||
|
return false;
|
||||||
|
})();
|
||||||
|
const haveScheduler = (() => {
|
||||||
|
const s = notifSettings.scheduler || 'schedy';
|
||||||
|
if (s !== 'schedy') return false;
|
||||||
|
return Boolean(notifSettings.schedyBaseUrl && notifSettings.schedyApiKey);
|
||||||
|
})();
|
||||||
|
const haveSettings = Boolean(haveProvider && haveScheduler && notifSettings.email);
|
||||||
|
if (next && !haveSettings) { setSettingsOpen(true); return; }
|
||||||
|
if (!next) {
|
||||||
|
// disable and cancel
|
||||||
|
if (p) await cancelNotification(p);
|
||||||
|
setPositionsNotif((prev) => ({ ...prev, [key]: { chainId: selectedChainId, debtAddress, nftAddress, tokenId: key, enabled: false } }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { jobs } = await scheduleNotification(row);
|
||||||
|
setPositionsNotif((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[key]: { chainId: selectedChainId, debtAddress, nftAddress, tokenId: key, enabled: true, jobs },
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[notif] schedule failed', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<label className="inline-flex items-center gap-1 text-xs text-gray-700">
|
||||||
|
<input type="checkbox" className="h-4 w-4" disabled={disabled} checked={checked} onChange={(e) => onToggle(e.target.checked)} />
|
||||||
|
Enable alert
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pay controls (hidden for Closed/Defaulted loans) */}
|
{/* Pay controls (hidden for Closed/Defaulted loans) */}
|
||||||
@@ -918,9 +1400,19 @@ export default function DappPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-xs text-gray-500">
|
{/* Footer note removed per requirements */}
|
||||||
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.
|
|
||||||
</div>
|
{/* Settings Modal */}
|
||||||
|
<SettingsModal
|
||||||
|
open={settingsOpen}
|
||||||
|
initial={notifSettings as NotificationSettings}
|
||||||
|
onClose={() => setSettingsOpen(false)}
|
||||||
|
onSave={(s) => {
|
||||||
|
console.log('[Settings] Save requested', s);
|
||||||
|
setNotifSettings(s);
|
||||||
|
setSettingsOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
26
app/dapp/position/[network]/[preset]/[tokenId]/page.tsx
Normal file
26
app/dapp/position/[network]/[preset]/[tokenId]/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import DappPage from "../../../../page";
|
||||||
|
|
||||||
|
export default function PositionDeepLinkPage() {
|
||||||
|
const params = useParams<{ network: string; preset: string; tokenId: string }>();
|
||||||
|
|
||||||
|
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 <DappPage />;
|
||||||
|
}
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useWeb3Modal } from '@web3modal/wagmi/react';
|
import { useAccount } from 'wagmi';
|
||||||
import { useAccount, useDisconnect } from 'wagmi';
|
|
||||||
import { formatAddress } from '@/utils/format';
|
import { formatAddress } from '@/utils/format';
|
||||||
|
|
||||||
export function ConnectButton() {
|
export function ConnectButton() {
|
||||||
const { open } = useWeb3Modal();
|
|
||||||
const { address, isConnected } = useAccount();
|
const { address, isConnected } = useAccount();
|
||||||
const { disconnect } = useDisconnect();
|
|
||||||
|
|
||||||
const handleConnect = () => {
|
const handleConnect = () => {
|
||||||
open();
|
try {
|
||||||
|
// Minimal connect without WalletConnect modal
|
||||||
|
if (typeof window !== 'undefined' && (window as any).ethereum?.request) {
|
||||||
|
(window as any).ethereum.request({ method: 'eth_requestAccounts' });
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isConnected && address) {
|
if (isConnected && address) {
|
||||||
@@ -19,12 +21,7 @@ export function ConnectButton() {
|
|||||||
<span className="text-sm font-medium text-gray-700">
|
<span className="text-sm font-medium text-gray-700">
|
||||||
{formatAddress(address)}
|
{formatAddress(address)}
|
||||||
</span>
|
</span>
|
||||||
<button
|
{/* Disconnect can be done from the wallet UI; no WalletConnect here */}
|
||||||
onClick={() => disconnect()}
|
|
||||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
|
||||||
>
|
|
||||||
Disconnect
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ConnectButton } from '@/components/ConnectButton';
|
|||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{ name: 'DApp', href: '/dapp', current: false },
|
{ name: 'DApp', href: '/dapp', current: false },
|
||||||
|
{ name: 'Settings', href: '/dapp?settings=1', current: false },
|
||||||
{ name: 'README', href: 'https://git.manko.yoga/manawenuz/mortgagefi-helper', current: false, external: true },
|
{ name: 'README', href: 'https://git.manko.yoga/manawenuz/mortgagefi-helper', current: false, external: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
466
components/SettingsModal.tsx
Normal file
466
components/SettingsModal.tsx
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { NotificationSettings, NotificationProvider, SchedulerProvider } from "@/types/notifications";
|
||||||
|
import { scheduleTestNotification, scheduleTestBackupNotification } from "@/utils/scheduler";
|
||||||
|
|
||||||
|
export interface SettingsModalProps {
|
||||||
|
open: boolean;
|
||||||
|
initial?: NotificationSettings;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (settings: NotificationSettings) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENV_NTFY = (process.env.NEXT_PUBLIC_NTFY_URL || process.env.NTFY_URL || 'https://ntfy.sh').replace(/\/$/, '');
|
||||||
|
const ENV_SCHEDY = (process.env.NEXT_PUBLIC_SCHEDY_URL || process.env.SCHEDY_URL || 'http://localhost:8080').replace(/\/$/, '');
|
||||||
|
const ENV_SCHEDY_API_KEY = (process.env.NEXT_PUBLIC_SCHEDY_API_KEY || process.env.SCHEDY_API_KEY || '').trim();
|
||||||
|
|
||||||
|
const defaultSettings: NotificationSettings = {
|
||||||
|
provider: "",
|
||||||
|
scheduler: 'schedy',
|
||||||
|
ntfyServer: ENV_NTFY,
|
||||||
|
ntfyTopic: "",
|
||||||
|
gotifyServer: "",
|
||||||
|
gotifyToken: "",
|
||||||
|
snsRegion: "",
|
||||||
|
snsTopicArn: "",
|
||||||
|
snsAccessKeyId: "",
|
||||||
|
snsSecretAccessKey: "",
|
||||||
|
schedyBaseUrl: ENV_SCHEDY,
|
||||||
|
schedyApiKey: ENV_SCHEDY_API_KEY,
|
||||||
|
email: "",
|
||||||
|
backupEmail: "",
|
||||||
|
backupDelayDays: 1,
|
||||||
|
daysBefore: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SettingsModal({ open, initial, onClose, onSave }: SettingsModalProps) {
|
||||||
|
const [form, setForm] = useState<NotificationSettings>(initial || defaultSettings);
|
||||||
|
const [testStatus, setTestStatus] = useState<string>("");
|
||||||
|
// RPC runtime overrides (stored in localStorage)
|
||||||
|
const [rpcBase, setRpcBase] = useState<string>("");
|
||||||
|
const [rpcArbitrum, setRpcArbitrum] = useState<string>("");
|
||||||
|
const [rpcMainnet, setRpcMainnet] = useState<string>("");
|
||||||
|
// NFTCache settings (stored in localStorage)
|
||||||
|
const ENV_NFTCACHE = (process.env.NEXT_PUBLIC_NFTCACHE_URL || process.env.NFTCACHE_URL || '/nftcache').replace(/\/$/, '');
|
||||||
|
const [nftcacheEnabled, setNftcacheEnabled] = useState<boolean>(false);
|
||||||
|
const [nftcacheBaseUrl, setNftcacheBaseUrl] = useState<string>(ENV_NFTCACHE);
|
||||||
|
const [nftcacheApiKey, setNftcacheApiKey] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
// 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);
|
||||||
|
// Load RPC overrides from localStorage
|
||||||
|
try {
|
||||||
|
const ls = typeof window !== 'undefined' ? window.localStorage : undefined;
|
||||||
|
const b = ls?.getItem('rpc:base') || '';
|
||||||
|
const a = ls?.getItem('rpc:arbitrum') || '';
|
||||||
|
const m = ls?.getItem('rpc:mainnet') || '';
|
||||||
|
setRpcBase(b || 'https://base.llamarpc.com');
|
||||||
|
setRpcArbitrum(a || '');
|
||||||
|
setRpcMainnet(m || '');
|
||||||
|
// Load NFTCache settings
|
||||||
|
const nEn = ls?.getItem('nftcache:enabled');
|
||||||
|
const nUrl = ls?.getItem('nftcache:baseUrl');
|
||||||
|
const nKey = ls?.getItem('nftcache:apiKey');
|
||||||
|
setNftcacheEnabled(nEn === '1');
|
||||||
|
setNftcacheBaseUrl((nUrl && nUrl.trim()) || ENV_NFTCACHE);
|
||||||
|
setNftcacheApiKey(nKey || (process.env.NEXT_PUBLIC_NFTCACHE_API_KEY || process.env.NFTCACHE_API_KEY || ''));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, initial]);
|
||||||
|
|
||||||
|
const provider: NotificationProvider | '' = form.provider || '';
|
||||||
|
const scheduler: SchedulerProvider | '' = form.scheduler || '';
|
||||||
|
|
||||||
|
const saveDisabled = useMemo(() => {
|
||||||
|
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);
|
||||||
|
if (provider === 'gotify') return !(form.gotifyServer && form.gotifyToken);
|
||||||
|
if (provider === 'sns') return !(form.snsRegion && form.snsTopicArn && form.snsAccessKeyId && form.snsSecretAccessKey);
|
||||||
|
if (scheduler === 'schedy') return !(form.schedyBaseUrl && form.schedyApiKey);
|
||||||
|
return true;
|
||||||
|
}, [form, provider, scheduler]);
|
||||||
|
|
||||||
|
const canTest = useMemo(() => {
|
||||||
|
// Basic same checks as save + provider must be ntfy
|
||||||
|
if (saveDisabled) return false;
|
||||||
|
return provider === 'ntfy' && scheduler === 'schedy';
|
||||||
|
}, [saveDisabled, provider, scheduler]);
|
||||||
|
|
||||||
|
const onTest = async () => {
|
||||||
|
setTestStatus('Scheduling test…');
|
||||||
|
try {
|
||||||
|
const { jobId } = await scheduleTestNotification(form);
|
||||||
|
setTestStatus(`Test scheduled (job ${jobId}). You should receive a notification shortly.`);
|
||||||
|
} catch (e: any) {
|
||||||
|
setTestStatus(`Test failed: ${e?.message || String(e)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
|
||||||
|
<div className="relative z-10 w-full max-w-xl rounded-lg border border-gray-700 bg-gray-900 p-4 text-gray-100 shadow-lg">
|
||||||
|
<div className="text-lg font-semibold">Notification Settings</div>
|
||||||
|
<p className="text-sm text-gray-300">Configure your notification provider, scheduler, RPC and NFTCache.</p>
|
||||||
|
|
||||||
|
{/* Section: Basics */}
|
||||||
|
<details className="mt-3" open>
|
||||||
|
<summary className="cursor-pointer select-none text-sm font-semibold text-gray-200">Basics</summary>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<label className="col-span-2">
|
||||||
|
<span className="text-gray-300">Provider</span>
|
||||||
|
<select
|
||||||
|
className="mt-1 w-full rounded-none border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100 appearance-none shadow-none focus:outline-none focus:ring-0"
|
||||||
|
value={provider}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, provider: e.target.value as NotificationProvider }))}
|
||||||
|
>
|
||||||
|
<option value="">Select…</option>
|
||||||
|
<option value="ntfy">ntfy.sh</option>
|
||||||
|
<option value="gotify">Gotify</option>
|
||||||
|
<option value="sns">Amazon SNS</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="col-span-2">
|
||||||
|
<span className="text-gray-300">Scheduler</span>
|
||||||
|
<select
|
||||||
|
className="mt-1 w-full rounded-none border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100 appearance-none shadow-none focus:outline-none focus:ring-0"
|
||||||
|
value={scheduler}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, scheduler: e.target.value as SchedulerProvider }))}
|
||||||
|
>
|
||||||
|
<option value="schedy">Schedy</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{/* Section: Email & Timing */}
|
||||||
|
<details className="mt-3" open>
|
||||||
|
<summary className="cursor-pointer select-none text-sm font-semibold text-gray-200">Email & Timing</summary>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<label>
|
||||||
|
<span className="text-gray-300">Email to notify</span>
|
||||||
|
<input
|
||||||
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={form.email || ''}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span className="text-gray-300">Backup Email (optional)</span>
|
||||||
|
<input
|
||||||
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
||||||
|
placeholder="backup@example.com"
|
||||||
|
value={form.backupEmail || ''}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, backupEmail: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span className="text-gray-300">Days before liquidation</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
||||||
|
value={form.daysBefore ?? 10}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, daysBefore: Number(e.target.value) }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className="text-gray-300">Backup delay (days after first email)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
||||||
|
value={form.backupDelayDays ?? 1}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, backupDelayDays: Number(e.target.value) }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{/* Section: Scheduler (Schedy) */}
|
||||||
|
<details className="mt-3">
|
||||||
|
<summary className="cursor-pointer select-none text-sm font-semibold text-gray-200">Scheduler</summary>
|
||||||
|
{scheduler === 'schedy' && (
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<label>
|
||||||
|
<span className="text-gray-300">Schedy Base URL</span>
|
||||||
|
<input
|
||||||
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
||||||
|
placeholder="http://localhost:8080"
|
||||||
|
value={form.schedyBaseUrl || ''}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, schedyBaseUrl: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className="text-gray-300">Schedy API Key</span>
|
||||||
|
<input
|
||||||
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
||||||
|
placeholder="your-secret"
|
||||||
|
value={form.schedyApiKey || ''}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, schedyApiKey: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{/* Section: Provider-specific */}
|
||||||
|
<details className="mt-3">
|
||||||
|
<summary className="cursor-pointer select-none text-sm font-semibold text-gray-200">Provider Settings</summary>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-3 text-sm">
|
||||||
|
{provider === 'ntfy' && (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
<span className="text-gray-300">ntfy Server</span>
|
||||||
|
<input
|
||||||
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
||||||
|
placeholder="https://ntfy.sh"
|
||||||
|
value={form.ntfyServer || ''}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, ntfyServer: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className="text-gray-300">ntfy Topic</span>
|
||||||
|
<input
|
||||||
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
||||||
|
placeholder="topic-name"
|
||||||
|
value={form.ntfyTopic || ''}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, ntfyTopic: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{provider === 'gotify' && (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
<span className="text-gray-300">Gotify Server</span>
|
||||||
|
<input
|
||||||
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
||||||
|
placeholder="https://gotify.example.com"
|
||||||
|
value={form.gotifyServer || ''}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, gotifyServer: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className="text-gray-300">Gotify App Token</span>
|
||||||
|
<input
|
||||||
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
||||||
|
placeholder="token"
|
||||||
|
value={form.gotifyToken || ''}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, gotifyToken: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{provider === 'sns' && (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
<span className="text-gray-300">AWS Region</span>
|
||||||
|
<input
|
||||||
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
||||||
|
placeholder="us-east-1"
|
||||||
|
value={form.snsRegion || ''}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, snsRegion: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className="text-gray-300">SNS Topic ARN</span>
|
||||||
|
<input
|
||||||
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
||||||
|
placeholder="arn:aws:sns:..."
|
||||||
|
value={form.snsTopicArn || ''}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, snsTopicArn: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className="text-gray-300">AWS Access Key ID</span>
|
||||||
|
<input
|
||||||
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
||||||
|
placeholder="AKIA..."
|
||||||
|
value={form.snsAccessKeyId || ''}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, snsAccessKeyId: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className="text-gray-300">AWS Secret Access Key</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={form.snsSecretAccessKey || ''}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, snsSecretAccessKey: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="col-span-2 text-xs text-amber-300">
|
||||||
|
Storing AWS credentials in the browser is insecure. Prefer a server-side relay.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{/* Section: RPC endpoints */}
|
||||||
|
<details className="mt-3">
|
||||||
|
<summary className="cursor-pointer select-none text-sm font-semibold text-gray-200">RPC endpoints</summary>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<label>
|
||||||
|
<span className="text-gray-300">Base RPC (default https://base.llamarpc.com)</span>
|
||||||
|
<input
|
||||||
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
||||||
|
placeholder="https://base.llamarpc.com"
|
||||||
|
value={rpcBase}
|
||||||
|
onChange={(e) => setRpcBase(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className="text-gray-300">Arbitrum RPC (optional)</span>
|
||||||
|
<input
|
||||||
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
||||||
|
placeholder="https://arb.yourrpc.example"
|
||||||
|
value={rpcArbitrum}
|
||||||
|
onChange={(e) => setRpcArbitrum(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="col-span-2">
|
||||||
|
<span className="text-gray-300">Mainnet RPC (optional)</span>
|
||||||
|
<input
|
||||||
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
||||||
|
placeholder="https://mainnet.yourrpc.example"
|
||||||
|
value={rpcMainnet}
|
||||||
|
onChange={(e) => setRpcMainnet(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="col-span-2 text-xs text-gray-400">Changes apply immediately after Save without rebuild.</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{/* Section: NFTCache */}
|
||||||
|
<details className="mt-3">
|
||||||
|
<summary className="cursor-pointer select-none text-sm font-semibold text-gray-200">NFTCache</summary>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4"
|
||||||
|
checked={nftcacheEnabled}
|
||||||
|
onChange={(e) => setNftcacheEnabled(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="text-gray-300">Enable NFTCache</span>
|
||||||
|
</label>
|
||||||
|
<label className="col-span-2">
|
||||||
|
<span className="text-gray-300">NFTCache Base URL</span>
|
||||||
|
<input
|
||||||
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
||||||
|
placeholder="/nftcache"
|
||||||
|
value={nftcacheBaseUrl}
|
||||||
|
onChange={(e) => setNftcacheBaseUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-gray-400 mt-1">Proxied default via nginx: /nftcache. Endpoint used in-app: {nftcacheBaseUrl || ENV_NFTCACHE}/nfts</div>
|
||||||
|
</label>
|
||||||
|
<label className="col-span-2">
|
||||||
|
<span className="text-gray-300">NFTCache API Key (X-API-Key)</span>
|
||||||
|
<input
|
||||||
|
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
||||||
|
placeholder="optional, can also be set via NEXT_PUBLIC_NFTCACHE_API_KEY"
|
||||||
|
value={nftcacheApiKey}
|
||||||
|
onChange={(e) => setNftcacheApiKey(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-gray-400 mt-1">If empty, the app will try NEXT_PUBLIC_NFTCACHE_API_KEY from the environment.</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
|
<button className="rounded bg-gray-700 px-3 py-1.5" onClick={onClose}>Cancel</button>
|
||||||
|
<button
|
||||||
|
className="rounded bg-emerald-600 px-3 py-1.5 disabled:opacity-50"
|
||||||
|
disabled={!canTest}
|
||||||
|
onClick={onTest}
|
||||||
|
title={provider !== 'ntfy' ? 'Test currently supports ntfy provider only' : ''}
|
||||||
|
>
|
||||||
|
Send test alert
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded bg-teal-600 px-3 py-1.5 disabled:opacity-50"
|
||||||
|
disabled={!canTest || !(form.backupEmail || '').trim()}
|
||||||
|
onClick={onTestBackup}
|
||||||
|
title={!((form.backupEmail || '').trim()) ? 'Set a backup email first' : ''}
|
||||||
|
>
|
||||||
|
Send backup test
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded bg-indigo-600 px-3 py-1.5 disabled:opacity-50"
|
||||||
|
disabled={saveDisabled}
|
||||||
|
onClick={() => {
|
||||||
|
// Persist RPC overrides
|
||||||
|
try {
|
||||||
|
if (typeof window !== 'undefined' && window.localStorage) {
|
||||||
|
const ls = window.localStorage;
|
||||||
|
const b = (rpcBase || '').trim();
|
||||||
|
const a = (rpcArbitrum || '').trim();
|
||||||
|
const m = (rpcMainnet || '').trim();
|
||||||
|
if (b) ls.setItem('rpc:base', b); else ls.removeItem('rpc:base');
|
||||||
|
if (a) ls.setItem('rpc:arbitrum', a); else ls.removeItem('rpc:arbitrum');
|
||||||
|
if (m) ls.setItem('rpc:mainnet', m); else ls.removeItem('rpc:mainnet');
|
||||||
|
// Persist NFTCache settings
|
||||||
|
ls.setItem('nftcache:enabled', nftcacheEnabled ? '1' : '0');
|
||||||
|
const nurl = (nftcacheBaseUrl || '').trim();
|
||||||
|
if (nurl) ls.setItem('nftcache:baseUrl', nurl); else ls.removeItem('nftcache:baseUrl');
|
||||||
|
const nkey = (nftcacheApiKey || '').trim();
|
||||||
|
if (nkey) ls.setItem('nftcache:apiKey', nkey); else ls.removeItem('nftcache:apiKey');
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
onSave(form);
|
||||||
|
try {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
// Ensure new RPCs are applied by reloading the app
|
||||||
|
setTimeout(() => window.location.reload(), 150);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{testStatus && (
|
||||||
|
<div className="mt-2 text-xs text-gray-300">{testStatus}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,7 @@
|
|||||||
import { createConfig, http } from 'wagmi';
|
import { createConfig, http } from 'wagmi';
|
||||||
import { mainnet, sepolia, base, arbitrum } from 'wagmi/chains';
|
import { mainnet, sepolia, base, arbitrum } from 'wagmi/chains';
|
||||||
import { createWeb3Modal } from '@web3modal/wagmi';
|
|
||||||
|
|
||||||
// 1. Get projectId at https://cloud.walletconnect.com
|
// Create wagmiConfig
|
||||||
const projectId = process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || '';
|
|
||||||
if (!projectId) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.warn('[Web3] Missing NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID. WalletConnect wallet list will be limited.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Create wagmiConfig
|
|
||||||
const metadata = {
|
const metadata = {
|
||||||
name: 'MortgageFi',
|
name: 'MortgageFi',
|
||||||
description: 'Decentralized Mortgage Lending Platform',
|
description: 'Decentralized Mortgage Lending Platform',
|
||||||
@@ -17,26 +9,35 @@ const metadata = {
|
|||||||
icons: ['https://mortgagefi.app/logo.png']
|
icons: ['https://mortgagefi.app/logo.png']
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Prefer custom RPCs to avoid public-provider rate limits (429)
|
||||||
|
// Support runtime overrides via localStorage:
|
||||||
|
// - rpc:base
|
||||||
|
// - rpc:arbitrum
|
||||||
|
// - rpc:mainnet
|
||||||
|
function runtimeRpc(key: string): string | null {
|
||||||
|
try {
|
||||||
|
if (typeof window !== 'undefined' && window.localStorage) {
|
||||||
|
const v = window.localStorage.getItem(key);
|
||||||
|
return (v && v.trim()) ? v.trim() : null;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enableMainnet = process.env.NEXT_PUBLIC_ENABLE_MAINNET === 'true';
|
||||||
|
|
||||||
|
const baseRpc = runtimeRpc('rpc:base') || process.env.NEXT_PUBLIC_RPC_BASE || 'https://base.llamarpc.com';
|
||||||
|
const arbitrumRpc = runtimeRpc('rpc:arbitrum') || process.env.NEXT_PUBLIC_RPC_ARBITRUM || '';
|
||||||
|
const mainnetRpc = runtimeRpc('rpc:mainnet') || '';
|
||||||
|
|
||||||
export const config = createConfig({
|
export const config = createConfig({
|
||||||
chains: [base, arbitrum, mainnet, sepolia],
|
chains: [base, arbitrum, ...(enableMainnet ? [mainnet] : []), sepolia],
|
||||||
transports: {
|
transports: {
|
||||||
[base.id]: http(),
|
[base.id]: baseRpc ? http(baseRpc, { batch: true, retryCount: 2, retryDelay: 250 }) : http(undefined, { batch: true, retryCount: 2, retryDelay: 250 }),
|
||||||
[arbitrum.id]: http(),
|
[arbitrum.id]: arbitrumRpc ? http(arbitrumRpc, { batch: true, retryCount: 2, retryDelay: 250 }) : http(undefined, { batch: true, retryCount: 2, retryDelay: 250 }),
|
||||||
[mainnet.id]: http(),
|
[mainnet.id]: mainnetRpc ? http(mainnetRpc, { batch: true, retryCount: 2, retryDelay: 250 }) : http(),
|
||||||
[sepolia.id]: http(),
|
[sepolia.id]: http(),
|
||||||
},
|
},
|
||||||
ssr: true,
|
ssr: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Create modal
|
|
||||||
export const web3Modal = createWeb3Modal({
|
|
||||||
wagmiConfig: config,
|
|
||||||
projectId: projectId || 'missing_project_id',
|
|
||||||
enableAnalytics: true,
|
|
||||||
enableOnramp: true,
|
|
||||||
themeMode: 'light',
|
|
||||||
themeVariables: {
|
|
||||||
'--w3m-accent': '#4F46E5',
|
|
||||||
'--w3m-font-family': 'Inter, sans-serif',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|||||||
75
docker-compose.yml
Normal file
75
docker-compose.yml
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
services:
|
||||||
|
ntfy:
|
||||||
|
image: binwiederhier/ntfy
|
||||||
|
container_name: ntfy
|
||||||
|
platform: linux/amd64
|
||||||
|
command: ["serve"]
|
||||||
|
env_file: .env.local
|
||||||
|
environment:
|
||||||
|
- TZ=Europe/Zurich
|
||||||
|
# ntfy email via SMTP (Gmail App Password or your SMTP)
|
||||||
|
- NTFY_BASE_URL=${NTFY_BASE_URL:-https://web.windsurf-project.orb.local}
|
||||||
|
- NTFY_SMTP_SENDER_ADDR=${NTFY_SMTP_SENDER_ADDR:-smtp.gmail.com:587}
|
||||||
|
- NTFY_SMTP_SENDER_USER=${NTFY_SMTP_SENDER_USER:-your.name@gmail.com}
|
||||||
|
- NTFY_SMTP_SENDER_PASS=${NTFY_SMTP_SENDER_PASS:-app-password-xxxx}
|
||||||
|
- NTFY_SMTP_SENDER_FROM=${NTFY_SMTP_SENDER_FROM:-your.name@gmail.com}
|
||||||
|
- NTFY_LOG_LEVEL=${NTFY_LOG_LEVEL:-info}
|
||||||
|
user: "4242:4242"
|
||||||
|
volumes:
|
||||||
|
- ../data/ntfy/cache:/var/cache/ntfy
|
||||||
|
- ../data/ntfy/etc/ntfy:/etc/ntfy
|
||||||
|
ports:
|
||||||
|
- "8081:80"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
schedy:
|
||||||
|
build: ./submodules/schedy
|
||||||
|
container_name: schedy
|
||||||
|
environment:
|
||||||
|
- SCHEDY_API_KEY=${SCHEDY_API_KEY}
|
||||||
|
- TZ=UTC
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
# Persist BadgerDB data so schedules survive restarts
|
||||||
|
- ../data/schedy:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
# image: node:20-alpine
|
||||||
|
image: git.manko.yoga/manawenuz/mortgagefi-frontend:alert
|
||||||
|
platform: linux/amd64
|
||||||
|
# container_name: mortgagefi-frontend
|
||||||
|
env_file: .env.local
|
||||||
|
environment:
|
||||||
|
- HOST=0.0.0.0
|
||||||
|
- PORT=3000
|
||||||
|
- NEXT_PUBLIC_NTFY_URL=${NEXT_PUBLIC_NTFY_URL:-/ntfy}
|
||||||
|
- NEXT_PUBLIC_SCHEDY_URL=${NEXT_PUBLIC_SCHEDY_URL:-/schedy}
|
||||||
|
# ports:
|
||||||
|
- "3000:3000"
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- ntfy
|
||||||
|
- schedy
|
||||||
|
|
||||||
|
web:
|
||||||
|
image: nginx:alpine
|
||||||
|
platform: linux/amd64
|
||||||
|
container_name: mortgagefi-proxy
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
volumes:
|
||||||
|
- ../nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
depends_on:
|
||||||
|
- frontend
|
||||||
|
- ntfy
|
||||||
|
- schedy
|
||||||
|
|
||||||
|
# Usage:
|
||||||
|
# 1) Optionally, set in .env.local:
|
||||||
|
# NEXT_PUBLIC_NTFY_URL=/ntfy
|
||||||
|
# NEXT_PUBLIC_SCHEDY_URL=/schedy
|
||||||
|
# NEXT_PUBLIC_SCHEDY_API_KEY=your-schedy-secret
|
||||||
|
# 2) docker compose up -d
|
||||||
|
# 3) App: http://localhost ; ntfy (proxied): http://localhost/ntfy ; schedy (proxied): http://localhost/schedy
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
// Skip ESLint during production builds (Vercel) – we can fix lint later
|
// Produce a self-contained server bundle at .next/standalone
|
||||||
eslint: {
|
output: 'standalone',
|
||||||
ignoreDuringBuilds: true,
|
|
||||||
},
|
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
|
|||||||
3861
package-lock.json
generated
3861
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
34
package.json
34
package.json
@@ -9,28 +9,28 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^2.2.7",
|
"@headlessui/react": "^2.2.9",
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"@tanstack/react-query": "^5.85.5",
|
"@tanstack/react-query": "^5.90.12",
|
||||||
"@web3-react/core": "^8.2.3",
|
"@web3-react/core": "^8.2.3",
|
||||||
"@web3-react/injected-connector": "^6.0.7",
|
"@web3-react/injected-connector": "^6.0.7",
|
||||||
"@web3modal/wagmi": "^5.1.11",
|
"@web3modal/wagmi": "^5.1.11",
|
||||||
"framer-motion": "^12.23.12",
|
"framer-motion": "^12.23.26",
|
||||||
"next": "15.5.0",
|
"next": "^16.0.10",
|
||||||
"react": "19.1.0",
|
"react": "^19.2.3",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "^19.2.3",
|
||||||
"viem": "^2.35.1",
|
"viem": "^2.42.1",
|
||||||
"wagmi": "^2.16.5"
|
"wagmi": "^3.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3.3.3",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@types/node": "^20",
|
"@types/node": "^25.0.2",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19.2.3",
|
||||||
"eslint": "^9",
|
"eslint": "^9.39.2",
|
||||||
"eslint-config-next": "15.5.0",
|
"eslint-config-next": "^16.0.10",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "^5"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { createWeb3Modal } from '@web3modal/wagmi/react';
|
import { WagmiProvider } from 'wagmi';
|
||||||
import { WagmiProvider, createConfig, http } from 'wagmi';
|
|
||||||
import { mainnet, sepolia, base, arbitrum } from 'wagmi/chains';
|
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { PropsWithChildren, useEffect, useState } from 'react';
|
import { PropsWithChildren, useEffect, useState } from 'react';
|
||||||
|
import { config } from '../config/web3';
|
||||||
const projectId = process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || '';
|
|
||||||
if (!projectId) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.warn('[Web3] Missing NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID. WalletConnect will be limited.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const metadata = {
|
const metadata = {
|
||||||
name: 'MortgageFi',
|
name: 'MortgageFi',
|
||||||
@@ -19,29 +12,6 @@ const metadata = {
|
|||||||
icons: ['https://mortgagefi.app/logo.png']
|
icons: ['https://mortgagefi.app/logo.png']
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = createConfig({
|
|
||||||
chains: [base, arbitrum, mainnet, sepolia],
|
|
||||||
transports: {
|
|
||||||
[base.id]: http(),
|
|
||||||
[arbitrum.id]: http(),
|
|
||||||
[mainnet.id]: http(),
|
|
||||||
[sepolia.id]: http(),
|
|
||||||
},
|
|
||||||
ssr: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
createWeb3Modal({
|
|
||||||
wagmiConfig: config,
|
|
||||||
projectId: projectId || 'missing_project_id',
|
|
||||||
enableAnalytics: true,
|
|
||||||
enableOnramp: true,
|
|
||||||
themeMode: 'light',
|
|
||||||
themeVariables: {
|
|
||||||
'--w3m-accent': '#4F46E5',
|
|
||||||
'--w3m-font-family': 'Inter, sans-serif',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
export function Web3Provider({ children }: PropsWithChildren) {
|
export function Web3Provider({ children }: PropsWithChildren) {
|
||||||
@@ -59,3 +29,4 @@ export function Web3Provider({ children }: PropsWithChildren) {
|
|||||||
</WagmiProvider>
|
</WagmiProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1
submodules/schedy
Submodule
1
submodules/schedy
Submodule
Submodule submodules/schedy added at 4a5a6a5aad
46
types/notifications.ts
Normal file
46
types/notifications.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
export type NotificationProvider = 'ntfy' | 'gotify' | 'sns';
|
||||||
|
export type SchedulerProvider = 'schedy';
|
||||||
|
|
||||||
|
export interface NotificationSettings {
|
||||||
|
provider: NotificationProvider | '';
|
||||||
|
scheduler?: SchedulerProvider | '';
|
||||||
|
// Ntfy
|
||||||
|
ntfyServer?: string; // e.g., https://ntfy.sh or self-hosted
|
||||||
|
ntfyTopic?: string;
|
||||||
|
// Gotify
|
||||||
|
gotifyServer?: string; // base URL
|
||||||
|
gotifyToken?: string;
|
||||||
|
// Amazon SNS
|
||||||
|
snsRegion?: string;
|
||||||
|
snsTopicArn?: string;
|
||||||
|
snsAccessKeyId?: string;
|
||||||
|
snsSecretAccessKey?: string;
|
||||||
|
// Schedy
|
||||||
|
schedyBaseUrl?: string; // e.g., http://localhost:8080
|
||||||
|
schedyApiKey?: string;
|
||||||
|
// Contact details
|
||||||
|
email?: string;
|
||||||
|
// Backup contact email and delay after first email
|
||||||
|
backupEmail?: string;
|
||||||
|
// Days after the first email to send a backup notification
|
||||||
|
backupDelayDays?: number;
|
||||||
|
// Lead time before liquidation in days
|
||||||
|
daysBefore?: number; // default 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PositionNotification {
|
||||||
|
chainId: number;
|
||||||
|
debtAddress: string;
|
||||||
|
nftAddress: string;
|
||||||
|
tokenId: string; // decimal string
|
||||||
|
// Legacy single-job fields (backward compat with already-stored values)
|
||||||
|
jobId?: string; // scheduler job id (from schedy)
|
||||||
|
scheduledAt?: number; // epoch seconds
|
||||||
|
// New multi-job support
|
||||||
|
jobs?: Array<{
|
||||||
|
id: string;
|
||||||
|
at: number; // epoch seconds
|
||||||
|
label: 'lead' | 'half' | 'last';
|
||||||
|
}>;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
38
utils/cronhost.ts
Normal file
38
utils/cronhost.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// NOTE: Real cronhost API integration pending official REST docs.
|
||||||
|
// These stubs simulate scheduling/canceling and should be replaced with actual fetch calls.
|
||||||
|
|
||||||
|
export interface ScheduleParams {
|
||||||
|
apiKey: string;
|
||||||
|
runAt: number; // epoch seconds
|
||||||
|
method: 'POST' | 'GET';
|
||||||
|
url: string;
|
||||||
|
body?: any;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleResult {
|
||||||
|
jobId: string;
|
||||||
|
scheduledRunAtUtc: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scheduleOneTimeJob(params: ScheduleParams): Promise<ScheduleResult> {
|
||||||
|
const { apiKey, runAt, method, url } = params;
|
||||||
|
if (!apiKey) throw new Error('Missing cronhost API key');
|
||||||
|
if (!url) throw new Error('Missing target URL');
|
||||||
|
if (!runAt || runAt < Math.floor(Date.now() / 1000)) throw new Error('runAt must be in the future');
|
||||||
|
|
||||||
|
console.warn('[cronhost] scheduleOneTimeJob is a stub. Replace with real API call.');
|
||||||
|
// Example real call outline:
|
||||||
|
// await fetch('https://api.cronho.st/schedules', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({...}) })
|
||||||
|
|
||||||
|
return {
|
||||||
|
jobId: `stub_${Math.random().toString(36).slice(2)}`,
|
||||||
|
scheduledRunAtUtc: runAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelJob(apiKey: string, jobId: string): Promise<void> {
|
||||||
|
if (!apiKey || !jobId) return;
|
||||||
|
console.warn('[cronhost] cancelJob is a stub. Replace with real API call.', { jobId });
|
||||||
|
// Example: await fetch(`https://api.cronho.st/jobs/${jobId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` } })
|
||||||
|
}
|
||||||
171
utils/scheduler.ts
Normal file
171
utils/scheduler.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { NotificationSettings } from '@/types/notifications';
|
||||||
|
|
||||||
|
export interface ScheduleArgs {
|
||||||
|
runAtEpoch: number; // seconds
|
||||||
|
method: 'POST' | 'GET';
|
||||||
|
url: string;
|
||||||
|
body?: any;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule a backup-email specific test (uses backupEmail target)
|
||||||
|
export async function scheduleTestBackupNotification(settings: NotificationSettings): Promise<ScheduleResult> {
|
||||||
|
const scheduler = settings.scheduler || 'schedy';
|
||||||
|
if (scheduler !== 'schedy') throw new Error('Only Schedy is supported for tests');
|
||||||
|
const backup = (settings.backupEmail || '').trim();
|
||||||
|
if (!backup) throw new Error('Please set a backup email');
|
||||||
|
|
||||||
|
if (settings.provider !== 'ntfy') {
|
||||||
|
throw new Error('Backup test currently supports ntfy provider only');
|
||||||
|
}
|
||||||
|
|
||||||
|
const topic = (settings.ntfyTopic || '').trim();
|
||||||
|
const base = (settings.ntfyServer || process.env.NEXT_PUBLIC_NTFY_URL || '/ntfy').replace(/\/$/, '');
|
||||||
|
if (!base || !topic) throw new Error('ntfy server or topic missing');
|
||||||
|
|
||||||
|
const relUrl = `${base}/${encodeURIComponent(topic)}`;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const runAtEpoch = now + 120; // 2 minutes
|
||||||
|
const primary = (settings.email || '').trim();
|
||||||
|
const prefix = `You are the backup contact for this position. Please contact the primary contact${primary ? ' ' + primary : ''} immediately. If they do not respond, please pay down the debt to avoid liquidation in a timely manner.`;
|
||||||
|
const body = `${prefix}\n\n(MortgageFi backup email test at ${new Date().toISOString()})`;
|
||||||
|
return schedySchedule((settings.schedyBaseUrl || ENV_SCHEDY).replace(/\/$/, ''), settings.schedyApiKey || '', {
|
||||||
|
runAtEpoch,
|
||||||
|
method: 'POST',
|
||||||
|
url: relUrl,
|
||||||
|
body,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain',
|
||||||
|
'X-Email': backup,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleResult { jobId: string; scheduledRunAtUtc: number }
|
||||||
|
|
||||||
|
// Resolve an absolute callback URL for schedulers that execute webhooks server-side
|
||||||
|
function toAbsoluteUrl(url: string): string {
|
||||||
|
// Already absolute
|
||||||
|
if (/^https?:\/\//i.test(url)) return url;
|
||||||
|
// If relative, prefer an explicit callback origin if provided
|
||||||
|
const explicit = (process.env.NEXT_PUBLIC_CALLBACK_ORIGIN || '').replace(/\/$/, '');
|
||||||
|
if (explicit) return `${explicit}${url.startsWith('/') ? '' : '/'}${url}`;
|
||||||
|
// Fallback to browser origin (client-side only). If unavailable, return as-is.
|
||||||
|
try {
|
||||||
|
if (typeof window !== 'undefined' && window.location?.origin) {
|
||||||
|
return `${window.location.origin}${url.startsWith('/') ? '' : '/'}${url}`;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedy implementation
|
||||||
|
async function schedySchedule(baseUrl: string, apiKey: string, args: ScheduleArgs): Promise<ScheduleResult> {
|
||||||
|
const executeAt = new Date(args.runAtEpoch * 1000).toISOString(); // RFC3339 UTC
|
||||||
|
const targetUrl = toAbsoluteUrl(args.url);
|
||||||
|
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/tasks`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },
|
||||||
|
body: JSON.stringify({
|
||||||
|
execute_at: executeAt,
|
||||||
|
url: targetUrl,
|
||||||
|
headers: args.headers || {},
|
||||||
|
payload: args.body ?? undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`Schedy schedule failed: ${res.status} ${text}`);
|
||||||
|
}
|
||||||
|
const json = await res.json().catch(() => ({} as any));
|
||||||
|
const id = json?.id || json?.task_id || `schedy_${Math.random().toString(36).slice(2)}`;
|
||||||
|
return { jobId: String(id), scheduledRunAtUtc: args.runAtEpoch };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function schedyCancel(baseUrl: string, apiKey: string, jobId: string): Promise<void> {
|
||||||
|
await fetch(`${baseUrl.replace(/\/$/, '')}/tasks/${encodeURIComponent(jobId)}`, { method: 'DELETE', headers: { 'X-API-Key': apiKey } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Schedy management helpers ---
|
||||||
|
interface SchedyTask { id: string; url: string; execute_at?: string; headers?: Record<string,string>; payload?: any }
|
||||||
|
|
||||||
|
async function schedyList(baseUrl: string, apiKey: string): Promise<SchedyTask[]> {
|
||||||
|
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/tasks`, { headers: { 'X-API-Key': apiKey } });
|
||||||
|
if (!res.ok) throw new Error(`Schedy list failed: ${res.status}`);
|
||||||
|
const json = await res.json().catch(() => []);
|
||||||
|
return Array.isArray(json) ? json as SchedyTask[] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function schedyDelete(baseUrl: string, apiKey: string, id: string): Promise<void> {
|
||||||
|
await fetch(`${baseUrl.replace(/\/$/, '')}/tasks/${encodeURIComponent(id)}`, { method: 'DELETE', headers: { 'X-API-Key': apiKey } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute absolute ntfy topic URL for comparison (matches what schedule uses)
|
||||||
|
function absoluteNtfyUrl(settings: NotificationSettings): string | null {
|
||||||
|
const topic = (settings.ntfyTopic || '').trim();
|
||||||
|
if (!topic) return null;
|
||||||
|
const base = (settings.ntfyServer || process.env.NEXT_PUBLIC_NTFY_URL || '/ntfy').replace(/\/$/, '');
|
||||||
|
const rel = `${base}/${encodeURIComponent(topic)}`;
|
||||||
|
return toAbsoluteUrl(rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function purgeNtfyTopicSchedules(settings: NotificationSettings): Promise<number> {
|
||||||
|
const scheduler = settings.scheduler || 'cronhost';
|
||||||
|
if (scheduler !== 'schedy') return 0; // only implemented for schedy
|
||||||
|
const base = (settings.schedyBaseUrl || ENV_SCHEDY).replace(/\/$/, '');
|
||||||
|
const key = settings.schedyApiKey || '';
|
||||||
|
if (!base || !key) throw new Error('Schedy base URL or API key missing');
|
||||||
|
const targetUrl = absoluteNtfyUrl(settings);
|
||||||
|
if (!targetUrl) return 0;
|
||||||
|
const tasks = await schedyList(base, key);
|
||||||
|
const victims = tasks.filter(t => t.url === targetUrl);
|
||||||
|
await Promise.allSettled(victims.map(v => schedyDelete(base, key, v.id)));
|
||||||
|
return victims.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENV_SCHEDY = (process.env.NEXT_PUBLIC_SCHEDY_URL || process.env.SCHEDY_URL || 'http://localhost:8080').replace(/\/$/, '');
|
||||||
|
|
||||||
|
export async function scheduleJob(settings: NotificationSettings, args: ScheduleArgs): Promise<ScheduleResult> {
|
||||||
|
const base = (settings.schedyBaseUrl || ENV_SCHEDY).replace(/\/$/, '');
|
||||||
|
const key = settings.schedyApiKey || '';
|
||||||
|
if (!base || !key) throw new Error('Schedy base URL or API key missing');
|
||||||
|
return schedySchedule(base, key, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelScheduledJob(settings: NotificationSettings, jobId: string): Promise<void> {
|
||||||
|
const base = (settings.schedyBaseUrl || ENV_SCHEDY).replace(/\/$/, '');
|
||||||
|
const key = settings.schedyApiKey || '';
|
||||||
|
if (!base || !key || !jobId) return;
|
||||||
|
return schedyCancel(base, key, jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule a quick test notification to validate configuration end-to-end.
|
||||||
|
export async function scheduleTestNotification(settings: NotificationSettings): Promise<ScheduleResult> {
|
||||||
|
const scheduler = settings.scheduler || 'schedy';
|
||||||
|
if (scheduler !== 'schedy') throw new Error('Only Schedy is supported for tests');
|
||||||
|
if (!settings.email) throw new Error('Please set an email (used to validate requirements)');
|
||||||
|
|
||||||
|
// Currently support ntfy provider for E2E test
|
||||||
|
if (settings.provider !== 'ntfy') {
|
||||||
|
throw new Error('Test alert currently supports ntfy provider only');
|
||||||
|
}
|
||||||
|
|
||||||
|
const topic = (settings.ntfyTopic || '').trim();
|
||||||
|
const base = (settings.ntfyServer || process.env.NEXT_PUBLIC_NTFY_URL || '/ntfy').replace(/\/$/, '');
|
||||||
|
if (!base || !topic) throw new Error('ntfy server or topic missing');
|
||||||
|
|
||||||
|
const relUrl = `${base}/${encodeURIComponent(topic)}`; // can be relative; toAbsoluteUrl will fix
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const runAtEpoch = now + 120; // 2 minutes from now
|
||||||
|
const body = `MortgageFi test alert at ${new Date().toISOString()}`;
|
||||||
|
return schedySchedule((settings.schedyBaseUrl || ENV_SCHEDY).replace(/\/$/, ''), settings.schedyApiKey || '', {
|
||||||
|
runAtEpoch,
|
||||||
|
method: 'POST',
|
||||||
|
url: relUrl,
|
||||||
|
body,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain',
|
||||||
|
...(settings.email ? { 'X-Email': settings.email } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
42
utils/useLocalStorage.ts
Normal file
42
utils/useLocalStorage.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function useLocalStorage<T>(key: string, initial: T) {
|
||||||
|
const [value, setValue] = useState<T>(() => {
|
||||||
|
try {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
console.log('[useLocalStorage] Init from storage', { key, value: parsed });
|
||||||
|
return parsed as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return initial;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const raw = typeof window !== 'undefined' ? localStorage.getItem(key) : null;
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
console.log('[useLocalStorage] Loaded', { key, value: parsed });
|
||||||
|
setValue(parsed);
|
||||||
|
} else {
|
||||||
|
console.log('[useLocalStorage] No existing value, using initial', { key, initial });
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [key]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
console.log('[useLocalStorage] Saved', { key, value });
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}, [key, value]);
|
||||||
|
|
||||||
|
return [value, setValue] as const;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user