diff --git a/apps/backend/.gitignore b/apps/backend/.gitignore index 4b56acf..c6ba09c 100644 --- a/apps/backend/.gitignore +++ b/apps/backend/.gitignore @@ -11,7 +11,7 @@ pnpm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* - +node_modules # OS .DS_Store diff --git a/apps/backend/src/clients/auth.ts b/apps/backend/src/clients/auth.ts new file mode 100644 index 0000000..0be36a7 --- /dev/null +++ b/apps/backend/src/clients/auth.ts @@ -0,0 +1,20 @@ +import api from './client'; +import { setTokens } from '../utils/token'; + +export const login = async (email: string, password: string) => { + const response = await api.post('/auth/login', { email, password }); + + const { accessToken, refreshToken } = response.data; + + setTokens(accessToken, refreshToken); + + return response.data; +}; + +export const register = async (data: any) => { + return api.post('/auth/register', data); +}; + +export const logout = async () => { + await api.post('/auth/logout'); +}; \ No newline at end of file diff --git a/apps/backend/src/clients/client.ts b/apps/backend/src/clients/client.ts new file mode 100644 index 0000000..f036d8f --- /dev/null +++ b/apps/backend/src/clients/client.ts @@ -0,0 +1,90 @@ +import axios, { AxiosError } from 'axios'; +import { getAccessToken, getRefreshToken, setTokens, } from '../utils/token'; +import { clearTokens } from '../utils/token'; + +const api = axios.create({ + baseURL: process.env.VITE_API_URL || 'http://localhost:3000', + withCredentials: true, +}); + + + +api.interceptors.request.use( + (config) => { + const token = getAccessToken(); + + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + + return config; + }, + (error) => Promise.reject(error), +); + + +// 🔄 RESPONSE INTERCEPTOR (Refresh Flow) +let isRefreshing = false; +let failedQueue: any[] = []; + +const processQueue = (error: any, token: string | null = null) => { + failedQueue.forEach((prom) => { + if (error) prom.reject(error); + else prom.resolve(token); + }); + + failedQueue = []; +}; + +api.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const originalRequest: any = error.config; + + if (error.response?.status === 401 && !originalRequest._retry) { + if (isRefreshing) { + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }); + }).then((token) => { + originalRequest.headers['Authorization'] = 'Bearer ' + token; + return api(originalRequest); + }); + } + + originalRequest._retry = true; + isRefreshing = true; + + try { + const refreshToken = getRefreshToken(); + + const response = await axios.post( + `${process.env.VITE_API_URL}/auth/refresh`, + { refreshToken }, + ); + + const { accessToken, refreshToken: newRefreshToken } = response.data; + + setTokens(accessToken, newRefreshToken); + + processQueue(null, accessToken); + + originalRequest.headers['Authorization'] = 'Bearer ' + accessToken; + + return api(originalRequest); + } catch (err) { + processQueue(err, null); + clearTokens(); + if (typeof globalThis.window !== 'undefined') { + globalThis.window.location.href = '/login'; + } + return Promise.reject(err); + } finally { + isRefreshing = false; + } + } + + return Promise.reject(error); + }, +); + +export default api; \ No newline at end of file diff --git a/apps/backend/src/services/escrow.ts b/apps/backend/src/services/escrow.ts new file mode 100644 index 0000000..7db3b96 --- /dev/null +++ b/apps/backend/src/services/escrow.ts @@ -0,0 +1,21 @@ +import api from "src/clients/client"; + +export const createEscrow = async (payload: any) => { + const response = await api.post('/escrow', payload); + return response.data; +}; + +export const getEscrows = async () => { + const response = await api.get('/escrow'); + return response.data; +}; + +export const getEscrowById = async (id: string) => { + const response = await api.get(`/escrow/${id}`); + return response.data; +}; + +export const releaseEscrow = async (id: string) => { + const response = await api.patch(`/escrow/${id}/release`); + return response.data; +}; \ No newline at end of file diff --git a/apps/backend/src/utils/token.ts b/apps/backend/src/utils/token.ts new file mode 100644 index 0000000..b0c78f4 --- /dev/null +++ b/apps/backend/src/utils/token.ts @@ -0,0 +1,30 @@ +const ACCESS_KEY = 'accessToken'; +const REFRESH_KEY = 'refreshToken'; + +export const setTokens = (access: string, refresh: string) => { + if (typeof globalThis.window !== 'undefined') { + globalThis.window.localStorage.setItem(ACCESS_KEY, access); + globalThis.window.localStorage.setItem(REFRESH_KEY, refresh); + } +}; + +export const getAccessToken = () => { + if (typeof globalThis.window !== 'undefined') { + return globalThis.window.localStorage.getItem(ACCESS_KEY); + } + return null; +}; + +export const getRefreshToken = () => { + if (typeof globalThis.window !== 'undefined') { + return globalThis.window.localStorage.getItem(REFRESH_KEY); + } + return null; +}; + +export const clearTokens = () => { + if (typeof globalThis.window !== 'undefined') { + globalThis.window.localStorage.removeItem(ACCESS_KEY); + globalThis.window.localStorage.removeItem(REFRESH_KEY); + } +}; \ No newline at end of file diff --git a/apps/frontend/app/escrow/[id]/page.tsx b/apps/frontend/app/escrow/[id]/page.tsx index e2bd339..03a5083 100644 --- a/apps/frontend/app/escrow/[id]/page.tsx +++ b/apps/frontend/app/escrow/[id]/page.tsx @@ -12,6 +12,8 @@ import TimelineSection from '@/components/escrow/detail/TimelineSection'; import TransactionHistory from '@/components/escrow/detail/TransactionHistory'; import ActivityFeed from '@/components/common/ActivityFeed'; import { IEscrowExtended } from '@/types/escrow'; +import FileDisputeModal from '@/components/escrow/detail/file-dispute-modal'; +import { Button } from '@/components/ui/button'; const EscrowDetailPage = () => { const { id } = useParams(); @@ -56,6 +58,7 @@ const EscrowDetailPage = () => { ); } +const [disputeOpen, setDisputeOpen] = useState(false); if (!escrow) { return ( @@ -104,6 +107,12 @@ const EscrowDetailPage = () => { + + setDisputeOpen(false)} + escrowId={escrow.id} + /> ); }; diff --git a/apps/frontend/app/globals.css b/apps/frontend/app/globals.css index a2dc41e..d767ad6 100644 --- a/apps/frontend/app/globals.css +++ b/apps/frontend/app/globals.css @@ -1,26 +1,126 @@ @import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; -:root { - --background: #ffffff; - --foreground: #171717; -} +@custom-variant dark (&:is(.dark *)); @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); + --radius-3xl: calc(var(--radius) + 12px); + --radius-4xl: calc(var(--radius) + 16px); } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); } -body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } } diff --git a/apps/frontend/app/layout.tsx b/apps/frontend/app/layout.tsx index 7667b2d..3180ac3 100644 --- a/apps/frontend/app/layout.tsx +++ b/apps/frontend/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import Providers from "@/component/Providers"; import Navbar from "@/component/layout/Navbar"; +import FileDisputeModal from "@/components/escrow/detail/file-dispute-modal"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -31,6 +32,7 @@ export default function RootLayout({ > +
{children}
diff --git a/apps/frontend/app/page.tsx b/apps/frontend/app/page.tsx index da685af..6630e0f 100644 --- a/apps/frontend/app/page.tsx +++ b/apps/frontend/app/page.tsx @@ -1,4 +1,5 @@ - import Image from "next/image"; + +import Image from "next/image"; import Link from "next/link"; export default function Home() { @@ -42,7 +43,8 @@ export default function Home() { - + +
🔒
@@ -60,6 +62,7 @@ export default function Home() {
🌐

Global Access

Access your escrow agreements from anywhere in the world

+
diff --git a/apps/frontend/components.json b/apps/frontend/components.json new file mode 100644 index 0000000..f87021e --- /dev/null +++ b/apps/frontend/components.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/apps/frontend/components/escrow/detail/file-dispute-modal.tsx b/apps/frontend/components/escrow/detail/file-dispute-modal.tsx new file mode 100644 index 0000000..a81bd06 --- /dev/null +++ b/apps/frontend/components/escrow/detail/file-dispute-modal.tsx @@ -0,0 +1,156 @@ +"use client"; + +import "react"; +import { use, useState } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { Input } from "@/components/ui/input"; +import { Loader2 } from "lucide-react"; +import axios from "../../../../backend/src/clients/client"; +import { i, u } from "framer-motion/client"; +import client from "../../../../backend/src/clients/client"; + +interface FileDisputeModalProps { + open: boolean; + onClose: () => void; + escrowId: string; +} +const [disputeOpen, setDisputeOpen] = useState(false); + + + +const disputeReasons = [ + { value: "DELIVERY_ISSUE", label: "Delivery Issue" }, + { value: "QUALITY_ISSUE", label: "Quality Issue" }, + { value: "NOT_AS_DESCRIBED", label: "Not As Described" }, + { value: "SCAM_SUSPECTED", label: "Suspected Scam" }, + { value: "OTHER", label: "Other" }, +]; + +export default function FileDisputeModal({ + open, + onClose, + escrowId, +}: FileDisputeModalProps) { + const [reason, setReason] = useState(""); + const [description, setDescription] = useState(""); + const [evidenceFile, setEvidenceFile] = useState(null); + const [evidenceLink, setEvidenceLink] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSubmit = async () => { + if (!reason || !description) return alert("Please fill all required fields"); + + try { + setLoading(true); + + const formData = new FormData(); + formData.append("reason", reason); + formData.append("description", description); + formData.append("escrowId", escrowId); + + if (evidenceFile) { + formData.append("file", evidenceFile); + } + + if (evidenceLink) { + formData.append("evidenceLink", evidenceLink); + } + + await axios.post("/disputes", formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); + + alert("Dispute filed successfully."); + onClose(); + } catch (error: any) { + alert(error?.response?.data?.message || "Failed to file dispute."); + } finally { + setLoading(false); + } + }; + + return ( + + + + Raise a Dispute + + + {/* Dispute Explanation */} +
+ When you raise a dispute: +
    +
  • Funds will be temporarily locked
  • +
  • Both parties will be reviewed
  • +
  • Admin will investigate the case
  • +
  • Resolution may take 24-72 hours
  • +
+
+ + {/* Reason */} +
+ + +
+ + {/* Description */} +
+ +