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
38 changes: 38 additions & 0 deletions apps/web/app/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"use client";

import { useEffect } from "react";

export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);

return (
<main className="mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-4 px-6 py-16 text-center">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-amber-500">
Something broke
</p>
<h1 className="text-3xl font-semibold tracking-tight text-slate-900 dark:text-slate-100">
We hit an unexpected error
</h1>
<p className="text-sm leading-6 text-slate-600 dark:text-slate-300">
The page failed to render. Try again without losing your current session.
</p>
<div>
<button
type="button"
onClick={reset}
className="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition duration-150 hover:bg-slate-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-300"
>
Retry page
</button>
</div>
</main>
);
}
9 changes: 9 additions & 0 deletions apps/web/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,12 @@ body {
color: var(--foreground);
font-family: var(--font-geist-sans), sans-serif;
}

@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
13 changes: 13 additions & 0 deletions apps/web/app/jobs/[id]/fund/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { SiteShell } from "@/components/site-shell";
import { api, type Job } from "@/lib/api";
import { depositEscrow } from "@/lib/contracts";
import { formatUsdc } from "@/lib/format";
import { TransactionPendingNotification } from "@/components/wallet/transaction-pending-notification";

const PLATFORM_FEE_BPS = 200;

Expand Down Expand Up @@ -145,6 +146,18 @@ export default function EscrowFundingPage() {
title="Fund Escrow"
description="Review the full contract value, platform fee, and milestone count before authorising the blockchain transfer."
>
<div className="mx-auto mb-6 max-w-5xl">
<TransactionPendingNotification
isPending={fundingState === "signing" || fundingState === "polling"}
pendingText={
fundingState === "signing"
? "Awaiting wallet approval for escrow funding signature."
: "Transaction submitted. Waiting for ledger confirmation."
}
txHash={txHash}
/>
</div>

<div className="mx-auto grid max-w-5xl gap-6 lg:grid-cols-[1.1fr_0.9fr]">
<section className="rounded-[2rem] border border-slate-200 bg-white/85 p-6 shadow-[0_25px_80px_-48px_rgba(15,23,42,0.5)] sm:p-8">
<div className="rounded-[1.6rem] border border-amber-200 bg-amber-50 p-5">
Expand Down
3 changes: 2 additions & 1 deletion apps/web/app/jobs/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from "lucide-react";
import { SiteShell } from "@/components/site-shell";
import { Stars } from "@/components/stars";
import { JobDetailsSkeleton } from "@/components/ui/skeleton";
import { useLiveJobWorkspace } from "@/hooks/use-live-job-workspace";
import { api } from "@/lib/api";
import { releaseFunds, openDispute } from "@/lib/contracts";
Expand Down Expand Up @@ -171,7 +172,7 @@ export default function JobDetailsPage() {
title="Loading workspace"
description="Fetching counterparties, milestones, deliverables, and dispute state."
>
<div className="h-96 animate-pulse rounded-[2rem] border border-slate-200 bg-white/70" />
<JobDetailsSkeleton />
</SiteShell>
);
}
Expand Down
9 changes: 4 additions & 5 deletions apps/web/app/jobs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Link from "next/link";
import { ArrowUpRight, Clock3, Search, SlidersHorizontal } from "lucide-react";
import { SiteShell } from "@/components/site-shell";
import { Stars } from "@/components/stars";
import { JobCardSkeleton } from "@/components/ui/skeleton";
import { useJobBoard } from "@/hooks/use-job-board";
import { formatDate, formatUsdc, shortenAddress } from "@/lib/format";

Expand Down Expand Up @@ -83,13 +84,11 @@ export default function JobsPage() {

<section className="mt-8">
{loading ? (
<div className="grid gap-4 lg:grid-cols-2">
<div className="grid gap-4 lg:grid-cols-2" role="status" aria-live="polite">
{Array.from({ length: 6 }, (_, index) => (
<div
key={index}
className="h-72 animate-pulse rounded-[1.75rem] border border-slate-200 bg-white/70"
/>
<JobCardSkeleton key={index} />
))}
<span className="sr-only">Loading open jobs</span>
</div>
) : (
<div className="grid gap-5 lg:grid-cols-2">
Expand Down
94 changes: 94 additions & 0 deletions apps/web/components/ui/skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { cn } from "@/lib/utils";

interface SkeletonProps {
className?: string;
}

export function Skeleton({ className }: SkeletonProps) {
return (
<div
aria-hidden="true"
className={cn(
"rounded-xl border border-white/10 bg-gradient-to-r from-zinc-800/60 via-zinc-700/70 to-zinc-800/60 bg-[length:220%_100%] animate-[shimmer_1.8s_ease-in-out_infinite]",
className,
)}
/>
);
}

export function RepoAvatarSkeleton({ className }: SkeletonProps) {
return <Skeleton className={cn("h-10 w-10 rounded-full", className)} />;
}

export function JobCardSkeleton() {
return (
<article className="rounded-3xl border border-white/10 bg-zinc-950/70 p-6 shadow-[0_24px_64px_-44px_rgba(0,0,0,0.85)] backdrop-blur-sm">
<div className="flex items-start justify-between gap-4">
<div className="space-y-3">
<Skeleton className="h-3 w-24 rounded-full" />
<Skeleton className="h-8 w-64 max-w-[85vw]" />
</div>
<RepoAvatarSkeleton />
</div>

<div className="mt-5 space-y-2">
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-[94%]" />
<Skeleton className="h-3 w-[68%]" />
</div>

<div className="mt-5 flex flex-wrap gap-2">
<Skeleton className="h-7 w-20 rounded-full" />
<Skeleton className="h-7 w-24 rounded-full" />
<Skeleton className="h-7 w-16 rounded-full" />
</div>

<div className="mt-6 grid gap-3 rounded-2xl border border-white/10 p-4 sm:grid-cols-3">
<Skeleton className="h-14 w-full" />
<Skeleton className="h-14 w-full" />
<Skeleton className="h-14 w-full" />
</div>
</article>
);
}

export function JobDetailsSkeleton() {
return (
<div className="grid gap-6 lg:grid-cols-[1.25fr_0.75fr]" role="status" aria-live="polite">
<div className="space-y-6">
<section className="rounded-[2rem] border border-white/10 bg-zinc-950/70 p-6 backdrop-blur-sm sm:p-8">
<div className="space-y-4">
<Skeleton className="h-3 w-20 rounded-full" />
<Skeleton className="h-10 w-[70%]" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-[88%]" />
</div>
<div className="mt-6 grid gap-4 sm:grid-cols-3">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</div>
</section>
<section className="rounded-[2rem] border border-white/10 bg-zinc-950/70 p-6 backdrop-blur-sm">
<Skeleton className="h-6 w-48" />
<div className="mt-4 space-y-3">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
</section>
</div>
<aside className="space-y-6">
<section className="rounded-[2rem] border border-white/10 bg-zinc-950/70 p-6 backdrop-blur-sm">
<Skeleton className="h-6 w-32" />
<div className="mt-4 space-y-3">
<Skeleton className="h-14 w-full" />
<Skeleton className="h-14 w-full" />
<Skeleton className="h-14 w-full" />
</div>
</section>
</aside>
<span className="sr-only">Loading job workspace</span>
</div>
);
}
114 changes: 114 additions & 0 deletions apps/web/components/wallet/transaction-pending-notification.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"use client";

import { LoaderCircle, TriangleAlert, Unplug } from "lucide-react";
import { useWalletSession } from "@/hooks/use-wallet-session";

interface TransactionPendingNotificationProps {
isPending: boolean;
pendingText?: string;
txHash?: string | null;
}

function shortAddress(address: string): string {
return `${address.slice(0, 6)}...${address.slice(-6)}`;
}

export function TransactionPendingNotification({
isPending,
pendingText = "Transaction pending on Stellar. Keep this tab open while confirmation finalizes.",
txHash,
}: TransactionPendingNotificationProps) {
const {
address,
appNetwork,
walletNetwork,
networkMismatch,
isConnecting,
isConnected,
error,
connect,
disconnect,
} = useWalletSession();

return (
<section
aria-label="Wallet connection and transaction pending state"
className="rounded-xl border border-zinc-800 bg-zinc-900 p-4 text-zinc-100 shadow-[0_20px_60px_-40px_rgba(79,70,229,0.75)] transition-opacity duration-200"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1.5">
<p className="text-xs uppercase tracking-[0.16em] text-indigo-300">
Wallet Session
</p>
<p className="text-sm text-zinc-300" aria-live="polite">
{isConnected && address
? `Connected as ${shortAddress(address)}`
: "No wallet connected"}
</p>
<p className="text-xs text-zinc-400">App network: {appNetwork}</p>
{walletNetwork ? (
<p className="text-xs text-zinc-400">Wallet network: {walletNetwork}</p>
) : null}
</div>

<div className="flex items-center gap-2">
{isConnected ? (
<button
type="button"
onClick={() => void disconnect()}
aria-label="Disconnect Stellar wallet"
className="inline-flex items-center gap-1 rounded-xl border border-zinc-700 px-3 py-2 text-xs font-medium text-zinc-200 transition-opacity duration-200 hover:opacity-80"
>
<Unplug className="h-3.5 w-3.5" />
Disconnect
</button>
) : (
<button
type="button"
onClick={() => void connect()}
disabled={isConnecting}
aria-label="Connect Stellar wallet"
className="rounded-xl bg-indigo-500 px-3 py-2 text-xs font-semibold text-white transition-opacity duration-200 hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
>
{isConnecting ? "Connecting..." : "Connect Wallet"}
</button>
)}
</div>
</div>

{networkMismatch ? (
<div
role="alert"
className="mt-3 flex items-start gap-2 rounded-xl border border-amber-500/40 bg-amber-500/10 p-3 text-xs text-amber-100"
>
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" />
<p>
Network mismatch detected. Your wallet is connected to {walletNetwork},
but this app is configured for {appNetwork}. Switch wallet network before
signing.
</p>
</div>
) : null}

{error ? (
<p className="mt-3 rounded-xl border border-red-500/40 bg-red-500/10 p-3 text-xs text-red-100">
{error}
</p>
) : null}

{isPending ? (
<div
role="status"
aria-live="polite"
className="mt-3 flex items-start gap-2 rounded-xl border border-indigo-500/40 bg-indigo-500/10 p-3 text-sm text-indigo-100"
>
<LoaderCircle className="mt-0.5 h-4 w-4 shrink-0 animate-spin" />
<div>
<p className="font-medium">{pendingText}</p>
{txHash ? <p className="mt-1 break-all text-xs opacity-80">tx: {txHash}</p> : null}
</div>
</div>
) : null}
</section>
);
}
Loading
Loading