Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 81 additions & 22 deletions app/escrows/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,23 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Wallet, Clock, ArrowRight, ShieldCheck, AlertCircle } from "lucide-react";
import { Wallet, Clock, ArrowRight, ShieldCheck, AlertCircle, Archive, ArchiveRestore } from "lucide-react";
import { useStellarAuth } from "@/context/StellarContext";
import { getUserEscrows } from "@/lib/stellar/queries";
import { useArchivedEscrows } from "@/hooks/useArchivedEscrows";
import type { ContractState } from "@/lib/stellar/types";

const ARCHIVABLE_STATUSES: ContractState["status"][] = ["released", "expired"];

export default function EscrowsPage() {
const router = useRouter();
const { isConnected, publicKey } = useStellarAuth();
const [escrows, setEscrows] = useState<ContractState[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showArchived, setShowArchived] = useState(false);

const { archiveEscrow, unarchiveEscrow, isArchived } = useArchivedEscrows();

useEffect(() => {
if (!isConnected) {
Expand All @@ -38,6 +44,12 @@ export default function EscrowsPage() {

if (!isConnected) return null;

const visibleEscrows = escrows.filter((e) =>
showArchived ? isArchived(e.id) : !isArchived(e.id)
);

const archivedCount = escrows.filter((e) => isArchived(e.id)).length;

return (
<main aria-label="User Escrows" className="container mx-auto max-w-5xl px-4 py-32 space-y-8 min-h-screen">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
Expand All @@ -49,12 +61,23 @@ export default function EscrowsPage() {
Manage and view all rent agreements associated with your wallet.
</p>
</div>
<Link
href="/escrow/create"
className="btn-primary !py-2.5 !px-5 !text-sm !rounded-lg shrink-0"
>
Create Escrow
</Link>
<div className="flex items-center gap-3 shrink-0">
{archivedCount > 0 && (
<button
onClick={() => setShowArchived((prev) => !prev)}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-white/10 transition-colors"
>
<Archive className="w-4 h-4" />
{showArchived ? "Show active" : `Show archived (${archivedCount})`}
</button>
)}
<Link
href="/escrow/create"
className="btn-primary !py-2.5 !px-5 !text-sm !rounded-lg"
>
Create Escrow
</Link>
</div>
</div>

{error && (
Expand All @@ -70,25 +93,32 @@ export default function EscrowsPage() {
<div key={i} className="glass-card p-6 h-48 animate-pulse bg-white/5" />
))}
</div>
) : escrows.length === 0 ? (
) : visibleEscrows.length === 0 ? (
<div className="glass-card p-12 text-center flex flex-col items-center justify-center space-y-4">
<div className="w-16 h-16 rounded-full bg-brand-500/10 flex items-center justify-center mb-2">
<Wallet className="w-8 h-8 text-brand-400" />
{showArchived ? (
<Archive className="w-8 h-8 text-brand-400" />
) : (
<Wallet className="w-8 h-8 text-brand-400" />
)}
</div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white">No escrows found</h2>
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
{showArchived ? "No archived escrows" : "No escrows found"}
</h2>
<p className="text-gray-500 dark:text-gray-400 max-w-md mx-auto">
You don&apos;t have any active or funded escrow agreements connected to this wallet yet.
{showArchived
? "Archived escrows will appear here."
: "You don’t have any active or funded escrow agreements connected to this wallet yet."}
</p>
<Link
href="/escrow/create"
className="btn-primary mt-4"
>
Create Your First Escrow
</Link>
{!showArchived && (
<Link href="/escrow/create" className="btn-primary mt-4">
Create Your First Escrow
</Link>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{escrows.map((escrow) => (
{visibleEscrows.map((escrow) => (
<div key={escrow.id} className="glass-card p-6 hover:-translate-y-1 transition-transform duration-300 group">
<div className="flex items-start justify-between mb-6">
<div>
Expand All @@ -100,18 +130,47 @@ export default function EscrowsPage() {
Deadline: {escrow.deadline}
</div>
</div>
<div>
<div className="flex items-center gap-2">
{escrow.status === "funded" ? (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-emerald-500/10 text-emerald-500 dark:text-emerald-400 text-xs font-bold uppercase tracking-wider border border-emerald-500/20">
<ShieldCheck className="w-3.5 h-3.5" />
Funded
</span>
) : escrow.status === "released" ? (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-blue-500/10 text-blue-500 dark:text-blue-400 text-xs font-bold uppercase tracking-wider border border-blue-500/20">
<ShieldCheck className="w-3.5 h-3.5" />
Released
</span>
) : escrow.status === "expired" ? (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-gray-500/10 text-gray-500 dark:text-gray-400 text-xs font-bold uppercase tracking-wider border border-gray-500/20">
<Clock className="w-3.5 h-3.5" />
Expired
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400 text-xs font-bold uppercase tracking-wider border border-amber-500/20">
<Clock className="w-3.5 h-3.5" />
Active
</span>
)}
{ARCHIVABLE_STATUSES.includes(escrow.status) && (
isArchived(escrow.id) ? (
<button
onClick={() => unarchiveEscrow(escrow.id)}
title="Unarchive escrow"
className="p-1.5 rounded-lg text-gray-400 hover:text-brand-400 hover:bg-white/10 transition-colors"
>
<ArchiveRestore className="w-4 h-4" />
</button>
) : (
<button
onClick={() => archiveEscrow(escrow.id)}
title="Archive escrow"
className="p-1.5 rounded-lg text-gray-400 hover:text-brand-400 hover:bg-white/10 transition-colors"
>
<Archive className="w-4 h-4" />
</button>
)
)}
</div>
</div>

Expand All @@ -124,13 +183,13 @@ export default function EscrowsPage() {
</span>
</div>
<div className="h-2 w-full bg-gray-200 dark:bg-white/10 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-brand-500 to-accent-500"
<div
className="h-full bg-gradient-to-r from-brand-500 to-accent-500"
style={{ width: `${Math.min(100, Math.round((escrow.totalFunded / Number(escrow.totalRent)) * 100))}%` }}
/>
</div>
</div>

<div className="grid grid-cols-2 gap-4 pt-4 border-t border-gray-100 dark:border-white/5">
<div>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Total Rent</div>
Expand Down
95 changes: 95 additions & 0 deletions docs/ops/uptime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Uptime Monitoring with UptimeRobot

## Overview

PayEasy uses UptimeRobot to continuously monitor the `/api/health` endpoint and alert the team when the application goes down.

## Setup

### 1. Create a free UptimeRobot account

Go to [https://uptimerobot.com](https://uptimerobot.com) and register. The free tier supports up to 50 monitors with 5-minute check intervals.

### 2. Add a new HTTP(S) monitor

1. Click **+ Add New Monitor**
2. Set the following fields:

| Field | Value |
|---|---|
| Monitor Type | HTTP(S) |
| Friendly Name | PayEasy API Health |
| URL | `https://<your-domain>/api/health` |
| Monitoring Interval | 5 minutes |
| Monitor Timeout | 30 seconds |

3. Under **Alert Contacts**, add at least one email address and optionally a Slack webhook (see below).
4. Click **Create Monitor**.

### 3. Configure email alerts

UptimeRobot sends email alerts by default to the account's registered email. To add additional recipients:

1. Go to **My Settings → Alert Contacts**
2. Click **+ Add Alert Contact**
3. Choose **E-mail** and enter the address
4. Click **Save Changes**
5. Return to the monitor and attach the new contact under **Alert Contacts**

### 4. Configure Slack alerts

1. In Slack, create an incoming webhook for your alerts channel:
- Go to **Apps → Incoming WebHooks → Add to Slack**
- Choose or create a channel (e.g. `#ops-alerts`)
- Copy the **Webhook URL**
2. In UptimeRobot, go to **My Settings → Alert Contacts → Add Alert Contact**
3. Choose **Slack** and paste the webhook URL
4. Save and attach the contact to the PayEasy monitor

### 5. Verify the health endpoint

The `/api/health` endpoint must respond with HTTP `200` when the application is healthy. A non-`2xx` response or a connection timeout will trigger a downtime event.

Expected response shape:

```json
{
"status": "ok",
"timestamp": "2024-01-01T00:00:00.000Z"
}
```

## Alert behavior

| Event | Trigger | Channels |
|---|---|---|
| Downtime | First failed check | Email + Slack |
| Reminder | Every 30 minutes while down | Email + Slack |
| Recovery | First successful check after downtime | Email + Slack |

UptimeRobot requires **two consecutive failures** before marking a monitor as down, so a single transient failure does not page the team.

## Simulating a 6-minute downtime (acceptance test)

To confirm that alerts fire correctly before going to production:

1. Temporarily stop the application server (or return a non-`200` status from `/api/health`).
2. Wait **6 minutes** (longer than the 5-minute check interval and the two-failure confirmation window).
3. Verify that a **downtime** alert email and Slack message are received.
4. Restore the server.
5. Verify that a **recovery** alert is received.

A successful test confirms that a real outage lasting 6 or more minutes will page the team through both channels.

## Maintenance windows

To suppress false alerts during planned maintenance:

1. Open the monitor in UptimeRobot.
2. Click **Maintenance Windows** → **Add Maintenance Window**.
3. Set the start time, duration, and recurrence (one-time or weekly).
4. Save. No alerts will fire while the maintenance window is active.

## Escalation

If UptimeRobot alerts fire and the on-call engineer does not respond within 15 minutes, escalate using the team's standard incident response runbook.
59 changes: 59 additions & 0 deletions hooks/useArchivedEscrows.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"use client";

import { useState, useEffect, useCallback } from "react";

const STORAGE_KEY = "payeasy:archived-escrows";

function readFromStorage(): string[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed: unknown = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.every((x) => typeof x === "string")) {
return parsed as string[];
}
return [];
} catch {
return [];
}
}

function writeToStorage(ids: string[]): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(ids));
} catch {
// quota exceeded or private browsing — fail silently
}
}

export function useArchivedEscrows() {
const [archivedIds, setArchivedIds] = useState<string[]>([]);

useEffect(() => {
setArchivedIds(readFromStorage());
}, []);

const archiveEscrow = useCallback((id: string) => {
setArchivedIds((prev) => {
if (prev.includes(id)) return prev;
const next = [...prev, id];
writeToStorage(next);
return next;
});
}, []);

const unarchiveEscrow = useCallback((id: string) => {
setArchivedIds((prev) => {
const next = prev.filter((x) => x !== id);
writeToStorage(next);
return next;
});
}, []);

const isArchived = useCallback(
(id: string) => archivedIds.includes(id),
[archivedIds]
);

return { archivedIds, archiveEscrow, unarchiveEscrow, isArchived };
}
56 changes: 56 additions & 0 deletions lib/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
type LogLevel = "debug" | "info" | "warn" | "error";

interface LogEntry {
level: LogLevel;
message: string;
timestamp: string;
context?: Record<string, unknown>;
}

const LEVEL_PRIORITY: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};

const isDev = process.env.NODE_ENV !== "production";
const minLevel: LogLevel = isDev ? "debug" : "warn";

function shouldLog(level: LogLevel): boolean {
return LEVEL_PRIORITY[level] >= LEVEL_PRIORITY[minLevel];
}

function formatEntry(entry: LogEntry): string {
if (isDev) {
const ctx = entry.context ? ` ${JSON.stringify(entry.context)}` : "";
return `[${entry.timestamp}] ${entry.level.toUpperCase()} ${entry.message}${ctx}`;
}
return JSON.stringify(entry);
}

function log(level: LogLevel, message: string, context?: Record<string, unknown>): void {
if (!shouldLog(level)) return;

const entry: LogEntry = {
level,
message,
timestamp: new Date().toISOString(),
...(context !== undefined ? { context } : {}),
};

const output = formatEntry(entry);

if (level === "error" || level === "warn") {
console.error(output);
} else {
console.log(output);
}
}

export const logger = {
debug: (message: string, context?: Record<string, unknown>) => log("debug", message, context),
info: (message: string, context?: Record<string, unknown>) => log("info", message, context),
warn: (message: string, context?: Record<string, unknown>) => log("warn", message, context),
error: (message: string, context?: Record<string, unknown>) => log("error", message, context),
};
Loading
Loading