feat(ui): Ghibli/Miyazaki reskin + Obsidian docs vault + project audit

UI: warm daylight design system (Tailwind v4 @theme palette, gh-* component
classes, watercolor grain, Zen Maru Gothic + Klee One fonts), animated SSR-safe
GhibliBackground (drifting clouds, meadow hills, soot sprites), and a full reskin
of navbar, connect button, dapp page, loan cards, settings modal, and readme.
Fixes the bg-white-on-dark loan-card inconsistency. Web3/business logic untouched.

Docs: converted docs/ into an Obsidian vault (frontmatter, [[wikilinks]],
callouts, Home MOC, folders Architecture/Operations/Audits) and added a
full-project audit note (Project Audit 2026-06). Redacted a real leaked Schedy
key value from the security audit example (rotate it at Schedy).

Also commits the previously-untracked server layer: app/api (cron + tasks routes)
and lib (redis, ssrf-guard, task-store).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-06-14 08:13:53 +04:00
parent cf76322008
commit 6ae581ab2e
25 changed files with 4245 additions and 369 deletions

View File

@@ -1,7 +1,7 @@
'use client';
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 } from 'wagmi';
import { base, arbitrum, mainnet } from 'wagmi/chains';
import { Abi, parseUnits } from 'viem';
import debtAbi from '@/ABIs/mortgagefiusdccbbtcupgraded.json';
@@ -76,7 +76,7 @@ export default function DappPage() {
const [payInputs, setPayInputs] = useState<Record<string, string>>({});
const publicClient = usePublicClient({ chainId: selectedChainId });
const { writeContractAsync, isPending: writePending } = useWriteContract();
const { sendTransactionAsync, isPending: txPending } = useSendTransaction();
const [compoundPending, setCompoundPending] = useState(false);
// NFTCache settings (from localStorage; defaults to env or '/nftcache')
const [nftcacheEnabled, setNftcacheEnabled] = useState<boolean>(false);
@@ -101,22 +101,13 @@ export default function DappPage() {
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 ENV_NTFY = (process.env.NEXT_PUBLIC_NTFY_URL || '/ntfy').replace(/\/$/, '');
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,
@@ -140,27 +131,25 @@ export default function DappPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// One-time migration: legacy scheduler providers -> schedy; remove legacy fields
// One-time migration: remove legacy and removed 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) {
const removedKeys = [
'scheduler', 'schedyBaseUrl', 'schedyApiKey',
'snsRegion', 'snsTopicArn', 'snsAccessKeyId', 'snsSecretAccessKey',
'cronhostApiKey', 'kronosBaseUrl', 'schedifyBaseUrl', 'schedifyApiKey', 'schedifyWebhookId'
];
for (const k of removedKeys) {
if (k in next) {
delete next[k];
changed = true;
}
}
if (changed) {
console.log('[Settings] Migrating legacy scheduler -> schedy and pruning legacy fields');
console.log('[Settings] Migrating and pruning legacy/removed fields');
setNotifSettings(next as NotificationSettings);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -773,24 +762,16 @@ export default function DappPage() {
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 MAX_SECONDS = 86400 * 365 * 5; // 5 years max
if (!Number.isFinite(seconds) || seconds <= 0 || seconds > MAX_SECONDS) {
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
@@ -842,7 +823,7 @@ export default function DappPage() {
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 });
const res1 = await scheduleJob({ 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' }> = [
@@ -861,7 +842,7 @@ export default function DappPage() {
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 });
const res2 = await scheduleJob({ runAtEpoch: runAt2, method: req2.method, url: req2.url, body: req2.body, headers: req2.headers });
jobs.push({ id: res2.jobId, at: runAt2, label: 'last' });
}
}
@@ -873,12 +854,12 @@ export default function DappPage() {
// 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); }
try { await cancelScheduledJob(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); }
try { await cancelScheduledJob(p.jobId); } catch (e) { console.warn('[scheduler] cancel failed', e); }
}
};
@@ -1032,37 +1013,55 @@ export default function DappPage() {
};
// Compound action: raw tx to debt token contract with provided data
// Only allowed against known preset addresses to prevent arbitrary contract calls
const handleCompound = async () => {
try {
if (!debtAddress) return;
await sendTransactionAsync({
to: debtAddress as `0x${string}`,
data: '0x4e71d92d',
const isKnownPreset = Object.values(PRESETS).some(
(list) => list?.some((p) => p.debt.toLowerCase() === debtAddress.toLowerCase())
);
if (!isKnownPreset) {
console.warn('[Compound] Rejected: debt address is not a known preset');
return;
}
setCompoundPending(true);
await writeContractAsync({
abi: debtAbi as Abi,
address: debtAddress as `0x${string}`,
functionName: 'compound',
chainId: selectedChainId,
});
} catch (e) {
console.warn('[Compound] Failed', e);
} finally {
setCompoundPending(false);
}
};
return (
<div className="max-w-4xl mx-auto p-6 space-y-6">
<h1 className="text-2xl font-semibold text-gray-100">MortgageFi DApp</h1>
<p className="text-sm text-gray-100">Connect wallet, detect your NFTs, and fetch debt details.</p>
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-8 space-y-6">
<header className="space-y-1">
<p className="gh-eyebrow text-sm">🌾 your quiet little vault</p>
<h1 className="font-display text-3xl sm:text-4xl font-semibold text-forest-deep">MortgageFi DApp</h1>
<p className="text-sm text-ink-soft">Connect your wallet, find your NFTs, and tend to your debt gently.</p>
</header>
{!isConnected && (
<div className="rounded border p-4 bg-amber-900/40 border-amber-700 text-amber-200">Please connect your wallet using the Connect Wallet button in the navbar.</div>
<div className="gh-note gh-note-warn flex items-center gap-2">
<span aria-hidden>🪧</span>
Please connect your wallet using the Connect Wallet button in the navbar.
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded border p-4 bg-gray-800 border-gray-700 space-y-3">
<div className="font-medium text-gray-100">Inputs</div>
<label className="block text-sm text-gray-200">
<span className="text-gray-300">Network</span>
<div className="gh-card gh-card-hover p-5 space-y-3">
<div className="gh-eyebrow text-lg flex items-center gap-2"><span aria-hidden>🌱</span>Inputs</div>
<label className="gh-label">
<span className="text-earth">Network</span>
<select
value={selectedChainId}
onChange={(e) => setSelectedChainId(Number(e.target.value))}
className="mt-1 w-full border rounded px-2 py-1 bg-gray-900 text-gray-100 border-gray-700"
className="gh-input"
>
<option value={base.id}>Base</option>
<option value={arbitrum.id}>Arbitrum</option>
@@ -1070,10 +1069,10 @@ export default function DappPage() {
</label>
{/* Preset selector for pair addresses */}
<label className="block text-sm text-gray-200">
<span className="text-gray-300">Preset</span>
<label className="gh-label">
<span className="text-earth">Preset</span>
<select
className="mt-1 w-full border rounded px-2 py-1 bg-gray-900 text-gray-100 border-gray-700"
className="gh-input"
value={presetKey}
onChange={(e) => {
const key = e.target.value;
@@ -1091,63 +1090,63 @@ export default function DappPage() {
))}
</select>
</label>
<label className="block text-sm text-gray-200">
<span className="text-gray-300">ERC-721 Address</span>
<input value={nftAddress} onChange={(e) => setNftAddress(e.target.value)} placeholder={DEFAULTS[selectedChainId as keyof typeof DEFAULTS]?.nft} className="mt-1 w-full border rounded px-2 py-1 bg-gray-900 text-gray-100 border-gray-700 placeholder-gray-400" />
<label className="gh-label">
<span className="text-earth">ERC-721 Address</span>
<input value={nftAddress} onChange={(e) => setNftAddress(e.target.value)} placeholder={DEFAULTS[selectedChainId as keyof typeof DEFAULTS]?.nft} className="gh-input" />
</label>
<label className="block text-sm text-gray-200">
<span className="text-gray-300">Debt Contract</span>
<input value={debtAddress} onChange={(e) => setDebtAddress(e.target.value)} placeholder={DEFAULTS[selectedChainId as keyof typeof DEFAULTS]?.debt} className="mt-1 w-full border rounded px-2 py-1 bg-gray-900 text-gray-100 border-gray-700 placeholder-gray-400" />
<label className="gh-label">
<span className="text-earth">Debt Contract</span>
<input value={debtAddress} onChange={(e) => setDebtAddress(e.target.value)} placeholder={DEFAULTS[selectedChainId as keyof typeof DEFAULTS]?.debt} className="gh-input" />
</label>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => loadChainDefaults(selectedChainId)}
className="px-3 py-1.5 bg-gray-700 text-gray-100 rounded hover:bg-gray-600"
className="gh-btn gh-btn-ghost"
>
Load defaults for selected network
</button>
</div>
<label className="block text-sm text-gray-200">
<span className="text-gray-300">Manual Wallet (optional)</span>
<input value={manualWallet} onChange={(e) => setManualWallet(e.target.value)} placeholder="0xYourWallet" className="mt-1 w-full border rounded px-2 py-1 bg-gray-900 text-gray-100 border-gray-700 placeholder-gray-400" />
<span className="text-xs text-gray-400">If set, scans and reads will use this address instead of the connected one.</span>
<label className="gh-label">
<span className="text-earth">Manual Wallet (optional)</span>
<input value={manualWallet} onChange={(e) => setManualWallet(e.target.value)} placeholder="0xYourWallet" className="gh-input" />
<span className="text-xs text-earth">If set, scans and reads will use this address instead of the connected one.</span>
</label>
<label className="block text-sm text-gray-200">
<span className="text-gray-300">Manual Token ID (optional)</span>
<input value={manualTokenId} onChange={(e) => setManualTokenId(e.target.value)} placeholder="e.g., 103" className="mt-1 w-full border rounded px-2 py-1 bg-gray-900 text-gray-100 border-gray-700 placeholder-gray-400" />
<label className="gh-label">
<span className="text-earth">Manual Token ID (optional)</span>
<input value={manualTokenId} onChange={(e) => setManualTokenId(e.target.value)} placeholder="e.g., 103" className="gh-input" />
</label>
<div className="flex items-center gap-2">
<button onClick={scanMore} disabled={!effectiveWallet || scanBusy || !nftAddress || scanComplete} className="px-3 py-1.5 bg-indigo-600 text-white rounded disabled:opacity-50">
<button onClick={scanMore} disabled={!effectiveWallet || scanBusy || !nftAddress || scanComplete} className="gh-btn gh-btn-primary">
{scanBusy ? 'Scanning…' : scanComplete ? 'Scan Complete' : 'Scan 12 more (ownerOf)'}
</button>
<button onClick={resetScan} disabled={!effectiveWallet || scanBusy || !nftAddress} className="px-3 py-1.5 bg-gray-700 text-gray-100 rounded hover:bg-gray-600">
<button onClick={resetScan} disabled={!effectiveWallet || scanBusy || !nftAddress} className="gh-btn gh-btn-ghost">
Reset
</button>
<button onClick={deleteLocalCache} disabled={scanBusy || !nftAddress} className="px-3 py-1.5 bg-gray-700 text-gray-100 rounded hover:bg-gray-600">
<button onClick={deleteLocalCache} disabled={scanBusy || !nftAddress} className="gh-btn gh-btn-ghost">
Delete Local Cache
</button>
</div>
{scanComplete && (
<div className="text-xs text-green-200 bg-green-900/30 border border-green-700 rounded p-2 mt-2">
<div className="mt-2 rounded-xl border border-forest/40 bg-forest/10 p-2 text-xs text-forest-deep">
Owner scan completed (gap limit reached). Reset or delete cache to rescan.
</div>
)}
<div className="text-xs text-gray-300">
<div className="text-xs text-ink-soft">
Cached IDs: {detectedTokenIds.length ? detectedTokenIds.map((id) => id.toString()).join(', ') : '—'}
</div>
</div>
<div className="rounded border p-4 bg-gray-800 border-gray-700">
<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-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 className="gh-card gh-card-hover p-5">
<div className="gh-eyebrow text-lg flex items-center gap-2"><span aria-hidden>🍃</span>About</div>
<p className="text-sm text-ink-soft mt-1">Enter your NFT contract and token ID(s), then fetch debt details from the mortgage contract.</p>
<p className="text-xs text-earth 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="gh-card gh-card-hover p-5 space-y-3">
<div className="flex items-center justify-between">
<div className="font-medium text-gray-100">Scheduled notifications</div>
<div className="gh-eyebrow text-lg flex items-center gap-2"><span aria-hidden>🔔</span>Scheduled notifications</div>
<button
className="text-xs px-2 py-1 border border-red-700 text-red-300 hover:bg-red-900/40 rounded"
className="gh-btn gh-btn-danger text-xs"
onClick={async () => {
// Purge remotely from Schedy by ntfy topic URL
try { await purgeNtfyTopicSchedules(notifSettings); } catch (e) { console.warn('[schedy] purge failed', e); }
@@ -1163,20 +1162,20 @@ export default function DappPage() {
</button>
</div>
{scheduledList.length === 0 ? (
<div className="text-sm text-gray-400">No schedules yet.</div>
<div className="text-sm text-ink-soft">No schedules yet.</div>
) : (
<ul className="divide-y divide-gray-700">
<ul className="divide-y divide-line">
{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">
<div className="text-ink font-medium">Token #{it.tokenId}</div>
<div className="text-ink-soft">
{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"
className="gh-btn gh-btn-ghost text-xs"
onClick={() => deleteSchedule(it.key)}
>
Delete
@@ -1188,8 +1187,8 @@ export default function DappPage() {
</div>
</div>
<div className="flex flex-wrap items-center gap-3 text-gray-100">
<button onClick={() => refetch()} disabled={!tokenIds.length} className="px-4 py-2 bg-indigo-600 text-white rounded disabled:opacity-50">Fetch Debt Data</button>
<div className="gh-panel p-4 flex flex-wrap items-center gap-x-4 gap-y-2 text-ink-soft">
<button onClick={() => refetch()} disabled={!tokenIds.length} className="gh-btn gh-btn-primary">🌾 Fetch Debt Data</button>
<span className="text-sm">Chain: {selectedChainId === base.id ? 'Base' : selectedChainId === arbitrum.id ? 'Arbitrum' : 'Other'}</span>
<span className="text-sm">{String(coinSymbol ?? '')} Balance: {fmt(contractCoinBalance as bigint | undefined, Number(coinDecimals ?? 8), 8)} {String(coinSymbol ?? '')}</span>
<span className="text-sm">Stable Balance: {fmt(stableBalance as bigint | undefined, Number(stableDecimals ?? 6), 6)} {String(stableSymbol ?? '')}</span>
@@ -1202,25 +1201,25 @@ export default function DappPage() {
const oneToken = BigInt(10) ** BigInt(dec);
const hasOnePlus = bal !== undefined && bal > oneToken;
const manualSet = Boolean(manualWallet?.trim());
const canCompound = !!address && !manualSet && !!debtAddress && hasOnePlus && !txPending;
const canCompound = !!address && !manualSet && !!debtAddress && hasOnePlus && !compoundPending;
return (
<button
className="px-2 py-1 bg-emerald-600 text-white rounded disabled:opacity-50 text-xs"
className="gh-btn gh-btn-sun text-xs"
disabled={!canCompound}
onClick={handleCompound}
title={manualSet ? 'Compound disabled when Manual Wallet is set' : (!hasOnePlus ? 'Requires > 1 token balance' : undefined)}
>
{txPending ? 'Compounding…' : 'Compound'}
{compoundPending ? 'Compounding…' : 'Compound'}
</button>
);
})()}
</span>
</div>
{debtError && <div className="rounded border border-red-300 bg-red-50 p-3 text-sm text-red-800">{String(debtError?.message || debtError)}</div>}
{debtError && <div className="gh-note gh-note-warn">{String(debtError?.message || debtError)}</div>}
<div className="space-y-3">
{debtLoading && <div>Loading...</div>}
<div className="space-y-4">
{debtLoading && <div className="text-ink-soft animate-breathe">🌙 Loading</div>}
{!debtLoading && parsed.map((row) => {
const stableDec = Number(stableDecimals ?? 6);
const coinDec = Number(coinDecimals ?? 8);
@@ -1247,54 +1246,54 @@ export default function DappPage() {
: undefined;
const isClosed = row.coinSize !== undefined && row.coinSize === BigInt(0);
return (
<div key={row.tokenId.toString()} className={`rounded border p-4 shadow-sm ${isClosed ? 'bg-gray-50 border-gray-200' : 'bg-white'}`}>
<div key={row.tokenId.toString()} className={`gh-card gh-card-hover p-5 border-l-4 ${isClosed ? 'border-l-earth/40 opacity-80' : 'border-l-forest'}`}>
<div className="flex items-center gap-2">
<div className="font-semibold text-gray-900">Loan Details for {String(stableSymbol ?? 'USDC')}-{String(coinSymbol ?? 'cbBTC')} - ID: {row.tokenId.toString()}</div>
<div className="font-display text-lg font-semibold text-forest-deep">🏡 {String(stableSymbol ?? 'USDC')}-{String(coinSymbol ?? 'cbBTC')} · #{row.tokenId.toString()}</div>
{isClosed && (
<span className="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium bg-yellow-100 text-yellow-800 border border-yellow-200">
<span className="gh-badge gh-badge-warn">
Closed/Defaulted
</span>
)}
</div>
<div className="grid grid-cols-2 gap-2 text-sm mt-2">
<div className="text-gray-800">Loan Collateral</div>
<div className={`text-gray-900 ${isClosed ? 'line-through' : ''}`}>{fmt(row.coinSize, coinDec, 8)} {String(coinSymbol ?? '')}</div>
<div className="text-earth">Loan Collateral</div>
<div className={`text-ink font-medium ${isClosed ? 'line-through' : ''}`}>{fmt(row.coinSize, coinDec, 8)} {String(coinSymbol ?? '')}</div>
<div className="text-gray-800">Interest Rate</div>
<div className="text-gray-900">{aprDisplay}</div>
<div className="text-earth">Interest Rate</div>
<div className="text-ink font-medium">{aprDisplay}</div>
<div className="text-gray-800">Loan Dollar Value</div>
<div className="text-gray-900">{fmt(row.baseSize, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-earth">Loan Dollar Value</div>
<div className="text-ink font-medium">{fmt(row.baseSize, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-gray-800">Cost to close Loan</div>
<div className="text-gray-900">{fmt(costToClose2pct, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-earth">Cost to close Loan</div>
<div className="text-ink font-medium">{fmt(costToClose2pct, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-gray-800">Total Cost to Close</div>
<div className="text-gray-900">{fmt(totalCostToClose102pct, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-earth">Total Cost to Close</div>
<div className="text-ink font-medium">{fmt(totalCostToClose102pct, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-gray-800">Total Interest to pay over Length of Loan</div>
<div className="text-gray-900">{fmt(row.feeSize, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-earth">Total Interest to pay over Length of Loan</div>
<div className="text-ink font-medium">{fmt(row.feeSize, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-gray-800">Total Repaid</div>
<div className="text-gray-900">{fmt(row.amountPaid, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-earth">Total Repaid</div>
<div className="text-ink font-medium">{fmt(row.amountPaid, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-gray-800">Debt Remaining</div>
<div className="text-gray-900">{fmt(row.debtAtThisSize, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-earth">Debt Remaining</div>
<div className="text-ink font-medium">{fmt(row.debtAtThisSize, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-gray-800">Amount to reset timer</div>
<div className="text-gray-900">{fmt(row.currentPaymentPending, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-earth">Amount to reset timer</div>
<div className="text-ink font-medium">{fmt(row.currentPaymentPending, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-gray-800">Monthly Payment Size</div>
<div className="text-gray-900">{fmt(monthlyPaymentFixed, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-earth">Monthly Payment Size</div>
<div className="text-ink font-medium">{fmt(monthlyPaymentFixed, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-gray-800">Loan Term</div>
<div className="text-gray-900">{loanTermDisplay}</div>
<div className="text-earth">Loan Term</div>
<div className="text-ink font-medium">{loanTermDisplay}</div>
<div className="text-gray-800">Start Date</div>
<div className="text-gray-900">{row.startDate ? new Date(Number(row.startDate) * 1000).toLocaleDateString() : '-'}</div>
<div className="text-earth">Start Date</div>
<div className="text-ink font-medium">{row.startDate ? new Date(Number(row.startDate) * 1000).toLocaleDateString() : '-'}</div>
<div className="text-gray-800">Seconds Till Liquidation</div>
<div className="text-gray-900 flex items-center gap-3">
<div className="text-earth">Seconds Till Liquidation</div>
<div className="text-ink font-medium flex items-center gap-3">
<span>{fmtDuration(row.secondsTillLiq)}</span>
{!isClosed && (
(() => {
@@ -1308,15 +1307,9 @@ export default function DappPage() {
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);
const haveSettings = Boolean(haveProvider && notifSettings.email);
if (next && !haveSettings) { setSettingsOpen(true); return; }
if (!next) {
// disable and cancel
@@ -1335,7 +1328,7 @@ export default function DappPage() {
}
};
return (
<label className="inline-flex items-center gap-1 text-xs text-gray-700">
<label className="inline-flex items-center gap-1 text-xs text-earth">
<input type="checkbox" className="h-4 w-4" disabled={disabled} checked={checked} onChange={(e) => onToggle(e.target.checked)} />
Enable alert
</label>
@@ -1347,8 +1340,8 @@ export default function DappPage() {
{/* Pay controls (hidden for Closed/Defaulted loans) */}
{!isClosed && (
<div className="mt-3 border-t pt-3">
<div className="text-sm font-medium text-gray-900 mb-2">Make a Payment</div>
<div className="mt-4 border-t border-line pt-4">
<div className="gh-eyebrow text-sm mb-2 flex items-center gap-1"><span aria-hidden>💧</span>Make a Payment</div>
<div className="flex flex-wrap items-center gap-2">
{(() => {
const key = row.tokenId.toString();
@@ -1365,13 +1358,13 @@ export default function DappPage() {
type="text"
inputMode="decimal"
placeholder={`Amount in ${String(stableSymbol ?? '')}`}
className="border rounded px-2 py-1 text-sm w-40 text-gray-900 placeholder-gray-500"
className="rounded-xl border border-line bg-cloud px-3 py-2 text-sm w-40 text-ink placeholder-earth/50 focus:outline-none focus:border-sky-deep"
value={inputVal}
onChange={(e) => setPaymentAmount(row.tokenId, e.target.value)}
/>
{!hasAllowance ? (
<button
className="px-3 py-1.5 bg-gray-200 text-gray-900 rounded"
className="gh-btn gh-btn-ghost"
onClick={() => handleApprove(row.tokenId)}
disabled={disableAll || amount === null || amount === BigInt(0)}
>
@@ -1379,7 +1372,7 @@ export default function DappPage() {
</button>
) : null}
<button
className="px-3 py-1.5 bg-indigo-600 text-white rounded disabled:opacity-50"
className="gh-btn gh-btn-primary"
onClick={() => handlePay(row.tokenId)}
disabled={disableAll || amount === null || amount === BigInt(0) || !hasAllowance || !hasBalance}
title={!hasAllowance ? 'Approve the stablecoin first' : (!hasBalance ? 'Insufficient balance' : undefined)}
@@ -1387,7 +1380,7 @@ export default function DappPage() {
Pay
</button>
{!hasBalance && amount !== null && (
<span className="text-xs text-red-600">Insufficient balance</span>
<span className="text-xs text-danger">Insufficient balance</span>
)}
</>
);