Skip to content

Commit 2a06d48

Browse files
authored
feat: populate USD payment currencies from the API (#104)
1 parent 4bdc63c commit 2a06d48

File tree

5 files changed

+191
-19
lines changed

5 files changed

+191
-19
lines changed

src/components/invoice-creator.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { InvoiceForm } from "@/components/invoice-form";
3+
import { InvoiceForm } from "@/components/invoice-form/invoice-form";
44
import { InvoicePreview } from "@/components/invoice-preview";
55
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
66
import { generateInvoiceNumber } from "@/lib/helpers/client";
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { Label } from "@/components/ui/label";
2+
import {
3+
Select,
4+
SelectContent,
5+
SelectItem,
6+
SelectTrigger,
7+
SelectValue,
8+
} from "@/components/ui/select";
9+
import {
10+
type InvoiceCurrency,
11+
formatCurrencyLabel,
12+
} from "@/lib/constants/currencies";
13+
import { api } from "@/trpc/react";
14+
import { Loader2 } from "lucide-react";
15+
16+
interface PaymentCurrencySelectorProps {
17+
onChange: (value: string) => void;
18+
targetCurrency: InvoiceCurrency;
19+
network: string;
20+
}
21+
22+
export function PaymentCurrencySelector({
23+
onChange,
24+
targetCurrency,
25+
network,
26+
}: PaymentCurrencySelectorProps) {
27+
const {
28+
data: conversionData,
29+
isLoading,
30+
error,
31+
refetch,
32+
} = api.currency.getConversionCurrencies.useQuery({
33+
targetCurrency,
34+
network,
35+
});
36+
37+
if (isLoading) {
38+
return (
39+
<div className="space-y-2">
40+
<Label htmlFor="paymentCurrency">Payment Currency</Label>
41+
<Select disabled>
42+
<SelectTrigger>
43+
<SelectValue>
44+
<div className="flex items-center gap-2">
45+
<Loader2 className="h-4 w-4 animate-spin" />
46+
<span>Loading currencies...</span>
47+
</div>
48+
</SelectValue>
49+
</SelectTrigger>
50+
</Select>
51+
</div>
52+
);
53+
}
54+
55+
if (error) {
56+
return (
57+
<div className="space-y-2">
58+
<Label htmlFor="paymentCurrency">Payment Currency</Label>
59+
<Select disabled>
60+
<SelectTrigger>
61+
<SelectValue placeholder="Error loading currencies" />
62+
</SelectTrigger>
63+
</Select>
64+
<p className="text-sm text-red-500">
65+
Failed to load payment currencies: {error.message}
66+
</p>
67+
<p className="text-sm text-red-500">
68+
<button
69+
type="button"
70+
onClick={() => refetch()}
71+
className="text-red-500 underline"
72+
>
73+
Retry
74+
</button>{" "}
75+
or refresh the page.
76+
</p>
77+
</div>
78+
);
79+
}
80+
81+
const conversionRoutes = conversionData?.conversionRoutes || [];
82+
83+
if (conversionRoutes.length === 0) {
84+
return (
85+
<div className="space-y-2">
86+
<Label htmlFor="paymentCurrency">Payment Currency</Label>
87+
<Select disabled>
88+
<SelectTrigger>
89+
<SelectValue placeholder="No payment currencies available" />
90+
</SelectTrigger>
91+
</Select>
92+
<p className="text-sm text-amber-600">
93+
No payment currencies are available for {targetCurrency} on {network}
94+
</p>
95+
</div>
96+
);
97+
}
98+
99+
return (
100+
<div className="space-y-2">
101+
<Label htmlFor="paymentCurrency">Payment Currency</Label>
102+
<Select onValueChange={onChange}>
103+
<SelectTrigger>
104+
<SelectValue placeholder="Select payment currency" />
105+
</SelectTrigger>
106+
<SelectContent>
107+
{conversionRoutes.map((currency) => (
108+
<SelectItem key={currency.id} value={currency.id}>
109+
{formatCurrencyLabel(currency.id)}
110+
</SelectItem>
111+
))}
112+
</SelectContent>
113+
</Select>
114+
</div>
115+
);
116+
}

src/components/invoice-form.tsx renamed to src/components/invoice-form/invoice-form.tsx

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import {
2626
MAINNET_CURRENCIES,
2727
type MainnetCurrency,
2828
formatCurrencyLabel,
29-
getPaymentCurrenciesForInvoice,
3029
} from "@/lib/constants/currencies";
3130
import type { InvoiceFormValues } from "@/lib/schemas/invoice";
3231
import type {
@@ -40,11 +39,13 @@ import { useCallback, useEffect, useState } from "react";
4039
import type { UseFormReturn } from "react-hook-form";
4140
import { useFieldArray } from "react-hook-form";
4241
import { toast } from "sonner";
43-
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
42+
import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
43+
import { PaymentCurrencySelector } from "./blocks/payment-currency-selector";
4444

4545
// Constants
4646
const PAYMENT_DETAILS_POLLING_INTERVAL = 30000; // 30 seconds in milliseconds
4747
const BANK_ACCOUNT_APPROVAL_TIMEOUT = 60000; // 1 minute timeout for bank account approval
48+
const DEFAULT_NETWORK = "sepolia";
4849

4950
type RecurringFrequency = "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY";
5051

@@ -896,22 +897,11 @@ export function InvoiceForm({
896897
{/* Only show payment currency selector for USD invoices */}
897898
{form.watch("invoiceCurrency") === "USD" && (
898899
<div className="space-y-2">
899-
<Label htmlFor="paymentCurrency">Payment Currency</Label>
900-
<Select
901-
onValueChange={(value) => form.setValue("paymentCurrency", value)}
902-
defaultValue={form.getValues("paymentCurrency")}
903-
>
904-
<SelectTrigger>
905-
<SelectValue placeholder="Select payment currency" />
906-
</SelectTrigger>
907-
<SelectContent>
908-
{getPaymentCurrenciesForInvoice("USD").map((currency) => (
909-
<SelectItem key={currency} value={currency}>
910-
{formatCurrencyLabel(currency)}
911-
</SelectItem>
912-
))}
913-
</SelectContent>
914-
</Select>
900+
<PaymentCurrencySelector
901+
onChange={(value) => form.setValue("paymentCurrency", value)}
902+
targetCurrency="USD"
903+
network={DEFAULT_NETWORK}
904+
/>
915905
{form.formState.errors.paymentCurrency && (
916906
<p className="text-sm text-red-500">
917907
{form.formState.errors.paymentCurrency.message}

src/server/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { authRouter } from "./routers/auth";
22
import { complianceRouter } from "./routers/compliance";
3+
import { currencyRouter } from "./routers/currency";
34
import { invoiceRouter } from "./routers/invoice";
45
import { invoiceMeRouter } from "./routers/invoice-me";
56
import { paymentRouter } from "./routers/payment";
@@ -15,6 +16,7 @@ export const appRouter = router({
1516
compliance: complianceRouter,
1617
recurringPayment: recurringPaymentRouter,
1718
subscriptionPlan: subscriptionPlanRouter,
19+
currency: currencyRouter,
1820
});
1921

2022
export type AppRouter = typeof appRouter;

src/server/routers/currency.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { apiClient } from "@/lib/axios";
2+
import { TRPCError } from "@trpc/server";
3+
import type { AxiosResponse } from "axios";
4+
import axios from "axios";
5+
import { z } from "zod";
6+
import { publicProcedure, router } from "../trpc";
7+
8+
export type ConversionCurrency = {
9+
id: string;
10+
symbol: string;
11+
decimals: number;
12+
address: string;
13+
type: "ERC20" | "ETH" | "ISO4217";
14+
network: string;
15+
};
16+
17+
export interface GetConversionCurrenciesResponse {
18+
currencyId: string;
19+
network: string;
20+
conversionRoutes: ConversionCurrency[];
21+
}
22+
23+
export const currencyRouter = router({
24+
getConversionCurrencies: publicProcedure
25+
.input(
26+
z.object({
27+
targetCurrency: z.string(),
28+
network: z.string(),
29+
}),
30+
)
31+
.query(async ({ input }): Promise<GetConversionCurrenciesResponse> => {
32+
const { targetCurrency, network } = input;
33+
34+
try {
35+
const response: AxiosResponse<GetConversionCurrenciesResponse> =
36+
await apiClient.get(
37+
`v2/currencies/${targetCurrency}/conversion-routes?network=${network}`,
38+
);
39+
40+
return response.data;
41+
} catch (error) {
42+
if (axios.isAxiosError(error)) {
43+
const statusCode = error.response?.status;
44+
const code =
45+
statusCode === 404
46+
? "NOT_FOUND"
47+
: statusCode === 400
48+
? "BAD_REQUEST"
49+
: "INTERNAL_SERVER_ERROR";
50+
51+
throw new TRPCError({
52+
code,
53+
message: error.response?.data?.message || error.message,
54+
cause: error,
55+
});
56+
}
57+
58+
throw new TRPCError({
59+
code: "INTERNAL_SERVER_ERROR",
60+
message: "Failed to fetch conversion currencies",
61+
});
62+
}
63+
}),
64+
});

0 commit comments

Comments
 (0)