Skip to content

Commit 82c9b3e

Browse files
committed
feat: implement ecommerce sales page
1 parent 89bce8f commit 82c9b3e

File tree

6 files changed

+363
-4
lines changed

6 files changed

+363
-4
lines changed

src/app/api/webhook/route.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,24 @@ import { ulid } from "ulid";
1818

1919
async function addClientPayment(webhookBody: any) {
2020
await db.transaction(async (tx) => {
21+
const existingPayment = await tx
22+
.select()
23+
.from(clientPaymentTable)
24+
.where(
25+
and(
26+
eq(clientPaymentTable.txHash, webhookBody.txHash),
27+
eq(clientPaymentTable.requestId, webhookBody.requestId),
28+
),
29+
)
30+
.limit(1);
31+
32+
if (existingPayment.length > 0) {
33+
console.warn(
34+
`Duplicate payment detected for txHash: ${webhookBody.txHash} and requestId: ${webhookBody.requestId}`,
35+
);
36+
return;
37+
}
38+
2139
const ecommerceClient = await tx
2240
.select()
2341
.from(ecommerceClientTable)
@@ -38,6 +56,8 @@ async function addClientPayment(webhookBody: any) {
3856
requestId: webhookBody.requestId,
3957
invoiceCurrency: webhookBody.currency,
4058
paymentCurrency: webhookBody.paymentCurrency,
59+
txHash: webhookBody.txHash,
60+
network: webhookBody.network,
4161
amount: webhookBody.amount,
4262
customerInfo: webhookBody.customerInfo || null,
4363
reference: webhookBody.reference || null,

src/app/ecommerce/sales/page.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import { EcommerceSales } from "@/components/ecommerce/sales";
12
import { getCurrentSession } from "@/server/auth";
2-
//import { api } from "@/trpc/server";
3+
import { api } from "@/trpc/server";
34
import { redirect } from "next/navigation";
45

56
export default async function SalesPage() {
@@ -9,7 +10,15 @@ export default async function SalesPage() {
910
redirect("/");
1011
}
1112

12-
// TODO fetch sales data
13+
const [clientPayments, ecommerceClients] = await Promise.all([
14+
api.ecommerce.getAllClientPayments.query(),
15+
api.ecommerce.getAll.query(),
16+
]);
1317

14-
return <div>Sales Page - to be implemented</div>;
18+
return (
19+
<EcommerceSales
20+
initialClientPayments={clientPayments}
21+
ecommerceClients={ecommerceClients}
22+
/>
23+
);
1524
}
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
"use client";
2+
3+
import { ShortAddress } from "@/components/short-address";
4+
import { Button } from "@/components/ui/button";
5+
import { Card, CardContent } from "@/components/ui/card";
6+
import {
7+
Select,
8+
SelectContent,
9+
SelectItem,
10+
SelectTrigger,
11+
SelectValue,
12+
} from "@/components/ui/select";
13+
import { EmptyState } from "@/components/ui/table/empty-state";
14+
import { Pagination } from "@/components/ui/table/pagination";
15+
import {
16+
Table,
17+
TableBody,
18+
TableCell,
19+
TableHeader,
20+
TableRow,
21+
} from "@/components/ui/table/table";
22+
import { TableHeadCell } from "@/components/ui/table/table-head-cell";
23+
import type { ClientPayment, EcommerceClient } from "@/server/db/schema";
24+
import { format } from "date-fns";
25+
import {
26+
ChevronDown,
27+
ChevronUp,
28+
CreditCard,
29+
ExternalLink,
30+
Filter,
31+
} from "lucide-react";
32+
import { useState } from "react";
33+
34+
interface ClientPaymentsTableProps {
35+
clientPayments: ClientPayment[];
36+
ecommerceClients: EcommerceClient[];
37+
}
38+
39+
interface CustomerInfoDisplayProps {
40+
customerInfo: ClientPayment["customerInfo"];
41+
}
42+
43+
function CustomerInfoDisplay({ customerInfo }: CustomerInfoDisplayProps) {
44+
const [isExpanded, setIsExpanded] = useState(false);
45+
46+
if (!customerInfo) {
47+
return <span className="text-zinc-500">-</span>;
48+
}
49+
50+
const hasExpandableInfo =
51+
customerInfo.firstName || customerInfo.lastName || customerInfo.address;
52+
53+
return (
54+
<div className="space-y-2">
55+
<div className="text-sm">{customerInfo.email || "No email"}</div>
56+
{hasExpandableInfo && (
57+
<>
58+
{isExpanded && (
59+
<div className="space-y-1 text-xs text-zinc-600 font-normal">
60+
{(customerInfo.firstName || customerInfo.lastName) && (
61+
<div>
62+
{customerInfo.firstName} {customerInfo.lastName}
63+
</div>
64+
)}
65+
{customerInfo.address && (
66+
<div className="space-y-1">
67+
{customerInfo.address.street && (
68+
<div>{customerInfo.address.street}</div>
69+
)}
70+
<div>
71+
{customerInfo.address.city}, {customerInfo.address.state}{" "}
72+
{customerInfo.address.postalCode}
73+
</div>
74+
{customerInfo.address.country && (
75+
<div>{customerInfo.address.country}</div>
76+
)}
77+
</div>
78+
)}
79+
</div>
80+
)}
81+
<Button
82+
variant="ghost"
83+
size="sm"
84+
onClick={() => setIsExpanded(!isExpanded)}
85+
className="h-6 px-2 text-xs"
86+
>
87+
{isExpanded ? (
88+
<>
89+
<ChevronUp className="h-3 w-3 mr-1" />
90+
Collapse
91+
</>
92+
) : (
93+
<>
94+
<ChevronDown className="h-3 w-3 mr-1" />
95+
Show details
96+
</>
97+
)}
98+
</Button>
99+
</>
100+
)}
101+
</div>
102+
);
103+
}
104+
105+
const ClientPaymentTableColumns = () => (
106+
<TableRow className="hover:bg-transparent border-none">
107+
<TableHeadCell>Date</TableHeadCell>
108+
<TableHeadCell>Amount</TableHeadCell>
109+
<TableHeadCell>Network</TableHeadCell>
110+
<TableHeadCell>Invoice Currency</TableHeadCell>
111+
<TableHeadCell>Payment Currency</TableHeadCell>
112+
<TableHeadCell>Customer Info</TableHeadCell>
113+
<TableHeadCell>Reference</TableHeadCell>
114+
<TableHeadCell>Client ID</TableHeadCell>
115+
<TableHeadCell>Origin</TableHeadCell>
116+
<TableHeadCell>Request Scan URL</TableHeadCell>
117+
</TableRow>
118+
);
119+
120+
const ClientPaymentRow = ({
121+
clientPayment,
122+
}: { clientPayment: ClientPayment }) => {
123+
return (
124+
<TableRow className="hover:bg-zinc-50/50">
125+
<TableCell>
126+
{clientPayment.createdAt
127+
? format(new Date(clientPayment.createdAt), "do MMM yyyy HH:mm")
128+
: "N/A"}
129+
</TableCell>
130+
<TableCell className="font-medium">{clientPayment.amount}</TableCell>
131+
<TableCell>{clientPayment.network}</TableCell>
132+
<TableCell>{clientPayment.invoiceCurrency}</TableCell>
133+
<TableCell>{clientPayment.paymentCurrency}</TableCell>
134+
<TableCell>
135+
<CustomerInfoDisplay customerInfo={clientPayment.customerInfo} />
136+
</TableCell>
137+
<TableCell>
138+
{clientPayment.reference || <span className="text-zinc-500">-</span>}
139+
</TableCell>
140+
<TableCell>
141+
<ShortAddress address={clientPayment.clientId} />
142+
</TableCell>
143+
<TableCell>
144+
{clientPayment.origin || <span className="text-zinc-500">-</span>}
145+
</TableCell>
146+
<TableCell>
147+
<a
148+
href={`https://scan.request.network/request/${clientPayment.requestId}`}
149+
target="_blank"
150+
rel="noopener noreferrer"
151+
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-800 transition-colors"
152+
>
153+
<span className="text-sm">View Request</span>
154+
<ExternalLink className="h-3 w-3" />
155+
</a>
156+
</TableCell>
157+
</TableRow>
158+
);
159+
};
160+
161+
const ITEMS_PER_PAGE = 10;
162+
export function ClientPaymentsTable({
163+
clientPayments,
164+
ecommerceClients,
165+
}: ClientPaymentsTableProps) {
166+
const [activeClientId, setActiveClientId] = useState<string | null>(null);
167+
const [currentPage, setCurrentPage] = useState(1);
168+
169+
const filteredPayments = activeClientId
170+
? clientPayments.filter((payment) => payment.clientId === activeClientId)
171+
: clientPayments;
172+
173+
const totalPages = Math.ceil(filteredPayments.length / ITEMS_PER_PAGE);
174+
const paginatedPayments = filteredPayments.slice(
175+
(currentPage - 1) * ITEMS_PER_PAGE,
176+
currentPage * ITEMS_PER_PAGE,
177+
);
178+
179+
const handleClientFilterChange = (value: string) => {
180+
setActiveClientId(value === "all" ? null : value);
181+
setCurrentPage(1);
182+
};
183+
184+
const clientIdToLabel = ecommerceClients.reduce(
185+
(acc, client) => {
186+
acc[client.rnClientId] = client.label;
187+
return acc;
188+
},
189+
{} as Record<string, string>,
190+
);
191+
192+
const uniqueClientIds = Array.from(
193+
new Set(clientPayments.map((payment) => payment.clientId)),
194+
);
195+
196+
return (
197+
<div className="space-y-6 w-full">
198+
<div className="flex items-center justify-between">
199+
<div className="flex items-center gap-4">
200+
<div className="flex items-center gap-2">
201+
<Filter className="h-4 w-4 text-zinc-600" />
202+
<span className="text-sm font-medium text-zinc-700">
203+
Filter by client:
204+
</span>
205+
</div>
206+
<Select
207+
value={activeClientId || "all"}
208+
onValueChange={handleClientFilterChange}
209+
>
210+
<SelectTrigger className="w-[200px]">
211+
<SelectValue placeholder="All Clients" />
212+
</SelectTrigger>
213+
<SelectContent>
214+
<SelectItem value="all">All Clients</SelectItem>
215+
{uniqueClientIds.map((clientId) => (
216+
<SelectItem key={clientId} value={clientId}>
217+
{clientIdToLabel[clientId] || `${clientId.slice(0, 8)}...`}
218+
</SelectItem>
219+
))}
220+
</SelectContent>
221+
</Select>
222+
</div>
223+
</div>
224+
225+
<Card className="border border-zinc-100">
226+
<CardContent className="p-0">
227+
<Table>
228+
<TableHeader>
229+
<ClientPaymentTableColumns />
230+
</TableHeader>
231+
<TableBody>
232+
{paginatedPayments.length === 0 ? (
233+
<TableRow>
234+
<TableCell colSpan={9} className="p-0">
235+
<EmptyState
236+
icon={<CreditCard className="h-6 w-6 text-zinc-600" />}
237+
title="No client payments"
238+
subtitle={
239+
activeClientId
240+
? "No payments found for the selected client"
241+
: "No payments received yet"
242+
}
243+
/>
244+
</TableCell>
245+
</TableRow>
246+
) : (
247+
paginatedPayments.map((clientPayment) => (
248+
<ClientPaymentRow
249+
key={clientPayment.id}
250+
clientPayment={clientPayment}
251+
/>
252+
))
253+
)}
254+
</TableBody>
255+
</Table>
256+
</CardContent>
257+
</Card>
258+
259+
{totalPages > 1 && (
260+
<Pagination
261+
page={currentPage}
262+
totalItems={filteredPayments.length}
263+
itemsPerPage={ITEMS_PER_PAGE}
264+
setPage={setCurrentPage}
265+
/>
266+
)}
267+
</div>
268+
);
269+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"use client";
2+
3+
import { ErrorState } from "@/components/ui/table/error-state";
4+
import type { ClientPayment, EcommerceClient } from "@/server/db/schema";
5+
import { api } from "@/trpc/react";
6+
import { ClientPaymentsTable } from "./blocks/client-payments-table";
7+
8+
interface EcommerceSalesProps {
9+
initialClientPayments: ClientPayment[];
10+
ecommerceClients: EcommerceClient[];
11+
}
12+
13+
export function EcommerceSales({
14+
initialClientPayments,
15+
ecommerceClients,
16+
}: EcommerceSalesProps) {
17+
const { data, error, refetch, isRefetching } =
18+
api.ecommerce.getAllClientPayments.useQuery(undefined, {
19+
initialData: initialClientPayments,
20+
refetchOnMount: true,
21+
});
22+
23+
if (error) {
24+
return (
25+
<ErrorState
26+
onRetry={refetch}
27+
isRetrying={isRefetching}
28+
explanation="We couldn't load the client payments data. Please try again."
29+
/>
30+
);
31+
}
32+
33+
return (
34+
<div className="flex flex-col items-start gap-6">
35+
<div>
36+
<h1 className="text-2xl font-semibold">Client Payments</h1>
37+
<p className="text-sm text-muted-foreground">
38+
View all payments received through your ecommerce integrations
39+
</p>
40+
</div>
41+
<ClientPaymentsTable
42+
clientPayments={data}
43+
ecommerceClients={ecommerceClients}
44+
/>
45+
</div>
46+
);
47+
}

src/server/db/schema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,8 @@ export const clientPaymentTable = createTable("client_payment", {
187187
requestId: text().notNull(),
188188
invoiceCurrency: text().notNull(),
189189
paymentCurrency: text().notNull(),
190+
txHash: text().notNull(),
191+
network: text().notNull(),
190192
amount: text().notNull(),
191193
customerInfo: json().$type<{
192194
firstName?: string;

0 commit comments

Comments
 (0)