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
51 changes: 34 additions & 17 deletions apps/dashboard/src/app/(dashboard)/links/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { useToast } from "@useroutr/ui";
import Link from "next/link";
import { LinkStatusBadge } from "@/components/links/LinkStatusBadge";
import { api } from "@/lib/api";
import { formatCurrency } from "@/lib/utils";

import type { PaymentLink } from "@useroutr/types";
Expand All @@ -28,7 +29,7 @@

export default function LinkDetailPage() {
const { id } = useParams<{ id: string }>();
const router = useRouter();

Check warning on line 32 in apps/dashboard/src/app/(dashboard)/links/page.tsx

View workflow job for this annotation

GitHub Actions / Lint

'router' is assigned a value but never used
const { toast } = useToast();

const [link, setLink] = useState<PaymentLink | null>(null);
Expand All @@ -40,24 +41,34 @@
try {
setLoading(true);

const [linkRes, statsRes, paymentsRes] = await Promise.all([
fetch(`/v1/payment-links/${id}`),
fetch(`/v1/payment-links/${id}/stats`),
fetch(`/v1/payments?linkId=${id}`),
]);

if (!linkRes.ok) {
// allSettled, not all: a missing link is "not found" (no toast), while a
// failing stats/payments call should still leave the link itself usable.
const [linkResult, statsResult, paymentsResult] = await Promise.allSettled(
[
api.get<PaymentLink>(`/payment-links/${id}`),
api.get<LinkStats>(`/payment-links/${id}/stats`),
// `/payments` is paginated: the envelope is { items, meta }, and the
// api client has already unwrapped the outer { data }. Reading
// `.data` here would always be undefined and the table would render
// empty with no error to explain why.
api.get<{ items?: LinkPayment[] }>(`/payments`, {
params: { linkId: id },
}),
]
);

if (linkResult.status !== "fulfilled") {
setLink(null);
return;
}

const linkData = await linkRes.json();
const statsData = await statsRes.json();
const paymentsData = await paymentsRes.json();

setLink(linkData);
setStats(statsData);
setPayments(paymentsData?.data ?? paymentsData ?? []);
setLink(linkResult.value);
setStats(statsResult.status === "fulfilled" ? statsResult.value : null);
setPayments(
paymentsResult.status === "fulfilled"
? (paymentsResult.value?.items ?? [])
: []
);
} catch {
toast(`Failed to load link`, "error");
} finally {
Expand All @@ -67,7 +78,7 @@

useEffect(() => {
fetchData();
}, [id]);

Check warning on line 81 in apps/dashboard/src/app/(dashboard)/links/page.tsx

View workflow job for this annotation

GitHub Actions / Lint

React Hook useEffect has a missing dependency: 'fetchData'. Either include it or remove the dependency array

async function copyUrl() {
if (!link) return;
Expand All @@ -93,9 +104,15 @@

if (!ok) return;

await fetch(`/v1/payment-links/${link.id}`, {
method: "DELETE",
});
try {
await api.delete(`/payment-links/${link.id}`);
} catch (error) {
toast(
error instanceof Error ? error.message : "Failed to deactivate link",
"error"
);
return;
}

toast("Link deactivated", "success");

Expand Down
8 changes: 3 additions & 5 deletions apps/dashboard/src/app/(dashboard)/payouts/page.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
"use client";

import { useQuery } from '@tanstack/react-query';
import { Button } from '@useroutr/ui';

Check warning on line 4 in apps/dashboard/src/app/(dashboard)/payouts/page.tsx

View workflow job for this annotation

GitHub Actions / Lint

'Button' is defined but never used
import { Plus } from 'lucide-react';

Check warning on line 5 in apps/dashboard/src/app/(dashboard)/payouts/page.tsx

View workflow job for this annotation

GitHub Actions / Lint

'Plus' is defined but never used
import type { PayoutListResponse } from '@useroutr/types';
import { RecipientsTable } from '@/components/recipients/RecipientsTable';

Check warning on line 7 in apps/dashboard/src/app/(dashboard)/payouts/page.tsx

View workflow job for this annotation

GitHub Actions / Lint

'RecipientsTable' is defined but never used
import { CreatePayoutDialog } from '@/components/payouts/CreatePayoutDialog';
import { PayoutsTable } from '@/components/payouts/PayoutsTable';
import { api } from '@/lib/api';

export default function PayoutsPage() {
const payoutsQuery = useQuery({
queryKey: ['payouts'],
queryFn: async () => {
const res = await fetch('/api/v1/payouts');
if (!res.ok) throw new Error('Failed to fetch payouts');
return res.json();
},
queryFn: () => api.get<PayoutListResponse>('/payouts'),
});

return (
Expand Down
24 changes: 5 additions & 19 deletions apps/dashboard/src/components/analytics/AnalyticsDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
DownloadSimple,
Info,
} from "@phosphor-icons/react";
import { api } from "@/lib/api";
import { cn, formatCurrency } from "@/lib/utils";

type Period = "7d" | "30d" | "90d" | "1y";
Expand Down Expand Up @@ -118,10 +119,6 @@ const PAYMENT_COLORS: Record<PaymentMethod, string> = {

const WEEKDAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];

function getApiBaseUrl() {
return (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
}

function formatMoney(value: number, currency = "USD") {
return formatCurrency(value, currency);
}
Expand Down Expand Up @@ -414,17 +411,6 @@ function normalizeLiveSnapshot(
};
}

async function fetchJson<T>(path: string): Promise<T> {
const base = getApiBaseUrl();
const response = await fetch(`${base}${path}`, { cache: "no-store" });

if (!response.ok) {
throw new Error(`${path} failed with ${response.status}`);
}

return response.json() as Promise<T>;
}

async function loadSnapshot(period: Period, range: DateRange) {
const hasCustomRange = Boolean(range.start && range.end);
const { bucket } = getBucketDates(period, range);
Expand All @@ -441,10 +427,10 @@ async function loadSnapshot(period: Period, range: DateRange) {
const baseQuery = query.toString();
const [revenueResult, paymentsResult, failuresResult, currenciesResult] =
await Promise.allSettled([
fetchJson<RevenueResponse>(`/v1/analytics/revenue?${baseQuery}`),
fetchJson<PaymentsResponse>(`/v1/analytics/payments?${baseQuery}`),
fetchJson<FailuresResponse>(`/v1/analytics/failures?${baseQuery}`),
fetchJson<CurrenciesResponse>(`/v1/analytics/currencies?${baseQuery}`),
api.get<RevenueResponse>(`/analytics/revenue?${baseQuery}`),
api.get<PaymentsResponse>(`/analytics/payments?${baseQuery}`),
api.get<FailuresResponse>(`/analytics/failures?${baseQuery}`),
api.get<CurrenciesResponse>(`/analytics/currencies?${baseQuery}`),
] as const);

if (
Expand Down
32 changes: 18 additions & 14 deletions apps/dashboard/src/components/payouts/CreatePayoutDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,29 +16,33 @@ import {
ShadSelectItem as SelectItem,
ShadSelectTrigger as SelectTrigger,
ShadSelectValue as SelectValue,
useToast,
} from '@useroutr/ui';
import { RecipientSelect } from '@/components/recipients/RecipientSelect';
import { api } from '@/lib/api';

export function CreatePayoutDialog() {
const [open, setOpen] = useState(false);
const [recipientId, setRecipientId] = useState('');
const [amount, setAmount] = useState('');
const [currency, setCurrency] = useState('USD');
const { toast } = useToast();

const handleSubmit = () => {
fetch('/api/v1/payouts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recipientId,
amount,
currency,
}),
}).then(() => {
setOpen(false);
// Refetch payouts
window.dispatchEvent(new CustomEvent('payouts:refetch'));
});
const handleSubmit = async () => {
try {
await api.post('/payouts', { recipientId, amount, currency });
} catch (error) {
// Keep the dialog open so the entered values survive a failed submit.
toast(
error instanceof Error ? error.message : 'Failed to create payout',
'error',
);
return;
}

setOpen(false);
// Refetch payouts
window.dispatchEvent(new CustomEvent('payouts:refetch'));
};

return (
Expand Down
8 changes: 3 additions & 5 deletions apps/dashboard/src/components/recipients/RecipientSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from '@useroutr/ui';
import { Check, ChevronsUpDown, Loader2 } from 'lucide-react';
import { Recipient } from '@useroutr/types';
import { api } from '@/lib/api';
import { cn } from '@/lib/utils';
import { useState } from 'react';

Expand All @@ -24,11 +25,8 @@ export function RecipientSelect({

const recipientsQuery = useQuery({
queryKey: ['recipients'],
queryFn: async () => {
const res = await fetch('/api/v1/recipients?limit=20');
if (!res.ok) throw new Error('Failed to fetch recipients');
return (await res.json()) as { data: Recipient[] };
},
queryFn: () =>
api.get<{ data: Recipient[] }>('/recipients', { params: { limit: 20 } }),
});

const selectedRecipient = recipientsQuery.data?.data.find(r => r.id === value);
Expand Down
Loading