mirror of
https://github.com/Such-Software/wowcheckout.git
synced 2026-10-17 05:51:45 +02:00
ProfileResponse schema's default_qr_logo literal was inherited from xmrcheckout: Literal["monero", "none", "custom"]. The DB column (models.py) defaults to "wownero" and main.py's migration backfills NULL → ... but the backfill also wrote "monero", contradicting the column default. End result: new users got default_qr_logo="wownero" (from the column default), but GET /api/core/profile blew up with a Pydantic ResponseValidationError on serialization because "wownero" isn't in the literal. UI showed "Unavailable" for both Store ID and Primary Address because the entire profile response 500'd, leaving profileData null on the dashboard. API key + webhook secret rendered fine because those come from cookies, not profileData. Fix: - schemas.py: QrLogoMode now Literal["wownero", "monero", "none", "custom"] (keep "monero" for backward compat with any rows from before the migration ran) - schemas.py: ProfileResponse.default_qr_logo default → "wownero" - main.py: backfill UPDATE → 'wownero' (was 'monero'); migration column DEFAULT already correct as 'wownero' - ui/dashboard/page.tsx: TS type for default_qr_logo widened to include "wownero"; fallback default → "wownero" - ui/dashboard/actions.ts: DefaultQrLogoState.logo type widened; validation in updateDefaultQrLogo + initialMetadataPayload now accepts "wownero" Suchshop didn't hit this because their wowcheckout user row was created back when this UI path wasn't being used (they were provisioned via direct SQL/API). The new shop.wowne.ro merchant created via the UI today triggered the column default → exposed the schema mismatch.
482 lines
19 KiB
TypeScript
482 lines
19 KiB
TypeScript
import type { Metadata } from "next";
|
|
import { cookies } from "next/headers";
|
|
import Link from "next/link";
|
|
import { redirect } from "next/navigation";
|
|
|
|
import ApiKeySection from "../../../components/api-key-section";
|
|
import BtcpayCheckoutStyleSection from "../../../components/btcpay-checkout-style-section";
|
|
import DefaultConfirmationTargetSection from "../../../components/default-confirmation-target-section";
|
|
import DefaultQrLogoSection from "../../../components/default-qr-logo-section";
|
|
import InvoicePanel from "../../../components/invoice-panel";
|
|
import WebhookHistoryPanel from "../../../components/webhook-history-panel";
|
|
import WebhookSecretSection from "../../../components/webhook-secret-section";
|
|
import WebhookSection from "../../../components/webhook-section";
|
|
|
|
export const metadata: Metadata = {
|
|
title: "Dashboard",
|
|
};
|
|
|
|
const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:8000";
|
|
const tabs = ["overview", "invoices", "webhooks", "profile"] as const;
|
|
const webhookTabs = ["settings", "history"] as const;
|
|
type DashboardTab = (typeof tabs)[number];
|
|
type WebhookTab = (typeof webhookTabs)[number];
|
|
|
|
type DashboardSearchParams = Record<string, string | string[] | undefined>;
|
|
type InvoiceSummary = {
|
|
id: string;
|
|
address: string;
|
|
subaddress_index?: number | null;
|
|
amount_xmr: string;
|
|
status: "pending" | "payment_detected" | "confirmed" | "expired" | "invalid";
|
|
confirmation_target: number;
|
|
paid_after_expiry?: boolean;
|
|
paid_after_expiry_at?: string | null;
|
|
created_at: string;
|
|
archived_at: string | null;
|
|
detected_at: string | null;
|
|
confirmed_at: string | null;
|
|
expires_at: string | null;
|
|
};
|
|
type WebhookSummary = {
|
|
id: string;
|
|
url: string;
|
|
events: string[];
|
|
event_urls?: Record<string, string> | null;
|
|
active: boolean;
|
|
created_at: string;
|
|
};
|
|
type WebhookDeliverySummary = {
|
|
id: string;
|
|
webhook_id: string | null;
|
|
event: string;
|
|
url: string;
|
|
invoice_id: string | null;
|
|
invoice_address: string | null;
|
|
invoice_subaddress_index: number | null;
|
|
invoice_amount_xmr: string | null;
|
|
invoice_status:
|
|
| "pending"
|
|
| "payment_detected"
|
|
| "confirmed"
|
|
| "expired"
|
|
| "invalid"
|
|
| null;
|
|
http_status: number | null;
|
|
error_message: string | null;
|
|
created_at: string;
|
|
};
|
|
type ProfileSummary = {
|
|
id: string;
|
|
payment_address: string;
|
|
default_confirmation_target: number;
|
|
default_qr_logo: "wownero" | "monero" | "none" | "custom";
|
|
default_qr_logo_data_url: string | null;
|
|
btcpay_checkout_style?: "standard" | "btcpay_classic";
|
|
created_at: string;
|
|
};
|
|
export default async function DashboardPage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<DashboardSearchParams>;
|
|
}) {
|
|
const apiKey = (await cookies()).get("wowcheckout_api_key")?.value;
|
|
const webhookSecret = (await cookies()).get("wowcheckout_webhook_secret")?.value ?? null;
|
|
if (!apiKey) {
|
|
redirect("/?login=1");
|
|
}
|
|
|
|
const resolvedSearchParams = await searchParams;
|
|
const tabParam = resolvedSearchParams?.tab;
|
|
const activeTabValue = Array.isArray(tabParam) ? tabParam[0] : tabParam;
|
|
const activeTab: DashboardTab = tabs.includes(activeTabValue as DashboardTab)
|
|
? (activeTabValue as DashboardTab)
|
|
: "overview";
|
|
const webhookTabParam = resolvedSearchParams?.webhook_tab;
|
|
const activeWebhookTabValue = Array.isArray(webhookTabParam)
|
|
? webhookTabParam[0]
|
|
: webhookTabParam;
|
|
const activeWebhookTab: WebhookTab = webhookTabs.includes(
|
|
activeWebhookTabValue as WebhookTab
|
|
)
|
|
? (activeWebhookTabValue as WebhookTab)
|
|
: "settings";
|
|
const includeArchived =
|
|
activeTab === "invoices" &&
|
|
(resolvedSearchParams?.archived === "1" ||
|
|
resolvedSearchParams?.archived === "true");
|
|
const qParam = resolvedSearchParams?.q;
|
|
const queryValue = Array.isArray(qParam) ? qParam[0] : qParam;
|
|
const invoiceSearchQuery = activeTab === "invoices" ? (queryValue ?? "") : "";
|
|
const sortParam = resolvedSearchParams?.sort;
|
|
const sortValue = Array.isArray(sortParam) ? sortParam[0] : sortParam;
|
|
const invoiceSort =
|
|
activeTab === "invoices" && typeof sortValue === "string" && sortValue
|
|
? sortValue
|
|
: "created_at";
|
|
const orderParam = resolvedSearchParams?.order;
|
|
const orderValue = Array.isArray(orderParam) ? orderParam[0] : orderParam;
|
|
const invoiceOrder =
|
|
activeTab === "invoices" && typeof orderValue === "string" && orderValue
|
|
? orderValue
|
|
: "desc";
|
|
|
|
// API key is already available from the cookie
|
|
const apiKeyData = activeTab === "profile" ? { api_key: apiKey } : null;
|
|
let webhooksData: WebhookSummary[] = [];
|
|
let webhookHistory: WebhookDeliverySummary[] = [];
|
|
|
|
const invoiceLimit = 100;
|
|
let invoicesData: { items: InvoiceSummary[]; total: number } | null = null;
|
|
let offset = 0;
|
|
let total = 0;
|
|
const items: InvoiceSummary[] = [];
|
|
do {
|
|
const invoiceUrl = new URL(`${apiBaseUrl}/api/core/invoices`);
|
|
invoiceUrl.searchParams.set("limit", invoiceLimit.toString());
|
|
invoiceUrl.searchParams.set("offset", offset.toString());
|
|
if (includeArchived) {
|
|
invoiceUrl.searchParams.set("include_archived", "true");
|
|
}
|
|
if (invoiceSearchQuery) {
|
|
invoiceUrl.searchParams.set("q", invoiceSearchQuery);
|
|
}
|
|
if (invoiceSort) {
|
|
invoiceUrl.searchParams.set("sort", invoiceSort);
|
|
}
|
|
if (invoiceOrder) {
|
|
invoiceUrl.searchParams.set("order", invoiceOrder);
|
|
}
|
|
const response = await fetch(invoiceUrl.toString(), {
|
|
headers: {
|
|
Authorization: `ApiKey ${apiKey}`,
|
|
},
|
|
cache: "no-store",
|
|
});
|
|
if (response.status === 401) {
|
|
redirect("/?login=1");
|
|
}
|
|
if (!response.ok) {
|
|
break;
|
|
}
|
|
const data = (await response.json()) as {
|
|
items: InvoiceSummary[];
|
|
total: number;
|
|
};
|
|
total = data.total;
|
|
items.push(...data.items);
|
|
offset += invoiceLimit;
|
|
} while (offset < total);
|
|
invoicesData = { items, total };
|
|
|
|
if (
|
|
activeTab === "overview" ||
|
|
(activeTab === "webhooks" && activeWebhookTab === "settings")
|
|
) {
|
|
const response = await fetch(`${apiBaseUrl}/api/core/webhooks`, {
|
|
headers: {
|
|
Authorization: `ApiKey ${apiKey}`,
|
|
},
|
|
cache: "no-store",
|
|
});
|
|
if (response.status === 401) {
|
|
redirect("/?login=1");
|
|
}
|
|
if (response.ok) {
|
|
webhooksData = (await response.json()) as WebhookSummary[];
|
|
}
|
|
}
|
|
if (activeTab === "webhooks" && activeWebhookTab === "history") {
|
|
const historyUrl = new URL(`${apiBaseUrl}/api/core/webhooks/history`);
|
|
historyUrl.searchParams.set("limit", "50");
|
|
const response = await fetch(historyUrl.toString(), {
|
|
headers: {
|
|
Authorization: `ApiKey ${apiKey}`,
|
|
},
|
|
cache: "no-store",
|
|
});
|
|
if (response.status === 401) {
|
|
redirect("/?login=1");
|
|
}
|
|
if (response.ok) {
|
|
webhookHistory = (await response.json()) as WebhookDeliverySummary[];
|
|
}
|
|
}
|
|
|
|
const allInvoices = invoicesData?.items ?? [];
|
|
let profileData: ProfileSummary | null = null;
|
|
|
|
if (activeTab === "overview" || activeTab === "profile" || activeTab === "invoices") {
|
|
const response = await fetch(`${apiBaseUrl}/api/core/profile`, {
|
|
headers: {
|
|
Authorization: `ApiKey ${apiKey}`,
|
|
},
|
|
cache: "no-store",
|
|
});
|
|
if (response.status === 401) {
|
|
redirect("/?login=1");
|
|
}
|
|
if (response.ok) {
|
|
profileData = (await response.json()) as ProfileSummary;
|
|
}
|
|
}
|
|
|
|
const isToday = (value: string) => {
|
|
const date = new Date(value);
|
|
const today = new Date();
|
|
return (
|
|
date.getFullYear() === today.getFullYear() &&
|
|
date.getMonth() === today.getMonth() &&
|
|
date.getDate() === today.getDate()
|
|
);
|
|
};
|
|
|
|
const invoicesTodayCount = allInvoices.filter((invoice) =>
|
|
isToday(invoice.created_at)
|
|
).length;
|
|
const awaitingConfirmationCount = allInvoices.filter(
|
|
(invoice) => invoice.status === "payment_detected"
|
|
).length;
|
|
const confirmedCount = allInvoices.filter(
|
|
(invoice) => invoice.status === "confirmed"
|
|
).length;
|
|
|
|
const tabBaseClass =
|
|
"inline-flex items-center rounded-xl border border-stroke bg-white/70 px-4 py-2 text-xs font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:-translate-y-0.5";
|
|
const tabActiveClass =
|
|
"border-ink bg-ink text-cream shadow-[0_12px_24px_rgba(16,18,23,0.18)]";
|
|
const subTabBaseClass =
|
|
"inline-flex items-center rounded-full border border-stroke bg-white/70 px-4 py-2 text-xs font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:-translate-y-0.5";
|
|
const subTabActiveClass =
|
|
"border-ink bg-ink text-cream shadow-[0_10px_18px_rgba(16,18,23,0.18)]";
|
|
|
|
return (
|
|
<main className="px-[6vw] py-12 text-ink">
|
|
<section className="mx-auto grid w-full max-w-6xl gap-8 lg:grid-cols-[260px_minmax(0,1fr)]">
|
|
<aside className="grid gap-6">
|
|
<nav className="flex flex-col gap-2" aria-label="Dashboard sections">
|
|
{tabs.map((tab) => (
|
|
<Link
|
|
key={tab}
|
|
className={`${tabBaseClass} ${activeTab === tab ? tabActiveClass : ""}`}
|
|
href={`/dashboard?tab=${tab}`}
|
|
>
|
|
{tab}
|
|
</Link>
|
|
))}
|
|
</nav>
|
|
</aside>
|
|
<div className="grid gap-6">
|
|
{activeTab === "overview" ? (
|
|
<div className="rounded-2xl border border-stroke bg-white/80 p-8 shadow-card backdrop-blur">
|
|
<h1 className="font-serif text-3xl">Operational overview.</h1>
|
|
<p className="mt-2 text-ink-soft">
|
|
Track invoice flow and confirmation progress at a glance.
|
|
</p>
|
|
<div className="mt-6 rounded-2xl border border-stroke bg-white/70 p-5 shadow-soft">
|
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
|
Primary address
|
|
</p>
|
|
<p className="mt-3 break-all font-mono text-sm text-ink">
|
|
{profileData?.payment_address ?? "Unavailable"}
|
|
</p>
|
|
<p className="mt-2 text-sm text-ink-soft">
|
|
This is the wallet address used for your invoices.
|
|
</p>
|
|
</div>
|
|
<div className="mt-4 grid gap-4 lg:grid-cols-3">
|
|
{[
|
|
{
|
|
title: "Invoices today",
|
|
value: invoicesTodayCount.toString(),
|
|
detail: "Totals will appear once invoices are created.",
|
|
},
|
|
{
|
|
title: "Invoices awaiting confirmation",
|
|
value: awaitingConfirmationCount.toString(),
|
|
detail: "Waiting for the configured confirmation target.",
|
|
},
|
|
{
|
|
title: "Confirmed invoices",
|
|
value: confirmedCount.toString(),
|
|
detail: "Confirmed on-chain invoices will display here.",
|
|
},
|
|
].map((item) => (
|
|
<div
|
|
key={item.title}
|
|
className="rounded-2xl border border-stroke bg-white/70 p-5 shadow-soft"
|
|
>
|
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
|
{item.title}
|
|
</p>
|
|
<h2 className="mt-3 text-2xl font-semibold">{item.value}</h2>
|
|
<p className="mt-2 text-sm text-ink-soft">{item.detail}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="mt-4 rounded-2xl border border-stroke bg-white/70 p-5 shadow-soft">
|
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
|
Webhook endpoints
|
|
</p>
|
|
{webhooksData.length === 0 ? (
|
|
<p className="mt-3 text-sm text-ink-soft">
|
|
No webhooks configured yet.{" "}
|
|
<Link className="font-semibold text-ink underline" href="/dashboard?tab=webhooks">
|
|
Add a webhook endpoint.
|
|
</Link>
|
|
</p>
|
|
) : (
|
|
<div className="mt-3 grid gap-3 text-sm text-ink">
|
|
{webhooksData.map((webhook) => (
|
|
<div
|
|
className="rounded-xl border border-stroke bg-white/80 px-4 py-3"
|
|
key={webhook.id}
|
|
>
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<p className="break-all font-semibold">{webhook.url}</p>
|
|
<span className="rounded-full border border-stroke bg-white/60 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink">
|
|
{webhook.active ? "Active" : "Paused"}
|
|
</span>
|
|
</div>
|
|
<p className="mt-2 text-xs text-ink-soft">
|
|
Events: {webhook.events.join(", ")}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="mt-5 rounded-2xl border border-ink/10 bg-ink/10 px-4 py-3 text-sm font-semibold text-ink">
|
|
We never hold funds. All payments move from the customer to your
|
|
wallet.
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
{activeTab === "invoices" ? (
|
|
<InvoicePanel
|
|
activeInvoices={allInvoices}
|
|
includeArchived={includeArchived}
|
|
searchQuery={invoiceSearchQuery}
|
|
sort={invoiceSort}
|
|
order={invoiceOrder}
|
|
defaultConfirmationTarget={profileData?.default_confirmation_target ?? 1}
|
|
/>
|
|
) : null}
|
|
{activeTab === "profile" ? (
|
|
<>
|
|
{apiKeyData ? (
|
|
<div className="rounded-2xl border border-stroke bg-white/80 p-8 shadow-card backdrop-blur">
|
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
<div>
|
|
<h1 className="font-serif text-3xl">Profile settings.</h1>
|
|
<p className="mt-2 text-ink-soft">
|
|
Manage credentials and delivery settings for your integration.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="mt-6 grid gap-6 lg:grid-cols-2">
|
|
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft">
|
|
<h2 className="font-serif text-2xl">Store id</h2>
|
|
<p className="mt-2 min-h-[2.5rem] text-sm text-ink-soft">
|
|
Single-store per primary address. Use this id in the BTCPay
|
|
WooCommerce plugin store field.
|
|
</p>
|
|
<div className="mt-4">
|
|
<p className="break-all rounded-xl bg-ink/5 px-3 py-2 font-mono text-xs text-ink sm:text-sm">
|
|
{profileData?.id ?? "Unavailable"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft">
|
|
<h2 className="font-serif text-2xl">API key</h2>
|
|
<p className="mt-2 min-h-[2.5rem] text-sm text-ink-soft">
|
|
Use this key with authenticated endpoints.
|
|
</p>
|
|
<div className="mt-4">
|
|
<ApiKeySection apiKey={apiKeyData.api_key} />
|
|
</div>
|
|
</div>
|
|
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft">
|
|
<h2 className="font-serif text-2xl">Webhook secret</h2>
|
|
<p className="mt-2 min-h-[2.5rem] text-sm text-ink-soft">
|
|
Sent with each delivery as the{" "}
|
|
<code>X-Webhook-Secret</code> header.
|
|
</p>
|
|
<div className="mt-4">
|
|
<WebhookSecretSection webhookSecret={webhookSecret} />
|
|
</div>
|
|
</div>
|
|
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft">
|
|
<BtcpayCheckoutStyleSection
|
|
initialStyle={
|
|
profileData?.btcpay_checkout_style === "btcpay_classic"
|
|
? "btcpay_classic"
|
|
: "btcpay_classic"
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft">
|
|
<DefaultConfirmationTargetSection
|
|
initialValue={profileData?.default_confirmation_target ?? 1}
|
|
/>
|
|
</div>
|
|
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft">
|
|
<DefaultQrLogoSection
|
|
initialLogo={profileData?.default_qr_logo ?? "wownero"}
|
|
initialLogoDataUrl={profileData?.default_qr_logo_data_url ?? null}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="rounded-2xl border border-stroke bg-white/80 p-8 shadow-card backdrop-blur">
|
|
<h1 className="font-serif text-3xl">API key</h1>
|
|
<p className="mt-2 text-ink-soft">
|
|
Sign in again to reveal your API key.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</>
|
|
) : null}
|
|
{activeTab === "webhooks" ? (
|
|
<div className="rounded-2xl border border-stroke bg-white/80 p-8 shadow-card backdrop-blur">
|
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
<div>
|
|
<h1 className="font-serif text-3xl">Webhook endpoints.</h1>
|
|
<p className="mt-2 text-ink-soft">
|
|
Relay invoice state updates to your systems. Optional overrides let
|
|
you target different URLs per event.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<nav className="mt-6 flex flex-wrap gap-2" aria-label="Webhook views">
|
|
{webhookTabs.map((tab) => (
|
|
<Link
|
|
key={tab}
|
|
className={`${subTabBaseClass} ${
|
|
activeWebhookTab === tab ? subTabActiveClass : ""
|
|
}`}
|
|
href={`/dashboard?tab=webhooks&webhook_tab=${tab}`}
|
|
>
|
|
{tab}
|
|
</Link>
|
|
))}
|
|
</nav>
|
|
{activeWebhookTab === "settings" ? (
|
|
<div className="mt-6">
|
|
<WebhookSection webhooks={webhooksData} />
|
|
</div>
|
|
) : null}
|
|
{activeWebhookTab === "history" ? (
|
|
<div className="mt-6">
|
|
<WebhookHistoryPanel deliveries={webhookHistory} />
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|