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
833 changes: 551 additions & 282 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/app/invoices/[ID]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export default async function PaymentPage({

<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Payment Section */}
<PaymentSection invoice={invoice} />
<PaymentSection serverInvoice={invoice} />

{/* Invoice Preview */}
<Card className="w-full bg-white shadow-sm border-0">
Expand Down
1 change: 1 addition & 0 deletions src/components/app-kit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ createAppKit({
adapters: [new Ethers5Adapter()],
metadata,
networks: [sepolia, base, mainnet, arbitrum, optimism, polygon],
themeMode: "light",
projectId,
features: {
analytics: false,
Expand Down
39 changes: 17 additions & 22 deletions src/components/crypto-to-fiat.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,29 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import type { User } from "@/server/db/schema";
import { AlertTriangle } from "lucide-react";
import { ComplianceForm } from "./compliance-form";

export function CryptoToFiat({ user }: { user: User }) {
return (
<div className="flex justify-center mx-auto w-full">
<Card className="w-[800px] shadow-lg border-zinc-200/80">
<CardHeader className="bg-zinc-50 rounded-t-lg border-b border-zinc-200/80" />

<CardContent className="p-8">
{!user.isCompliant && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6 flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-amber-500 mt-0.5" />
<div>
<h3 className="font-medium text-amber-800">
Compliance Required
</h3>
<p className="text-sm text-amber-700 mt-1">
To send Crypto-to-fiat payments, you need to complete KYC
verification and sign the compliance agreement.
</p>
</div>
<div>
{!user.isCompliant && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6 flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-amber-500 mt-0.5" />
<div>
<h3 className="font-medium text-amber-800">
Compliance Required
</h3>
<p className="text-sm text-amber-700 mt-1">
To send Crypto-to-fiat payments, you need to complete KYC
verification and sign the compliance agreement.
</p>
</div>
)}
<div className="w-full">
<ComplianceForm user={user} />
</div>
</CardContent>
</Card>
)}
<div className="w-full">
<ComplianceForm user={user} />
</div>
</div>
</div>
);
}
20 changes: 18 additions & 2 deletions src/components/invoice-creator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,29 @@ export function InvoiceCreator({
description: "You can safely close this page now",
});
},
onError: (error) => {
toast.error("Failed to create invoice", {
description:
error instanceof Error
? error.message
: "An unexpected error occurred",
});
},
})
: api.invoice.create.useMutation({
onSuccess: async () => {
toast.success("Invoice created successfully");
await utils.invoice.getAll.invalidate();
router.push("/dashboard");
},
onError: (error) => {
toast.error("Failed to create invoice", {
description:
error instanceof Error
? error.message
: "An unexpected error occurred",
});
},
});

const form = useForm<InvoiceFormValues>({
Expand All @@ -72,9 +88,9 @@ export function InvoiceCreator({
},
});

const onSubmit = (data: InvoiceFormValues) => {
const onSubmit = async (data: InvoiceFormValues) => {
try {
createInvoice(data);
await createInvoice(data);
} catch (error) {
toast.error("Failed to create invoice", {
description:
Expand Down
40 changes: 34 additions & 6 deletions src/components/payment-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
useAppKitAccount,
useAppKitNetwork,
useAppKitProvider,
useDisconnect,
} from "@reown/appkit/react";
import { ethers } from "ethers";
import { AlertCircle, CheckCircle, Clock, Loader2, Wallet } from "lucide-react";
Expand All @@ -40,7 +41,7 @@ type InvoiceStatus =
| "overdue";

interface PaymentSectionProps {
invoice: NonNullable<Request>;
serverInvoice: NonNullable<Request>;
}

const getCurrencyChain = (currency: string) => {
Expand Down Expand Up @@ -101,8 +102,9 @@ const getRouteType = (route: PaymentRouteType, invoiceChain: string | null) => {
};
};

export function PaymentSection({ invoice }: PaymentSectionProps) {
export function PaymentSection({ serverInvoice }: PaymentSectionProps) {
const { open } = useAppKit();
const { disconnect } = useDisconnect();
const { isConnected, address } = useAppKitAccount();
const { walletProvider } = useAppKitProvider("eip155");
const { chainId, switchNetwork } = useAppKitNetwork();
Expand All @@ -111,9 +113,29 @@ export function PaymentSection({ invoice }: PaymentSectionProps) {
null,
);

const [invoice, setInvoice] = useState(serverInvoice);

const [paymentStatus, setPaymentStatus] = useState<InvoiceStatus>(
invoice.status as InvoiceStatus,
serverInvoice.status as InvoiceStatus,
);

// Only Poll when Invoice is not paid
const [polling, setPolling] = useState(paymentStatus !== "paid");

// Poll the invoice status every 3 seconds until it's paid
api.invoice.getById.useQuery(serverInvoice.id, {
refetchInterval: polling ? 3000 : false,
onSuccess: (data) => {
setInvoice(data);
if (data.status !== "pending") {
setPaymentStatus(data.status);
}
if (data.status === "paid") {
setPolling(false);
}
},
});

const [paymentProgress, setPaymentProgress] = useState("idle");
const [currentStep, setCurrentStep] = useState(1);
const [isAppKitReady, setIsAppKitReady] = useState(false);
Expand Down Expand Up @@ -197,6 +219,12 @@ export function PaymentSection({ invoice }: PaymentSectionProps) {
}
};

const handleDisconnect = () => {
if (isAppKitReady) {
disconnect();
}
};

const handleCrosschainPayments = async (paymentData: any, signer: any) => {
const paymentIntent = JSON.parse(paymentData.paymentIntent);
const supportsEIP2612 = paymentData.metadata.supportsEIP2612;
Expand Down Expand Up @@ -515,10 +543,10 @@ export function PaymentSection({ invoice }: PaymentSectionProps) {
<Button
variant="ghost"
size="sm"
onClick={handleConnectWallet}
disabled={!isAppKitReady}
onClick={handleDisconnect}
disabled={!isAppKitReady || paymentProgress !== "idle"}
>
Switch Wallet
Disconnect
</Button>
</div>
<div className="font-mono bg-zinc-100 p-2 rounded">
Expand Down
22 changes: 20 additions & 2 deletions src/components/user-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { truncateEmail } from "@/lib/utils";
import type { User } from "@/server/db/schema";
import { api } from "@/trpc/react";
import { LogOut } from "lucide-react";
import { CopyIcon, LogOut } from "lucide-react";
import { useRouter } from "next/navigation";

interface UserMenuProps {
user: Pick<User, "name" | "id">;
user: Pick<User, "name" | "id" | "email">;
}

export function UserMenu({ user }: UserMenuProps) {
Expand Down Expand Up @@ -48,6 +49,23 @@ export function UserMenu({ user }: UserMenuProps) {
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-row items-center justify-between gap-2">
<p className="text-sm font-medium text-neutral-900">
{truncateEmail(user.email ?? "")}
</p>
<Button
variant="ghost"
className="size-8"
aria-label="Copy email"
onClick={async () => {
await navigator.clipboard.writeText(user.email ?? "");
}}
>
<CopyIcon className="size-4" />
</Button>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => logout.mutate()}
Expand Down
8 changes: 8 additions & 0 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ export function filterDefinedValues<T extends Record<string, unknown>>(
) as Partial<T>;
}


export function truncateEmail(email: string, maxLength = 20): string {
if (email.length <= maxLength) return email;
const [user, domain] = email.split("@");
const keep = maxLength - domain.length - 4;
return `${user.slice(0, keep)}...@${domain}`;
}

export const getCanCancelPayment = (status: string) => {
return status === "pending" || status === "active";
};
14 changes: 12 additions & 2 deletions src/server/routers/invoice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ const createInvoiceHelper = async (
0,
);

// Throw error if totalAmount is less than or equal to 0
if (totalAmount <= 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Total amount must be greater than 0",
});
}

// FIXME: This logic can be removed after we implement it inside the Request Network API.
// We set the payee address to an address controlled by the Request Network Foundation,
// even in the case of crypto-to-fiat, because it's required by the protocol.
Expand Down Expand Up @@ -150,7 +158,8 @@ export const invoiceRouter = router({
});
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create invoice",
message:
error instanceof Error ? error.message : "Failed to create invoice",
});
}
}),
Expand Down Expand Up @@ -203,7 +212,8 @@ export const invoiceRouter = router({
);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create invoice",
message:
error instanceof Error ? error.message : "Failed to create invoice",
});
}
}),
Expand Down