Loading limits...
;
- }
+ if (isLoading) {
+ return (
+ Loading limits...
+ );
+ }
- if (!data) return null;
+ if (!data) return null;
- const { compliance, remaining, nextTier } = data;
+ const { compliance, remaining, nextTier } = data;
- return (
-
-
-
- Verification Tier:
-
- {compliance.currentTier}
-
-
- {nextTier && onUpgradeClick && (
-
- )}
-
-
-
-
-
-
-
+ return (
+
+
+
+ Verification Tier:
+
+ {compliance.currentTier}
+
- );
+ {nextTier && onUpgradeClick && (
+
+ )}
+
+
+
+
+
+
+
+
+ );
}
-function LimitBar({ label, used, total, remaining, percent }: {
- label: string;
- used: number;
- total: number;
- remaining: number;
- percent: number;
+function LimitBar({
+ label,
+ total,
+ remaining,
+ percent,
+}: {
+ label: string;
+ total: number;
+ remaining: number;
+ percent: number;
}) {
- return (
-
-
- {label}
-
- {formatCurrency(remaining)} / {formatCurrency(total)}
-
-
-
-
- );
+ return (
+
+
+ {label}
+
+ {formatCurrency(remaining)} / {formatCurrency(total)}
+
+
+
+
+ );
}
diff --git a/components/compliance/terms-dialog.tsx b/components/compliance/terms-dialog.tsx
index f8823d7..e5f97b5 100644
--- a/components/compliance/terms-dialog.tsx
+++ b/components/compliance/terms-dialog.tsx
@@ -1,7 +1,14 @@
"use client";
-import { useState, useEffect } from "react";
-import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { useState } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { useQuery } from "@tanstack/react-query";
@@ -10,79 +17,85 @@ import { TermsVersion } from "@/types/terms";
import { useAcceptTerms } from "@/hooks/use-compliance";
interface TermsDialogProps {
- open: boolean;
- onOpenChange?: (open: boolean) => void;
- onAccepted: () => void;
+ open: boolean;
+ onOpenChange?: (open: boolean) => void;
+ onAccepted: () => void;
}
-export function TermsDialog({ open, onOpenChange, onAccepted }: TermsDialogProps) {
- const [agreed, setAgreed] = useState(false);
- const [scrolled, setScrolled] = useState(false);
+export function TermsDialog({
+ open,
+ onOpenChange,
+ onAccepted,
+}: TermsDialogProps) {
+ const [agreed, setAgreed] = useState(false);
+ const [scrolled, setScrolled] = useState(false);
- const { data: terms } = useQuery({
- queryKey: ['terms'],
- queryFn: () => get
('/api/compliance/terms'),
- });
+ const { data: terms } = useQuery({
+ queryKey: ["terms"],
+ queryFn: () => get("/api/compliance/terms"),
+ });
- const acceptMutation = useAcceptTerms();
+ const acceptMutation = useAcceptTerms();
- const handleAccept = async () => {
- if (!terms || !agreed) return;
+ const handleAccept = async () => {
+ if (!terms || !agreed) return;
- try {
- await acceptMutation.mutateAsync(terms.id);
- onAccepted();
- } catch (error) {
- console.error('Failed to accept terms:', error);
- }
- };
+ try {
+ await acceptMutation.mutateAsync(terms.id);
+ onAccepted();
+ } catch (error) {
+ console.error("Failed to accept terms:", error);
+ }
+ };
- const handleScroll = (e: React.UIEvent) => {
- const target = e.target as HTMLDivElement;
- const bottom = target.scrollHeight - target.scrollTop <= target.clientHeight + 50;
- if (bottom) setScrolled(true);
- };
+ const handleScroll = (e: React.UIEvent) => {
+ const { scrollHeight, scrollTop, clientHeight } = e.currentTarget;
+ const isAtBottom = scrollHeight - scrollTop <= clientHeight + 50;
+ if (isAtBottom) setScrolled(true);
+ };
- return (
-
+ );
}
diff --git a/components/compliance/tier-upgrade-dialog.tsx b/components/compliance/tier-upgrade-dialog.tsx
index 3800a29..ead1474 100644
--- a/components/compliance/tier-upgrade-dialog.tsx
+++ b/components/compliance/tier-upgrade-dialog.tsx
@@ -1,7 +1,14 @@
"use client";
-import { useState } from "react";
-import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { useState, useEffect } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Check } from "lucide-react";
import { KYCTier, DocumentType } from "@/types/compliance";
@@ -10,151 +17,207 @@ import { VerificationService } from "@/lib/services/verification";
import { useUpgradeTier } from "@/hooks/use-compliance";
import { DocumentUpload } from "./document-upload";
+const TIER_ORDER: Record = {
+ UNVERIFIED: 0,
+ BASIC: 1,
+ VERIFIED: 2,
+ ENHANCED: 3,
+};
+
interface TierUpgradeDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- currentTier: KYCTier;
- targetTier: KYCTier;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ currentTier: KYCTier;
+ targetTier: KYCTier;
}
const formatCurrency = (amount: number) =>
- new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(amount);
-
-export function TierUpgradeDialog({ open, onOpenChange, currentTier, targetTier }: TierUpgradeDialogProps) {
- const [step, setStep] = useState<'info' | 'documents'>('info');
- const [requestId, setRequestId] = useState(null);
- const [uploadedDocs, setUploadedDocs] = useState>(new Set());
- const upgradeMutation = useUpgradeTier();
- const tierConfig = ComplianceService.getTierConfig(targetTier);
- const requiredDocs = VerificationService.getRequiredDocuments(targetTier);
-
- const handleUpgrade = async () => {
- try {
- const request = await upgradeMutation.mutateAsync(targetTier);
- setRequestId(request.id);
- if (requiredDocs.length > 0) {
- setStep('documents');
- } else {
- onOpenChange(false);
- }
- } catch (error: any) {
- alert(error.message || 'Failed to request upgrade');
- }
- };
-
- const handleDocumentUpload = async (type: DocumentType, file: File) => {
- if (!requestId) return;
-
- await VerificationService.uploadDocument(requestId, {
- type,
- fileName: file.name,
- file,
- });
-
- setUploadedDocs(prev => new Set(prev).add(type));
- };
-
- const handleComplete = () => {
- setStep('info');
- setUploadedDocs(new Set());
- setRequestId(null);
+ new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ maximumFractionDigits: 0,
+ }).format(amount);
+
+export function TierUpgradeDialog({
+ open,
+ onOpenChange,
+ currentTier,
+ targetTier,
+}: TierUpgradeDialogProps) {
+ const [step, setStep] = useState<"info" | "documents">("info");
+ const [requestId, setRequestId] = useState(null);
+ const [uploadedDocs, setUploadedDocs] = useState>(
+ new Set(),
+ );
+
+ // Reset state when dialog opens or target tier changes
+ useEffect(() => {
+ if (open) {
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setStep("info");
+
+ setRequestId(null);
+
+ setUploadedDocs(new Set());
+ }
+ }, [open, targetTier]);
+
+ const upgradeMutation = useUpgradeTier();
+ const tierConfig = ComplianceService.getTierConfig(targetTier);
+ const requiredDocs = VerificationService.getRequiredDocuments(targetTier);
+
+ const isUpgradeValid = TIER_ORDER[targetTier] > TIER_ORDER[currentTier];
+
+ const handleUpgrade = async () => {
+ if (!isUpgradeValid) return;
+ try {
+ const request = await upgradeMutation.mutateAsync(targetTier);
+ setRequestId(request.id);
+ if (requiredDocs.length > 0) {
+ setStep("documents");
+ } else {
onOpenChange(false);
- };
-
- return (
-
-
-
- Upgrade to {targetTier} Tier
-
- {step === 'info'
- ? 'Increase your withdrawal limits by upgrading your verification tier.'
- : 'Upload required documents to complete your verification.'}
-
-
-
- {step === 'info' ? (
-
-
-
New Limits
-
-
- Daily:
- {formatCurrency(tierConfig.limits.daily)}
-
-
- Weekly:
- {formatCurrency(tierConfig.limits.weekly)}
-
-
- Monthly:
- {formatCurrency(tierConfig.limits.monthly)}
-
-
- Per Transaction:
- {formatCurrency(tierConfig.limits.perTransaction)}
-
-
-
-
-
-
Requirements
-
- {tierConfig.requirements.map((req, i) => (
- -
-
- {req}
-
- ))}
-
-
-
-
- Processing time: {tierConfig.processingTime}
-
-
- ) : (
-
- {requiredDocs.map((docType) => (
- handleDocumentUpload(docType, file)}
- uploaded={uploadedDocs.has(docType)}
- />
- ))}
-
- )}
-
-
- {step === 'info' ? (
- <>
-
-
- >
- ) : (
- <>
-
-
- >
- )}
-
-
-
- );
+ }
+ } catch (error) {
+ alert((error as Error).message || "Failed to request upgrade");
+ }
+ };
+
+ const handleDocumentUpload = async (type: DocumentType, file: File) => {
+ if (!requestId) return;
+
+ try {
+ await VerificationService.uploadDocument(requestId, {
+ type,
+ fileName: file.name,
+ file,
+ });
+
+ setUploadedDocs((prev) => new Set(prev).add(type));
+ } catch (error) {
+ alert((error as Error).message || "Failed to upload document");
+ }
+ };
+
+ const handleComplete = () => {
+ setStep("info");
+ setUploadedDocs(new Set());
+ setRequestId(null);
+ onOpenChange(false);
+ };
+
+ return (
+
+
+
+ Upgrade to {targetTier} Tier
+
+ {step === "info"
+ ? "Increase your withdrawal limits by upgrading your verification tier."
+ : "Upload required documents to complete your verification."}
+
+
+
+ {step === "info" ? (
+
+
+
New Limits
+
+
+ Daily:
+
+ {formatCurrency(tierConfig.limits.daily)}
+
+
+
+ Weekly:
+
+ {formatCurrency(tierConfig.limits.weekly)}
+
+
+
+ Monthly:
+
+ {formatCurrency(tierConfig.limits.monthly)}
+
+
+
+
+ Per Transaction:
+
+
+ {formatCurrency(tierConfig.limits.perTransaction)}
+
+
+
+
+
+
+
Requirements
+
+ {tierConfig.requirements.map((req, i) => (
+ -
+
+ {req}
+
+ ))}
+
+
+
+
+ Processing time: {tierConfig.processingTime}
+
+
+ {!isUpgradeValid && (
+
+ You are already at this tier or higher.
+
+ )}
+
+ ) : (
+
+ {requiredDocs.map((docType) => (
+ handleDocumentUpload(docType, file)}
+ uploaded={uploadedDocs.has(docType)}
+ />
+ ))}
+
+ )}
+
+
+ {step === "info" ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+
+ );
}
diff --git a/components/wallet/wallet-sheet.tsx b/components/wallet/wallet-sheet.tsx
index 42087e9..6446ddc 100644
--- a/components/wallet/wallet-sheet.tsx
+++ b/components/wallet/wallet-sheet.tsx
@@ -12,7 +12,6 @@ import {
SheetTrigger,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
-import { Separator } from "@/components/ui/separator";
import {
Wallet,
Copy,
diff --git a/components/wallet/withdrawal-section.tsx b/components/wallet/withdrawal-section.tsx
index 63f0a03..5a85a9f 100644
--- a/components/wallet/withdrawal-section.tsx
+++ b/components/wallet/withdrawal-section.tsx
@@ -1,11 +1,18 @@
"use client";
-import { useState, useEffect } from "react";
+import { useState, useEffect, useMemo } from "react";
import { WalletInfo } from "@/types/wallet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
-import { Building, Plus, ArrowRight, History, Info, AlertCircle } from "lucide-react";
+import {
+ Building,
+ Plus,
+ ArrowRight,
+ History,
+ Info,
+ AlertCircle,
+} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Alert, AlertDescription } from "@/components/ui/alert";
@@ -14,231 +21,303 @@ import { TermsDialog } from "@/components/compliance/terms-dialog";
import { TierUpgradeDialog } from "@/components/compliance/tier-upgrade-dialog";
import { HoldMessage } from "@/components/compliance/hold-message";
import { useComplianceStatus } from "@/hooks/use-compliance";
-import { useValidateWithdrawal, useSubmitWithdrawal } from "@/hooks/use-withdrawal";
+import {
+ useValidateWithdrawal,
+ useSubmitWithdrawal,
+} from "@/hooks/use-withdrawal";
+import { toast } from "sonner";
interface WithdrawalSectionProps {
- walletInfo: WalletInfo;
+ walletInfo: WalletInfo;
}
+const formatCurrency = (amount: number) => {
+ return new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ }).format(amount);
+};
+
export function WithdrawalSection({ walletInfo }: WithdrawalSectionProps) {
- const [amount, setAmount] = useState("");
- const [showTermsDialog, setShowTermsDialog] = useState(false);
- const [showUpgradeDialog, setShowUpgradeDialog] = useState(false);
- const [validationError, setValidationError] = useState(null);
-
- const { data: complianceData } = useComplianceStatus();
- const validateMutation = useValidateWithdrawal();
- const submitMutation = useSubmitWithdrawal();
-
- const bankAccounts = [
- { id: '1', name: 'Chase Bank', last4: '4242', isPrimary: true },
- ];
-
- const parsedAmount = parseFloat(amount);
- const isValidAmount = !isNaN(parsedAmount) && isFinite(parsedAmount) && parsedAmount >= 10;
-
- useEffect(() => {
- if (isValidAmount && parsedAmount <= walletInfo.balance) {
- validateMutation.mutate(parsedAmount, {
- onSuccess: (result) => {
- if (!result.valid) {
- setValidationError(result.errors[0] || 'Validation failed');
- } else {
- setValidationError(null);
- }
- },
- });
- } else {
+ const [amount, setAmount] = useState("");
+ const [showTermsDialog, setShowTermsDialog] = useState(false);
+ const [showUpgradeDialog, setShowUpgradeDialog] = useState(false);
+ const [validationError, setValidationError] = useState(null);
+
+ const { data: complianceData } = useComplianceStatus();
+ const validateMutation = useValidateWithdrawal();
+ const submitMutation = useSubmitWithdrawal();
+
+ const { mutate: validate } = validateMutation;
+
+ // TODO: Replace with real bank accounts from a service/hook
+ const bankAccounts: {
+ id: string;
+ name: string;
+ last4: string;
+ isPrimary: boolean;
+ }[] = [];
+
+ const parsedAmount = isNaN(parseFloat(amount)) ? 0 : parseFloat(amount);
+ const isValidAmount = isFinite(parsedAmount) && parsedAmount >= 10;
+
+ const syncValidationError = useMemo(() => {
+ if (!amount || Number.isNaN(parsedAmount)) return null;
+ if (!isValidAmount) return "Minimum withdrawal is $10.00";
+ if (parsedAmount > walletInfo.balance) return "Insufficient balance";
+ return null;
+ }, [amount, isValidAmount, parsedAmount, walletInfo.balance]);
+
+ useEffect(() => {
+ // If we have a sync error, we don't need to run async validation.
+ // The UI will prefer syncValidationError automatically.
+ if (syncValidationError) {
+ return;
+ }
+
+ if (parsedAmount <= 0) return;
+
+ const debounceTimer = setTimeout(() => {
+ validate(parsedAmount, {
+ onSuccess: (result) => {
+ if (!result.valid) {
+ setValidationError(result.errors[0] || "Validation failed");
+ } else {
setValidationError(null);
- }
- }, [parsedAmount]);
-
- const handleWithdraw = async () => {
- if (!isValidAmount || !complianceData) return;
-
- try {
- await submitMutation.mutateAsync({
- amount: parsedAmount,
- currency: 'USD',
- destinationId: bankAccounts[0].id,
- });
- alert('Withdrawal submitted successfully!');
- setAmount('');
- } catch (error: any) {
- alert(error.message || 'Withdrawal failed');
- }
- };
-
- const formatCurrency = (amount: number) => {
- return new Intl.NumberFormat("en-US", {
- style: "currency",
- currency: "USD",
- }).format(amount);
- };
-
- const canWithdraw = isValidAmount &&
- parsedAmount <= walletInfo.balance &&
- !validationError &&
- complianceData?.compliance.holdState === 'NONE' &&
- !complianceData?.termsStatus.requiresAcceptance;
-
- return (
-
- {complianceData && (
- <>
-
-
-
{
- setShowTermsDialog(false);
- }}
- />
-
- {complianceData.nextTier && (
-
- )}
- >
- )}
+ }
+ },
+ });
+ }, 500);
-
-
-
Off-Ramp to Fiat
-
- Withdraw your crypto earnings directly to your bank account via our secure payment partners.
-
-
-
-
-
-
- $
- setAmount(e.target.value)}
- />
-
-
-
- Balance: {formatCurrency(walletInfo.balance)}
- Min: $10.00
-
- {validationError && (
-
-
- {validationError}
-
- )}
-
+ return () => clearTimeout(debounceTimer);
+ }, [syncValidationError, validate, parsedAmount]);
-
-
-
- {bankAccounts.map((account) => (
-
-
-
-
-
-
-
{account.name}
-
Ending in {account.last4}
-
-
- {account.isPrimary &&
Primary}
-
- ))}
-
-
-
+ const handleWithdraw = async () => {
+ if (!canWithdraw || !complianceData || bankAccounts.length === 0) return;
-
-
- Exchange Rate (Est)
- 1 USDC = $1.00 USD
-
-
- Processing Fee
- $2.50
-
-
-
- You'll Receive
- {formatCurrency(Math.max(0, (parsedAmount || 0) - 2.50))}
-
-
+ try {
+ await submitMutation.mutateAsync({
+ amount: parsedAmount,
+ currency: "USD",
+ destinationId: bankAccounts[0].id,
+ });
+ toast.success("Withdrawal submitted successfully!");
+ setAmount("");
+ } catch (error) {
+ toast.error((error as Error).message || "Withdrawal failed");
+ }
+ };
- {complianceData?.termsStatus.requiresAcceptance && (
-
- )}
-
-
-
-
+ const isAmountValidated =
+ validateMutation.isSuccess && validateMutation.variables === parsedAmount;
-
-
-
-
-
Withdrawal History
-
+ const serverFee = validateMutation.data?.valid ? 2.5 : 2.5; // TODO: Use real fee from server when available
-
- {/* Empty state or items */}
-
-
-
No recent withdrawals
-
Your payout history will appear here.
-
-
+ const canWithdraw =
+ isValidAmount &&
+ parsedAmount <= walletInfo.balance &&
+ !validationError &&
+ !syncValidationError &&
+ !validateMutation.isPending &&
+ isAmountValidated &&
+ bankAccounts.length > 0 &&
+ complianceData?.compliance.holdState === "NONE" &&
+ !complianceData?.termsStatus.requiresAcceptance;
+
+ return (
+
+ {complianceData && (
+ <>
+
+
+
{
+ setShowTermsDialog(false);
+ }}
+ />
-
+ {complianceData.nextTier && (
+
+ )}
+ >
+ )}
-
-
-
-
Limits & Settings
-
-
setShowUpgradeDialog(true)} />
+
+
+
Off-Ramp to Fiat
+
+ Withdraw your crypto earnings directly to your bank account via our
+ secure payment partners.
+
+
+
+
+
+
+
+ $
+
+ setAmount(e.target.value)}
+ />
+
+
+
+ Balance: {formatCurrency(walletInfo.balance)}
+ Min: $10.00
+
+ {(syncValidationError || validationError) && (
+
+
+
+ {syncValidationError || validationError}
+
+
+ )}
+
+
+
+
+
+ {bankAccounts.length > 0 ? (
+ bankAccounts.map((account) => (
+
+
+
+
+
+
+ {account.name}
+
+
+ Ending in {account.last4}
+
+
+
+ {account.isPrimary && (
+
Primary
+ )}
-
+ ))
+ ) : (
+
+ No bank accounts linked
+
+ )}
+
+
+
+
+
+
+ Exchange Rate (Est)
+ 1 USDC = $1.00 USD
+
+
+ Processing Fee
+
+ {formatCurrency(serverFee)}
+
+
+
+
+ You'll Receive
+
+ {formatCurrency(Math.max(0, (parsedAmount || 0) - serverFee))}
+
+
+
+
+ {complianceData?.termsStatus.requiresAcceptance && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ Withdrawal History
+
+
+
+
+ {/* Empty state or items */}
+
+
+
No recent withdrawals
+
+ Your payout history will appear here.
+
+
+
+
+
+
+
+
+
+
+ Limits & Settings
+
+
+
setShowUpgradeDialog(true)}
+ />
+
- );
+
+
+ );
}
diff --git a/eslint.config.mjs b/eslint.config.mjs
index e1e39a9..5f53b14 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -1,10 +1,12 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
+import prettier from "eslint-config-prettier/flat";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
+ prettier,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
@@ -12,7 +14,7 @@ const eslintConfig = defineConfig([
"out/**",
"build/**",
"next-env.d.ts",
- "jest.config.js"
+ "jest.config.js",
]),
]);
diff --git a/lib/services/appeal.ts b/lib/services/appeal.ts
index 272bd49..3bb465e 100644
--- a/lib/services/appeal.ts
+++ b/lib/services/appeal.ts
@@ -1,44 +1,50 @@
-import { VerificationAppeal, AppealStatus } from "@/types/compliance";
+import { VerificationAppeal } from "@/types/compliance";
import { EmailService } from "./email";
const MOCK_APPEALS: Record
= {};
export class AppealService {
- static async submitAppeal(
- userId: string,
- verificationRequestId: string,
- reason: string,
- additionalInfo?: string
- ): Promise {
- const appealId = `appeal-${userId}-${Date.now()}`;
- const appeal: VerificationAppeal = {
- id: appealId,
- userId,
- verificationRequestId,
- reason,
- additionalInfo,
- status: 'PENDING',
- submittedAt: new Date().toISOString(),
- };
-
- MOCK_APPEALS[appealId] = appeal;
-
- const userEmail = `user-${userId}@example.com`;
- await EmailService.sendAppealConfirmation(userEmail, appealId);
-
- return { ...appeal };
- }
-
- static async getAppeal(verificationRequestId: string): Promise {
- const appeal = Object.values(MOCK_APPEALS)
- .find(a => a.verificationRequestId === verificationRequestId);
-
- return appeal ? { ...appeal } : null;
- }
-
- static async getUserAppeals(userId: string): Promise {
- return Object.values(MOCK_APPEALS)
- .filter(a => a.userId === userId)
- .sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime());
- }
+ static async submitAppeal(
+ userId: string,
+ verificationRequestId: string,
+ reason: string,
+ additionalInfo?: string,
+ ): Promise {
+ const appealId = `appeal-${userId}-${Date.now()}`;
+ const appeal: VerificationAppeal = {
+ id: appealId,
+ userId,
+ verificationRequestId,
+ reason,
+ additionalInfo,
+ status: "PENDING",
+ submittedAt: new Date().toISOString(),
+ };
+
+ MOCK_APPEALS[appealId] = appeal;
+
+ const userEmail = `user-${userId}@example.com`;
+ await EmailService.sendAppealConfirmation(userEmail, appealId);
+
+ return { ...appeal };
+ }
+
+ static async getAppeal(
+ verificationRequestId: string,
+ ): Promise {
+ const appeal = Object.values(MOCK_APPEALS).find(
+ (a) => a.verificationRequestId === verificationRequestId,
+ );
+
+ return appeal ? { ...appeal } : null;
+ }
+
+ static async getUserAppeals(userId: string): Promise {
+ return Object.values(MOCK_APPEALS)
+ .filter((a) => a.userId === userId)
+ .sort(
+ (a, b) =>
+ new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime(),
+ );
+ }
}
diff --git a/lib/services/compliance.ts b/lib/services/compliance.ts
index 07437bd..69b9b1e 100644
--- a/lib/services/compliance.ts
+++ b/lib/services/compliance.ts
@@ -1,350 +1,393 @@
import {
- UserCompliance,
- KYCTier,
- KYCTierConfig,
- WithdrawalLimits,
- WithdrawalUsage,
- RemainingLimits,
- ComplianceHoldState,
- VerificationStatus,
+ UserCompliance,
+ KYCTier,
+ KYCTierConfig,
+ RemainingLimits,
+ ComplianceHoldState,
+ VerificationStatus,
} from "@/types/compliance";
// Mock Data Store (In-memory for prototype)
const MOCK_COMPLIANCE_DB: Record = {};
export class ComplianceService {
- // KYC Tier Configuration
- static readonly TIER_CONFIGS: Record = {
- UNVERIFIED: {
- tier: 'UNVERIFIED',
- description: 'Basic account access with limited withdrawal capabilities',
- limits: {
- daily: 100,
- weekly: 300,
- monthly: 500,
- perTransaction: 50,
- },
- requirements: [
- 'Email verification required',
- 'No additional documentation needed',
- ],
- processingTime: 'Instant',
- },
- BASIC: {
- tier: 'BASIC',
- description: 'Standard account with moderate withdrawal limits',
- limits: {
- daily: 1000,
- weekly: 5000,
- monthly: 15000,
- perTransaction: 500,
- },
- requirements: [
- 'Email verification',
- 'Phone number verification',
- 'Basic personal information (name, date of birth)',
- ],
- processingTime: '1-2 business days',
- },
- VERIFIED: {
- tier: 'VERIFIED',
- description: 'Verified account with higher withdrawal limits',
- limits: {
- daily: 5000,
- weekly: 25000,
- monthly: 75000,
- perTransaction: 2500,
- },
- requirements: [
- 'All BASIC requirements',
- 'Government-issued ID (passport, driver\'s license)',
- 'Proof of address (utility bill, bank statement)',
- ],
- processingTime: '2-5 business days',
- },
- ENHANCED: {
- tier: 'ENHANCED',
- description: 'Premium account with maximum withdrawal limits',
- limits: {
- daily: 25000,
- weekly: 100000,
- monthly: 300000,
- perTransaction: 10000,
- },
- requirements: [
- 'All VERIFIED requirements',
- 'Enhanced identity verification',
- 'Video verification call',
- 'Source of funds documentation',
- ],
- processingTime: '5-10 business days',
- },
- };
-
- /**
- * Get user's current compliance status
- */
- static async getUserCompliance(userId: string): Promise {
- await this.simulateDelay();
-
- // Return existing or create new
- if (!MOCK_COMPLIANCE_DB[userId]) {
- MOCK_COMPLIANCE_DB[userId] = this.createDefaultCompliance(userId);
- }
-
- return { ...MOCK_COMPLIANCE_DB[userId] };
- }
-
- /**
- * Create default compliance record for new user
- */
- private static createDefaultCompliance(userId: string): UserCompliance {
- const now = new Date().toISOString();
- const tier: KYCTier = 'UNVERIFIED';
-
- return {
- userId,
- currentTier: tier,
- limits: { ...this.TIER_CONFIGS[tier].limits },
- usage: {
- dailyUsed: 0,
- weeklyUsed: 0,
- monthlyUsed: 0,
- lifetimeTotal: 0,
- lastResetDaily: now,
- lastResetWeekly: now,
- lastResetMonthly: now,
- },
- holdState: 'NONE',
- verificationStatus: 'NOT_STARTED',
- restrictedJurisdiction: false,
- createdAt: now,
- updatedAt: now,
- };
+ // KYC Tier Configuration
+ static readonly TIER_CONFIGS: Record = {
+ UNVERIFIED: {
+ tier: "UNVERIFIED",
+ description: "Basic account access with limited withdrawal capabilities",
+ limits: {
+ daily: 100,
+ weekly: 300,
+ monthly: 500,
+ perTransaction: 50,
+ },
+ requirements: [
+ "Email verification required",
+ "No additional documentation needed",
+ ],
+ processingTime: "Instant",
+ },
+ BASIC: {
+ tier: "BASIC",
+ description: "Standard account with moderate withdrawal limits",
+ limits: {
+ daily: 1000,
+ weekly: 5000,
+ monthly: 15000,
+ perTransaction: 500,
+ },
+ requirements: [
+ "Email verification",
+ "Phone number verification",
+ "Basic personal information (name, date of birth)",
+ ],
+ processingTime: "1-2 business days",
+ },
+ VERIFIED: {
+ tier: "VERIFIED",
+ description: "Verified account with higher withdrawal limits",
+ limits: {
+ daily: 5000,
+ weekly: 25000,
+ monthly: 75000,
+ perTransaction: 2500,
+ },
+ requirements: [
+ "All BASIC requirements",
+ "Government-issued ID (passport, driver's license)",
+ "Proof of address (utility bill, bank statement)",
+ ],
+ processingTime: "2-5 business days",
+ },
+ ENHANCED: {
+ tier: "ENHANCED",
+ description: "Premium account with maximum withdrawal limits",
+ limits: {
+ daily: 25000,
+ weekly: 100000,
+ monthly: 300000,
+ perTransaction: 10000,
+ },
+ requirements: [
+ "All VERIFIED requirements",
+ "Enhanced identity verification",
+ "Video verification call",
+ "Source of funds documentation",
+ ],
+ processingTime: "5-10 business days",
+ },
+ };
+
+ /**
+ * Internal accessor for mutating database records directly
+ */
+ private static getComplianceRecord(userId: string): UserCompliance {
+ if (!MOCK_COMPLIANCE_DB[userId]) {
+ MOCK_COMPLIANCE_DB[userId] = this.createDefaultCompliance(userId);
}
-
- /**
- * Calculate remaining withdrawal amounts with rolling windows
- */
- static async getRemainingLimits(userId: string): Promise {
- const compliance = await this.getUserCompliance(userId);
-
- // Reset usage if windows have passed
- await this.resetExpiredWindows(userId, compliance);
-
- const { limits, usage } = compliance;
-
- const dailyRemaining = Math.max(0, limits.daily - usage.dailyUsed);
- const weeklyRemaining = Math.max(0, limits.weekly - usage.weeklyUsed);
- const monthlyRemaining = Math.max(0, limits.monthly - usage.monthlyUsed);
-
- return {
- daily: dailyRemaining,
- weekly: weeklyRemaining,
- monthly: monthlyRemaining,
- percentUsed: {
- daily: limits.daily > 0 ? Math.min(100, (usage.dailyUsed / limits.daily) * 100) : 0,
- weekly: limits.weekly > 0 ? Math.min(100, (usage.weeklyUsed / limits.weekly) * 100) : 0,
- monthly: limits.monthly > 0 ? Math.min(100, (usage.monthlyUsed / limits.monthly) * 100) : 0,
- },
- };
- }
-
- /**
- * Reset usage for expired rolling windows
- */
- private static async resetExpiredWindows(
- userId: string,
- compliance: UserCompliance
- ): Promise {
- const now = new Date();
- const { usage } = compliance;
- let updated = false;
-
- // Daily reset (24 hours)
- const lastDaily = new Date(usage.lastResetDaily);
- if (now.getTime() - lastDaily.getTime() >= 24 * 60 * 60 * 1000) {
- compliance.usage.dailyUsed = 0;
- compliance.usage.lastResetDaily = now.toISOString();
- updated = true;
- }
-
- // Weekly reset (7 days)
- const lastWeekly = new Date(usage.lastResetWeekly);
- if (now.getTime() - lastWeekly.getTime() >= 7 * 24 * 60 * 60 * 1000) {
- compliance.usage.weeklyUsed = 0;
- compliance.usage.lastResetWeekly = now.toISOString();
- updated = true;
- }
-
- // Monthly reset (30 days)
- const lastMonthly = new Date(usage.lastResetMonthly);
- if (now.getTime() - lastMonthly.getTime() >= 30 * 24 * 60 * 60 * 1000) {
- compliance.usage.monthlyUsed = 0;
- compliance.usage.lastResetMonthly = now.toISOString();
- updated = true;
- }
-
- if (updated) {
- compliance.updatedAt = now.toISOString();
- MOCK_COMPLIANCE_DB[userId] = compliance;
- }
- }
-
- /**
- * Check if withdrawal would exceed any limits
- */
- static async validateWithdrawalAmount(
- userId: string,
- amount: number
- ): Promise<{ valid: boolean; exceededLimit?: 'daily' | 'weekly' | 'monthly' | 'perTransaction' }> {
- const compliance = await this.getUserCompliance(userId);
- await this.resetExpiredWindows(userId, compliance);
-
- const { limits, usage } = compliance;
-
- // Check per-transaction limit
- if (amount > limits.perTransaction) {
- return { valid: false, exceededLimit: 'perTransaction' };
- }
-
- // Check daily limit
- if (usage.dailyUsed + amount > limits.daily) {
- return { valid: false, exceededLimit: 'daily' };
- }
-
- // Check weekly limit
- if (usage.weeklyUsed + amount > limits.weekly) {
- return { valid: false, exceededLimit: 'weekly' };
- }
-
- // Check monthly limit
- if (usage.monthlyUsed + amount > limits.monthly) {
- return { valid: false, exceededLimit: 'monthly' };
- }
-
- return { valid: true };
+ return MOCK_COMPLIANCE_DB[userId];
+ }
+
+ /**
+ * Get user's current compliance status (returns a deep clone for callers)
+ */
+ static async getUserCompliance(userId: string): Promise {
+ await this.simulateDelay();
+
+ const record = this.getComplianceRecord(userId);
+
+ // Return a deep clone to prevent external mutation of the DB
+ return JSON.parse(JSON.stringify(record));
+ }
+
+ /**
+ * Create default compliance record for new user
+ */
+ private static createDefaultCompliance(userId: string): UserCompliance {
+ const now = new Date().toISOString();
+ const tier: KYCTier = "UNVERIFIED";
+
+ return {
+ userId,
+ currentTier: tier,
+ limits: { ...this.TIER_CONFIGS[tier].limits },
+ usage: {
+ dailyUsed: 0,
+ weeklyUsed: 0,
+ monthlyUsed: 0,
+ lifetimeTotal: 0,
+ lastResetDaily: now,
+ lastResetWeekly: now,
+ lastResetMonthly: now,
+ },
+ holdState: "NONE",
+ verificationStatus: "NOT_STARTED",
+ restrictedJurisdiction: false,
+ createdAt: now,
+ updatedAt: now,
+ };
+ }
+
+ /**
+ * Calculate remaining withdrawal amounts with rolling windows
+ */
+ static async getRemainingLimits(userId: string): Promise {
+ // Note: We access the record directly to reset windows
+ const compliance = this.getComplianceRecord(userId);
+
+ // Reset usage if windows have passed
+ await this.resetExpiredWindows(userId, compliance);
+
+ const { limits, usage } = compliance;
+
+ const dailyRemaining = Math.max(0, limits.daily - usage.dailyUsed);
+ const weeklyRemaining = Math.max(0, limits.weekly - usage.weeklyUsed);
+ const monthlyRemaining = Math.max(0, limits.monthly - usage.monthlyUsed);
+
+ return {
+ daily: dailyRemaining,
+ weekly: weeklyRemaining,
+ monthly: monthlyRemaining,
+ percentUsed: {
+ daily:
+ limits.daily > 0
+ ? Math.min(100, (usage.dailyUsed / limits.daily) * 100)
+ : 0,
+ weekly:
+ limits.weekly > 0
+ ? Math.min(100, (usage.weeklyUsed / limits.weekly) * 100)
+ : 0,
+ monthly:
+ limits.monthly > 0
+ ? Math.min(100, (usage.monthlyUsed / limits.monthly) * 100)
+ : 0,
+ },
+ };
+ }
+
+ /**
+ * Reset usage for expired rolling windows
+ */
+ private static async resetExpiredWindows(
+ userId: string,
+ compliance: UserCompliance,
+ ): Promise {
+ const now = new Date();
+ const { usage } = compliance;
+ let updated = false;
+
+ // Daily reset (24 hours)
+ const lastDaily = new Date(usage.lastResetDaily);
+ if (now.getTime() - lastDaily.getTime() >= 24 * 60 * 60 * 1000) {
+ compliance.usage.dailyUsed = 0;
+ compliance.usage.lastResetDaily = now.toISOString();
+ updated = true;
}
- /**
- * Track withdrawal usage (call after successful withdrawal)
- */
- static async trackWithdrawal(userId: string, amount: number): Promise {
- const compliance = await this.getUserCompliance(userId);
-
- compliance.usage.dailyUsed += amount;
- compliance.usage.weeklyUsed += amount;
- compliance.usage.monthlyUsed += amount;
- compliance.usage.lifetimeTotal += amount;
- compliance.updatedAt = new Date().toISOString();
-
- MOCK_COMPLIANCE_DB[userId] = compliance;
+ // Weekly reset (7 days)
+ const lastWeekly = new Date(usage.lastResetWeekly);
+ if (now.getTime() - lastWeekly.getTime() >= 7 * 24 * 60 * 60 * 1000) {
+ compliance.usage.weeklyUsed = 0;
+ compliance.usage.lastResetWeekly = now.toISOString();
+ updated = true;
}
- /**
- * Upgrade user tier after verification approval
- */
- static async upgradeTier(userId: string, newTier: KYCTier): Promise {
- const compliance = await this.getUserCompliance(userId);
-
- // Update tier and limits
- compliance.currentTier = newTier;
- compliance.limits = { ...this.TIER_CONFIGS[newTier].limits };
- compliance.verificationStatus = 'APPROVED';
- compliance.updatedAt = new Date().toISOString();
-
- MOCK_COMPLIANCE_DB[userId] = compliance;
- return true;
+ // Monthly reset (30 days)
+ const lastMonthly = new Date(usage.lastResetMonthly);
+ if (now.getTime() - lastMonthly.getTime() >= 30 * 24 * 60 * 60 * 1000) {
+ compliance.usage.monthlyUsed = 0;
+ compliance.usage.lastResetMonthly = now.toISOString();
+ updated = true;
}
- /**
- * Set hold state on user account
- */
- static async setHoldState(
- userId: string,
- state: ComplianceHoldState,
- reason?: string
- ): Promise {
- const compliance = await this.getUserCompliance(userId);
-
- compliance.holdState = state;
- compliance.holdReason = reason;
- compliance.updatedAt = new Date().toISOString();
-
- MOCK_COMPLIANCE_DB[userId] = compliance;
+ if (updated) {
+ compliance.updatedAt = now.toISOString();
+ MOCK_COMPLIANCE_DB[userId] = compliance;
}
-
- /**
- * Update verification status
- */
- static async updateVerificationStatus(
- userId: string,
- status: VerificationStatus
- ): Promise {
- const compliance = await this.getUserCompliance(userId);
-
- compliance.verificationStatus = status;
- compliance.updatedAt = new Date().toISOString();
-
- MOCK_COMPLIANCE_DB[userId] = compliance;
+ }
+
+ /**
+ * Check if withdrawal would exceed any limits.
+ * NOTE: This does not mutate state. Callers must ensure windows are reset if needed.
+ */
+ static async validateWithdrawalAmount(
+ userId: string,
+ amount: number,
+ ): Promise<{
+ valid: boolean;
+ exceededLimit?: "daily" | "weekly" | "monthly" | "perTransaction";
+ }> {
+ const compliance = this.getComplianceRecord(userId);
+
+ // Reset windows before checking limits to ensure data is fresh
+ await this.resetExpiredWindows(userId, compliance);
+
+ const { limits, usage } = compliance;
+
+ // Check per-transaction limit
+ if (amount > limits.perTransaction) {
+ return { valid: false, exceededLimit: "perTransaction" };
}
- /**
- * Mark user as restricted jurisdiction
- */
- static async setJurisdictionRestriction(
- userId: string,
- restricted: boolean,
- jurisdictionCode?: string
- ): Promise {
- const compliance = await this.getUserCompliance(userId);
-
- compliance.restrictedJurisdiction = restricted;
- compliance.jurisdictionCode = jurisdictionCode;
- compliance.updatedAt = new Date().toISOString();
-
- MOCK_COMPLIANCE_DB[userId] = compliance;
+ // Check daily limit
+ if (usage.dailyUsed + amount > limits.daily) {
+ return { valid: false, exceededLimit: "daily" };
}
- /**
- * Get tier configuration
- */
- static getTierConfig(tier: KYCTier): KYCTierConfig {
- return { ...this.TIER_CONFIGS[tier] };
+ // Check weekly limit
+ if (usage.weeklyUsed + amount > limits.weekly) {
+ return { valid: false, exceededLimit: "weekly" };
}
- /**
- * Get all tier configurations
- */
- static getAllTierConfigs(): KYCTierConfig[] {
- return Object.values(this.TIER_CONFIGS);
+ // Check monthly limit
+ if (usage.monthlyUsed + amount > limits.monthly) {
+ return { valid: false, exceededLimit: "monthly" };
}
- /**
- * Get next tier
- */
- static getNextTier(currentTier: KYCTier): KYCTier | null {
- const tiers: KYCTier[] = ['UNVERIFIED', 'BASIC', 'VERIFIED', 'ENHANCED'];
- const currentIndex = tiers.indexOf(currentTier);
-
- if (currentIndex === -1 || currentIndex === tiers.length - 1) {
- return null;
- }
+ return { valid: true };
+ }
+
+ /**
+ * Track withdrawal usage (call after successful withdrawal)
+ */
+ static async trackWithdrawal(userId: string, amount: number): Promise {
+ const compliance = this.getComplianceRecord(userId);
+
+ compliance.usage.dailyUsed += amount;
+ compliance.usage.weeklyUsed += amount;
+ compliance.usage.monthlyUsed += amount;
+ compliance.usage.lifetimeTotal += amount;
+ compliance.updatedAt = new Date().toISOString();
+
+ MOCK_COMPLIANCE_DB[userId] = compliance;
+ }
+
+ /**
+ * Upgrade user tier after verification approval
+ */
+ static async upgradeTier(userId: string, newTier: KYCTier): Promise {
+ const compliance = this.getComplianceRecord(userId);
+
+ const TIER_ORDER: Record = {
+ UNVERIFIED: 0,
+ BASIC: 1,
+ VERIFIED: 2,
+ ENHANCED: 3,
+ };
- return tiers[currentIndex + 1];
+ // Prevent downgrades or invalid upgrades
+ if (TIER_ORDER[newTier] <= TIER_ORDER[compliance.currentTier]) {
+ throw new Error("Cannot downgrade or remain at current tier");
}
- /**
- * Simulate database delay
- */
- private static async simulateDelay(): Promise {
- await new Promise(resolve => setTimeout(resolve, 100));
+ // Update tier and limits
+ compliance.currentTier = newTier;
+ compliance.limits = { ...this.TIER_CONFIGS[newTier].limits };
+ compliance.verificationStatus = "APPROVED";
+ compliance.updatedAt = new Date().toISOString();
+
+ MOCK_COMPLIANCE_DB[userId] = compliance;
+ return true;
+ }
+
+ /**
+ * Set hold state on user account
+ */
+ static async setHoldState(
+ userId: string,
+ state: ComplianceHoldState,
+ reason?: string,
+ ): Promise {
+ const compliance = this.getComplianceRecord(userId);
+
+ compliance.holdState = state;
+ compliance.holdReason = reason;
+ compliance.updatedAt = new Date().toISOString();
+
+ MOCK_COMPLIANCE_DB[userId] = compliance;
+ }
+
+ /**
+ * Update verification status
+ */
+ static async updateVerificationStatus(
+ userId: string,
+ status: VerificationStatus,
+ ): Promise {
+ const compliance = this.getComplianceRecord(userId);
+
+ compliance.verificationStatus = status;
+ compliance.updatedAt = new Date().toISOString();
+
+ MOCK_COMPLIANCE_DB[userId] = compliance;
+ }
+
+ /**
+ * Mark user as restricted jurisdiction
+ */
+ static async setJurisdictionRestriction(
+ userId: string,
+ restricted: boolean,
+ jurisdictionCode?: string,
+ ): Promise {
+ const compliance = this.getComplianceRecord(userId);
+
+ compliance.restrictedJurisdiction = restricted;
+ compliance.jurisdictionCode = jurisdictionCode;
+ compliance.updatedAt = new Date().toISOString();
+
+ MOCK_COMPLIANCE_DB[userId] = compliance;
+ }
+
+ /**
+ * Get tier configuration (returns a deep copy)
+ */
+ static getTierConfig(tier: KYCTier): KYCTierConfig {
+ const config = this.TIER_CONFIGS[tier];
+ return {
+ ...config,
+ limits: { ...config.limits },
+ requirements: [...config.requirements],
+ };
+ }
+
+ /**
+ * Get all tier configurations (returns deep copies)
+ */
+ static getAllTierConfigs(): KYCTierConfig[] {
+ return (Object.keys(this.TIER_CONFIGS) as KYCTier[]).map((tier) =>
+ this.getTierConfig(tier),
+ );
+ }
+
+ /**
+ * Get next tier
+ */
+ static getNextTier(currentTier: KYCTier): KYCTier | null {
+ const tiers: KYCTier[] = ["UNVERIFIED", "BASIC", "VERIFIED", "ENHANCED"];
+ const currentIndex = tiers.indexOf(currentTier);
+
+ if (currentIndex === -1 || currentIndex === tiers.length - 1) {
+ return null;
}
- /**
- * Clear all compliance data (for testing)
- */
- static clearAllData(): void {
- Object.keys(MOCK_COMPLIANCE_DB).forEach(key => delete MOCK_COMPLIANCE_DB[key]);
- }
+ return tiers[currentIndex + 1];
+ }
+
+ /**
+ * Simulate database delay
+ */
+ private static async simulateDelay(): Promise {
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ }
+
+ /**
+ * Clear all compliance data (for testing)
+ */
+ static clearAllData(): void {
+ Object.keys(MOCK_COMPLIANCE_DB).forEach(
+ (key) => delete MOCK_COMPLIANCE_DB[key],
+ );
+ }
}
diff --git a/lib/services/geo-restriction.ts b/lib/services/geo-restriction.ts
index 19ecd01..dc2b4a0 100644
--- a/lib/services/geo-restriction.ts
+++ b/lib/services/geo-restriction.ts
@@ -1,104 +1,153 @@
import { RestrictedJurisdiction, UserLocation } from "@/types/geography";
const RESTRICTED: RestrictedJurisdiction[] = [
- { code: 'CU', name: 'Cuba', type: 'COUNTRY', reason: 'OFAC sanctions', effectiveDate: '2024-01-01' },
- { code: 'IR', name: 'Iran', type: 'COUNTRY', reason: 'OFAC sanctions', effectiveDate: '2024-01-01' },
- { code: 'KP', name: 'North Korea', type: 'COUNTRY', reason: 'OFAC sanctions', effectiveDate: '2024-01-01' },
- { code: 'SY', name: 'Syria', type: 'COUNTRY', reason: 'OFAC sanctions', effectiveDate: '2024-01-01' },
- { code: 'US-NY', name: 'New York', type: 'STATE', reason: 'BitLicense requirements', effectiveDate: '2024-01-01' },
-];
-
-const VPN_INDICATORS = [
- 'vpn', 'proxy', 'tor', 'relay', 'hosting', 'datacenter'
+ {
+ code: "CU",
+ name: "Cuba",
+ type: "COUNTRY",
+ reason: "OFAC sanctions",
+ effectiveDate: "2024-01-01",
+ },
+ {
+ code: "IR",
+ name: "Iran",
+ type: "COUNTRY",
+ reason: "OFAC sanctions",
+ effectiveDate: "2024-01-01",
+ },
+ {
+ code: "KP",
+ name: "North Korea",
+ type: "COUNTRY",
+ reason: "OFAC sanctions",
+ effectiveDate: "2024-01-01",
+ },
+ {
+ code: "SY",
+ name: "Syria",
+ type: "COUNTRY",
+ reason: "OFAC sanctions",
+ effectiveDate: "2024-01-01",
+ },
+ {
+ code: "US-NY",
+ name: "New York",
+ type: "STATE",
+ reason: "BitLicense requirements",
+ effectiveDate: "2024-01-01",
+ },
];
export class GeoRestrictionService {
- static async checkLocation(ip: string): Promise {
- // In production: call ipapi.co, MaxMind, or ip-api.com
- // Example: const response = await fetch(`https://ipapi.co/${ip}/json/`)
-
- const mockLocation: UserLocation = {
- ip,
- countryCode: 'US',
- countryName: 'United States',
- regionCode: 'CA',
- regionName: 'California',
- city: 'San Francisco',
- isVPN: await this.detectVPN(ip),
- isProxy: await this.detectProxy(ip),
- isRestricted: false,
- };
-
- // Check country-level restrictions
- const countryRestriction = RESTRICTED.find(r => r.code === mockLocation.countryCode && r.type === 'COUNTRY');
- if (countryRestriction) {
- mockLocation.isRestricted = true;
- mockLocation.restrictionReason = countryRestriction.reason;
- }
-
- // Check state-level restrictions
- if (mockLocation.countryCode === 'US' && mockLocation.regionCode) {
- const stateCode = `US-${mockLocation.regionCode}`;
- const stateRestriction = RESTRICTED.find(r => r.code === stateCode && r.type === 'STATE');
- if (stateRestriction) {
- mockLocation.isRestricted = true;
- mockLocation.restrictionReason = stateRestriction.reason;
- }
- }
-
- return mockLocation;
+ static async checkLocation(
+ ip: string,
+ override?: Partial,
+ ): Promise {
+ // In production: call ipapi.co, MaxMind, or ip-api.com
+ // Example: const response = await fetch(`https://ipapi.co/${ip}/json/`)
+
+ const mockLocation: UserLocation = {
+ ip,
+ countryCode: override?.countryCode ?? "US",
+ countryName: override?.countryName ?? "United States",
+ regionCode: override?.regionCode ?? "CA",
+ regionName: override?.regionName ?? "California",
+ city: override?.city ?? "San Francisco",
+ isVPN:
+ override?.isVPN !== undefined
+ ? override.isVPN
+ : await this.detectVPN(ip),
+ isProxy:
+ override?.isProxy !== undefined
+ ? override.isProxy
+ : await this.detectProxy(ip),
+ isRestricted: false,
+ };
+
+ // Check country-level restrictions
+ const countryRestriction = RESTRICTED.find(
+ (r) => r.code === mockLocation.countryCode && r.type === "COUNTRY",
+ );
+ if (countryRestriction) {
+ mockLocation.isRestricted = true;
+ mockLocation.restrictionReason = countryRestriction.reason;
}
- static async detectVPN(ip: string): Promise {
- // In production: use VPN detection API like:
- // - IPHub: https://iphub.info/
- // - IP2Proxy: https://www.ip2location.com/
- // - VPNApi: https://vpnapi.io/
-
- // Mock: check if IP is in common VPN ranges
- const vpnRanges = ['10.', '172.16.', '192.168.'];
- return vpnRanges.some(range => ip.startsWith(range));
+ // Check state-level restrictions (generic support for any country)
+ if (mockLocation.regionCode) {
+ const reason = this.getRestrictionReason(
+ mockLocation.countryCode,
+ mockLocation.regionCode,
+ );
+ if (reason) {
+ mockLocation.isRestricted = true;
+ mockLocation.restrictionReason = reason;
+ }
}
- static async detectProxy(ip: string): Promise {
- // In production: check proxy databases or use APIs
- // For now, use similar logic as VPN detection
- return false;
+ return mockLocation;
+ }
+
+ static async detectVPN(ip: string): Promise {
+ /**
+ * NOTE: This is a placeholder mock for VPN detection.
+ * TODO: Integrate with a real VPN detection service like IPHub, IP2Proxy, or VPNApi.
+ */
+
+ // Mock: check against a small list of "VPN-like" public IPs (placeholder logic)
+ const vpnIps = ["192.0.2.1", "198.51.100.1"];
+ return vpnIps.includes(ip);
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ static async detectProxy(_ip: string): Promise {
+ // In production: check proxy databases or use APIs
+ // For now, use similar logic as VPN detection
+ return false;
+ }
+
+ static isRestricted(countryCode: string, regionCode?: string): boolean {
+ // Check country restriction
+ if (
+ RESTRICTED.some((r) => r.code === countryCode && r.type === "COUNTRY")
+ ) {
+ return true;
}
- static isRestricted(countryCode: string, regionCode?: string): boolean {
- // Check country restriction
- if (RESTRICTED.some(r => r.code === countryCode && r.type === 'COUNTRY')) {
- return true;
- }
-
- // Check state restriction
- if (regionCode) {
- const stateCode = `${countryCode}-${regionCode}`;
- return RESTRICTED.some(r => r.code === stateCode && r.type === 'STATE');
- }
-
- return false;
+ // Check state restriction
+ if (regionCode) {
+ const stateCode = `${countryCode}-${regionCode}`;
+ return RESTRICTED.some((r) => r.code === stateCode && r.type === "STATE");
}
- static getRestrictionReason(countryCode: string, regionCode?: string): string | null {
- const countryRestriction = RESTRICTED.find(r => r.code === countryCode && r.type === 'COUNTRY');
- if (countryRestriction) {
- return countryRestriction.reason;
- }
-
- if (regionCode) {
- const stateCode = `${countryCode}-${regionCode}`;
- const stateRestriction = RESTRICTED.find(r => r.code === stateCode && r.type === 'STATE');
- if (stateRestriction) {
- return stateRestriction.reason;
- }
- }
-
- return null;
+ return false;
+ }
+
+ static getRestrictionReason(
+ countryCode: string,
+ regionCode?: string,
+ ): string | null {
+ const countryRestriction = RESTRICTED.find(
+ (r) => r.code === countryCode && r.type === "COUNTRY",
+ );
+ if (countryRestriction) {
+ return countryRestriction.reason;
}
- static getRestrictedJurisdictions(): RestrictedJurisdiction[] {
- return [...RESTRICTED];
+ if (regionCode) {
+ const stateCode = `${countryCode}-${regionCode}`;
+ const stateRestriction = RESTRICTED.find(
+ (r) => r.code === stateCode && r.type === "STATE",
+ );
+ if (stateRestriction) {
+ return stateRestriction.reason;
+ }
}
+
+ return null;
+ }
+
+ static getRestrictedJurisdictions(): RestrictedJurisdiction[] {
+ return [...RESTRICTED];
+ }
}
diff --git a/package-lock.json b/package-lock.json
index 55d5417..a5b7809 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -81,8 +81,12 @@
"@types/react-dom": "^19.2.3",
"eslint": "^9",
"eslint-config-next": "16.1.1",
+ "eslint-config-prettier": "^10.1.8",
+ "husky": "^9.1.7",
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
+ "lint-staged": "^16.2.7",
+ "prettier": "3.8.1",
"tailwindcss": "^4",
"ts-node": "^10.9.2",
"tw-animate-css": "^1.4.0",
@@ -174,7 +178,7 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^2.1.3",
@@ -188,7 +192,7 @@
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
- "devOptional": true,
+ "dev": true,
"license": "ISC"
},
"node_modules/@babel/code-frame": {
@@ -1267,7 +1271,7 @@
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
- "devOptional": true,
+ "dev": true,
"funding": [
{
"type": "github",
@@ -1287,7 +1291,7 @@
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
- "devOptional": true,
+ "dev": true,
"funding": [
{
"type": "github",
@@ -1311,7 +1315,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
- "devOptional": true,
+ "dev": true,
"funding": [
{
"type": "github",
@@ -1339,7 +1343,7 @@
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
- "devOptional": true,
+ "dev": true,
"funding": [
{
"type": "github",
@@ -1362,7 +1366,7 @@
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
- "devOptional": true,
+ "dev": true,
"funding": [
{
"type": "github",
@@ -1468,6 +1472,7 @@
"cpu": [
"ppc64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1484,6 +1489,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1500,6 +1506,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1516,6 +1523,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1532,6 +1540,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1548,6 +1557,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1564,6 +1574,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1580,6 +1591,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1596,6 +1608,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1612,6 +1625,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1628,6 +1642,7 @@
"cpu": [
"ia32"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1644,6 +1659,7 @@
"cpu": [
"loong64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1660,6 +1676,7 @@
"cpu": [
"mips64el"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1676,6 +1693,7 @@
"cpu": [
"ppc64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1692,6 +1710,7 @@
"cpu": [
"riscv64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1708,6 +1727,7 @@
"cpu": [
"s390x"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1724,6 +1744,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1740,6 +1761,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1756,6 +1778,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1772,6 +1795,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1788,6 +1812,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1804,6 +1829,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1820,6 +1846,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1836,6 +1863,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1852,6 +1880,7 @@
"cpu": [
"ia32"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1868,6 +1897,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7123,6 +7153,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7136,6 +7167,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7149,6 +7181,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7162,6 +7195,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7175,6 +7209,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7188,6 +7223,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7201,6 +7237,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7214,6 +7251,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7227,6 +7265,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7240,6 +7279,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7253,6 +7293,7 @@
"cpu": [
"loong64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7266,6 +7307,7 @@
"cpu": [
"loong64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7279,6 +7321,7 @@
"cpu": [
"ppc64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7292,6 +7335,7 @@
"cpu": [
"ppc64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7305,6 +7349,7 @@
"cpu": [
"riscv64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7318,6 +7363,7 @@
"cpu": [
"riscv64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7331,6 +7377,7 @@
"cpu": [
"s390x"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7344,6 +7391,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7357,6 +7405,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7370,6 +7419,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7383,6 +7433,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7396,6 +7447,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7409,6 +7461,7 @@
"cpu": [
"ia32"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7422,6 +7475,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7435,6 +7489,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -8265,7 +8320,7 @@
"version": "20.19.29",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.29.tgz",
"integrity": "sha512-YrT9ArrGaHForBaCNwFjoqJWmn8G1Pr7+BH/vwyLHciA9qT/wSiuOhxGCT50JA5xLvFBd6PIiGkE3afxcPE1nw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@@ -9137,7 +9192,7 @@
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
@@ -10391,6 +10446,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/commander": {
+ "version": "14.0.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
+ "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/common-tags": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz",
@@ -10516,7 +10581,7 @@
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^3.2.0",
@@ -10674,7 +10739,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^4.0.0",
@@ -10805,7 +10870,7 @@
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/decimal.js-light": {
@@ -11180,7 +11245,7 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
- "devOptional": true,
+ "dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
@@ -11567,6 +11632,22 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/eslint-config-prettier": {
+ "version": "10.1.8",
+ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz",
+ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "eslint-config-prettier": "bin/cli.js"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint-config-prettier"
+ },
+ "peerDependencies": {
+ "eslint": ">=7.0.0"
+ }
+ },
"node_modules/eslint-import-resolver-node": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz",
@@ -12317,6 +12398,7 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -13157,7 +13239,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"whatwg-encoding": "^3.1.1"
@@ -13187,7 +13269,7 @@
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.0",
@@ -13201,7 +13283,7 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
@@ -13221,11 +13303,27 @@
"node": ">=10.17.0"
}
},
+ "node_modules/husky": {
+ "version": "9.1.7",
+ "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
+ "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "husky": "bin.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/typicode"
+ }
+ },
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -13764,7 +13862,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/is-regex": {
@@ -15102,7 +15200,7 @@
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
@@ -15140,7 +15238,7 @@
"version": "26.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"cssstyle": "^4.2.1",
@@ -15327,7 +15425,7 @@
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
- "devOptional": true,
+ "dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
@@ -15360,6 +15458,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -15380,6 +15479,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -15400,6 +15500,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -15420,6 +15521,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -15440,6 +15542,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -15460,6 +15563,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -15480,6 +15584,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -15500,6 +15605,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -15520,6 +15626,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -15540,6 +15647,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -15560,6 +15668,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -15580,6 +15689,31 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/lint-staged": {
+ "version": "16.2.7",
+ "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.7.tgz",
+ "integrity": "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "commander": "^14.0.2",
+ "listr2": "^9.0.5",
+ "micromatch": "^4.0.8",
+ "nano-spawn": "^2.0.0",
+ "pidtree": "^0.6.0",
+ "string-argv": "^0.3.2",
+ "yaml": "^2.8.1"
+ },
+ "bin": {
+ "lint-staged": "bin/lint-staged.js"
+ },
+ "engines": {
+ "node": ">=20.17"
+ },
+ "funding": {
+ "url": "https://opencollective.com/lint-staged"
+ }
+ },
"node_modules/listr2": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz",
@@ -16730,6 +16864,19 @@
"node": "^18.17.0 || >=20.5.0"
}
},
+ "node_modules/nano-spawn": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz",
+ "integrity": "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.17"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/nano-spawn?sponsor=1"
+ }
+ },
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -17003,7 +17150,7 @@
"version": "2.2.23",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz",
"integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/object-assign": {
@@ -17349,7 +17496,7 @@
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
@@ -17500,6 +17647,19 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/pidtree": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz",
+ "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "pidtree": "bin/pidtree.js"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
"node_modules/pirates": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
@@ -17642,6 +17802,22 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/prettier": {
+ "version": "3.8.1",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
+ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
@@ -17718,7 +17894,7 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -18301,7 +18477,7 @@
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/run-parallel": {
@@ -18397,14 +18573,14 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
- "devOptional": true,
+ "dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
@@ -18927,6 +19103,16 @@
"node": ">= 0.4"
}
},
+ "node_modules/string-argv": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
+ "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6.19"
+ }
+ },
"node_modules/string-env-interpolation": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/string-env-interpolation/-/string-env-interpolation-1.0.1.tgz",
@@ -19299,7 +19485,7 @@
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/sync-fetch": {
@@ -19525,7 +19711,7 @@
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^6.1.86"
@@ -19538,7 +19724,7 @@
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/tmpl": {
@@ -19565,7 +19751,7 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
- "devOptional": true,
+ "dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^6.1.32"
@@ -19578,7 +19764,7 @@
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
@@ -19925,7 +20111,7 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/unified": {
@@ -20489,7 +20675,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
@@ -20522,7 +20708,7 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
- "devOptional": true,
+ "dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
@@ -20533,7 +20719,7 @@
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"iconv-lite": "0.6.3"
@@ -20546,7 +20732,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -20556,7 +20742,7 @@
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"tr46": "^5.1.0",
@@ -20836,7 +21022,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
- "devOptional": true,
+ "dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
@@ -20846,7 +21032,7 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/xmlhttprequest-ssl": {
@@ -20878,7 +21064,7 @@
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
- "devOptional": true,
+ "dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
diff --git a/package.json b/package.json
index 7b8416d..19377c9 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,8 @@
"lint": "eslint",
"test": "jest",
"codegen": "graphql-codegen",
- "codegen:watch": "graphql-codegen --watch"
+ "codegen:watch": "graphql-codegen --watch",
+ "prepare": "husky"
},
"dependencies": {
"@apollo/client": "^4.1.2",
@@ -85,8 +86,12 @@
"@types/react-dom": "^19.2.3",
"eslint": "^9",
"eslint-config-next": "16.1.1",
+ "eslint-config-prettier": "^10.1.8",
+ "husky": "^9.1.7",
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
+ "lint-staged": "^16.2.7",
+ "prettier": "3.8.1",
"tailwindcss": "^4",
"ts-node": "^10.9.2",
"tw-animate-css": "^1.4.0",