Skip to content
Merged
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
44 changes: 44 additions & 0 deletions apps/backend/src/modules/escrow/escrow-timeout.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
Dispute,
DisputeOutcome,
DisputeStatus,
} from './entities/dispute.entity';

@Injectable()
export class EscrowTimeoutService {
private readonly logger = new Logger(EscrowTimeoutService.name);

constructor(
@InjectRepository(Dispute)
private readonly disputeRepo: Repository<Dispute>,
) {}

/**
* Auto-resolves disputes that have been OPEN longer than `timeoutHours`.
* Defaults the outcome to REFUNDED_TO_BUYER when no admin action is taken.
*/
async resolveExpiredDisputes(timeoutHours = 72): Promise<number> {
const cutoff = new Date(Date.now() - timeoutHours * 60 * 60 * 1000);
const openDisputes = await this.disputeRepo.find({
where: { status: DisputeStatus.OPEN },
});

const expired = openDisputes.filter((d) => d.createdAt < cutoff);

for (const dispute of expired) {
dispute.status = DisputeStatus.RESOLVED;
dispute.outcome = DisputeOutcome.REFUNDED_TO_BUYER;
dispute.resolvedAt = new Date();
dispute.resolutionNotes = `Auto-resolved: no admin action within ${timeoutHours}h`;
await this.disputeRepo.save(dispute);
this.logger.log(
`Dispute ${dispute.id} auto-resolved after ${timeoutHours}h timeout`,
);
}

return expired.length;
}
}
56 changes: 56 additions & 0 deletions apps/frontend/components/common/Pagination.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"use client";
import React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";

interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
}

export default function Pagination({
currentPage,
totalPages,
onPageChange,
}: PaginationProps) {
if (totalPages <= 1) return null;

const windowSize = Math.min(5, totalPages);
const start = Math.max(1, Math.min(currentPage - 2, totalPages - windowSize + 1));
const pages = Array.from({ length: windowSize }, (_, i) => start + i);

return (
<nav className="mt-4 flex items-center justify-center gap-1" aria-label="Pagination">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="rounded p-2 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
aria-label="Previous page"
>
<ChevronLeft className="h-4 w-4" />
</button>
{pages.map((page) => (
<button
key={page}
onClick={() => onPageChange(page)}
aria-current={currentPage === page ? "page" : undefined}
className={`h-8 w-8 rounded text-sm font-medium transition-colors ${
currentPage === page
? "bg-blue-600 text-white"
: "text-gray-700 hover:bg-gray-100"
}`}
>
{page}
</button>
))}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="rounded p-2 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
aria-label="Next page"
>
<ChevronRight className="h-4 w-4" />
</button>
</nav>
);
}
53 changes: 53 additions & 0 deletions apps/frontend/components/dashboard/EscrowListItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React, { memo } from "react";
import Link from "next/link";

interface IEscrow {
id: string;
title: string;
amount: string;
asset: string;
status: string;
deadline: string;
}

const STATUS_COLORS: Record<string, string> = {
created: "bg-blue-100 text-blue-800",
funded: "bg-indigo-100 text-indigo-800",
confirmed: "bg-yellow-100 text-yellow-800",
completed: "bg-green-100 text-green-800",
cancelled: "bg-gray-100 text-gray-800",
disputed: "bg-red-100 text-red-800",
expired: "bg-orange-100 text-orange-800",
};

const EscrowListItem = memo(function EscrowListItem({
escrow,
}: {
escrow: IEscrow;
}) {
const colorClass =
STATUS_COLORS[escrow.status] ?? "bg-gray-100 text-gray-800";
return (
<Link
href={`/escrow/${escrow.id}`}
className="block rounded-lg border p-4 transition-shadow hover:shadow-md"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-gray-900">{escrow.title}</p>
<p className="mt-0.5 text-sm text-gray-500">
{escrow.amount} {escrow.asset}
</p>
</div>
<span className={`shrink-0 rounded-full px-2 py-0.5 text-xs font-medium ${colorClass}`}>
{escrow.status}
</span>
</div>
<p className="mt-2 text-xs text-gray-400">
Due: {new Date(escrow.deadline).toLocaleDateString()}
</p>
</Link>
);
});

export default EscrowListItem;
36 changes: 36 additions & 0 deletions apps/frontend/e2e/core-flows.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { test, expect } from '@playwright/test';

test.describe('Core User Flows', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});

test('landing page renders a top-level heading', async ({ page }) => {
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
});

test('connect wallet call-to-action is present', async ({ page }) => {
const connectBtn = page.getByRole('button', { name: /connect wallet/i });
const getStarted = page.getByRole('link', { name: /get started/i });
await expect(connectBtn.or(getStarted)).toBeVisible();
});

test('dashboard route is reachable', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/(dashboard|login|\?)/);
});

test('create-escrow page is reachable', async ({ page }) => {
await page.goto('/escrow/create');
await expect(page).toHaveURL(/\/escrow\/create/);
});

test('transactions page is reachable', async ({ page }) => {
await page.goto('/transactions');
await expect(page).toHaveURL(/\/transactions/);
});

test('page title contains Vaultix', async ({ page }) => {
await expect(page).toHaveTitle(/vaultix/i);
});
});
Loading