This commit is contained in:
xmrcheckout
2026-01-04 17:57:07 +01:00
parent 3465c2054b
commit c92dfd2ef1
62 changed files with 15945 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
API_BASE_URL=http://localhost:8000
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
NEXT_PUBLIC_SITE_URL=http://localhost
NEXT_PUBLIC_DONATIONS_ENABLED=false
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: ["next/core-web-vitals"],
};
+22
View File
@@ -0,0 +1,22 @@
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
EXPOSE 3000
CMD ["npm", "start"]
+707
View File
@@ -0,0 +1,707 @@
"use server";
import { revalidatePath } from "next/cache";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:8000";
export type InvoiceState = {
error: string | null;
invoiceId: string | null;
address: string | null;
amount: string | null;
recipientName: string | null;
description: string | null;
subaddressIndex: number | null;
warnings: string[] | null;
};
export type ApiKeyState = {
apiKey: string;
error: string | null;
};
export type WebhookSecretState = {
webhookSecret: string | null;
error: string | null;
};
export type WebhookFormState = {
error: string | null;
success: string | null;
};
export type ArchiveInvoiceState = {
error: string | null;
success: string | null;
archivedId: string | null;
};
export type BtcpayCheckoutPreferenceState = {
style: "standard" | "btcpay_classic";
error: string | null;
success: string | null;
};
export type DefaultConfirmationTargetState = {
value: number;
error: string | null;
success: string | null;
};
export type DefaultQrLogoState = {
logo: "monero" | "none" | "custom";
logoDataUrl: string | null;
error: string | null;
success: string | null;
};
export type WebhookRedeliverState = {
error: string | null;
success: string | null;
};
const webhookEventKeys = [
{ event: "invoice.created", key: "invoice_created" },
{ event: "invoice.payment_detected", key: "invoice_payment_detected" },
{ event: "invoice.confirmed", key: "invoice_confirmed" },
{ event: "invoice.expired", key: "invoice_expired" },
] as const;
export async function createInvoiceAction(
_prevState: InvoiceState,
formData: FormData
): Promise<InvoiceState> {
const instantConfirmation = formData.get("instant_confirmation") === "1";
const confirmationRaw = String(formData.get("confirmation_target") ?? "").trim();
const confirmationTarget = instantConfirmation ? 0 : Number(confirmationRaw);
if (
Number.isNaN(confirmationTarget) ||
confirmationTarget < 0 ||
confirmationTarget > 10
) {
return {
error: "Confirmation target must be between 0 and 10.",
invoiceId: null,
address: null,
amount: null,
recipientName: null,
description: null,
subaddressIndex: null,
warnings: null,
};
}
const amount = Number(formData.get("amount_xmr"));
if (Number.isNaN(amount) || amount <= 0) {
return {
error: "Enter a valid XMR amount.",
invoiceId: null,
address: null,
amount: null,
recipientName: null,
description: null,
subaddressIndex: null,
warnings: null,
};
}
const amountRaw = String(formData.get("amount_xmr") ?? "");
const recipientName = String(formData.get("recipient_name") ?? "").trim();
const description = String(formData.get("description") ?? "").trim();
const expiresAtRaw = String(formData.get("expires_at") ?? "").trim();
const expiresDate = String(formData.get("expires_date") ?? "").trim();
const expiresTime = String(formData.get("expires_time") ?? "").trim();
let expiresAt: string | null = null;
if (!expiresAtRaw && expiresTime && !expiresDate) {
return {
error: "Expiry date must include a date.",
invoiceId: null,
address: null,
amount: null,
recipientName: null,
description: null,
subaddressIndex: null,
warnings: null,
};
}
if (!expiresAtRaw && expiresDate) {
const timeValue = expiresTime || "00:00";
expiresAt = `${expiresDate}T${timeValue}`;
} else if (expiresAtRaw) {
const normalized =
expiresAtRaw.length === 10 || expiresAtRaw.endsWith("T")
? `${expiresAtRaw.replace(/T$/, "")}T00:00`
: expiresAtRaw;
const parsed = new Date(normalized);
if (Number.isNaN(parsed.getTime())) {
return {
error: "Expiry date must be a valid date and time.",
invoiceId: null,
address: null,
amount: null,
recipientName: null,
description: null,
subaddressIndex: null,
warnings: null,
};
}
expiresAt = parsed.toISOString();
}
const apiKey = (await cookies()).get("xmrcheckout_api_key")?.value;
if (!apiKey) {
return {
error: "API key not found. Please sign in again.",
invoiceId: null,
address: null,
amount: null,
recipientName: null,
description: null,
subaddressIndex: null,
warnings: null,
};
}
const metadata: Record<string, string> = {};
if (recipientName) {
metadata.recipient_name = recipientName;
}
if (description) {
metadata.description = description;
}
const qrLogoModeRaw = String(formData.get("qr_logo_mode") ?? "").trim();
const qrLogoDataUrl = String(formData.get("qr_logo_data_url") ?? "").trim();
const qrLogoMode =
qrLogoModeRaw === "account_default" ||
qrLogoModeRaw === "monero" ||
qrLogoModeRaw === "none" ||
qrLogoModeRaw === "custom"
? qrLogoModeRaw
: "account_default";
const qrPayload =
qrLogoMode === "account_default"
? null
: qrLogoMode === "custom"
? qrLogoDataUrl
? { logo: "custom", logo_data_url: qrLogoDataUrl }
: null
: { logo: qrLogoMode };
const response = await fetch(`${apiBaseUrl}/api/core/invoices`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `ApiKey ${apiKey}`,
},
body: JSON.stringify({
amount_xmr: amount,
confirmation_target: confirmationTarget,
expires_at: expiresAt,
metadata:
Object.keys(metadata).length > 0 || qrPayload
? {
...(Object.keys(metadata).length > 0 ? metadata : {}),
...(qrPayload ? { qr: qrPayload } : {}),
}
: null,
}),
cache: "no-store",
});
if (!response.ok) {
const detail = await response.json().catch(() => null);
const message = detail?.detail ?? "Unable to create invoice.";
return {
error: message,
invoiceId: null,
address: null,
amount: null,
recipientName: null,
description: null,
subaddressIndex: null,
warnings: null,
};
}
const data = (await response.json()) as {
id: string;
address: string;
subaddress_index?: number | null;
warnings?: string[] | null;
};
return {
error: null,
invoiceId: data.id,
address: data.address,
amount: amountRaw,
recipientName: recipientName || null,
description: description || null,
subaddressIndex: data.subaddress_index ?? null,
warnings: data.warnings ?? null,
};
}
export async function resetApiKeyAction(
_prevState: ApiKeyState
): Promise<ApiKeyState> {
const apiKey = (await cookies()).get("xmrcheckout_api_key")?.value;
if (!apiKey) {
return { apiKey: _prevState.apiKey, error: "API key not found. Please sign in again." };
}
const response = await fetch(`${apiBaseUrl}/api/core/api-credentials/reset`, {
method: "POST",
headers: {
Authorization: `ApiKey ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ reset_api_key: true }),
cache: "no-store",
});
if (!response.ok) {
const detail = await response.json().catch(() => null);
const message = detail?.detail ?? "Unable to reset API key.";
return { apiKey: _prevState.apiKey, error: message };
}
const data = (await response.json()) as { api_key?: string | null };
if (!data.api_key) {
return { apiKey: _prevState.apiKey, error: "API key reset did not return a key." };
}
// Update the cookie with the new API key
const cookieStore = await cookies();
cookieStore.set("xmrcheckout_api_key", data.api_key, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
});
return { apiKey: data.api_key, error: null };
}
export async function resetWebhookSecretAction(
_prevState: WebhookSecretState
): Promise<WebhookSecretState> {
const apiKey = (await cookies()).get("xmrcheckout_api_key")?.value;
if (!apiKey) {
return {
webhookSecret: _prevState.webhookSecret,
error: "API key not found. Please sign in again.",
};
}
const response = await fetch(`${apiBaseUrl}/api/core/api-credentials/reset`, {
method: "POST",
headers: {
Authorization: `ApiKey ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ reset_webhook_secret: true }),
cache: "no-store",
});
if (!response.ok) {
const detail = await response.json().catch(() => null);
const message = detail?.detail ?? "Unable to reset webhook secret.";
return { webhookSecret: _prevState.webhookSecret, error: message };
}
const data = (await response.json()) as { webhook_secret?: string | null };
if (!data.webhook_secret) {
return {
webhookSecret: _prevState.webhookSecret,
error: "Webhook secret reset did not return a secret.",
};
}
const cookieStore = await cookies();
cookieStore.set("xmrcheckout_webhook_secret", data.webhook_secret, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
});
return { webhookSecret: data.webhook_secret, error: null };
}
export async function updateBtcpayCheckoutStyleAction(
_prevState: BtcpayCheckoutPreferenceState,
formData: FormData
): Promise<BtcpayCheckoutPreferenceState> {
const nextStyle = String(formData.get("btcpay_checkout_style") ?? "").trim();
if (nextStyle !== "standard" && nextStyle !== "btcpay_classic") {
return {
style: _prevState.style,
error: "Select a valid BTCPay checkout layout.",
success: null,
};
}
const apiKey = (await cookies()).get("xmrcheckout_api_key")?.value;
if (!apiKey) {
return {
style: _prevState.style,
error: "API key not found. Please sign in again.",
success: null,
};
}
const response = await fetch(`${apiBaseUrl}/api/core/profile`, {
method: "PATCH",
headers: {
Authorization: `ApiKey ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ btcpay_checkout_style: nextStyle }),
cache: "no-store",
});
if (!response.ok) {
const detail = await response.json().catch(() => null);
const message = detail?.detail ?? "Unable to update BTCPay preference.";
return { style: _prevState.style, error: message, success: null };
}
const data = (await response.json()) as { btcpay_checkout_style?: string | null };
const resolvedStyle =
data.btcpay_checkout_style === "btcpay_classic" ||
data.btcpay_checkout_style === "standard"
? data.btcpay_checkout_style
: nextStyle;
revalidatePath("/dashboard");
return {
style: resolvedStyle as "standard" | "btcpay_classic",
error: null,
success: "Preference saved.",
};
}
export async function updateDefaultConfirmationTargetAction(
_prevState: DefaultConfirmationTargetState,
formData: FormData
): Promise<DefaultConfirmationTargetState> {
const valueRaw = String(formData.get("default_confirmation_target") ?? "").trim();
const parsed = Number(valueRaw);
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 10) {
return {
value: _prevState.value,
error: "Default confirmation target must be an integer between 0 and 10.",
success: null,
};
}
const apiKey = (await cookies()).get("xmrcheckout_api_key")?.value;
if (!apiKey) {
return {
value: _prevState.value,
error: "API key not found. Please sign in again.",
success: null,
};
}
const response = await fetch(`${apiBaseUrl}/api/core/profile`, {
method: "PATCH",
headers: {
Authorization: `ApiKey ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ default_confirmation_target: parsed }),
cache: "no-store",
});
if (!response.ok) {
const detail = await response.json().catch(() => null);
const message = detail?.detail ?? "Unable to update the default confirmation target.";
return { value: _prevState.value, error: message, success: null };
}
const data = (await response.json()) as { default_confirmation_target?: number | null };
const resolvedValue =
typeof data.default_confirmation_target === "number"
? data.default_confirmation_target
: parsed;
revalidatePath("/dashboard");
return { value: resolvedValue, error: null, success: "Default updated." };
}
export async function updateDefaultQrLogoAction(
_prevState: DefaultQrLogoState,
formData: FormData
): Promise<DefaultQrLogoState> {
const logo = String(formData.get("default_qr_logo") ?? "").trim();
const logoDataUrlRaw = String(formData.get("default_qr_logo_data_url") ?? "").trim();
const normalizedLogo =
logo === "none" || logo === "custom" || logo === "monero" ? logo : null;
if (!normalizedLogo) {
return {
logo: _prevState.logo,
logoDataUrl: _prevState.logoDataUrl,
error: "Select a valid QR logo mode.",
success: null,
};
}
const logoDataUrl =
normalizedLogo === "custom" ? (logoDataUrlRaw ? logoDataUrlRaw : null) : null;
if (normalizedLogo === "custom" && !logoDataUrl) {
return {
logo: _prevState.logo,
logoDataUrl: _prevState.logoDataUrl,
error: "Upload a logo image for custom mode.",
success: null,
};
}
const apiKey = (await cookies()).get("xmrcheckout_api_key")?.value;
if (!apiKey) {
return {
logo: _prevState.logo,
logoDataUrl: _prevState.logoDataUrl,
error: "API key not found. Please sign in again.",
success: null,
};
}
const response = await fetch(`${apiBaseUrl}/api/core/profile`, {
method: "PATCH",
headers: {
Authorization: `ApiKey ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
default_qr_logo: normalizedLogo,
default_qr_logo_data_url: logoDataUrl,
}),
cache: "no-store",
});
if (!response.ok) {
const detail = await response.json().catch(() => null);
const message = detail?.detail ?? "Unable to update QR settings.";
return {
logo: _prevState.logo,
logoDataUrl: _prevState.logoDataUrl,
error: message,
success: null,
};
}
const data = (await response.json()) as {
default_qr_logo?: string | null;
default_qr_logo_data_url?: string | null;
};
const resolvedLogo =
data.default_qr_logo === "none" ||
data.default_qr_logo === "custom" ||
data.default_qr_logo === "monero"
? data.default_qr_logo
: normalizedLogo;
const resolvedDataUrl =
resolvedLogo === "custom" ? data.default_qr_logo_data_url ?? logoDataUrl : null;
revalidatePath("/dashboard");
return {
logo: resolvedLogo as "monero" | "none" | "custom",
logoDataUrl: resolvedDataUrl ?? null,
error: null,
success: "QR default saved.",
};
}
export async function createWebhookAction(
_prevState: WebhookFormState,
formData: FormData
): Promise<WebhookFormState> {
const apiKey = (await cookies()).get("xmrcheckout_api_key")?.value;
if (!apiKey) {
return { error: "API key not found. Please sign in again.", success: null };
}
const defaultUrl = String(formData.get("default_url") ?? "").trim();
const events: string[] = [];
const eventUrls: Record<string, string> = {};
webhookEventKeys.forEach(({ event, key }) => {
const enabled = formData.get(`event_${key}`) === "on";
const override = String(formData.get(`event_url_${key}`) ?? "").trim();
if (enabled || override) {
events.push(event);
}
if (override) {
eventUrls[event] = override;
}
});
if (events.length === 0) {
return { error: "Select at least one invoice event.", success: null };
}
if (!defaultUrl) {
const missing = events.filter((event) => !eventUrls[event]);
if (missing.length > 0) {
return {
error: "Provide a default URL or an override URL for each selected event.",
success: null,
};
}
}
const payload: {
url?: string;
events?: string[];
event_urls?: Record<string, string>;
} = {
events,
};
if (defaultUrl) {
payload.url = defaultUrl;
}
if (Object.keys(eventUrls).length > 0) {
payload.event_urls = eventUrls;
}
const response = await fetch(`${apiBaseUrl}/api/core/webhooks`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `ApiKey ${apiKey}`,
},
body: JSON.stringify(payload),
cache: "no-store",
});
if (!response.ok) {
const detail = await response.json().catch(() => null);
const message = detail?.detail ?? "Unable to create webhook.";
return { error: message, success: null };
}
revalidatePath("/dashboard");
return { error: null, success: "Webhook saved." };
}
export async function deleteWebhookAction(
_prevState: WebhookFormState,
formData: FormData
): Promise<WebhookFormState> {
const apiKey = (await cookies()).get("xmrcheckout_api_key")?.value;
if (!apiKey) {
return { error: "API key not found. Please sign in again.", success: null };
}
const webhookId = String(formData.get("webhook_id") ?? "").trim();
if (!webhookId) {
return { error: "Webhook id missing.", success: null };
}
const response = await fetch(`${apiBaseUrl}/api/core/webhooks/${webhookId}`, {
method: "DELETE",
headers: {
Authorization: `ApiKey ${apiKey}`,
},
cache: "no-store",
});
if (!response.ok) {
const detail = await response.json().catch(() => null);
const message = detail?.detail ?? "Unable to remove webhook.";
return { error: message, success: null };
}
revalidatePath("/dashboard");
return { error: null, success: "Webhook removed." };
}
export async function archiveInvoiceAction(
_prevState: ArchiveInvoiceState,
formData: FormData
): Promise<ArchiveInvoiceState> {
const apiKey = (await cookies()).get("xmrcheckout_api_key")?.value;
if (!apiKey) {
return {
error: "API key not found. Please sign in again.",
success: null,
archivedId: null,
};
}
const invoiceId = String(formData.get("invoice_id") ?? "").trim();
if (!invoiceId) {
return { error: "Invoice id missing.", success: null, archivedId: null };
}
const response = await fetch(`${apiBaseUrl}/api/core/invoices/${invoiceId}`, {
method: "DELETE",
headers: {
Authorization: `ApiKey ${apiKey}`,
},
cache: "no-store",
});
if (!response.ok) {
const detail = await response.json().catch(() => null);
const message = detail?.detail ?? "Unable to archive invoice.";
return { error: message, success: null, archivedId: null };
}
revalidatePath("/dashboard");
return { error: null, success: "Invoice archived until expiry.", archivedId: invoiceId };
}
export async function logoutAction() {
const cookieStore = await cookies();
cookieStore.set("xmrcheckout_api_key", "", {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 0,
});
cookieStore.set("xmrcheckout_webhook_secret", "", {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 0,
});
redirect("/");
}
export async function redeliverWebhookDeliveryAction(
_prevState: WebhookRedeliverState,
formData: FormData
): Promise<WebhookRedeliverState> {
const apiKey = (await cookies()).get("xmrcheckout_api_key")?.value;
if (!apiKey) {
return { error: "API key not found. Please sign in again.", success: null };
}
const deliveryId = String(formData.get("delivery_id") ?? "").trim();
if (!deliveryId) {
return { error: "Delivery id missing.", success: null };
}
const response = await fetch(
`${apiBaseUrl}/api/core/webhooks/deliveries/${encodeURIComponent(deliveryId)}/redeliver`,
{
method: "POST",
headers: { Authorization: `ApiKey ${apiKey}` },
cache: "no-store",
}
);
if (!response.ok) {
const detail = await response.json().catch(() => null);
const message = detail?.detail ?? "Unable to redeliver the webhook.";
return { error: message, success: null };
}
revalidatePath("/dashboard");
return { error: null, success: "Redelivery queued." };
}
@@ -0,0 +1,37 @@
import { cookies } from "next/headers";
const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:8000";
export async function GET(request: Request) {
const apiKey = (await cookies()).get("xmrcheckout_api_key")?.value;
if (!apiKey) {
return new Response("Unauthorized", { status: 401 });
}
const sourceUrl = new URL(request.url);
const targetUrl = new URL(`${apiBaseUrl}/api/core/invoices/export.csv`);
sourceUrl.searchParams.forEach((value, key) => {
targetUrl.searchParams.set(key, value);
});
const response = await fetch(targetUrl.toString(), {
headers: { Authorization: `ApiKey ${apiKey}` },
cache: "no-store",
});
if (!response.ok) {
const message = await response.text().catch(() => "Unable to export invoices.");
return new Response(message, { status: response.status });
}
const headers = new Headers();
headers.set("Content-Type", response.headers.get("content-type") ?? "text/csv");
const disposition = response.headers.get("content-disposition");
headers.set(
"Content-Disposition",
disposition ?? 'attachment; filename="invoices.csv"'
);
return new Response(response.body, { status: 200, headers });
}
+481
View File
@@ -0,0 +1,481 @@
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: "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("xmrcheckout_api_key")?.value;
const webhookSecret = (await cookies()).get("xmrcheckout_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 ?? 10}
/>
) : 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"
: "standard"
}
/>
</div>
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft">
<DefaultConfirmationTargetSection
initialValue={profileData?.default_confirmation_target ?? 10}
/>
</div>
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft">
<DefaultQrLogoSection
initialLogo={profileData?.default_qr_logo ?? "monero"}
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>
);
}
+61
View File
@@ -0,0 +1,61 @@
import Image from "next/image";
import Link from "next/link";
import { Suspense } from "react";
import "../(marketing)/marketing.css";
import DonateModal from "../../components/donate-modal";
import { logoutAction } from "./dashboard/actions";
export default function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div>
<header className="site-header">
<div className="brand">
<Link href="/" aria-label="XMR Checkout home">
<Image
className="brand-logo"
src="/logo.png"
alt="XMR Checkout logo"
width={128}
height={128}
priority
/>
</Link>
<Link className="name" href="/">
XMR Checkout
</Link>
</div>
<nav className="nav">
<Link href="/invoice">Check Invoice</Link>
<Link href="/docs">Documentation</Link>
<Link href="/faq">FAQ</Link>
<Link href="/dashboard">Dashboard</Link>
<form action={logoutAction}>
<button type="submit">Log out</button>
</form>
</nav>
</header>
{children}
<footer className="site-footer">
<div className="site-footer-copy">
<p>xmrcheckout.com - Non-custodial Monero checkout software.</p>
<p>
<Link
className="underline underline-offset-4"
href="https://github.com/xmrcheckout/xmrcheckout"
>
GitHub
</Link>
</p>
</div>
<Suspense fallback={null}>
<DonateModal />
</Suspense>
</footer>
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:8000";
export type LoginState = {
error: string | null;
};
export async function loginAction(
_prevState: LoginState,
formData: FormData
): Promise<LoginState> {
const paymentAddress = String(formData.get("payment_address") ?? "").trim();
const viewKey = String(formData.get("view_key") ?? "").trim();
if (!paymentAddress || !viewKey) {
return { error: "Primary address and secret view key are required." };
}
const response = await fetch(`${apiBaseUrl}/api/core/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ payment_address: paymentAddress, view_key: viewKey }),
cache: "no-store",
});
if (!response.ok) {
const detail = await response.json().catch(() => null);
const message = detail?.detail ?? "Unable to sign in.";
return { error: message };
}
const data = (await response.json()) as {
api_key: string;
webhook_secret: string;
store_id?: string;
};
const cookieStore = await cookies();
cookieStore.set("xmrcheckout_api_key", data.api_key, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
});
cookieStore.set("xmrcheckout_webhook_secret", data.webhook_secret, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
});
redirect("/dashboard");
}
+198
View File
@@ -0,0 +1,198 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useFormState } from "react-dom";
import { loginAction, type LoginState } from "./actions";
const initialState: LoginState = { error: null };
const apiBaseUrl =
typeof window === "undefined" ? process.env.NEXT_PUBLIC_API_BASE_URL ?? "" : "";
const base58Pattern = /^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$/;
const viewKeyPattern = /^[0-9a-fA-F]{64}$/;
const validationDelayMs = 450;
const isLikelyPrimaryAddress = (value: string) => {
const trimmed = value.trim();
if (trimmed.length !== 95) {
return false;
}
if (!trimmed.startsWith("4")) {
return false;
}
return base58Pattern.test(trimmed);
};
const isLikelySecretViewKey = (value: string) => viewKeyPattern.test(value.trim());
export default function LoginForm() {
const [state, formAction] = useFormState(loginAction, initialState);
const [paymentAddress, setPaymentAddress] = useState("");
const [viewKey, setViewKey] = useState("");
const [validationStatus, setValidationStatus] = useState<
"idle" | "checking" | "valid" | "invalid" | "error"
>("idle");
const [validationMessage, setValidationMessage] = useState<string | null>(null);
const canSubmit = useMemo(() => {
if (!isLikelyPrimaryAddress(paymentAddress) || !isLikelySecretViewKey(viewKey)) {
return false;
}
return validationStatus !== "checking" && validationStatus !== "invalid";
}, [paymentAddress, validationStatus, viewKey]);
useEffect(() => {
if (!isLikelyPrimaryAddress(paymentAddress) || !isLikelySecretViewKey(viewKey)) {
setValidationStatus("idle");
setValidationMessage(null);
return;
}
const controller = new AbortController();
const timeoutId = window.setTimeout(async () => {
setValidationStatus("checking");
setValidationMessage(null);
try {
const response = await fetch(`${apiBaseUrl}/api/core/auth/validate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
payment_address: paymentAddress.trim(),
view_key: viewKey.trim(),
}),
cache: "no-store",
signal: controller.signal,
});
if (!response.ok) {
const detail = await response.json().catch(() => null);
const message =
detail?.detail ?? "Address and view key did not pass validation.";
setValidationStatus("invalid");
setValidationMessage(message);
return;
}
setValidationStatus("valid");
} catch (error) {
if (controller.signal.aborted) {
return;
}
setValidationStatus("error");
setValidationMessage(
"Unable to validate the address and view key yet. You can still sign in to confirm."
);
}
}, validationDelayMs);
return () => {
controller.abort();
window.clearTimeout(timeoutId);
};
}, [paymentAddress, viewKey]);
return (
<form className="mt-6 grid gap-5" action={formAction}>
<div className="grid gap-2">
<div className="flex items-center justify-between gap-3">
<label
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
htmlFor="payment_address"
>
Primary address
</label>
{isLikelyPrimaryAddress(paymentAddress) ? (
<span className="inline-flex items-center gap-1 text-[0.65rem] font-semibold uppercase tracking-[0.16em] text-emerald-600">
<svg
className="h-3.5 w-3.5"
viewBox="0 0 16 16"
aria-hidden="true"
>
<path
d="M6.4 11.4 3.2 8.2l1.1-1.1 2.1 2.1 5.2-5.2 1.1 1.1-6.3 6.3z"
fill="currentColor"
/>
</svg>
Valid
</span>
) : null}
</div>
<textarea
className="min-h-[96px] w-full resize-none rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
id="payment_address"
name="payment_address"
rows={3}
autoComplete="off"
required
value={paymentAddress}
onChange={(event) => setPaymentAddress(event.target.value)}
/>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between gap-3">
<label
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
htmlFor="view_key"
>
Secret view key
</label>
{isLikelySecretViewKey(viewKey) ? (
<span className="inline-flex items-center gap-1 text-[0.65rem] font-semibold uppercase tracking-[0.16em] text-emerald-600">
<svg
className="h-3.5 w-3.5"
viewBox="0 0 16 16"
aria-hidden="true"
>
<path
d="M6.4 11.4 3.2 8.2l1.1-1.1 2.1 2.1 5.2-5.2 1.1 1.1-6.3 6.3z"
fill="currentColor"
/>
</svg>
Valid
</span>
) : null}
</div>
<input
className="min-h-[48px] w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
id="view_key"
name="view_key"
type="password"
autoComplete="off"
required
value={viewKey}
onChange={(event) => setViewKey(event.target.value)}
/>
</div>
{validationStatus === "checking" ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-amber-700">
Checking address and view key
</p>
) : null}
{validationStatus === "valid" ? (
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-emerald-700">
Address and view key match
</p>
) : null}
{validationStatus === "invalid" || validationStatus === "error" ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{validationMessage}
</p>
) : null}
{state.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{state.error}
</p>
) : null}
<div className="flex items-center justify-end">
<button
className="inline-flex items-center justify-center rounded-full bg-ink px-6 py-3 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5 disabled:cursor-not-allowed disabled:bg-ink/60 disabled:text-cream/70 disabled:shadow-none disabled:hover:translate-y-0"
type="submit"
disabled={!canSubmit}
>
Sign in
</button>
</div>
</form>
);
}
+10
View File
@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Login",
};
export default function LoginPage() {
redirect("/?login=1");
}
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Welcome",
};
export default async function WelcomePage() {
const session = (await cookies()).get("xmrcheckout_session")?.value;
if (!session) {
redirect("/?login=1");
}
redirect("/dashboard");
}
@@ -0,0 +1,553 @@
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "Integration Recipes",
};
const siteUrl =
process.env.NEXT_PUBLIC_SITE_URL ?? process.env.SITE_URL ?? "http://localhost:3000";
const normalizedSiteUrl = siteUrl.replace(/\/+$/, "");
const publicApiBaseUrl = `${normalizedSiteUrl}/api/core`;
const codeBlockClass =
"overflow-x-auto rounded-2xl border border-stroke bg-ink px-6 py-5 text-xs leading-relaxed text-cream shadow-soft";
const sectionCardClass =
"rounded-2xl border border-stroke bg-white/70 p-7 shadow-soft backdrop-blur";
export default function IntegrationsPage() {
return (
<main className="px-[6vw] pb-20 pt-10 text-ink">
<section className="grid max-w-[54rem] gap-4">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
Integration recipes
</p>
<h1 className="font-serif text-[clamp(2.2rem,2rem+1.4vw,3.4rem)] leading-[1.1]">
Copy/paste examples for common backends.
</h1>
<p className="text-[1.05rem] leading-relaxed text-ink-soft">
Recipes follow the same pattern: create an invoice with an XMR amount, send
the customer to the hosted invoice page, then fulfill your order when the
invoice is confirmed.
</p>
<div className="flex flex-wrap items-center gap-3 text-sm">
<Link
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white/60 px-5 py-2.5 text-xs font-semibold uppercase tracking-[0.18em] text-ink transition hover:-translate-y-0.5"
href="/docs"
>
Back to API docs
</Link>
</div>
<nav
className="flex flex-wrap items-center gap-3 rounded-2xl border border-stroke bg-white/60 px-5 py-4 text-sm text-ink-soft shadow-soft backdrop-blur"
aria-label="Recipe languages"
>
<span className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Jump to:
</span>
{[
{ href: "#btcpay", label: "BTCPay" },
{ href: "#curl", label: "curl" },
{ href: "#node", label: "Node.js" },
{ href: "#php", label: "PHP" },
{ href: "#python", label: "Python" },
{ href: "#go", label: "Go" },
].map((item) => (
<a
key={item.href}
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white/60 px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-ink transition hover:-translate-y-0.5"
href={item.href}
>
{item.label}
</a>
))}
</nav>
</section>
<section className="mt-10 grid gap-6 lg:grid-cols-2">
<div className={sectionCardClass}>
<h2 className="font-serif text-2xl">Conceptual flow</h2>
<ol className="mt-3 list-decimal space-y-2 pl-5 text-ink-soft">
<li>
Create an invoice via{" "}
<span className="font-mono text-ink">POST /api/core/invoices</span>.
</li>
<li>
Redirect the customer to <span className="font-mono text-ink">invoice_url</span>.
</li>
<li>
Wait for <span className="font-mono text-ink">invoice.confirmed</span> via webhook
(recommended) or polling.
</li>
</ol>
<div className="mt-4 rounded-xl border border-stroke bg-white/60 p-4 text-sm text-ink-soft">
<p className="font-semibold text-ink">What to store</p>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>
Your internal order id in{" "}
<span className="font-mono text-ink">metadata.order_id</span>
</li>
<li>
The returned <span className="font-mono text-ink">invoice.id</span>
</li>
</ul>
</div>
</div>
<div className={sectionCardClass}>
<h2 className="font-serif text-2xl">Environment variables</h2>
<p className="mt-2 text-ink-soft">
Keep secrets server-side. Never expose API keys or webhook secrets in browser
code.
</p>
<div className="mt-4 grid gap-3 text-sm">
<div className="rounded-xl border border-stroke bg-white/60 p-4">
<p className="font-mono text-xs font-semibold uppercase tracking-[0.18em] text-ink-soft">
XMRCHECKOUT_API_KEY
</p>
<p className="mt-2 text-ink-soft">Used to call authenticated endpoints.</p>
</div>
<div className="rounded-xl border border-stroke bg-white/60 p-4">
<p className="font-mono text-xs font-semibold uppercase tracking-[0.18em] text-ink-soft">
XMRCHECKOUT_WEBHOOK_SECRET
</p>
<p className="mt-2 text-ink-soft">
Used to verify the <span className="font-mono text-ink">X-Webhook-Secret</span>{" "}
header on webhook deliveries.
</p>
</div>
<div className="rounded-xl border border-stroke bg-white/60 p-4">
<p className="font-mono text-xs font-semibold uppercase tracking-[0.18em] text-ink-soft">
XMRCHECKOUT_API_BASE_URL
</p>
<p className="mt-2 text-ink-soft">
Public base URL, including <span className="font-mono text-ink">/api/core</span>:{" "}
<span className="font-mono text-ink">{publicApiBaseUrl}</span>
</p>
</div>
</div>
</div>
</section>
<section id="btcpay" className="mt-10 grid gap-6 scroll-mt-24">
<div className="grid max-w-[54rem] gap-2">
<h2 className="font-serif text-2xl">BTCPay compatibility (WooCommerce)</h2>
<p className="text-ink-soft">
Use the official WooCommerce Greenfield plugin with the compatibility
endpoints. Keys remain view-only, and invoices stay non-custodial.
</p>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<div className={sectionCardClass}>
<h3 className="font-serif text-xl">Setup steps</h3>
<ol className="mt-3 list-decimal space-y-2 pl-5 text-ink-soft">
<li>Sign in to XMR Checkout to get your API key.</li>
<li>
Store id: returned by the login response or via{" "}
<span className="font-mono text-ink">GET /api/v1/stores</span>. The
stores call returns a single store (one per primary address); use the{" "}
<span className="font-mono text-ink">id</span> field.
</li>
<li>Install the official BTCPay WooCommerce plugin.</li>
<li>
In WooCommerce → BTCPay settings, set:
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>
Server URL:{" "}
<span className="font-mono text-ink">{normalizedSiteUrl}</span>{" "}
(the plugin appends <span className="font-mono text-ink">/api/v1</span>).
</li>
<li>Store ID: the id returned from the stores call.</li>
<li>API key: your XMR Checkout API key.</li>
<li>Payment method: XMR-CHAIN (shows as XMR_CHAIN in WooCommerce).</li>
<li>Modal checkout is supported; the hosted invoice page is recommended for clarity.</li>
</ul>
</li>
</ol>
</div>
<div className={sectionCardClass}>
<h3 className="font-serif text-xl">Behavior notes</h3>
<ul className="mt-3 list-disc space-y-2 pl-5 text-ink-soft">
<li>Authorization header: Authorization: token &lt;api_key&gt;.</li>
<li>
Status mapping: pending → New, payment detected → Processing, confirmed → final.
</li>
<li>Expired and Invalid statuses map directly.</li>
<li>Webhook deliveries use the BTCPay-Sig header.</li>
</ul>
</div>
</div>
<pre className={codeBlockClass}>
<code>{`# Verify compatibility endpoints
export XMRCHECKOUT_BTCPAY_URL="${normalizedSiteUrl}/api/v1"
export XMRCHECKOUT_API_KEY="xmrcheckout_..."
curl -sS "$XMRCHECKOUT_BTCPAY_URL/stores" \\
-H "Authorization: token $XMRCHECKOUT_API_KEY"`}</code>
</pre>
</section>
<section id="curl" className="mt-12 grid gap-8 scroll-mt-24">
<div className="grid max-w-[52rem] gap-2">
<h2 className="font-serif text-2xl">curl</h2>
<p className="text-ink-soft">
Create an invoice, then poll status until it is confirmed.
</p>
</div>
<div className="grid gap-4">
<pre className={codeBlockClass}>
<code>{`# 1) Create invoice
export XMRCHECKOUT_API_BASE_URL="${publicApiBaseUrl}"
export XMRCHECKOUT_API_KEY="xmrcheckout_..."
curl -sS -X POST "$XMRCHECKOUT_API_BASE_URL/invoices" \\
-H "Authorization: ApiKey $XMRCHECKOUT_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{
"amount_xmr": "0.15",
"confirmation_target": 10,
"metadata": { "order_id": "ORDER-1234" }
}'`}</code>
</pre>
<pre className={codeBlockClass}>
<code>{`# 1b) Create invoice from fiat (non-binding conversion)
curl -sS -X POST "$XMRCHECKOUT_API_BASE_URL/invoices" \\
-H "Authorization: ApiKey $XMRCHECKOUT_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{
"amount_fiat": "100.00",
"currency": "USD",
"confirmation_target": 10,
"metadata": { "order_id": "ORDER-1234" }
}'`}</code>
</pre>
<pre className={codeBlockClass}>
<code>{`# 2) Poll public status (no auth)
export INVOICE_ID="uuid-from-response"
curl -sS "$XMRCHECKOUT_API_BASE_URL/public/invoice/$INVOICE_ID"`}</code>
</pre>
</div>
</section>
<section id="node" className="mt-12 grid gap-8 scroll-mt-24">
<div className="grid max-w-[52rem] gap-2">
<h2 className="font-serif text-2xl">Node.js</h2>
<p className="text-ink-soft">
Example shows creating an invoice with <span className="font-mono text-ink">fetch</span>{" "}
and receiving webhooks with Express.
</p>
</div>
<div className="grid gap-4">
<pre className={codeBlockClass}>
<code>{`// create-invoice.mjs
const API_BASE_URL = process.env.XMRCHECKOUT_API_BASE_URL;
const API_KEY = process.env.XMRCHECKOUT_API_KEY;
export async function createInvoice({ amountXmr, orderId }) {
const response = await fetch(\`\${API_BASE_URL}/invoices\`, {
method: "POST",
headers: {
Authorization: \`ApiKey \${API_KEY}\`,
"Content-Type": "application/json",
},
body: JSON.stringify({
amount_xmr: String(amountXmr),
confirmation_target: 10,
metadata: { order_id: orderId },
}),
});
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(\`xmrcheckout create invoice failed: \${response.status} \${text}\`);
}
return await response.json(); // includes invoice_url
}`}</code>
</pre>
<pre className={codeBlockClass}>
<code>{`// webhook-server.mjs
import express from "express";
const WEBHOOK_SECRET = process.env.XMRCHECKOUT_WEBHOOK_SECRET;
const app = express();
app.use(express.json({ type: "application/json" }));
app.post("/xmrcheckout/webhook", (req, res) => {
const headerSecret = req.get("x-webhook-secret") ?? "";
if (!WEBHOOK_SECRET || headerSecret !== WEBHOOK_SECRET) {
return res.sendStatus(401);
}
const event = req.body?.event;
const invoice = req.body?.invoice;
const orderId = invoice?.metadata?.order_id;
if (event === "invoice.confirmed" && orderId) {
// Mark your order paid here.
}
return res.sendStatus(204);
});
app.listen(3001, () => {
console.log("Listening on http://localhost:3001/xmrcheckout/webhook");
});`}</code>
</pre>
</div>
</section>
<section id="php" className="mt-12 grid gap-8 scroll-mt-24">
<div className="grid max-w-[52rem] gap-2">
<h2 className="font-serif text-2xl">PHP</h2>
<p className="text-ink-soft">
Minimal create-invoice example and a webhook receiver endpoint.
</p>
</div>
<div className="grid gap-4">
<pre className={codeBlockClass}>
<code>{`<?php
// create_invoice.php
$apiBaseUrl = getenv("XMRCHECKOUT_API_BASE_URL");
$apiKey = getenv("XMRCHECKOUT_API_KEY");
$payload = json_encode([
"amount_xmr" => "0.15",
"confirmation_target" => 10,
"metadata" => ["order_id" => "ORDER-1234"],
]);
$ch = curl_init($apiBaseUrl . "/invoices");
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: ApiKey " . $apiKey,
"Content-Type: application/json",
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($body === false || $status < 200 || $status >= 300) {
http_response_code(500);
echo "xmrcheckout error: HTTP " . $status;
exit;
}
$invoice = json_decode($body, true);
echo $invoice["invoice_url"];`}</code>
</pre>
<pre className={codeBlockClass}>
<code>{`<?php
// webhook.php
$webhookSecret = getenv("XMRCHECKOUT_WEBHOOK_SECRET");
$headerSecret = $_SERVER["HTTP_X_WEBHOOK_SECRET"] ?? "";
if (!$webhookSecret || $headerSecret !== $webhookSecret) {
http_response_code(401);
exit;
}
$raw = file_get_contents("php://input");
$payload = json_decode($raw, true);
$event = $payload["event"] ?? null;
$invoice = $payload["invoice"] ?? null;
$orderId = $invoice["metadata"]["order_id"] ?? null;
if ($event === "invoice.confirmed" && $orderId) {
// Mark your order paid here.
}
http_response_code(204);`}</code>
</pre>
</div>
</section>
<section id="python" className="mt-12 grid gap-8 scroll-mt-24">
<div className="grid max-w-[52rem] gap-2">
<h2 className="font-serif text-2xl">Python</h2>
<p className="text-ink-soft">
Create an invoice with <span className="font-mono text-ink">requests</span> and
receive webhooks with FastAPI.
</p>
</div>
<div className="grid gap-4">
<pre className={codeBlockClass}>
<code>{`# create_invoice.py
import os
import requests
api_base_url = os.environ["XMRCHECKOUT_API_BASE_URL"].rstrip("/")
api_key = os.environ["XMRCHECKOUT_API_KEY"]
response = requests.post(
f"{api_base_url}/invoices",
headers={"Authorization": f"ApiKey {api_key}"},
json={
"amount_xmr": "0.15",
"confirmation_target": 10,
"metadata": {"order_id": "ORDER-1234"},
},
timeout=10,
)
response.raise_for_status()
invoice = response.json()
print(invoice["invoice_url"])`}</code>
</pre>
<pre className={codeBlockClass}>
<code>{`# webhook_server.py
import os
from fastapi import FastAPI, Header, HTTPException
app = FastAPI()
webhook_secret = os.environ["XMRCHECKOUT_WEBHOOK_SECRET"]
@app.post("/xmrcheckout/webhook", status_code=204)
async def xmrcheckout_webhook(payload: dict, x_webhook_secret: str | None = Header(default=None)):
if not x_webhook_secret or x_webhook_secret != webhook_secret:
raise HTTPException(status_code=401, detail="Invalid webhook secret")
event = payload.get("event")
invoice = payload.get("invoice") or {}
metadata = invoice.get("metadata") or {}
order_id = metadata.get("order_id")
if event == "invoice.confirmed" and order_id:
# Mark your order paid here.
pass
return None`}</code>
</pre>
</div>
</section>
<section id="go" className="mt-12 grid gap-8 scroll-mt-24">
<div className="grid max-w-[52rem] gap-2">
<h2 className="font-serif text-2xl">Go</h2>
<p className="text-ink-soft">
Create an invoice with <span className="font-mono text-ink">net/http</span> and
receive webhooks with a standard handler.
</p>
</div>
<div className="grid gap-4">
<pre className={codeBlockClass}>
<code>{`// create_invoice.go
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
type invoiceResponse struct {
ID string ` + "`json:\"id\"`" + `
InvoiceURL string ` + "`json:\"invoice_url\"`" + `
}
func main() {
apiBaseURL := strings.TrimRight(os.Getenv("XMRCHECKOUT_API_BASE_URL"), "/")
apiKey := os.Getenv("XMRCHECKOUT_API_KEY")
payload := map[string]any{
"amount_xmr": "0.15",
"confirmation_target": 10,
"metadata": map[string]any{
"order_id": "ORDER-1234",
},
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", apiBaseURL+"/invoices", bytes.NewReader(body))
req.Header.Set("Authorization", "ApiKey "+apiKey)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
data, _ := io.ReadAll(resp.Body)
panic(fmt.Sprintf("xmrcheckout create invoice failed: %d %s", resp.StatusCode, string(data)))
}
var invoice invoiceResponse
if err := json.NewDecoder(resp.Body).Decode(&invoice); err != nil {
panic(err)
}
fmt.Println(invoice.InvoiceURL)
}`}</code>
</pre>
<pre className={codeBlockClass}>
<code>{`// webhook_server.go
package main
import (
"encoding/json"
"net/http"
"os"
)
type webhookPayload struct {
Event string ` + "`json:\"event\"`" + `
Invoice struct {
Metadata map[string]any ` + "`json:\"metadata\"`" + `
} ` + "`json:\"invoice\"`" + `
}
func main() {
webhookSecret := os.Getenv("XMRCHECKOUT_WEBHOOK_SECRET")
http.HandleFunc("/xmrcheckout/webhook", func(w http.ResponseWriter, r *http.Request) {
headerSecret := r.Header.Get("X-Webhook-Secret")
if webhookSecret == "" || headerSecret != webhookSecret {
w.WriteHeader(http.StatusUnauthorized)
return
}
var payload webhookPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
if payload.Event == "invoice.confirmed" {
if orderID, ok := payload.Invoice.Metadata["order_id"].(string); ok && orderID != "" {
// Mark your order paid here.
_ = orderID
}
}
w.WriteHeader(http.StatusNoContent)
})
_ = http.ListenAndServe(":3001", nil)
}`}</code>
</pre>
</div>
</section>
</main>
);
}
+840
View File
@@ -0,0 +1,840 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Fragment } from "react";
export const metadata: Metadata = {
title: "Docs",
};
const apiBaseUrl =
process.env.NEXT_PUBLIC_API_BASE_URL ??
process.env.API_BASE_URL ??
"http://localhost:8000";
const siteUrl =
process.env.NEXT_PUBLIC_SITE_URL ?? process.env.SITE_URL ?? "http://localhost:3000";
const normalizedSiteUrl = siteUrl.replace(/\/+$/, "");
const methodStyles: Record<string, string> = {
GET: "bg-emerald-900/10 text-emerald-800",
POST: "bg-amber-900/15 text-amber-800",
DELETE: "bg-red-900/10 text-red-700",
PUT: "bg-blue-900/10 text-blue-800",
PATCH: "bg-blue-900/10 text-blue-800",
};
const endpointGroups = [
{
title: "Login",
auth: "None",
items: [
{
method: "POST",
path: "/api/core/auth/login",
description: "Exchange a primary address and secret view key for an API key and webhook secret.",
},
],
},
{
title: "Public invoice status",
auth: "None",
items: [
{
method: "GET",
path: "/api/core/public/invoice/{invoice_id}",
description: "Read invoice status and payment details without auth.",
},
],
},
{
title: "Invoice workflow",
auth: "API key",
items: [
{
method: "GET",
path: "/api/core/invoices",
description: "List invoices for the signed-in user (supports search and sorting).",
},
{
method: "GET",
path: "/api/core/invoices/export.csv",
description: "Export invoices as CSV (same filters as list endpoint).",
},
{
method: "GET",
path: "/api/core/invoices/{invoice_id}",
description: "Retrieve a single invoice for the signed-in user.",
},
{
method: "POST",
path: "/api/core/invoices",
description: "Create invoices for the signed-in user.",
},
{
method: "DELETE",
path: "/api/core/invoices/{invoice_id}",
description:
"Archive a pending invoice until it expires (payment-detected/confirmed invoices cannot be archived).",
},
],
},
{
title: "API key webhooks",
auth: "API key",
items: [
{
method: "GET",
path: "/api/core/webhooks",
description: "List active webhook registrations.",
},
{
method: "GET",
path: "/api/core/webhooks/history",
description: "List recent webhook delivery attempts and HTTP status.",
},
{
method: "POST",
path: "/api/core/webhooks/deliveries/{delivery_id}/redeliver",
description: "Manually redeliver a failed webhook delivery.",
},
{
method: "POST",
path: "/api/core/webhooks",
description: "Register webhook URLs (single or per-event) and event filters.",
},
{
method: "DELETE",
path: "/api/core/webhooks/{webhook_id}",
description: "Remove a webhook registration.",
},
],
},
{
title: "API key management",
auth: "API key",
items: [
{
method: "POST",
path: "/api/core/api-credentials/reset",
description: "Reset the API key, webhook secret, or both.",
},
{
method: "PATCH",
path: "/api/core/profile",
description:
"Update profile preferences like the BTCPay checkout layout or default confirmations.",
},
],
},
{
title: "BTCPay Greenfield stores",
auth: "API key (token)",
items: [
{
method: "GET",
path: "/api/v1/stores",
description: "List stores accessible to the API key.",
},
{
method: "GET",
path: "/api/v1/stores/{store_id}",
description: "Retrieve a store by id.",
},
{
method: "GET",
path: "/api/v1/stores/{store_id}/payment-methods",
description: "List enabled payment methods (XMR-CHAIN only).",
},
],
},
{
title: "BTCPay Greenfield invoices",
auth: "API key (token)",
items: [
{
method: "POST",
path: "/api/v1/stores/{store_id}/invoices",
description: "Create a BTCPay-compatible invoice.",
},
{
method: "GET",
path: "/api/v1/stores/{store_id}/invoices/{invoice_id}",
description: "Retrieve a BTCPay-compatible invoice.",
},
{
method: "GET",
path: "/api/v1/stores/{store_id}/invoices/{invoice_id}/payment-methods",
description: "Return payment details for the invoice.",
},
{
method: "POST",
path: "/api/v1/stores/{store_id}/invoices/{invoice_id}/status",
description: "Mark an invoice as Invalid (compatibility only).",
},
],
},
{
title: "BTCPay Greenfield webhooks",
auth: "API key (token)",
items: [
{
method: "POST",
path: "/api/v1/stores/{store_id}/webhooks",
description: "Register a webhook with authorized events.",
},
{
method: "GET",
path: "/api/v1/stores/{store_id}/webhooks",
description: "List registered webhooks for a store.",
},
{
method: "GET",
path: "/api/v1/stores/{store_id}/webhooks/{webhook_id}",
description: "Retrieve a webhook by id.",
},
{
method: "PUT",
path: "/api/v1/stores/{store_id}/webhooks/{webhook_id}",
description: "Update a webhook (enabled, url, or events).",
},
{
method: "DELETE",
path: "/api/v1/stores/{store_id}/webhooks/{webhook_id}",
description: "Delete a webhook registration.",
},
],
},
{
title: "BTCPay server info",
auth: "API key (token)",
items: [
{
method: "GET",
path: "/api/v1/server/info",
description: "Return server info for compatibility checks.",
},
{
method: "GET",
path: "/api/v1/api-keys/current",
description: "Return the API key label and permissions.",
},
],
},
];
const endpointRequirements = [
{
method: "POST",
path: "/api/core/auth/login",
auth: "None",
required: ["payment_address", "view_key"],
optional: [],
notes: [
"Primary address only; subaddresses and integrated addresses are rejected.",
"Use the secret view key (never submit a spend key).",
],
},
{
method: "GET",
path: "/api/core/public/invoice/{invoice_id}",
auth: "None",
required: ["invoice_id (path)"],
optional: [],
notes: ["Returns invoice status and payment details."],
},
{
method: "GET",
path: "/api/core/invoices",
auth: "API key",
required: [],
optional: [
"limit (default 50, 1-100)",
"offset (default 0)",
"status",
"include_archived (default false)",
"q (invoice id, subaddress, metadata)",
"sort (created_at, expires_at, amount_xmr, status, confirmations, confirmation_target)",
"order (asc or desc)",
"created_from (ISO timestamp)",
"created_to (ISO timestamp)",
],
notes: ["Returns invoices excluding archived entries."],
},
{
method: "GET",
path: "/api/core/invoices/export.csv",
auth: "API key",
required: [],
optional: [
"include_archived (default false)",
"status",
"q",
"sort",
"order",
"created_from",
"created_to",
],
notes: ["Returns CSV with invoice rows."],
},
{
method: "GET",
path: "/api/core/invoices/{invoice_id}",
auth: "API key",
required: ["invoice_id (path)"],
optional: [],
notes: ["Returns a single invoice (archived invoices are excluded)."],
},
{
method: "GET",
path: "/api/core/webhooks",
auth: "API key",
required: [],
optional: [],
notes: ["Returns the most recent webhooks first."],
},
{
method: "GET",
path: "/api/core/webhooks/history",
auth: "API key",
required: [],
optional: ["limit (default 50, 1-200)", "offset (default 0)"],
notes: ["Returns the most recent webhook deliveries first."],
},
{
method: "POST",
path: "/api/core/webhooks/deliveries/{delivery_id}/redeliver",
auth: "API key",
required: ["delivery_id (path)"],
optional: [],
notes: ["Only failed deliveries can be redelivered."],
},
{
method: "POST",
path: "/api/core/webhooks",
auth: "API key",
required: ["events (at least one) or event_urls (at least one)"],
optional: ["url", "event_urls", "events"],
notes: [
"Provide url for all selected events unless every event is mapped in event_urls.",
"Events must be one of: invoice.created, invoice.payment_detected, invoice.confirmed, invoice.expired.",
],
},
{
method: "DELETE",
path: "/api/core/webhooks/{webhook_id}",
auth: "API key",
required: ["webhook_id (path)"],
optional: [],
notes: ["Returns 204 on success."],
},
{
method: "POST",
path: "/api/core/api-credentials/reset",
auth: "API key",
required: ["reset_api_key or reset_webhook_secret"],
optional: [],
notes: ["At least one reset flag must be true."],
},
{
method: "PATCH",
path: "/api/core/profile",
auth: "API key",
required: [],
optional: ["btcpay_checkout_style", "default_confirmation_target"],
notes: [
"Set btcpay_checkout_style to standard or btcpay_classic.",
"Applies to hosted invoice pages served for BTCPay compatibility.",
],
},
{
method: "POST",
path: "/api/core/invoices",
auth: "API key",
required: ["amount_xmr or amount_fiat + currency"],
optional: [
"confirmation_target (default 10, 0-10)",
"metadata",
"expires_at (ISO 8601)",
],
notes: [
"Fiat conversion is non-binding and does not lock a rate.",
"Creates a pending invoice and returns invoice details.",
],
},
{
method: "DELETE",
path: "/api/core/invoices/{invoice_id}",
auth: "API key",
required: [],
optional: [],
notes: [
"Only pending, expired, or invalid invoices can be archived. Returns 204 on success.",
],
},
{
method: "GET",
path: "/api/v1/stores",
auth: "API key (token)",
required: [],
optional: [],
notes: [
"Single-store per user: the list returns one store.",
"Store ids map to the authenticated user id.",
"Header format: Authorization: token <api_key>.",
],
},
{
method: "GET",
path: "/api/v1/stores/{store_id}",
auth: "API key (token)",
required: ["store_id (path)"],
optional: [],
notes: [
"Only the authenticated store id is valid.",
"Returns the store metadata expected by Greenfield clients.",
],
},
{
method: "GET",
path: "/api/v1/stores/{store_id}/payment-methods",
auth: "API key (token)",
required: ["store_id (path)"],
optional: [],
notes: ["Returns XMR-CHAIN as the only enabled payment method."],
},
{
method: "POST",
path: "/api/v1/stores/{store_id}/invoices",
auth: "API key (token)",
required: ["store_id (path)", "amount", "currency"],
optional: ["metadata", "checkout"],
notes: [
"Fiat inputs are converted once at creation time and stored as metadata.",
"Invoices are defined and payable in XMR only.",
],
},
{
method: "GET",
path: "/api/v1/stores/{store_id}/invoices/{invoice_id}",
auth: "API key (token)",
required: ["store_id (path)", "invoice_id (path)"],
optional: [],
notes: ["Returns BTCPay-compatible status fields."],
},
{
method: "GET",
path: "/api/v1/stores/{store_id}/invoices/{invoice_id}/payment-methods",
auth: "API key (token)",
required: ["store_id (path)", "invoice_id (path)"],
optional: [],
notes: [
"Read-only payment details derived from observed on-chain data.",
"Returns a list even when only one payment method is enabled.",
],
},
{
method: "POST",
path: "/api/v1/stores/{store_id}/invoices/{invoice_id}/status",
auth: "API key (token)",
required: ["store_id (path)", "invoice_id (path)", "status"],
optional: [],
notes: ["Only supports status=Invalid for compatibility; no funds move."],
},
{
method: "POST",
path: "/api/v1/stores/{store_id}/webhooks",
auth: "API key (token)",
required: ["store_id (path)", "url", "authorizedEvents"],
optional: ["enabled", "automaticRedelivery"],
notes: [
"Deliveries include BTCPay-Sig: sha256=<hmac>.",
"Events follow BTCPay naming conventions for compatibility.",
],
},
{
method: "GET",
path: "/api/v1/stores/{store_id}/webhooks",
auth: "API key (token)",
required: ["store_id (path)"],
optional: [],
notes: ["Returns the most recent webhooks first."],
},
{
method: "GET",
path: "/api/v1/stores/{store_id}/webhooks/{webhook_id}",
auth: "API key (token)",
required: ["store_id (path)", "webhook_id (path)"],
optional: [],
notes: ["Returns a single webhook registration."],
},
{
method: "PUT",
path: "/api/v1/stores/{store_id}/webhooks/{webhook_id}",
auth: "API key (token)",
required: ["store_id (path)", "webhook_id (path)"],
optional: ["enabled", "automaticRedelivery", "url", "authorizedEvents"],
notes: ["Updates a webhook registration."],
},
{
method: "DELETE",
path: "/api/v1/stores/{store_id}/webhooks/{webhook_id}",
auth: "API key (token)",
required: ["store_id (path)", "webhook_id (path)"],
optional: [],
notes: ["Returns 204 on success."],
},
{
method: "GET",
path: "/api/v1/server/info",
auth: "API key (token)",
required: [],
optional: [],
notes: ["Version is set for compatibility with refund UI behavior."],
},
{
method: "GET",
path: "/api/v1/api-keys/current",
auth: "API key (token)",
required: [],
optional: [],
notes: [
"Permissions are store-scoped strings tied to one store id.",
"Refund permissions are intentionally omitted.",
],
},
];
const requirementsByEndpoint = Object.fromEntries(
endpointRequirements.map((entry) => [`${entry.method} ${entry.path}`, entry])
);
export default function DocsPage() {
return (
<main className="px-[6vw] pb-20 pt-10 text-ink">
<section className="grid max-w-[52rem] gap-4">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
Documentation
</p>
<h1 className="font-serif text-[clamp(2.2rem,2rem+1.4vw,3.4rem)] leading-[1.1]">
Monero checkout documentation & API reference.
</h1>
<p className="text-[1.05rem] leading-relaxed text-ink-soft">
xmrcheckout.com generates invoices, detects Monero payments using view-only access,
and relays status events. Setup is simple: bring your primary address and secret
view key - never spend keys, never custody.
</p>
<div className="flex flex-wrap gap-2">
<span className="rounded-full bg-ink/10 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink-soft">
Customer → Merchant wallet
</span>
<span className="rounded-full bg-ink/10 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink-soft">
No spend keys required
</span>
<span className="rounded-full bg-ink/10 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink-soft">
OpenAPI schema available
</span>
</div>
<div className="flex flex-wrap items-center gap-3">
<Link
className="inline-flex items-center justify-center rounded-full bg-ink px-5 py-2.5 text-xs font-semibold uppercase tracking-[0.18em] text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
href="/docs/integrations"
>
Integration recipes
</Link>
<span className="text-sm text-ink-soft">
Copy/paste examples for curl, Node, PHP, Python, and Go.
</span>
</div>
</section>
<section className="mt-10 rounded-2xl border border-stroke bg-white/60 p-6 shadow-soft backdrop-blur">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Start here
</p>
<ol className="mt-3 grid gap-3 text-sm text-ink-soft sm:grid-cols-4">
<li className="rounded-xl border border-stroke bg-white/70 p-4">
<p className="font-semibold text-ink">1) Sign in (view-only)</p>
<p className="mt-1">
Use your primary address + secret view key to retrieve an API key.
</p>
</li>
<li className="rounded-xl border border-stroke bg-white/70 p-4">
<p className="font-semibold text-ink">2) Create an invoice</p>
<p className="mt-1">
Call <span className="font-mono text-ink">POST /api/core/invoices</span> and
redirect the customer to the hosted invoice URL.
</p>
</li>
<li className="rounded-xl border border-stroke bg-white/70 p-4">
<p className="font-semibold text-ink">3) Optional: configure webhooks</p>
<p className="mt-1">
Subscribe to <span className="font-mono text-ink">invoice.confirmed</span> to
get status events pushed to your system.
</p>
</li>
<li className="rounded-xl border border-stroke bg-white/70 p-4">
<p className="font-semibold text-ink">4) Confirm and fulfill</p>
<p className="mt-1">
Wait for <span className="font-mono text-ink">invoice.confirmed</span> (webhook
or polling). You can also check the public status endpoint, then mark your
order paid.
</p>
</li>
</ol>
<div className="mt-4 flex flex-wrap items-center gap-3">
<Link
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white/50 px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-ink transition hover:-translate-y-0.5"
href="/docs/integrations"
>
See recipes
</Link>
<a
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white/50 px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-ink transition hover:-translate-y-0.5"
href={`${normalizedSiteUrl}/openapi.json`}
rel="noreferrer"
target="_blank"
>
OpenAPI JSON
</a>
</div>
</section>
<section className="mt-10 rounded-2xl border border-stroke bg-white/60 p-6 shadow-soft backdrop-blur">
<h2 className="font-serif text-2xl">Fiat inputs (optional)</h2>
<p className="mt-2 text-ink-soft">
You can request an invoice using a fiat amount. The backend converts the
value to XMR using the current rate and returns a non-binding quote in the
response. The invoice is always stored in XMR.
</p>
<pre className="mt-4 overflow-x-auto rounded-2xl border border-stroke bg-ink px-6 py-5 text-xs leading-relaxed text-cream shadow-soft">
<code>{`POST /api/core/invoices
Authorization: ApiKey <api_key>
Content-Type: application/json
{
"amount_fiat": "100.00",
"currency": "USD",
"confirmation_target": 10,
"metadata": { "order_id": "ORDER-1234" }
}`}</code>
</pre>
</section>
<section className="mt-10 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<div className="rounded-xl border border-stroke bg-white/60 p-6 shadow-soft backdrop-blur">
<h3 className="mb-2 font-serif text-xl">Core principles</h3>
<p className="text-ink-soft">
All behavior is constrained by non-custodial rules.
</p>
<ul className="mt-3 list-disc space-y-2 pl-5 text-ink-soft">
<li>Merchants retain wallet access (your keys, your funds)</li>
<li>View-only access maximum</li>
<li>No fiat rails or execution of financial actions</li>
</ul>
</div>
<div className="rounded-xl border border-stroke bg-white/60 p-6 shadow-soft backdrop-blur">
<h3 className="mb-2 font-serif text-xl">Subaddress-only invoices</h3>
<p className="text-ink-soft">
Every invoice uses a subaddress derived from your primary address for
clearer reconciliation.
</p>
<ul className="mt-3 list-disc space-y-2 pl-5 text-ink-soft">
<li>Subaddress index shown on each invoice</li>
<li>Wallet lookahead controls discovery</li>
<li>Subaddresses cycle through indices 1 to 100</li>
<li>Set a start index to shift the range to start + 1 through 100</li>
<li>Wallets may require adding the index to view funds</li>
</ul>
</div>
<div className="rounded-xl border border-stroke bg-white/60 p-6 shadow-soft backdrop-blur">
<h3 className="mb-2 font-serif text-xl">Invoice lifecycle</h3>
<p className="text-ink-soft">
Clear state transitions without hidden automation.
</p>
<ul className="mt-3 list-disc space-y-2 pl-5 text-ink-soft">
<li>Pending → Payment detected → Confirmed</li>
<li>Pending → Expired</li>
<li>Pending → Invalid (manual)</li>
</ul>
</div>
</section>
<section className="mt-6 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<div className="rounded-xl border border-stroke bg-white/60 p-6 shadow-soft backdrop-blur">
<h3 className="mb-2 font-serif text-xl">Authentication</h3>
<p className="text-ink-soft">
Two auth modes keep integrations explicit and observable.
</p>
<ul className="mt-3 list-disc space-y-2 pl-5 text-ink-soft">
<li>API key for authenticated endpoints</li>
<li>Primary address + secret view key to retrieve API credentials</li>
<li>Headers: Authorization: ApiKey &lt;api_key&gt;</li>
<li>BTCPay compatibility: Authorization: token &lt;api_key&gt;</li>
</ul>
</div>
<div className="rounded-xl border border-stroke bg-white/60 p-6 shadow-soft backdrop-blur">
<h3 className="mb-2 font-serif text-xl">Webhook events</h3>
<p className="text-ink-soft">Observable events for merchant systems.</p>
<ul className="mt-3 list-disc space-y-2 pl-5 text-ink-soft">
<li>invoice.created</li>
<li>invoice.payment_detected</li>
<li>invoice.confirmed</li>
<li>invoice.expired</li>
<li>Deliveries include the X-Webhook-Secret header</li>
<li>Webhooks are optional; poll /api/core/public/invoice/&lt;invoice_id&gt; without auth</li>
</ul>
</div>
<div className="rounded-xl border border-stroke bg-white/60 p-6 shadow-soft backdrop-blur">
<h3 className="mb-2 font-serif text-xl">Base URL</h3>
<p className="text-ink-soft">
The API can be reached from:
</p>
<ul className="mt-3 list-disc space-y-2 pl-5 text-ink-soft">
<li>{normalizedSiteUrl}/api/core</li>
</ul>
</div>
</section>
<section className="mt-6 grid gap-6 sm:grid-cols-2">
<div className="rounded-xl border border-stroke bg-white/60 p-6 shadow-soft backdrop-blur">
<h3 className="mb-2 font-serif text-xl">BTCPay compatibility (WooCommerce)</h3>
<p className="text-ink-soft">
A Greenfield-compatible subset keeps the official WooCommerce plugin working
without custodial behavior.
</p>
<ul className="mt-3 list-disc space-y-2 pl-5 text-ink-soft">
<li>
Base URL uses <span className="font-mono text-ink">/api/v1</span>
</li>
<li>Supported endpoints: stores, invoices, payment methods, webhooks</li>
<li>Invoices are defined in XMR; fiat inputs are quote-only metadata</li>
<li>Modal checkout is supported; the hosted invoice page is recommended for clarity</li>
</ul>
</div>
<div className="rounded-xl border border-stroke bg-white/60 p-6 shadow-soft backdrop-blur">
<h3 className="mb-2 font-serif text-xl">BTCPay webhooks</h3>
<p className="text-ink-soft">Deliveries are signed with the BTCPay header format.</p>
<ul className="mt-3 list-disc space-y-2 pl-5 text-ink-soft">
<li>
<span className="font-mono text-ink">BTCPay-Sig: sha256=&lt;hmac&gt;</span>
</li>
<li>Event names follow BTCPay conventions for compatibility.</li>
</ul>
</div>
</section>
<section className="mt-12 grid gap-6">
<div className="grid max-w-[52rem] gap-2">
<h2 className="font-serif text-2xl">API reference</h2>
<p className="text-ink-soft">
Every endpoint exposed by the API is listed below, grouped by access
mode.
</p>
</div>
<div className="grid gap-6 lg:grid-cols-2">
{endpointGroups.map((group, index) => {
const needsDivider =
group.title.startsWith("BTCPay") &&
endpointGroups[index - 1] &&
!endpointGroups[index - 1].title.startsWith("BTCPay");
return (
<Fragment key={group.title}>
{needsDivider ? (
<div className="h-px w-full bg-monero/70 lg:col-span-2" aria-hidden="true"></div>
) : null}
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft backdrop-blur">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h3 className="font-serif text-xl">{group.title}</h3>
<p className="text-sm font-semibold text-sage">
Auth: {group.auth}
</p>
</div>
<span className="rounded-full bg-ink/10 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink">
{group.items.length} endpoints
</span>
</div>
<div className="mt-5 grid gap-3">
{group.items.map((endpoint) => {
const requirements =
requirementsByEndpoint[`${endpoint.method} ${endpoint.path}`];
return (
<div
className="grid gap-4 rounded-xl border border-ink/10 bg-white/60 p-4 sm:grid-cols-[auto_1fr]"
key={`${endpoint.method}-${endpoint.path}`}
>
<span
className={`inline-flex items-center justify-center rounded-lg px-2.5 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] ${
methodStyles[endpoint.method] ?? "bg-ink/10 text-ink-soft"
}`}
>
{endpoint.method}
</span>
<div className="grid gap-2">
<div className="grid gap-1">
<span className="font-mono text-sm text-ink">
{endpoint.path}
</span>
<span className="text-sm text-ink-soft">
{endpoint.description}
</span>
</div>
{requirements ? (
<details className="rounded-lg border border-ink/10 bg-white/70 px-3 py-2 text-sm text-ink-soft">
<summary className="cursor-pointer select-none text-xs font-semibold uppercase tracking-[0.18em] text-sage">
Requirements
</summary>
<div className="mt-2 grid gap-2">
<div>
<span className="font-semibold text-ink">
Required:
</span>{" "}
{requirements.required.length > 0
? requirements.required.join(", ")
: "None"}
</div>
{requirements.optional.length > 0 ? (
<div>
<span className="font-semibold text-ink">
Optional:
</span>{" "}
{requirements.optional.join(", ")}
</div>
) : null}
{requirements.notes.length > 0 ? (
<ul className="list-disc space-y-1 pl-5">
{requirements.notes.map((note) => (
<li key={note}>{note}</li>
))}
</ul>
) : null}
</div>
</details>
) : null}
</div>
</div>
);
})}
</div>
</div>
</Fragment>
);
})}
</div>
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-stroke bg-white/60 px-5 py-4 text-sm text-ink-soft shadow-soft backdrop-blur">
<span>Need the machine-readable schema?</span>
<a
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white/50 px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-ink transition hover:-translate-y-0.5"
href={`${normalizedSiteUrl}/openapi.json`}
rel="noreferrer"
target="_blank"
>
OpenAPI JSON
</a>
</div>
</section>
</main>
);
}
@@ -0,0 +1,331 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import InvoicePaymentDetails from "../../../../components/invoice-payment-details";
import InvoiceStatusAutoRefresh from "../../../../components/invoice-status-auto-refresh";
import DonationStatusActions from "../../../../components/donation-status-actions";
import InvoiceStatusLookup from "../../../../components/invoice-status-lookup";
import { formatRelativeTime } from "../../../../components/relative-time";
import StatusRefreshButton from "../../../../components/status-refresh-button";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Donation Request",
};
const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:8000";
type InvoiceStatus =
| "pending"
| "payment_detected"
| "confirmed"
| "expired"
| "invalid";
type InvoiceStatusResponse = {
id: string;
address: string;
subaddress_index: number | null;
amount_xmr: string;
status: InvoiceStatus;
confirmation_target: number;
confirmations: number;
created_at: string;
expires_at: string | null;
detected_at: string | null;
confirmed_at: string | null;
};
const formatStatus = (status: InvoiceStatus) => {
if (status === "payment_detected") {
return "Payment detected";
}
if (status === "pending") {
return "Awaiting funds";
}
if (status === "confirmed") {
return "Confirmed";
}
if (status === "invalid") {
return "Invalid";
}
return "Expired";
};
const statusDescription = (
status: InvoiceStatus,
confirmationTarget: number,
confirmations: number
) => {
if (status === "pending") {
return "Awaiting on-chain detection of your donation.";
}
if (status === "payment_detected") {
return `Donation detected. ${confirmations}/${confirmationTarget} confirmations.`;
}
if (status === "confirmed") {
return `Donation confirmed on-chain at ${confirmationTarget} confirmations.`;
}
if (status === "invalid") {
return "Donation marked invalid. Do not send a payment.";
}
return "Donation expired before detection. Start a new donation if needed.";
};
const formatTimestamp = (value: string | null) => {
if (!value) {
return { label: "-", relative: null };
}
return {
label: new Date(value).toLocaleString(),
relative: formatRelativeTime(value),
};
};
const statusPillStyles: Record<InvoiceStatus, string> = {
pending: "bg-amber-100 text-amber-900",
payment_detected: "bg-emerald-100 text-emerald-900",
confirmed: "bg-ink/20 text-ink",
expired: "bg-red-100 text-red-700",
invalid: "bg-clay/15 text-clay",
};
export default async function DonateStatusDetailPage({
params,
}: {
params: Promise<{ invoiceId: string }>;
}) {
const donationsEnabled = process.env.NEXT_PUBLIC_DONATIONS_ENABLED === "true";
if (!donationsEnabled) {
notFound();
}
const { invoiceId } = await params;
const response = await fetch(
`${apiBaseUrl}/api/core/public/donation/${encodeURIComponent(invoiceId)}`,
{ cache: "no-store" }
);
if (response.status === 404) {
return (
<main className="px-[6vw] pb-20 pt-10 text-ink">
<section className="grid gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-start">
<div className="grid gap-4">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
Donation status
</p>
<h1 className="font-serif text-[clamp(2.2rem,2rem+1.4vw,3.4rem)] leading-[1.1]">
Donation invoice not found.
</h1>
<p className="text-[1.05rem] leading-relaxed text-ink-soft">
The donation id does not match a known invoice. Check the id and
try again.
</p>
</div>
<div className="rounded-2xl border border-stroke bg-card p-7 shadow-card backdrop-blur">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-clay">
Need another lookup?
</p>
<div className="mt-4">
<InvoiceStatusLookup initialValue={invoiceId} compact />
</div>
</div>
</section>
</main>
);
}
if (!response.ok) {
return (
<main className="px-[6vw] pb-20 pt-10 text-ink">
<section className="grid gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-start">
<div className="grid gap-4">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
Donation status
</p>
<h1 className="font-serif text-[clamp(2.2rem,2rem+1.4vw,3.4rem)] leading-[1.1]">
Status unavailable.
</h1>
<p className="text-[1.05rem] leading-relaxed text-ink-soft">
We could not load this donation status. Refresh the page or try
again later.
</p>
</div>
<div className="rounded-2xl border border-stroke bg-card p-7 shadow-card backdrop-blur">
<InvoiceStatusLookup initialValue={invoiceId} compact />
</div>
</section>
</main>
);
}
const invoice = (await response.json()) as InvoiceStatusResponse;
const statusLabel = formatStatus(invoice.status);
const confirmations = Math.max(0, invoice.confirmations ?? 0);
const confirmationTarget = Math.max(0, invoice.confirmation_target);
const isInvalid = invoice.status === "invalid";
const hasDetectedPayment =
invoice.status === "payment_detected" || invoice.status === "confirmed";
const timelineItems: {
label: string;
timestamp: ReturnType<typeof formatTimestamp>;
state: "complete";
}[] = [];
timelineItems.push({
label: "Donation created",
timestamp: formatTimestamp(invoice.created_at),
state: "complete",
});
if (invoice.detected_at) {
timelineItems.push({
label: "Donation detected",
timestamp: formatTimestamp(invoice.detected_at),
state: "complete",
});
}
if (invoice.confirmed_at) {
timelineItems.push({
label: "Donation confirmed",
timestamp: formatTimestamp(invoice.confirmed_at),
state: "complete",
});
}
if (invoice.status === "expired") {
timelineItems.push({
label: "Donation expired",
timestamp: formatTimestamp(invoice.expires_at),
state: "complete",
});
}
const showConfirmedStamp = invoice.status === "confirmed";
const createdTimestamp = formatTimestamp(invoice.created_at);
const expiresTimestamp = formatTimestamp(invoice.expires_at);
return (
<main className="px-[6vw] pb-20 pt-10 text-ink">
<section className="grid gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-start">
<InvoiceStatusAutoRefresh intervalMs={30000} />
<div className="grid gap-4">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
Donation request
</p>
<h1 className="font-serif text-[clamp(2.2rem,2rem+1.4vw,3.4rem)] leading-[1.1]">
Donation request
</h1>
<p className="text-[1.05rem] leading-relaxed text-ink-soft">
{isInvalid
? "This donation is marked invalid. Do not pay."
: "Send the exact amount shown below to donate."}
</p>
<p className="text-[1.05rem] leading-relaxed text-ink-soft">
{statusDescription(invoice.status, confirmationTarget, confirmations)}
</p>
<details className="rounded-xl border border-stroke bg-white/60 px-4 py-3 text-sm text-ink-soft shadow-soft">
<summary className="cursor-pointer select-none text-xs font-semibold uppercase tracking-[0.18em] text-ink">
Details
</summary>
<div className="mt-3 grid gap-2">
<p className="font-mono text-xs text-ink">Donation ID: {invoiceId}</p>
<p>This status page is public. Anyone with the link can view the current state.</p>
</div>
</details>
</div>
<div className="relative overflow-hidden rounded-2xl border border-stroke bg-card p-7 shadow-card backdrop-blur">
{showConfirmedStamp ? (
<div
className="pointer-events-none absolute left-1/2 top-4 z-10 grid h-20 w-20 -translate-x-1/2 -rotate-[12deg] place-items-center rounded-full border-2 border-emerald-700 bg-cream/80 text-center text-[0.55rem] font-bold uppercase tracking-[0.2em] text-emerald-800 shadow-[0_10px_20px_rgba(16,18,23,0.18)]"
aria-hidden="true"
>
<span className="leading-tight">Payment confirmed</span>
</div>
) : null}
<div className="flex items-center justify-between gap-3">
<span className="rounded-full bg-ink/10 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink">
Status
</span>
<div className="flex items-center gap-2">
<span
className={`rounded-full px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] ${statusPillStyles[invoice.status]}`}
>
{statusLabel}
</span>
<StatusRefreshButton label="Refresh" />
</div>
</div>
<div className="mt-6 grid gap-4 sm:grid-cols-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Confirmations
</p>
<p className="mt-1 text-lg font-semibold">
{confirmations}/{confirmationTarget}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Created
</p>
<p
className="mt-1 text-sm font-semibold"
title={createdTimestamp.relative ?? undefined}
>
{createdTimestamp.label}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Expires
</p>
<p
className="mt-1 text-sm font-semibold"
title={expiresTimestamp.relative ?? undefined}
>
{expiresTimestamp.label}
</p>
</div>
</div>
</div>
</section>
<InvoicePaymentDetails
address={invoice.address}
amount={invoice.amount_xmr}
hasDetectedPayment={hasDetectedPayment}
status={invoice.status}
confirmationTarget={confirmationTarget}
/>
<section className="mt-8 rounded-2xl border border-stroke bg-card p-7 shadow-card backdrop-blur">
<h2 className="font-serif text-2xl">Timeline</h2>
<ol className="mt-5 grid gap-3">
{[...timelineItems].reverse().map((item) => (
<li
className="flex items-center justify-between gap-6 rounded-xl border border-ink/20 bg-white/70 px-4 py-3"
key={item.label}
>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
{item.label}
</p>
<p
className="mt-1 text-sm font-semibold"
title={item.timestamp.relative ?? undefined}
>
{item.timestamp.label}
</p>
</div>
</li>
))}
</ol>
</section>
<section className="mt-6 grid gap-4">
<DonationStatusActions />
</section>
</main>
);
}
+38
View File
@@ -0,0 +1,38 @@
"use server";
const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:8000";
export type DonationState = {
error: string | null;
invoiceId: string | null;
};
export async function createDonationAction(
_prevState: DonationState,
formData: FormData
): Promise<DonationState> {
const donationsEnabled = process.env.NEXT_PUBLIC_DONATIONS_ENABLED === "true";
if (!donationsEnabled) {
return { error: "Donations are disabled.", invoiceId: null };
}
const amount = Number(formData.get("amount_xmr"));
if (Number.isNaN(amount) || amount <= 0) {
return { error: "Enter a valid XMR amount.", invoiceId: null };
}
const response = await fetch(`${apiBaseUrl}/api/core/donations`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amount_xmr: amount }),
cache: "no-store",
});
if (!response.ok) {
const detail = await response.json().catch(() => null);
const message = detail?.detail ?? "Unable to create a donation invoice.";
return { error: message, invoiceId: null };
}
const data = (await response.json()) as { id: string };
return { error: null, invoiceId: data.id };
}
+14
View File
@@ -0,0 +1,14 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Donate",
};
export default function DonatePage() {
const donationsEnabled = process.env.NEXT_PUBLIC_DONATIONS_ENABLED === "true";
if (!donationsEnabled) {
notFound();
}
redirect("/?donate=1");
}
+289
View File
@@ -0,0 +1,289 @@
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "FAQ",
description:
"Frequently asked questions about XMR Checkout: non-custodial Monero invoices with view-only detection.",
};
const donationsEnabled = process.env.NEXT_PUBLIC_DONATIONS_ENABLED === "true";
type FaqItem = {
question: string;
answer: React.ReactNode;
};
function ComparisonYesNo({
value,
label,
}: {
value: boolean;
label: string;
}) {
return (
<span className="inline-flex items-center justify-center">
<span
className={[
"inline-flex h-7 w-7 items-center justify-center rounded-full text-sm font-semibold",
value ? "bg-sage/15 text-sage" : "bg-ink/10 text-ink-soft",
].join(" ")}
aria-label={label}
title={label}
>
{value ? "✓" : "✕"}
</span>
</span>
);
}
const faqItems: FaqItem[] = [
{
question: "Is XMR Checkout custodial?",
answer: (
<>
<p className="text-ink-soft">
No. Payments go directly from the customer to your Monero wallet. XMR
Checkout only observes the blockchain to detect payments and update
invoice status.
</p>
</>
),
},
{
question: "What wallet access does XMR Checkout require?",
answer: (
<>
<p className="text-ink-soft">
The maximum permission we ask for is view-only access: your primary
address and your secret view key. We never request spend keys and we
never request signing access.
</p>
</>
),
},
{
question: "How easy is setup?",
answer: (
<>
<p className="text-ink-soft">
Setup is simple and explicit: provide your primary address and secret
view key, then create invoices via the dashboard or API. We never
request spend keys or signing access.
</p>
</>
),
},
{
question: "Do you require email or personal information?",
answer: (
<>
<p className="text-ink-soft">
No. We do not require email or personal information. Your primary
address is the identifier for access, paired with your secret view key.
</p>
</>
),
},
{
question: "Why is XMR Checkout free?",
answer: (
<>
<p className="text-ink-soft">
We want to give back to the Monero community and help improve
adoption. XMR Checkout is intentionally offered as free checkout
software.
</p>
{donationsEnabled ? (
<p className="text-ink-soft">
If you find XMR Checkout useful, we happily accept{" "}
<Link className="underline underline-offset-4" href="/donate">
donations
</Link>
.
</p>
) : null}
</>
),
},
{
question: "Can XMR Checkout move funds, issue refunds, or reverse payments?",
answer: (
<>
<p className="text-ink-soft">
No. XMR Checkout does not sign transactions and cannot move funds on
your behalf. It is checkout software: create invoices, detect
payments, and relay status via API/webhooks.
</p>
</>
),
},
{
question: "What happens if XMR Checkout is down when a customer pays?",
answer: (
<>
<p className="text-ink-soft">
Your wallet can still receive a valid on-chain payment because the
payment is customer → merchant wallet. If the service is unavailable,
you may temporarily lose invoice status updates until it comes back
online.
</p>
</>
),
},
{
question: "Why choose XMR Checkout instead of hosted services (e.g. NOWPayments)?",
answer: (
<>
<p className="text-ink-soft">
The main differences are scope and data collection: XMR Checkout is
Monero-only, has no fees, and does not collect personal information.
</p>
<ul className="mt-4 grid gap-2 text-sm text-ink-soft">
<li>
<span className="font-semibold text-ink">Monero-only:</span> a focused
product without a multi-asset stack.
</li>
<li>
<span className="font-semibold text-ink">No fees:</span> free to use.
</li>
<li>
<span className="font-semibold text-ink">No personal info:</span>{" "}
your address is the identifier.
</li>
<li>
<span className="font-semibold text-ink">No custody:</span> payments
go directly to your wallet.
</li>
<li>
<span className="font-semibold text-ink">View-only by design:</span>{" "}
we never ask for spend keys.
</li>
<li>
<span className="font-semibold text-ink">Clear failure modes:</span>{" "}
downtime can delay status updates, but it does not affect on-chain
validity of payments.
</li>
</ul>
<p className="mt-4 text-ink-soft">
If you prefer a third-party hosted flow for convenience, compare
providers carefully and choose what matches your risk tolerance and
operational needs.
</p>
</>
),
},
{
question: "Why choose XMR Checkout instead of BTCPay Server?",
answer: (
<>
<p className="text-ink-soft">
BTCPay Server is a strong self-hosted option, with a primary focus on
Bitcoin (including the Lightning Network) and optional integrations
for other assets (including Monero via a plugin). XMR Checkout is
intentionally focused on Monero checkout with a view-only trust
boundary and a minimal, deterministic core.
</p>
<p className="text-ink-soft">
BTCPay Server configuration can be difficult, and while hosted
BTCPay options exist, they are often not configured with the Monero
plugin. For that reason we provide a basic (greenfield) integration
layer so teams can accept Monero without the requirement of
self-hosting.
</p>
<ul className="mt-4 grid gap-2 text-sm text-ink-soft">
<li>
<span className="font-semibold text-ink">Monero-first UX:</span>{" "}
invoice and confirmation language is tailored to Monero.
</li>
<li>
<span className="font-semibold text-ink">Fewer moving parts:</span>{" "}
a narrow scope keeps operations and audits simpler.
</li>
<li>
<span className="font-semibold text-ink">API/webhooks-first:</span>{" "}
designed to fit into existing order systems.
</li>
</ul>
</>
),
},
{
question: "Can I self-host XMR Checkout?",
answer: (
<>
<p className="text-ink-soft">
Yes. XMR Checkout is open source, and you can self-host it from the
official{" "}
<Link
className="underline underline-offset-4"
href="https://github.com/xmrcheckout/xmrcheckout"
>
GitHub repository
</Link>
.
</p>
<p className="text-ink-soft">
Start with{" "}
<Link className="underline underline-offset-4" href="/docs">
Documentation
</Link>{" "}
for setup and integration details.
</p>
</>
),
},
];
function FaqCard({ item }: { item: FaqItem }) {
return (
<details className="group rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft backdrop-blur">
<summary className="cursor-pointer list-none select-none font-serif text-xl text-ink [&::-webkit-details-marker]:hidden">
<span className="inline-flex items-start justify-between gap-4">
<span>{item.question}</span>
<span className="mt-1 text-sm text-ink-soft transition group-open:rotate-45">
+
</span>
</span>
</summary>
<div className="mt-4 grid gap-3 text-sm leading-relaxed">{item.answer}</div>
</details>
);
}
export default function FaqPage() {
return (
<main className="text-ink">
<section className="px-[6vw] pb-20 pt-10">
<div className="grid gap-4">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-monero">
FAQ
</p>
<h1 className="font-serif text-3xl">Direct answers.</h1>
<p className="max-w-[46rem] text-[1.02rem] leading-relaxed text-ink-soft">
XMR Checkout is non-custodial checkout software for accepting Monero
payments. We focus on a conservative trust boundary: view-only
detection and merchant-owned funds.
</p>
</div>
<div className="mt-10 grid gap-4">
{faqItems.map((item) => (
<FaqCard key={item.question} item={item} />
))}
</div>
<div className="mt-12 rounded-3xl border border-stroke bg-card p-8 shadow-card backdrop-blur">
<h2 className="font-serif text-2xl">Still have questions?</h2>
<p className="mt-2 text-ink-soft">
Start with{" "}
<Link className="underline underline-offset-4" href="/docs">
Documentation
</Link>{" "}
for API details and integration guidance.
</p>
</div>
</section>
</main>
);
}
@@ -0,0 +1,333 @@
import type { Metadata } from "next";
import InvoicePaymentDetails from "../../../../components/invoice-payment-details";
import InvoiceStatusAutoRefresh from "../../../../components/invoice-status-auto-refresh";
import InvoiceStatusActions from "../../../../components/invoice-status-actions";
import InvoiceStatusLookup from "../../../../components/invoice-status-lookup";
import { formatRelativeTime } from "../../../../components/relative-time";
import StatusRefreshButton from "../../../../components/status-refresh-button";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Payment Request",
};
const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:8000";
type InvoiceStatus =
| "pending"
| "payment_detected"
| "confirmed"
| "expired"
| "invalid";
type InvoiceStatusResponse = {
id: string;
address: string;
subaddress_index: number | null;
amount_xmr: string;
status: InvoiceStatus;
confirmation_target: number;
confirmations: number;
qr_logo?: "monero" | "none" | "custom" | null;
qr_logo_data_url?: string | null;
created_at: string;
expires_at: string | null;
detected_at: string | null;
confirmed_at: string | null;
};
const formatStatus = (status: InvoiceStatus) => {
if (status === "payment_detected") {
return "Payment detected";
}
if (status === "pending") {
return "Awaiting funds";
}
if (status === "confirmed") {
return "Confirmed";
}
if (status === "invalid") {
return "Invalid";
}
return "Expired";
};
const statusDescription = (
status: InvoiceStatus,
confirmationTarget: number,
confirmations: number
) => {
if (status === "pending") {
return "Waiting for a payment to be detected on-chain.";
}
if (status === "payment_detected") {
return `Payment detected. ${confirmations}/${confirmationTarget} confirmations.`;
}
if (status === "confirmed") {
return `Payment confirmed on-chain at ${confirmationTarget} confirmations.`;
}
if (status === "invalid") {
return "Invoice marked invalid. Do not send payment.";
}
return "Invoice expired before detection. Create a new invoice if needed.";
};
const formatTimestamp = (value: string | null) => {
if (!value) {
return { label: "-", relative: null };
}
return {
label: new Date(value).toLocaleString(),
relative: formatRelativeTime(value),
};
};
const statusPillStyles: Record<InvoiceStatus, string> = {
pending: "bg-amber-100 text-amber-900",
payment_detected: "bg-emerald-100 text-emerald-900",
confirmed: "bg-ink/20 text-ink",
expired: "bg-red-100 text-red-700",
invalid: "bg-clay/15 text-clay",
};
export default async function InvoiceStatusDetailPage({
params,
}: {
params: Promise<{ invoiceId: string }>;
}) {
const { invoiceId } = await params;
const response = await fetch(
`${apiBaseUrl}/api/core/public/invoice/${encodeURIComponent(invoiceId)}`,
{ cache: "no-store" }
);
if (response.status === 404) {
return (
<main className="px-[6vw] pb-20 pt-10 text-ink">
<section className="grid gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-start">
<div className="grid gap-4">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
Invoice status
</p>
<h1 className="font-serif text-[clamp(2.2rem,2rem+1.4vw,3.4rem)] leading-[1.1]">
Invoice not found.
</h1>
<p className="text-[1.05rem] leading-relaxed text-ink-soft">
The invoice id does not match a known invoice. Check the id and
try again.
</p>
</div>
<div className="rounded-2xl border border-stroke bg-card p-7 shadow-card backdrop-blur">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-clay">
Need another lookup?
</p>
<div className="mt-4">
<InvoiceStatusLookup initialValue={invoiceId} compact />
</div>
</div>
</section>
</main>
);
}
if (!response.ok) {
return (
<main className="px-[6vw] pb-20 pt-10 text-ink">
<section className="grid gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-start">
<div className="grid gap-4">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
Invoice status
</p>
<h1 className="font-serif text-[clamp(2.2rem,2rem+1.4vw,3.4rem)] leading-[1.1]">
Status unavailable.
</h1>
<p className="text-[1.05rem] leading-relaxed text-ink-soft">
We could not load this invoice status. Refresh the page or try
again later.
</p>
</div>
<div className="rounded-2xl border border-stroke bg-card p-7 shadow-card backdrop-blur">
<InvoiceStatusLookup initialValue={invoiceId} compact />
</div>
</section>
</main>
);
}
const invoice = (await response.json()) as InvoiceStatusResponse;
const statusLabel = formatStatus(invoice.status);
const confirmations = Math.max(0, invoice.confirmations ?? 0);
const confirmationTarget = Math.max(0, invoice.confirmation_target);
const isInvalid = invoice.status === "invalid";
const hasDetectedPayment =
invoice.status === "payment_detected" || invoice.status === "confirmed";
const timelineItems: {
label: string;
timestamp: ReturnType<typeof formatTimestamp>;
state: "complete";
}[] = [];
timelineItems.push({
label: "Invoice created",
timestamp: formatTimestamp(invoice.created_at),
state: "complete",
});
if (invoice.detected_at) {
timelineItems.push({
label: "Payment detected",
timestamp: formatTimestamp(invoice.detected_at),
state: "complete",
});
}
if (invoice.confirmed_at) {
timelineItems.push({
label: "Payment confirmed",
timestamp: formatTimestamp(invoice.confirmed_at),
state: "complete",
});
}
if (invoice.status === "expired") {
timelineItems.push({
label: "Invoice expired",
timestamp: formatTimestamp(invoice.expires_at),
state: "complete",
});
}
const showConfirmedStamp = invoice.status === "confirmed";
const createdTimestamp = formatTimestamp(invoice.created_at);
const expiresTimestamp = formatTimestamp(invoice.expires_at);
return (
<main className="px-[6vw] pb-20 pt-10 text-ink">
<section className="grid gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-start">
<InvoiceStatusAutoRefresh intervalMs={30000} />
<div className="grid gap-4">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
Payment request
</p>
<h1 className="font-serif text-[clamp(2.2rem,2rem+1.4vw,3.4rem)] leading-[1.1]">
Payment request
</h1>
<p className="text-[1.05rem] leading-relaxed text-ink-soft">
{isInvalid
? "This invoice is marked invalid. Do not pay."
: "Send the exact amount shown below to pay this invoice."}
</p>
<p className="text-[1.05rem] leading-relaxed text-ink-soft">
{statusDescription(invoice.status, confirmationTarget, confirmations)}
</p>
<details className="rounded-xl border border-stroke bg-white/60 px-4 py-3 text-sm text-ink-soft shadow-soft">
<summary className="cursor-pointer select-none text-xs font-semibold uppercase tracking-[0.18em] text-ink">
Details
</summary>
<div className="mt-3 grid gap-2">
<p className="font-mono text-xs text-ink">Invoice ID: {invoiceId}</p>
<p>
This status page is public. Anyone with the link can view the current
invoice state.
</p>
</div>
</details>
</div>
<div className="relative overflow-hidden rounded-2xl border border-stroke bg-card p-7 shadow-card backdrop-blur">
{showConfirmedStamp ? (
<div
className="pointer-events-none absolute left-1/2 top-4 z-10 grid h-20 w-20 -translate-x-1/2 -rotate-[12deg] place-items-center rounded-full border-2 border-emerald-700 bg-cream/80 text-center text-[0.55rem] font-bold uppercase tracking-[0.2em] text-emerald-800 shadow-[0_10px_20px_rgba(16,18,23,0.18)]"
aria-hidden="true"
>
<span className="leading-tight">Payment confirmed</span>
</div>
) : null}
<div className="flex items-center justify-between gap-3">
<span className="rounded-full bg-ink/10 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink">
Status
</span>
<div className="flex items-center gap-2">
<span
className={`rounded-full px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] ${statusPillStyles[invoice.status]}`}
>
{statusLabel}
</span>
<StatusRefreshButton label="Refresh" />
</div>
</div>
<div className="mt-6 grid gap-4 sm:grid-cols-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Confirmations
</p>
<p className="mt-1 text-lg font-semibold">
{confirmations}/{confirmationTarget}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Created
</p>
<p
className="mt-1 text-sm font-semibold"
title={createdTimestamp.relative ?? undefined}
>
{createdTimestamp.label}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Expires
</p>
<p
className="mt-1 text-sm font-semibold"
title={expiresTimestamp.relative ?? undefined}
>
{expiresTimestamp.label}
</p>
</div>
</div>
</div>
</section>
<InvoicePaymentDetails
address={invoice.address}
amount={invoice.amount_xmr}
hasDetectedPayment={hasDetectedPayment}
status={invoice.status}
confirmationTarget={confirmationTarget}
qrLogoMode={invoice.qr_logo ?? "monero"}
qrLogoDataUrl={invoice.qr_logo_data_url ?? null}
/>
<section className="mt-8 rounded-2xl border border-stroke bg-card p-7 shadow-card backdrop-blur">
<h2 className="font-serif text-2xl">Timeline</h2>
<ol className="mt-5 grid gap-3">
{[...timelineItems].reverse().map((item) => (
<li
className="flex items-center justify-between gap-6 rounded-xl border border-ink/20 bg-white/70 px-4 py-3"
key={item.label}
>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
{item.label}
</p>
<p
className="mt-1 text-sm font-semibold"
title={item.timestamp.relative ?? undefined}
>
{item.timestamp.label}
</p>
</div>
</li>
))}
</ol>
</section>
<section className="mt-6 grid gap-4">
<InvoiceStatusActions />
</section>
</main>
);
}
+84
View File
@@ -0,0 +1,84 @@
import type { Metadata } from "next";
import Link from "next/link";
import InvoiceStatusLookup from "../../../components/invoice-status-lookup";
export const metadata: Metadata = {
title: "Invoice Status",
};
export default function InvoiceStatusPage() {
return (
<main className="px-[6vw] pb-20 pt-10 text-ink">
<section className="grid gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-start">
<div className="grid gap-6">
<div className="grid gap-4">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
Invoice status
</p>
<h1 className="font-serif text-[clamp(2.2rem,2rem+1.4vw,3.4rem)] leading-[1.1]">
Check a Monero invoice status.
</h1>
<p className="text-[1.05rem] leading-relaxed text-ink-soft">
Paste the invoice id to see detection and confirmation state. This
view is read-only and never touches funds.
</p>
</div>
<div className="rounded-2xl border border-stroke bg-card p-7 shadow-card backdrop-blur">
<InvoiceStatusLookup />
</div>
</div>
<div className="grid gap-6">
<div className="rounded-2xl border border-stroke bg-card p-7 shadow-card backdrop-blur">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-clay">
View-only detection
</p>
<h2 className="mt-2 font-serif text-2xl">No sign-in required.</h2>
<p className="mt-2 text-ink-soft">
Anyone with the invoice id can view its status. Share this page with
customers who need a quick update.
</p>
</div>
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft backdrop-blur">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-clay">
Example status
</p>
<h2 className="mt-2 font-serif text-2xl">What you will see.</h2>
<div className="mt-4 grid gap-3 text-sm text-ink-soft">
<div className="flex items-center gap-3">
<span className="timeline-dot is-active h-2.5 w-2.5 rounded-full bg-monero"></span>
<span className="font-semibold text-ink">Detected</span>
<span>Seen on the network</span>
</div>
<div className="flex items-center gap-3">
<span className="h-2.5 w-2.5 rounded-full bg-monero/60"></span>
<span className="font-semibold text-ink">Confirming</span>
<span>Waiting on confirmations</span>
</div>
<div className="flex items-center gap-3">
<span className="h-2.5 w-2.5 rounded-full bg-ink/20"></span>
<span className="font-semibold text-ink">Confirmed</span>
<span>Target reached</span>
</div>
</div>
<p className="mt-4 text-sm text-ink-soft">
This view is read-only and never touches funds.
</p>
</div>
</div>
</section>
<section className="mt-8 grid gap-4">
<p className="text-ink-soft">
Funds always move from the customer to the merchant wallet.
</p>
<Link
className="inline-flex w-fit items-center justify-center rounded-full border border-stroke bg-white/40 px-5 py-2.5 text-sm font-semibold text-ink transition hover:-translate-y-0.5"
href="/docs"
>
View API docs
</Link>
</section>
</main>
);
}
+88
View File
@@ -0,0 +1,88 @@
import Image from "next/image";
import Link from "next/link";
import { Suspense } from "react";
import { cookies } from "next/headers";
import "./marketing.css";
import DonateModal from "../../components/donate-modal";
import LoginModal, { LoginTrigger } from "../../components/login-modal";
import { logoutAction } from "../(app)/dashboard/actions";
export default async function MarketingLayout({
children,
}: {
children: React.ReactNode;
}) {
const cookieStore = await cookies();
const apiKey = cookieStore.get("xmrcheckout_api_key")?.value;
const isAuthenticated = Boolean(apiKey);
return (
<div>
<div className="ambient">
<span className="orb orb-a"></span>
<span className="orb orb-b"></span>
<span className="ambient-grid"></span>
</div>
<header className="site-header">
<div className="brand">
<Link href="/" aria-label="XMR Checkout home">
<Image
className="brand-logo"
src="/logo.png"
alt="XMR Checkout logo"
width={128}
height={128}
priority
/>
</Link>
<Link className="name" href="/">
XMR Checkout
</Link>
</div>
<nav className="nav">
<Link href="/invoice">Check Invoice</Link>
<Link href="/docs">Documentation</Link>
<Link href="/faq">FAQ</Link>
{isAuthenticated ? (
<>
<Link href="/dashboard">Dashboard</Link>
<form action={logoutAction}>
<button type="submit">Log out</button>
</form>
</>
) : (
<LoginTrigger className="nav-primary">Log in</LoginTrigger>
)}
</nav>
</header>
{children}
<Suspense fallback={null}>
<LoginModal />
</Suspense>
<footer className="site-footer">
<div className="site-footer-copy">
<p>
xmrcheckout.com - Non-custodial Monero checkout software. Zero
service fees.
</p>
<p>
<Link
className="underline underline-offset-4"
href="https://github.com/xmrcheckout/xmrcheckout"
>
GitHub
</Link>
</p>
</div>
<Suspense fallback={null}>
<DonateModal />
</Suspense>
</footer>
</div>
);
}
File diff suppressed because it is too large Load Diff
+270
View File
@@ -0,0 +1,270 @@
import type { Metadata } from "next";
import Link from "next/link";
import { LoginTrigger } from "../../components/login-modal";
export const metadata: Metadata = {
title: "Home",
};
export default function MarketingHomePage() {
return (
<main className="text-ink">
<section className="relative overflow-hidden px-[6vw] pb-16 pt-10 sm:pb-20">
<div className="grid items-center gap-12 lg:grid-cols-[1.1fr_0.9fr]">
<div className="grid max-w-[38rem] gap-6 animate-[rise_0.9s_ease_both]">
<h1 className="font-serif text-[clamp(2.6rem,2.2rem+2.3vw,4.3rem)] leading-[1.02]">
Hosted Monero checkout that keeps custody and personal data with you.
</h1>
<p className="text-[1.08rem] leading-relaxed text-ink-soft">
Create invoices, detect on-chain payments with view-only access,
and relay status to your systems - all without ever holding or
moving funds.
</p>
<ul className="grid gap-3 text-sm text-ink-soft">
<li className="flex items-start gap-3">
<span className="mt-1.5 h-2 w-2 rounded-full bg-monero"></span>
<span>
View-only detection using your primary address and secret view key.
</span>
</li>
<li className="flex items-start gap-3">
<span className="mt-1.5 h-2 w-2 rounded-full bg-monero"></span>
<span>
No identifying information required - your primary address is the identifier.
</span>
</li>
<li className="flex items-start gap-3">
<span className="mt-1.5 h-2 w-2 rounded-full bg-monero"></span>
<span>BTCPay Server compatibility for existing integrations.</span>
</li>
</ul>
<p className="inline-flex w-fit items-center rounded-full bg-monero px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-ink shadow-[0_12px_24px_rgba(242,104,34,0.25)]">
Non-custodial · view-only access · merchant-owned funds
</p>
<p className="text-sm font-semibold text-ink-soft">
Zero service fees. We are not a payment processor, we are a payment enabler.
</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Link
className="inline-flex w-full items-center justify-center rounded-full bg-ink px-8 py-4 text-base font-semibold text-cream shadow-[0_18px_34px_rgba(16,18,23,0.2)] transition hover:-translate-y-0.5 sm:w-auto"
href="/login"
>
Start accepting Monero
</Link>
<Link
className="inline-flex w-full items-center justify-center rounded-full border border-stroke bg-white/60 px-6 py-3 text-sm font-semibold text-ink transition hover:-translate-y-0.5 sm:w-auto"
href="/docs"
>
View documentation
</Link>
</div>
</div>
<div className="relative grid gap-4">
<div className="absolute -left-6 top-4 hidden h-[calc(100%-2rem)] w-px bg-gradient-to-b from-monero/0 via-monero/60 to-monero/0 lg:block"></div>
<div className="rounded-3xl border border-stroke bg-card p-7 shadow-card backdrop-blur animate-[rise_1s_ease_both] [animation-delay:150ms]">
<div className="flex items-center justify-between">
<span className="rounded-full bg-monero/15 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-monero">
Detection
</span>
<span className="text-sm font-semibold text-sage">
View-only
</span>
</div>
<div className="mt-6 grid gap-4">
<p className="text-2xl font-semibold">
Detection timeline
</p>
<div className="relative grid gap-3 pl-6 text-sm text-ink-soft">
<span className="timeline-rail" aria-hidden="true"></span>
<span className="timeline-scan" aria-hidden="true"></span>
<div className="flex items-center gap-3">
<span className="timeline-dot is-active h-2.5 w-2.5 rounded-full bg-monero"></span>
<span className="font-semibold text-ink">
Detected
</span>
<span className="text-ink-soft">Seen on the network (unconfirmed)</span>
</div>
<div className="flex items-center gap-3">
<span className="h-2.5 w-2.5 rounded-full bg-monero/60"></span>
<span className="font-semibold text-ink">
Confirming
</span>
<span className="text-ink-soft">
Waiting for confirmations
</span>
</div>
<div className="flex items-center gap-3">
<span className="h-2.5 w-2.5 rounded-full bg-ink/20"></span>
<span className="font-semibold text-ink">
Confirmed
</span>
<span className="text-ink-soft">Target reached</span>
</div>
</div>
<p className="text-sm text-ink-soft">
We observe confirmations without touching funds or keys.
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-2 sm:gap-4">
<div className="rounded-2xl border border-stroke bg-white/70 p-4 shadow-soft backdrop-blur animate-[rise_0.9s_ease_both] [animation-delay:260ms] sm:p-5">
<p className="text-[0.7rem] uppercase tracking-[0.18em] text-ink-soft sm:text-[0.75rem]">
View-only
</p>
<h3 className="mt-2 font-serif text-base sm:text-xl">
Access ends at detection.
</h3>
<p className="mt-2 text-xs text-ink-soft sm:text-sm">
We never request spend keys or signing access.
</p>
</div>
<div className="rounded-2xl border border-stroke bg-white/70 p-4 shadow-soft backdrop-blur animate-[rise_0.9s_ease_both] [animation-delay:320ms] sm:p-5">
<p className="text-[0.7rem] uppercase tracking-[0.18em] text-ink-soft sm:text-[0.75rem]">
Relay
</p>
<h3 className="mt-2 font-serif text-base sm:text-xl">
Status updates in real time.
</h3>
<p className="mt-2 text-xs text-ink-soft sm:text-sm">
Keep your stack updated without handing over control.
</p>
</div>
</div>
</div>
</div>
</section>
<section id="how" className="px-[6vw] py-16">
<div className="mb-10 grid gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-monero">
How it works
</p>
<h2 className="font-serif text-3xl">
A simple, non-custodial flow.
</h2>
<p className="text-ink-soft">
Four steps. View-only access. No custody.
</p>
</div>
<div className="grid gap-7 sm:grid-cols-2 lg:grid-cols-4">
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft backdrop-blur animate-[rise_0.9s_ease_both]">
<span className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-full bg-monero/15 text-sm font-semibold text-monero">
01
</span>
<h3 className="mb-2 font-serif text-xl">
Log in with view-only access
</h3>
<p className="text-ink-soft">
Use your primary address and secret view key to sign in.
</p>
</div>
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft backdrop-blur animate-[rise_0.9s_ease_both] [animation-delay:100ms]">
<span className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-full bg-monero/15 text-sm font-semibold text-monero">
02
</span>
<h3 className="mb-2 font-serif text-xl">Create an invoice</h3>
<p className="text-ink-soft">
A subaddress for each invoice is generated automatically.
</p>
</div>
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft backdrop-blur animate-[rise_0.9s_ease_both] [animation-delay:200ms]">
<span className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-full bg-monero/15 text-sm font-semibold text-monero">
03
</span>
<h3 className="mb-2 font-serif text-xl">Await payment</h3>
<p className="text-ink-soft">
A view-only wallet scans the blockchain for incoming funds.
</p>
</div>
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft backdrop-blur animate-[rise_0.9s_ease_both] [animation-delay:300ms]">
<span className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-full bg-monero/15 text-sm font-semibold text-monero">
04
</span>
<h3 className="mb-2 font-serif text-xl">Relay status</h3>
<p className="text-ink-soft">
Webhook events deliver status updates to your systems.
</p>
</div>
</div>
</section>
<section id="trust" className="px-[6vw] py-16">
<div className="mb-10 grid gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-monero">
Trust &amp; security
</p>
<h2 className="font-serif text-3xl">
Built for clear trust boundaries.
</h2>
</div>
<div className="grid gap-7 lg:grid-cols-3">
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft backdrop-blur animate-[rise_0.9s_ease_both] lg:col-span-2">
<div className="grid gap-6 sm:grid-cols-2">
<div>
<h3 className="mb-2 font-serif text-xl">What we do</h3>
<ul className="grid gap-2 text-sm text-ink-soft">
<li>Detect on-chain payments with view-only keys.</li>
<li>Create invoices and subaddresses automatically.</li>
<li>Relay status via API and webhooks.</li>
</ul>
</div>
<div>
<h3 className="mb-2 font-serif text-xl">What we never do</h3>
<ul className="grid gap-2 text-sm text-ink-soft">
<li>Never request spend keys or signing access.</li>
<li>Never move, pool, or intermediate funds.</li>
<li>Never touch bank accounts or fiat rails.</li>
</ul>
</div>
</div>
</div>
<div className="grid gap-7">
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft backdrop-blur animate-[rise_0.9s_ease_both] [animation-delay:100ms]">
<h3 className="mb-2 font-serif text-xl">View-only boundary</h3>
<p className="text-ink-soft">
View-only access is the only permission we request, and it stops at
detection.
</p>
</div>
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft backdrop-blur animate-[rise_0.9s_ease_both] [animation-delay:200ms]">
<h3 className="mb-2 font-serif text-xl">Safe on failure</h3>
<p className="text-ink-soft">
If the service is unavailable, funds remain safe and payments stay valid on-chain.
</p>
</div>
</div>
</div>
</section>
<section id="cta" className="px-[6vw] pb-24 pt-10">
<div className="relative grid gap-5 overflow-hidden rounded-3xl bg-ink p-10 text-cream shadow-deep">
<div className="pointer-events-none absolute inset-0">
<div className="absolute -right-10 -top-10 h-48 w-48 rounded-full bg-[radial-gradient(circle,rgba(242,104,34,0.5),transparent_70%)] blur-2xl"></div>
<div className="absolute bottom-[-120px] left-[-40px] h-56 w-56 rounded-full bg-[radial-gradient(circle,rgba(93,122,106,0.4),transparent_70%)] blur-2xl"></div>
</div>
<h2 className="font-serif text-3xl">
Start accepting Monero without custody.
</h2>
<p className="text-cream/80">
Use the hosted checkout. Create invoices, query status. Control
stays with you.
</p>
<div className="flex flex-wrap gap-3">
<LoginTrigger
className="inline-flex items-center justify-center rounded-full border border-cream/40 px-6 py-3 text-sm font-semibold text-cream transition hover:-translate-y-0.5"
>
Log in
</LoginTrigger>
<Link
className="inline-flex items-center justify-center rounded-full border border-cream/40 px-6 py-3 text-sm font-semibold text-cream transition hover:-translate-y-0.5"
href="/docs"
>
Read the docs
</Link>
</div>
</div>
</section>
</main>
);
}
+22
View File
@@ -0,0 +1,22 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
}
img {
max-width: 100%;
display: block;
}
+254
View File
@@ -0,0 +1,254 @@
import type { Metadata } from "next";
import BtcpayModalBridge from "../../../components/btcpay-modal-bridge";
import BtcpayModalCloseButton from "../../../components/btcpay-modal-close-button";
import BtcpayClassicCheckout from "../../../components/btcpay-classic-checkout";
import InvoicePaymentDetails from "../../../components/invoice-payment-details";
import InvoiceStatusAutoRefresh from "../../../components/invoice-status-auto-refresh";
import { formatRelativeTime } from "../../../components/relative-time";
import StatusRefreshButton from "../../../components/status-refresh-button";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Payment Request",
};
const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:8000";
type InvoiceStatus =
| "pending"
| "payment_detected"
| "confirmed"
| "expired"
| "invalid";
type InvoiceStatusResponse = {
id: string;
address: string;
subaddress_index: number | null;
amount_xmr: string;
status: InvoiceStatus;
confirmation_target: number;
confirmations: number;
created_at: string;
expires_at: string | null;
detected_at: string | null;
confirmed_at: string | null;
btcpay_amount?: string | null;
btcpay_currency?: string | null;
btcpay_checkout_style?: "standard" | "btcpay_classic" | null;
qr_logo?: "monero" | "none" | "custom" | null;
qr_logo_data_url?: string | null;
quote?: {
fiat_amount: string;
fiat_currency: string;
rate: string;
source: string;
quoted_at: string;
} | null;
};
const formatStatus = (status: InvoiceStatus) => {
if (status === "payment_detected") {
return "Payment detected";
}
if (status === "pending") {
return "Awaiting funds";
}
if (status === "confirmed") {
return "Confirmed";
}
if (status === "invalid") {
return "Invalid";
}
return "Expired";
};
const formatTimestamp = (value: string | null) => {
if (!value) {
return { label: "-", relative: null };
}
return {
label: new Date(value).toLocaleString(),
relative: formatRelativeTime(value),
};
};
const statusPillStyles: Record<InvoiceStatus, string> = {
pending: "bg-amber-100 text-amber-900",
payment_detected: "bg-emerald-100 text-emerald-900",
confirmed: "bg-ink/20 text-ink",
expired: "bg-red-100 text-red-700",
invalid: "bg-clay/15 text-clay",
};
export default async function BtcpayModalInvoicePage({
params,
}: {
params: Promise<{ invoiceId: string }>;
}) {
const { invoiceId } = await params;
const response = await fetch(
`${apiBaseUrl}/api/core/public/invoice/${encodeURIComponent(invoiceId)}`,
{ cache: "no-store" }
);
if (response.status === 404) {
return (
<main className="min-h-screen bg-cream px-6 py-6 text-ink">
<BtcpayModalBridge invoiceId={invoiceId} status="invalid" />
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
Payment request
</p>
<h1 className="mt-2 font-serif text-2xl">Invoice not found</h1>
</div>
<BtcpayModalCloseButton />
</div>
<p className="mt-3 text-sm text-ink-soft">
The invoice id does not match a known invoice. Check the id and try
again.
</p>
</main>
);
}
if (!response.ok) {
return (
<main className="min-h-screen bg-cream px-6 py-6 text-ink">
<BtcpayModalBridge invoiceId={invoiceId} status="invalid" />
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
Payment request
</p>
<h1 className="mt-2 font-serif text-2xl">Status unavailable</h1>
</div>
<BtcpayModalCloseButton />
</div>
<p className="mt-3 text-sm text-ink-soft">
We could not load this invoice status. Refresh the page or try again
later.
</p>
</main>
);
}
const invoice = (await response.json()) as InvoiceStatusResponse;
const statusLabel = formatStatus(invoice.status);
const confirmations = Math.max(0, invoice.confirmations ?? 0);
const confirmationTarget = Math.max(0, invoice.confirmation_target);
const hasDetectedPayment =
invoice.status === "payment_detected" || invoice.status === "confirmed";
const createdTimestamp = formatTimestamp(invoice.created_at);
const expiresTimestamp = formatTimestamp(invoice.expires_at);
const isBtcpayInvoice = Boolean(invoice.btcpay_amount && invoice.btcpay_currency);
const useClassicCheckout =
isBtcpayInvoice && invoice.btcpay_checkout_style === "btcpay_classic";
return (
<main className="min-h-screen bg-cream px-6 py-6 text-ink">
<InvoiceStatusAutoRefresh intervalMs={30000} />
<BtcpayModalBridge invoiceId={invoiceId} status={invoice.status} />
{useClassicCheckout ? (
<div className="mx-auto grid max-w-md gap-5">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
Payment request
</p>
</div>
<div className="flex items-center gap-2">
<StatusRefreshButton label="Refresh" className="text-xs" />
<BtcpayModalCloseButton />
</div>
</div>
<BtcpayClassicCheckout
address={invoice.address}
amountXmr={invoice.amount_xmr}
btcpayAmount={invoice.btcpay_amount ?? null}
btcpayCurrency={invoice.btcpay_currency ?? null}
quote={invoice.quote ?? null}
status={invoice.status}
confirmationTarget={confirmationTarget}
qrLogoMode={invoice.qr_logo ?? "monero"}
qrLogoDataUrl={invoice.qr_logo_data_url ?? null}
/>
</div>
) : (
<>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
Payment request
</p>
<h1 className="mt-2 font-serif text-2xl">Payment request</h1>
</div>
<div className="flex items-center gap-2">
<StatusRefreshButton label="Refresh" className="text-xs" />
<BtcpayModalCloseButton />
</div>
</div>
<div className="mt-4 rounded-2xl border border-stroke bg-card p-5 shadow-card backdrop-blur">
<div className="flex items-center justify-between gap-3">
<span className="rounded-full bg-ink/10 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink">
Status
</span>
<span
className={`rounded-full px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] ${statusPillStyles[invoice.status]}`}
>
{statusLabel}
</span>
</div>
<div className="mt-4 grid gap-4 sm:grid-cols-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Confirmations
</p>
<p className="mt-1 text-lg font-semibold">
{confirmations}/{confirmationTarget}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Created
</p>
<p
className="mt-1 text-sm font-semibold"
title={createdTimestamp.relative ?? undefined}
>
{createdTimestamp.label}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Expires
</p>
<p
className="mt-1 text-sm font-semibold"
title={expiresTimestamp.relative ?? undefined}
>
{expiresTimestamp.label}
</p>
</div>
</div>
</div>
<div className="mt-5">
<InvoicePaymentDetails
address={invoice.address}
amount={invoice.amount_xmr}
hasDetectedPayment={hasDetectedPayment}
status={invoice.status}
confirmationTarget={confirmationTarget}
qrLogoMode={invoice.qr_logo ?? "monero"}
qrLogoDataUrl={invoice.qr_logo_data_url ?? null}
/>
</div>
</>
)}
</main>
);
}
+35
View File
@@ -0,0 +1,35 @@
import type { Metadata } from "next";
import { Crimson_Pro, Space_Grotesk } from "next/font/google";
import "./globals.css";
const spaceGrotesk = Space_Grotesk({
subsets: ["latin"],
display: "swap",
variable: "--font-sans",
});
const crimsonPro = Crimson_Pro({
subsets: ["latin"],
display: "swap",
variable: "--font-serif",
});
export const metadata: Metadata = {
title: {
default: "XMR Checkout | Home",
template: "XMR Checkout | %s",
},
description: "Non-custodial Monero checkout with view-only detection.",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={`${spaceGrotesk.variable} ${crimsonPro.variable}`}>
<body>{children}</body>
</html>
);
}
+18
View File
@@ -0,0 +1,18 @@
import type { MetadataRoute } from "next";
const siteUrl =
process.env.NEXT_PUBLIC_SITE_URL ??
process.env.SITE_URL ??
"http://localhost";
const normalizedSiteUrl = siteUrl.replace(/\/+$/, "");
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/dashboard", "/login", "/api", "/_next", "/invoice/", "/donate/"],
},
sitemap: `${normalizedSiteUrl}/sitemap.xml`,
};
}
+41
View File
@@ -0,0 +1,41 @@
import type { MetadataRoute } from "next";
const siteUrl =
process.env.NEXT_PUBLIC_SITE_URL ??
process.env.SITE_URL ??
"http://localhost";
const normalizedSiteUrl = siteUrl.replace(/\/+$/, "");
const donationsEnabled = process.env.NEXT_PUBLIC_DONATIONS_ENABLED === "true";
export default function sitemap(): MetadataRoute.Sitemap {
const lastModified = new Date();
const entries: MetadataRoute.Sitemap = [
{
url: `${normalizedSiteUrl}/`,
lastModified,
},
{
url: `${normalizedSiteUrl}/docs`,
lastModified,
},
{
url: `${normalizedSiteUrl}/docs/integrations`,
lastModified,
},
{
url: `${normalizedSiteUrl}/faq`,
lastModified,
},
{
url: `${normalizedSiteUrl}/invoice`,
lastModified,
},
];
if (donationsEnabled) {
entries.push({
url: `${normalizedSiteUrl}/donate`,
lastModified,
});
}
return entries;
}
+43
View File
@@ -0,0 +1,43 @@
"use client";
import { useState } from "react";
type ApiKeyCardProps = {
apiKey: string;
};
export default function ApiKeyCard({ apiKey }: ApiKeyCardProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(apiKey);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch {
setCopied(false);
}
};
return (
<div className="grid gap-4 rounded-2xl border border-stroke bg-white/80 p-6 shadow-soft backdrop-blur">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Your API key
</p>
<code className="mt-2 block break-all rounded-xl bg-ink/10 px-3 py-2 text-sm text-ink">
{apiKey}
</code>
</div>
<div className="flex justify-end">
<button
className="inline-flex items-center justify-center rounded-full bg-ink px-5 py-2.5 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
type="button"
onClick={handleCopy}
>
{copied ? "Copied" : "Copy API key"}
</button>
</div>
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useFormState } from "react-dom";
import { resetApiKeyAction, type ApiKeyState } from "../app/(app)/dashboard/actions";
import ApiKeyCard from "./api-key-card";
const initialState = (apiKey: string): ApiKeyState => ({
apiKey,
error: null,
});
type ApiKeySectionProps = {
apiKey: string;
};
export default function ApiKeySection({ apiKey }: ApiKeySectionProps) {
const [state, formAction] = useFormState(resetApiKeyAction, initialState(apiKey));
return (
<div className="grid gap-4">
<ApiKeyCard apiKey={state.apiKey} />
{state.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{state.error}
</p>
) : null}
<form action={formAction} className="flex justify-end">
<button
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white/60 px-5 py-2.5 text-sm font-semibold text-ink transition hover:-translate-y-0.5"
type="submit"
>
Reset API key
</button>
</form>
</div>
);
}
@@ -0,0 +1,84 @@
"use client";
import { useEffect, useState } from "react";
import { useFormState } from "react-dom";
import {
updateBtcpayCheckoutStyleAction,
type BtcpayCheckoutPreferenceState,
} from "../app/(app)/dashboard/actions";
type BtcpayCheckoutStyle = "standard" | "btcpay_classic";
type BtcpayCheckoutStyleSectionProps = {
initialStyle: BtcpayCheckoutStyle;
};
const initialState = (
style: BtcpayCheckoutStyle
): BtcpayCheckoutPreferenceState => ({
style,
error: null,
success: null,
});
export default function BtcpayCheckoutStyleSection({
initialStyle,
}: BtcpayCheckoutStyleSectionProps) {
const [state, formAction] = useFormState(
updateBtcpayCheckoutStyleAction,
initialState(initialStyle)
);
const [style, setStyle] = useState<BtcpayCheckoutStyle>(state.style);
useEffect(() => {
setStyle(state.style);
}, [state.style]);
return (
<form action={formAction} className="grid gap-4">
<div>
<h3 className="font-serif text-2xl">BTCPay checkout layout</h3>
<p className="mt-2 text-sm text-ink-soft">
Choose the hosted invoice layout shown to payers when you use the BTCPay
compatibility endpoints.
</p>
</div>
<label className="grid gap-2 text-sm font-semibold text-ink">
Layout
<select
name="btcpay_checkout_style"
className="rounded-xl border border-stroke bg-white/80 px-3 py-2 text-sm text-ink"
value={style}
onChange={(event) =>
setStyle(event.target.value as BtcpayCheckoutStyle)
}
>
<option value="standard">Standard XMR Checkout</option>
<option value="btcpay_classic">BTCPay classic</option>
</select>
</label>
<p className="text-xs text-ink-soft">
The classic layout mirrors the BTCPay checkout screen for drop-in migration.
</p>
{state.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{state.error}
</p>
) : null}
{state.success ? (
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">
{state.success}
</p>
) : null}
<div className="flex justify-end">
<button
className="inline-flex items-center justify-center rounded-full border border-stroke bg-ink px-5 py-2.5 text-sm font-semibold text-cream transition hover:-translate-y-0.5"
type="submit"
>
Save preference
</button>
</div>
</form>
);
}
+265
View File
@@ -0,0 +1,265 @@
"use client";
import Image from "next/image";
import { useEffect, useMemo, useState } from "react";
import QRCode from "qrcode";
import { formatXmrAmount } from "../lib/formatting";
type InvoiceStatus =
| "pending"
| "payment_detected"
| "confirmed"
| "expired"
| "invalid";
type QuotePayload = {
fiat_amount: string;
fiat_currency: string;
rate: string;
source: string;
quoted_at: string;
};
type BtcpayClassicCheckoutProps = {
address: string;
amountXmr: string;
btcpayAmount: string | null;
btcpayCurrency: string | null;
quote: QuotePayload | null;
status: InvoiceStatus;
confirmationTarget: number;
qrLogoMode?: "monero" | "none" | "custom" | null;
qrLogoDataUrl?: string | null;
};
const buildMoneroUri = (address: string, amount: string) => {
const params = new URLSearchParams();
params.set("tx_amount", amount);
return `monero:${address}?${params.toString()}`;
};
const formatFiatAmount = (value: string, currency: string) => {
const amount = Number.parseFloat(value);
if (!Number.isFinite(amount)) {
return `${value} ${currency}`;
}
try {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
maximumFractionDigits: 2,
minimumFractionDigits: 2,
}).format(amount);
} catch {
return `${amount.toFixed(2)} ${currency}`;
}
};
const statusMessage = (
status: InvoiceStatus,
confirmationTarget: number
): string => {
if (status === "invalid") {
return "Invoice marked invalid. Do not send payment.";
}
if (status === "expired") {
return "Invoice expired. Do not send payment.";
}
if (status === "confirmed") {
return `Payment confirmed at ${confirmationTarget} confirmations.`;
}
if (status === "payment_detected") {
return "Payment detected. Awaiting confirmations.";
}
return "Awaiting funds.";
};
export default function BtcpayClassicCheckout({
address,
amountXmr,
btcpayAmount,
btcpayCurrency,
quote,
status,
confirmationTarget,
qrLogoMode,
qrLogoDataUrl,
}: BtcpayClassicCheckoutProps) {
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [copiedField, setCopiedField] = useState<string | null>(null);
const formattedAmount = useMemo(() => formatXmrAmount(amountXmr), [amountXmr]);
const uri = useMemo(
() => buildMoneroUri(address, formattedAmount),
[address, formattedAmount]
);
const totalFiat =
btcpayAmount && btcpayCurrency && btcpayCurrency !== "XMR"
? formatFiatAmount(btcpayAmount, btcpayCurrency)
: null;
const exchangeRate =
quote?.rate && quote.fiat_currency
? `1 XMR = ${formatFiatAmount(quote.rate, quote.fiat_currency)}`
: null;
const handleCopy = async (value: string, field: string) => {
try {
await navigator.clipboard.writeText(value);
setCopiedField(field);
window.setTimeout(() => {
setCopiedField((current) => (current === field ? null : current));
}, 1500);
} catch {
setCopiedField(null);
}
};
useEffect(() => {
let active = true;
const size = 260;
const canvas = document.createElement("canvas");
QRCode.toCanvas(canvas, uri, {
margin: 1,
width: size,
errorCorrectionLevel: "H",
})
.then(() => {
const context = canvas.getContext("2d");
if (!context) {
return null;
}
const mode = qrLogoMode ?? "monero";
const wantsLogo = mode !== "none";
if (wantsLogo) {
const blankSize = Math.round(size * 0.28);
const blankOffset = Math.round((size - blankSize) / 2);
context.fillStyle = "#ffffff";
context.fillRect(blankOffset, blankOffset, blankSize, blankSize);
}
return canvas.toDataURL();
})
.then((url) => {
if (active) {
setQrDataUrl(url ?? null);
}
})
.catch(() => {
if (active) {
setQrDataUrl(null);
}
});
return () => {
active = false;
};
}, [uri, qrLogoMode]);
const isActionDisabled = status === "expired" || status === "invalid";
const resolvedQrLogoMode = qrLogoMode ?? "monero";
const resolvedLogoSrc =
resolvedQrLogoMode === "custom"
? qrLogoDataUrl
: resolvedQrLogoMode === "monero"
? "/monero-logo.svg"
: null;
return (
<div className="rounded-[28px] border border-stroke bg-white px-6 py-8 shadow-card">
<div className="text-center">
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-ink-soft">
Payment request
</p>
<p className="mt-3 text-[1.9rem] font-semibold text-ink">
{formattedAmount} XMR
</p>
<p className="mt-2 text-sm text-sage">{statusMessage(status, confirmationTarget)}</p>
</div>
<details className="group mt-5 rounded-2xl border border-stroke bg-cream/70 px-5 py-4">
<summary className="flex cursor-pointer items-center justify-center gap-2 text-sm font-semibold text-sage [&::-webkit-details-marker]:hidden">
<span className="group-open:hidden">View details</span>
<span className="hidden group-open:inline">Hide details</span>
</summary>
<dl className="mt-4 grid gap-3 text-sm text-ink">
<div className="flex items-center justify-between gap-4">
<dt className="text-ink-soft">Total price</dt>
<dd className="font-semibold">{formattedAmount} XMR</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt className="text-ink-soft">Total fiat</dt>
<dd className="font-semibold">{totalFiat ?? "-"}</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt className="text-ink-soft">Exchange rate</dt>
<dd className="font-semibold">{exchangeRate ?? "-"}</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt className="text-ink-soft">Network cost</dt>
<dd className="font-semibold">Added by wallet</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt className="text-ink-soft">Amount due</dt>
<dd className="font-semibold">{formattedAmount} XMR</dd>
</div>
</dl>
</details>
<div className="mt-6 flex items-center justify-center">
{qrDataUrl ? (
<div className="relative rounded-2xl border border-ink/10 bg-white p-3 shadow-soft">
<Image
className="h-[260px] w-[260px]"
src={qrDataUrl}
alt="Payment request QR"
width={260}
height={260}
unoptimized
/>
{resolvedLogoSrc ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-white shadow-[0_8px_16px_rgba(16,18,23,0.18)]">
<Image
src={resolvedLogoSrc}
alt="QR logo"
width={38}
height={38}
unoptimized={resolvedQrLogoMode === "custom"}
/>
</span>
</div>
) : null}
</div>
) : null}
</div>
<div className="mt-6">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Address
</p>
<button
className="rounded-full border border-stroke bg-white/80 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:bg-white"
type="button"
onClick={() => handleCopy(address, "address")}
>
{copiedField === "address" ? "Copied" : "Copy"}
</button>
</div>
<p className="mt-3 break-all rounded-xl bg-ink/5 px-3 py-2 font-mono text-xs text-ink">
{address}
</p>
</div>
<a
className={`mt-6 inline-flex w-full items-center justify-center rounded-full px-6 py-3 text-sm font-semibold text-cream transition ${
isActionDisabled
? "cursor-not-allowed bg-ink/30"
: "bg-sage shadow-[0_14px_22px_rgba(93,122,106,0.25)] hover:-translate-y-0.5"
}`}
href={isActionDisabled ? undefined : uri}
aria-disabled={isActionDisabled}
>
Pay in wallet
</a>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import { useEffect } from "react";
type InvoiceStatus =
| "pending"
| "payment_detected"
| "confirmed"
| "expired"
| "invalid";
type BtcpayModalBridgeProps = {
invoiceId: string;
status: InvoiceStatus;
};
const statusMap: Record<InvoiceStatus, string> = {
pending: "New",
payment_detected: "Processing",
confirmed: "Settled",
expired: "Expired",
invalid: "Invalid",
};
const postToParent = (payload: unknown) => {
if (typeof window === "undefined" || !window.parent) {
return;
}
window.parent.postMessage(payload, "*");
};
export default function BtcpayModalBridge({
invoiceId,
status,
}: BtcpayModalBridgeProps) {
useEffect(() => {
postToParent("loaded");
}, []);
useEffect(() => {
postToParent({
invoiceId,
status: statusMap[status] ?? "new",
});
}, [invoiceId, status]);
return null;
}
@@ -0,0 +1,20 @@
"use client";
export default function BtcpayModalCloseButton() {
const handleClick = () => {
if (typeof window === "undefined" || !window.parent) {
return;
}
window.parent.postMessage("close", "*");
};
return (
<button
className="rounded-full border border-stroke bg-white/80 px-3 py-1 text-[0.65rem] font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:bg-white"
type="button"
onClick={handleClick}
>
Close
</button>
);
}
+387
View File
@@ -0,0 +1,387 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { useFormState } from "react-dom";
import QRCode from "qrcode";
import {
createInvoiceAction,
type InvoiceState,
} from "../app/(app)/dashboard/actions";
import { formatUsdAmount } from "../lib/formatting";
import { useXmrUsdRate } from "../lib/use-xmr-usd-rate";
const initialState: InvoiceState = {
error: null,
invoiceId: null,
address: null,
amount: null,
recipientName: null,
description: null,
subaddressIndex: null,
warnings: null,
};
const buildMoneroUri = (state: InvoiceState): string | null => {
if (!state.address || !state.amount) {
return null;
}
const params = new URLSearchParams();
params.set("tx_amount", state.amount);
if (state.recipientName) {
params.set("recipient_name", state.recipientName);
}
if (state.description) {
params.set("tx_description", state.description);
}
return `monero:${state.address}?${params.toString()}`;
};
export default function CreateInvoiceCard() {
const [state, formAction] = useFormState(createInvoiceAction, initialState);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [qrColor, setQrColor] = useState("#1b1b1a");
const [qrLogo, setQrLogo] = useState<string | null>(null);
const [origin, setOrigin] = useState<string | null>(null);
const [amountInput, setAmountInput] = useState("");
const { rate: usdRate } = useXmrUsdRate();
const labelClass = "text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft";
const inputClass =
"w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10";
const secondaryButton =
"inline-flex items-center justify-center rounded-full border border-stroke bg-white/60 px-5 py-2.5 text-sm font-semibold text-ink transition hover:-translate-y-0.5";
useEffect(() => {
setOrigin(window.location.origin);
}, []);
const publicInvoiceUrl = useMemo(() => {
if (!state.invoiceId) {
return null;
}
if (origin) {
return new URL(`/invoice/${state.invoiceId}`, origin).toString();
}
return `/invoice/${state.invoiceId}`;
}, [origin, state.invoiceId]);
const uri = useMemo(() => buildMoneroUri(state), [state]);
const amountValue = useMemo(() => {
const raw = amountInput || state.amount || "";
const parsed = Number.parseFloat(raw);
return Number.isFinite(parsed) ? parsed : null;
}, [amountInput, state.amount]);
const usdEstimate = useMemo(() => {
if (!usdRate || amountValue === null || amountValue <= 0) {
return null;
}
return formatUsdAmount(usdRate * amountValue);
}, [usdRate, amountValue]);
useEffect(() => {
let active = true;
if (!uri) {
setQrDataUrl(null);
return undefined;
}
QRCode.toDataURL(uri, {
margin: 1,
width: 220,
errorCorrectionLevel: "H",
color: {
dark: qrColor,
light: "#ffffff",
},
})
.then((url) => {
if (active) {
setQrDataUrl(url);
}
})
.catch(() => {
if (active) {
setQrDataUrl(null);
}
});
return () => {
active = false;
};
}, [uri, qrColor]);
const handleLogoChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
setQrLogo(null);
return;
}
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
if (typeof result === "string") {
setQrLogo(result);
}
};
reader.readAsDataURL(file);
};
return (
<div className="rounded-2xl border border-stroke bg-white/80 p-6 shadow-card backdrop-blur">
<h3 className="font-serif text-xl">Create test invoice</h3>
<p className="mt-2 text-ink-soft">
Generate an invoice address and draft invoice for validation.
</p>
<form className="mt-6 grid gap-4" action={formAction}>
<div className="grid gap-2">
<label className={labelClass} htmlFor="payment_address">
Primary address
</label>
<input
className={inputClass}
id="payment_address"
name="payment_address"
type="text"
placeholder="Enter your primary address"
required
/>
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="amount_xmr">
Amount (XMR)
</label>
<input
className={inputClass}
id="amount_xmr"
name="amount_xmr"
type="number"
step="0.000001"
min="0"
placeholder="0.10"
required
onChange={(event) => setAmountInput(event.target.value)}
/>
{usdEstimate ? (
<>
<p className="text-sm text-ink-soft">
Approx. USD reference: ~{usdEstimate}
</p>
<details className="w-fit text-xs text-ink-soft">
<summary className="cursor-pointer select-none underline underline-offset-4">
About this estimate
</summary>
<p className="mt-2 max-w-[46ch] leading-relaxed">
Reference only, uses CoinGecko spot rate. Not a quote or guarantee.
</p>
</details>
</>
) : null}
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="confirmation_target">
Confirmation target
</label>
<input
className={inputClass}
id="confirmation_target"
name="confirmation_target"
type="number"
min="0"
max="10"
step="1"
defaultValue={10}
required
/>
<p className="text-sm text-ink-soft">Expiry defaults to 24 hours.</p>
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="expires_date">
Expiry date
</label>
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_160px]">
<input
className={inputClass}
id="expires_date"
name="expires_date"
type="date"
/>
<input
className={inputClass}
id="expires_time"
name="expires_time"
type="time"
/>
</div>
<p className="text-sm text-ink-soft">
Leave blank to keep the default 24-hour expiry.
</p>
</div>
<div className="flex items-center gap-3 rounded-xl border border-stroke bg-white/60 px-4 py-3 text-sm font-semibold text-ink">
<label className="flex items-center gap-3" htmlFor="instant_confirmation">
<input
className="h-4 w-4 rounded border-stroke text-ink focus:ring-ink/20"
id="instant_confirmation"
name="instant_confirmation"
type="checkbox"
value="1"
/>
Instant confirmation (0 confirmations)
</label>
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="recipient_name">
Recipient name
</label>
<input
className={inputClass}
id="recipient_name"
name="recipient_name"
type="text"
placeholder="Your store"
/>
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="description">
Description
</label>
<input
className={inputClass}
id="description"
name="description"
type="text"
placeholder="Order 1042"
/>
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="qr_color">
QR accent color
</label>
<input
className="h-12 w-full rounded-xl border border-stroke bg-white/80 px-3 py-2"
id="qr_color"
name="qr_color"
type="color"
value={qrColor}
onChange={(event) => setQrColor(event.target.value)}
aria-label="QR accent color"
/>
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="qr_logo">
Logo overlay
</label>
<input
className="w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink"
id="qr_logo"
name="qr_logo"
type="file"
accept="image/*"
onChange={handleLogoChange}
/>
<p className="text-sm text-ink-soft">Logo overlays the QR center for branding.</p>
{qrLogo ? (
<button
className={secondaryButton}
type="button"
onClick={() => setQrLogo(null)}
>
Remove logo
</button>
) : null}
</div>
{state.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{state.error}
</p>
) : null}
{state.invoiceId ? (
<div className="grid gap-3 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">
Invoice created
</p>
<code className="break-all rounded-lg bg-ink/10 px-3 py-2 text-sm text-ink">
{state.invoiceId}
</code>
{publicInvoiceUrl ? (
<>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Status link
</p>
<Link
className="break-all text-sm font-semibold text-ink underline"
href={publicInvoiceUrl}
target="_blank"
rel="noreferrer"
>
{publicInvoiceUrl}
</Link>
</>
) : null}
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Address
</p>
<code className="break-all rounded-lg bg-ink/10 px-3 py-2 text-sm text-ink">
{state.address}
</code>
{state.subaddressIndex !== null ? (
<>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Subaddress index
</p>
<code className="rounded-lg bg-ink/10 px-3 py-2 text-sm text-ink">
{state.subaddressIndex}
</code>
</>
) : null}
{state.warnings && state.warnings.length > 0 ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
{state.warnings.map((warning) => (
<p key={warning}>{warning}</p>
))}
</div>
) : null}
{uri ? (
<>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Payment request
</p>
<code className="break-all rounded-lg bg-ink/10 px-3 py-2 text-sm text-ink">
{uri}
</code>
</>
) : null}
{qrDataUrl ? (
<div className="relative mx-auto mt-2 h-[220px] w-[220px] rounded-2xl border border-stroke bg-white p-2">
<Image
className="h-full w-full rounded-xl border border-ink/10 bg-white"
src={qrDataUrl}
alt="Payment request QR"
width={220}
height={220}
unoptimized
/>
{qrLogo ? (
<Image
className="absolute left-1/2 top-1/2 h-[54px] w-[54px] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-white bg-white object-cover"
src={qrLogo}
alt="Logo overlay"
width={54}
height={54}
unoptimized
/>
) : null}
</div>
) : null}
</div>
) : null}
<div className="flex justify-end">
<button className={secondaryButton} type="submit">
Create invoice
</button>
</div>
</form>
</div>
);
}
@@ -0,0 +1,85 @@
"use client";
import { useEffect } from "react";
import { useFormState } from "react-dom";
import { useRouter } from "next/navigation";
import {
updateDefaultConfirmationTargetAction,
type DefaultConfirmationTargetState,
} from "../app/(app)/dashboard/actions";
const buildInitialState = (value: number): DefaultConfirmationTargetState => ({
value,
error: null,
success: null,
});
export default function DefaultConfirmationTargetSection({
initialValue,
}: {
initialValue: number;
}) {
const router = useRouter();
const [state, formAction] = useFormState(
updateDefaultConfirmationTargetAction,
buildInitialState(initialValue)
);
useEffect(() => {
if (state.success) {
router.refresh();
}
}, [router, state.success]);
return (
<div className="grid gap-4">
<div>
<h2 className="font-serif text-2xl">Default confirmations</h2>
<p className="mt-2 text-sm text-ink-soft">
Used as the default confirmation target for new invoices.
</p>
</div>
<form className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto]" action={formAction}>
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
htmlFor="default_confirmation_target"
>
Confirmation target
</label>
<input
className="w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
id="default_confirmation_target"
name="default_confirmation_target"
type="number"
min={0}
max={10}
step={1}
defaultValue={state.value}
required
/>
</div>
<div className="flex items-end justify-end">
<button
className="inline-flex items-center justify-center rounded-full bg-ink px-6 py-3 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
type="submit"
>
Save
</button>
</div>
</form>
{state.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{state.error}
</p>
) : null}
{state.success ? (
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">
{state.success}
</p>
) : null}
</div>
);
}
+179
View File
@@ -0,0 +1,179 @@
"use client";
import Image from "next/image";
import { useEffect, useMemo, useState } from "react";
import { useFormState } from "react-dom";
import { useRouter } from "next/navigation";
import {
updateDefaultQrLogoAction,
type DefaultQrLogoState,
} from "../app/(app)/dashboard/actions";
const buildInitialState = (logo: "monero" | "none" | "custom", logoDataUrl: string | null) => ({
logo,
logoDataUrl,
error: null,
success: null,
});
export default function DefaultQrLogoSection({
initialLogo,
initialLogoDataUrl,
}: {
initialLogo: "monero" | "none" | "custom";
initialLogoDataUrl: string | null;
}) {
const router = useRouter();
const [logo, setLogo] = useState<"monero" | "none" | "custom">(initialLogo);
const [logoDataUrl, setLogoDataUrl] = useState<string | null>(initialLogoDataUrl);
const [state, formAction] = useFormState(
updateDefaultQrLogoAction,
buildInitialState(initialLogo, initialLogoDataUrl)
);
useEffect(() => {
if (state.success) {
router.refresh();
}
}, [router, state.success]);
useEffect(() => {
setLogo(initialLogo);
setLogoDataUrl(initialLogoDataUrl);
}, [initialLogo, initialLogoDataUrl]);
const previewSrc = useMemo(() => {
if (logo === "monero") {
return "/monero-logo.svg";
}
if (logo === "custom") {
return logoDataUrl;
}
return null;
}, [logo, logoDataUrl]);
const handleLogoFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
setLogoDataUrl(null);
return;
}
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
if (typeof result === "string") {
setLogoDataUrl(result);
}
};
reader.readAsDataURL(file);
};
return (
<div className="grid gap-4">
<div>
<h2 className="font-serif text-2xl">Default QR logo</h2>
<p className="mt-2 text-sm text-ink-soft">
Applies to new invoices unless overridden at creation time.
</p>
</div>
<form className="grid gap-4" action={formAction}>
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
htmlFor="default_qr_logo"
>
Logo mode
</label>
<select
className="w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
id="default_qr_logo"
name="default_qr_logo"
value={logo}
onChange={(event) => {
const value = event.target.value;
if (value === "none" || value === "custom" || value === "monero") {
setLogo(value);
}
}}
>
<option value="monero">Monero logo</option>
<option value="none">No logo</option>
<option value="custom">Custom image</option>
</select>
</div>
{logo === "custom" ? (
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
htmlFor="default_qr_logo_file"
>
Custom logo image
</label>
<input
className="w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink"
id="default_qr_logo_file"
type="file"
accept="image/*"
onChange={handleLogoFileChange}
/>
<p className="text-sm text-ink-soft">
Stored as a data URL for use on public invoice pages. Keep it small.
</p>
{logoDataUrl ? (
<button
className="inline-flex w-fit items-center justify-center rounded-full border border-stroke bg-white/60 px-5 py-2.5 text-sm font-semibold text-ink transition hover:-translate-y-0.5"
type="button"
onClick={() => setLogoDataUrl(null)}
>
Remove image
</button>
) : null}
</div>
) : null}
<input
type="hidden"
name="default_qr_logo_data_url"
value={logo === "custom" ? logoDataUrl ?? "" : ""}
/>
{previewSrc ? (
<div className="flex items-center gap-3 rounded-2xl border border-stroke bg-white/70 p-4 shadow-soft">
<span className="inline-flex h-12 w-12 items-center justify-center rounded-full bg-white shadow-[0_6px_12px_rgba(16,18,23,0.18)]">
<Image
src={previewSrc}
alt="QR logo preview"
width={32}
height={32}
unoptimized={logo === "custom"}
/>
</span>
<p className="text-sm text-ink-soft">Preview (used in the QR center).</p>
</div>
) : null}
{state.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{state.error}
</p>
) : null}
{state.success ? (
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">
{state.success}
</p>
) : null}
<div className="flex justify-end">
<button
className="inline-flex items-center justify-center rounded-full bg-ink px-6 py-3 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
type="submit"
>
Save
</button>
</div>
</form>
</div>
);
}
+195
View File
@@ -0,0 +1,195 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useFormState } from "react-dom";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import {
createDonationAction,
type DonationState,
} from "../app/(marketing)/donate/actions";
import { formatUsdAmount } from "../lib/formatting";
import { useXmrUsdRate } from "../lib/use-xmr-usd-rate";
const donateParam = "donate";
const initialState: DonationState = {
error: null,
invoiceId: null,
};
export default function DonateModal() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const donationsEnabled = process.env.NEXT_PUBLIC_DONATIONS_ENABLED === "true";
const isOpen = searchParams.get(donateParam) === "1";
const [state, formAction] = useFormState(createDonationAction, initialState);
const formRef = useRef<HTMLFormElement | null>(null);
const [amountInput, setAmountInput] = useState("");
const { rate: usdRate } = useXmrUsdRate();
const openModal = useCallback(() => {
const params = new URLSearchParams(searchParams.toString());
params.set(donateParam, "1");
const query = params.toString();
router.push(query ? `${pathname}?${query}` : pathname);
}, [pathname, router, searchParams]);
const closeModal = useCallback(() => {
const params = new URLSearchParams(searchParams.toString());
params.delete(donateParam);
const query = params.toString();
router.replace(query ? `${pathname}?${query}` : pathname);
}, [pathname, router, searchParams]);
useEffect(() => {
if (!state.invoiceId) {
return;
}
closeModal();
router.push(`/donate/${state.invoiceId}`);
}, [closeModal, router, state.invoiceId]);
useEffect(() => {
if (!isOpen) {
return;
}
formRef.current?.reset();
setAmountInput("");
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeModal();
}
};
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
window.addEventListener("keydown", handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener("keydown", handleKeyDown);
};
}, [closeModal, isOpen]);
const amountValue = useMemo(() => {
const parsed = Number.parseFloat(amountInput);
return Number.isFinite(parsed) ? parsed : null;
}, [amountInput]);
const usdEstimate = useMemo(() => {
if (!usdRate || amountValue === null || amountValue <= 0) {
return null;
}
return formatUsdAmount(usdRate * amountValue);
}, [usdRate, amountValue]);
if (!donationsEnabled) {
return null;
}
return (
<div className="flex items-center">
<button
className="rounded-full border border-stroke bg-white/40 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.18em] text-clay transition hover:text-ink"
type="button"
onClick={openModal}
>
Donate
</button>
{isOpen ? (
<div
className="fixed inset-0 z-30 grid place-items-center"
role="dialog"
aria-modal="true"
aria-labelledby="donate-title"
>
<div className="absolute inset-0 bg-ink/60 backdrop-blur" onClick={closeModal} />
<div
className="relative w-[min(420px,92vw)] rounded-3xl border border-stroke bg-card p-7 shadow-deep"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
Support xmrcheckout.com
</p>
<h3 id="donate-title" className="mt-2 font-serif text-xl">
Send a quick donation.
</h3>
</div>
<button
className="rounded-full border border-stroke bg-white/70 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-ink transition hover:-translate-y-0.5"
type="button"
onClick={closeModal}
aria-label="Close donation modal"
>
Close
</button>
</div>
<p className="mt-3 text-ink-soft">
Choose an amount and we will open a read-only invoice page.
</p>
<form className="mt-4 grid gap-4" action={formAction} ref={formRef}>
<label
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
htmlFor="donation_amount"
>
Amount (XMR)
</label>
<input
className="w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
id="donation_amount"
name="amount_xmr"
type="number"
step="0.000001"
min="0"
placeholder="0.10"
required
onChange={(event) => setAmountInput(event.target.value)}
/>
<div className="min-h-[2.75rem]">
{usdEstimate ? (
<>
<p className="text-sm text-ink-soft">
Approx. USD reference: ~{usdEstimate}
</p>
<details className="w-fit text-xs text-ink-soft">
<summary className="cursor-pointer select-none underline underline-offset-4">
About this estimate
</summary>
<p className="mt-2 max-w-[46ch] leading-relaxed">
Reference only, uses CoinGecko spot rate. Not a quote or guarantee.
</p>
</details>
</>
) : null}
</div>
{state.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{state.error}
</p>
) : null}
<div className="flex flex-wrap justify-end gap-3">
<button
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white/60 px-5 py-2.5 text-sm font-semibold text-ink transition hover:-translate-y-0.5"
type="button"
onClick={closeModal}
>
Cancel
</button>
<button
className="inline-flex items-center justify-center rounded-full bg-ink px-5 py-2.5 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
type="submit"
>
Donate
</button>
</div>
</form>
<p className="mt-4 text-sm text-ink-soft">
Payments go directly to the founder wallet. This checkout never
moves funds.
</p>
</div>
</div>
) : null}
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
"use client";
import Link from "next/link";
export default function DonationStatusActions() {
return (
<div className="flex flex-wrap gap-3">
<Link
className="inline-flex items-center justify-center rounded-full bg-ink px-5 py-2.5 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
href="/"
>
Back to home
</Link>
</div>
);
}
+935
View File
@@ -0,0 +1,935 @@
"use client";
import Link from "next/link";
import { Fragment, useEffect, useMemo, useState } from "react";
import { useFormState } from "react-dom";
import { useRouter } from "next/navigation";
import { formatRelativeTime } from "./relative-time";
import { formatUsdAmount, formatXmrAmount } from "../lib/formatting";
import { useXmrUsdRate } from "../lib/use-xmr-usd-rate";
import {
createInvoiceAction,
archiveInvoiceAction,
type ArchiveInvoiceState,
type InvoiceState,
} from "../app/(app)/dashboard/actions";
type InvoiceItem = {
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;
metadata?: Record<string, string> | null;
created_at: string;
archived_at: string | null;
detected_at: string | null;
confirmed_at: string | null;
expires_at: string | null;
};
type InvoicePanelProps = {
activeInvoices: InvoiceItem[];
includeArchived: boolean;
searchQuery: string;
sort: string;
order: string;
defaultConfirmationTarget: number;
};
const initialState: InvoiceState = {
error: null,
invoiceId: null,
address: null,
amount: null,
recipientName: null,
description: null,
subaddressIndex: null,
warnings: null,
};
const initialArchiveState: ArchiveInvoiceState = {
error: null,
success: null,
archivedId: null,
};
const formatStatus = (status: InvoiceItem["status"]) => {
if (status === "payment_detected") {
return "Payment detected";
}
if (status === "pending") {
return "Awaiting funds";
}
if (status === "confirmed") {
return "Confirmed";
}
if (status === "invalid") {
return "Invalid";
}
return "Expired";
};
const formatTimestamp = (value: string | null) => {
if (!value) {
return "-";
}
return new Date(value).toLocaleString();
};
export default function InvoicePanel({
activeInvoices,
includeArchived,
searchQuery,
sort,
order,
defaultConfirmationTarget,
}: InvoicePanelProps) {
const router = useRouter();
const [state, formAction] = useFormState(createInvoiceAction, initialState);
const [archiveState, archiveAction] = useFormState(
archiveInvoiceAction,
initialArchiveState
);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [expandedInvoiceId, setExpandedInvoiceId] = useState<string | null>(null);
const [createStep, setCreateStep] = useState<1 | 2 | 3>(1);
const [formKey, setFormKey] = useState(0);
const [amount, setAmount] = useState("");
const [confirmationTarget, setConfirmationTarget] = useState(
String(defaultConfirmationTarget ?? 10)
);
const [expiresDate, setExpiresDate] = useState("");
const [expiresTime, setExpiresTime] = useState("");
const [recipientName, setRecipientName] = useState("");
const [description, setDescription] = useState("");
const [archiveModalInvoiceId, setArchiveModalInvoiceId] = useState<string | null>(null);
const [searchInput, setSearchInput] = useState(searchQuery ?? "");
const [qrLogoMode, setQrLogoMode] = useState<
"account_default" | "monero" | "none" | "custom"
>("account_default");
const [qrLogoDataUrl, setQrLogoDataUrl] = useState<string | null>(null);
const [createInvoiceOrigin, setCreateInvoiceOrigin] = useState<string | null>(
process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/+$/, "") ?? null
);
const { rate: usdRate } = useXmrUsdRate();
const activeList = activeInvoices;
const archivedToggleHref = includeArchived
? "/dashboard?tab=invoices"
: "/dashboard?tab=invoices&archived=1";
useEffect(() => {
setSearchInput(searchQuery ?? "");
}, [searchQuery]);
const buildInvoicesHref = (next: {
q?: string;
sort?: string;
order?: string;
includeArchived?: boolean;
}) => {
const params = new URLSearchParams();
params.set("tab", "invoices");
const nextArchived = next.includeArchived ?? includeArchived;
if (nextArchived) {
params.set("archived", "1");
}
const nextQuery = (next.q ?? searchQuery ?? "").trim();
if (nextQuery) {
params.set("q", nextQuery);
}
const nextSort = next.sort ?? sort;
if (nextSort) {
params.set("sort", nextSort);
}
const nextOrder = next.order ?? order;
if (nextOrder) {
params.set("order", nextOrder);
}
return `/dashboard?${params.toString()}`;
};
const csvExportHref = (() => {
const params = new URLSearchParams();
if (includeArchived) {
params.set("include_archived", "true");
}
const trimmed = (searchQuery ?? "").trim();
if (trimmed) {
params.set("q", trimmed);
}
if (sort) {
params.set("sort", sort);
}
if (order) {
params.set("order", order);
}
const suffix = params.toString();
return suffix ? `/dashboard/invoices.csv?${suffix}` : "/dashboard/invoices.csv";
})();
useEffect(() => {
if (createInvoiceOrigin) {
return;
}
setCreateInvoiceOrigin(window.location.origin);
}, [createInvoiceOrigin]);
useEffect(() => {
if (state.invoiceId) {
setCreateStep(3);
router.refresh();
}
}, [state.invoiceId, router]);
const publicInvoiceUrl = state.invoiceId
? createInvoiceOrigin
? new URL(`/invoice/${state.invoiceId}`, createInvoiceOrigin).toString()
: `/invoice/${state.invoiceId}`
: null;
const formattedDraftAmount = amount ? formatXmrAmount(amount) : "";
const draftAmountValue = useMemo(() => {
const parsed = Number.parseFloat(amount);
return Number.isFinite(parsed) ? parsed : null;
}, [amount]);
const draftUsdEstimate = useMemo(() => {
if (!usdRate || draftAmountValue === null || draftAmountValue <= 0) {
return null;
}
return formatUsdAmount(usdRate * draftAmountValue);
}, [usdRate, draftAmountValue]);
const expiryTimeValue = expiresTime || "00:00";
const expiryValue = expiresDate ? `${expiresDate}T${expiryTimeValue}` : "";
const expiryIsValid = (!expiresDate && !expiresTime) || !!expiresDate;
const openCreate = () => {
setIsCreateOpen(true);
setCreateStep(1);
setFormKey((prev) => prev + 1);
setAmount("");
setConfirmationTarget(String(defaultConfirmationTarget ?? 10));
setExpiresDate("");
setExpiresTime("");
setRecipientName("");
setDescription("");
setQrLogoMode("account_default");
setQrLogoDataUrl(null);
};
const closeCreate = () => {
setIsCreateOpen(false);
setCreateStep(1);
setFormKey((prev) => prev + 1);
};
const handleQrLogoFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
setQrLogoDataUrl(null);
return;
}
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
if (typeof result === "string") {
setQrLogoDataUrl(result);
}
};
reader.readAsDataURL(file);
};
const toggleInvoice = (invoiceId: string) => {
setExpandedInvoiceId((prev) => (prev === invoiceId ? null : invoiceId));
};
const openArchiveModal = (invoiceId: string) => {
setArchiveModalInvoiceId(invoiceId);
};
const closeArchiveModal = () => {
setArchiveModalInvoiceId(null);
};
useEffect(() => {
if (archiveState.archivedId && expandedInvoiceId === archiveState.archivedId) {
setExpandedInvoiceId(null);
setArchiveModalInvoiceId(null);
router.refresh();
}
}, [archiveState.archivedId, expandedInvoiceId, router]);
const labelClass = "text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft";
const inputClass =
"w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10";
const primaryButton =
"inline-flex items-center justify-center rounded-full bg-ink px-5 py-2.5 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5 disabled:cursor-not-allowed disabled:opacity-70";
const secondaryButton =
"inline-flex items-center justify-center rounded-full border border-stroke bg-white/60 px-5 py-2.5 text-sm font-semibold text-ink transition hover:-translate-y-0.5 disabled:cursor-not-allowed disabled:opacity-70";
const smallSecondaryButton =
"inline-flex items-center justify-center rounded-full border border-stroke bg-white/60 px-4 py-2 text-xs font-semibold uppercase tracking-[0.12em] text-ink transition hover:-translate-y-0.5 disabled:cursor-not-allowed disabled:opacity-70";
const sortHref = (key: string) => {
const nextOrder = sort === key && order === "asc" ? "desc" : "asc";
return buildInvoicesHref({ sort: key, order: nextOrder });
};
const applySearch = () => {
router.push(buildInvoicesHref({ q: searchInput }));
};
return (
<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">Invoices</h1>
<p className="mt-2 text-ink-soft">
Search, sort, and expand invoices for details.
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<Link className={secondaryButton} href={archivedToggleHref}>
{includeArchived ? "Hide archived" : "Show archived"}
</Link>
<a className={secondaryButton} href={csvExportHref}>
Export CSV
</a>
<button className={primaryButton} type="button" onClick={openCreate}>
Create invoice
</button>
</div>
</div>
<div className="mt-6 grid gap-4">
<div className="flex flex-wrap items-end justify-between gap-3">
<form
className="flex w-full flex-1 flex-wrap items-end gap-3"
onSubmit={(event) => {
event.preventDefault();
applySearch();
}}
>
<div className="grid w-full gap-2 sm:max-w-lg">
<label className={labelClass} htmlFor="invoice_search">
Search
</label>
<input
className={inputClass}
id="invoice_search"
type="search"
value={searchInput}
placeholder="Invoice id, subaddress, or metadata"
onChange={(event) => setSearchInput(event.target.value)}
/>
</div>
<div className="flex gap-2">
<button className={smallSecondaryButton} type="submit">
Apply
</button>
{searchQuery ? (
<Link className={smallSecondaryButton} href={buildInvoicesHref({ q: "" })}>
Clear
</Link>
) : null}
</div>
</form>
</div>
{activeList.length === 0 ? (
<p className="text-sm font-semibold text-ink-soft">
No invoices match your filters.
</p>
) : (
<div className="overflow-x-auto rounded-2xl border border-stroke bg-white/70 shadow-soft">
<table className="w-full min-w-[720px] border-collapse text-left text-sm">
<thead className="bg-white/60">
<tr className="border-b border-stroke">
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Invoice
</th>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
<Link className="underline underline-offset-4" href={sortHref("amount_xmr")}>
Amount
</Link>
</th>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
<Link className="underline underline-offset-4" href={sortHref("status")}>
Status
</Link>
</th>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
<Link className="underline underline-offset-4" href={sortHref("created_at")}>
Created
</Link>
</th>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
<Link className="underline underline-offset-4" href={sortHref("expires_at")}>
Expires
</Link>
</th>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Actions
</th>
</tr>
</thead>
<tbody>
{activeList.map((invoice) => {
const isExpanded = expandedInvoiceId === invoice.id;
const showPaidAfterExpiry = Boolean(invoice.paid_after_expiry);
const isArchivable =
!invoice.archived_at &&
(invoice.status === "pending" ||
invoice.status === "expired" ||
invoice.status === "invalid");
return (
<Fragment key={invoice.id}>
<tr
className={`border-b border-stroke ${invoice.archived_at ? "bg-ink/5" : ""}`}
>
<td className="px-4 py-3 align-top">
<button
className="grid gap-1 text-left"
type="button"
onClick={() => toggleInvoice(invoice.id)}
>
<span className="font-mono text-xs text-ink">{invoice.id}</span>
<span className="text-xs text-ink-soft">
{invoice.address.slice(0, 18)}…{invoice.address.slice(-10)}
</span>
</button>
<div className="mt-2 flex flex-wrap gap-2">
{invoice.archived_at ? (
<span className="rounded-full border border-stroke bg-white/60 px-3 py-1 text-[0.65rem] font-semibold uppercase tracking-[0.12em] text-ink">
Archived
</span>
) : null}
{showPaidAfterExpiry ? (
<span className="rounded-full border border-stroke bg-white/60 px-3 py-1 text-[0.65rem] font-semibold uppercase tracking-[0.12em] text-ink">
Paid after expiry
</span>
) : null}
</div>
</td>
<td className="px-4 py-3 align-top font-semibold text-ink">
{formatXmrAmount(invoice.amount_xmr)} XMR
</td>
<td className="px-4 py-3 align-top">
<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">
{formatStatus(invoice.status)}
</span>
</td>
<td
className="px-4 py-3 align-top text-ink"
title={formatRelativeTime(invoice.created_at) ?? undefined}
>
{formatTimestamp(invoice.created_at)}
</td>
<td
className="px-4 py-3 align-top text-ink"
title={formatRelativeTime(invoice.expires_at) ?? undefined}
>
{formatTimestamp(invoice.expires_at)}
</td>
<td className="px-4 py-3 align-top">
<div className="flex flex-wrap gap-2">
<Link
className={smallSecondaryButton}
href={`/invoice/${invoice.id}`}
target="_blank"
rel="noreferrer"
>
Open
</Link>
<button
className={smallSecondaryButton}
type="button"
onClick={() => toggleInvoice(invoice.id)}
>
{isExpanded ? "Hide" : "Details"}
</button>
</div>
</td>
</tr>
{isExpanded ? (
<tr className="border-b border-stroke">
<td className="px-4 py-4" colSpan={6}>
<div className="grid gap-4 rounded-xl border border-stroke bg-white/80 p-4 shadow-soft">
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-2">
<p className={labelClass}>Created at</p>
<p
className="text-sm font-semibold text-ink"
title={formatRelativeTime(invoice.created_at) ?? undefined}
>
{formatTimestamp(invoice.created_at)}
</p>
</div>
<div className="grid gap-2">
<p className={labelClass}>Detected at</p>
<p
className="text-sm font-semibold text-ink"
title={formatRelativeTime(invoice.detected_at) ?? undefined}
>
{formatTimestamp(invoice.detected_at)}
</p>
</div>
<div className="grid gap-2">
<p className={labelClass}>Confirmed at</p>
<p
className="text-sm font-semibold text-ink"
title={formatRelativeTime(invoice.confirmed_at) ?? undefined}
>
{formatTimestamp(invoice.confirmed_at)}
</p>
</div>
<div className="grid gap-2">
<p className={labelClass}>Expires at</p>
<p
className="text-sm font-semibold text-ink"
title={formatRelativeTime(invoice.expires_at) ?? undefined}
>
{formatTimestamp(invoice.expires_at)}
</p>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-2">
<p className={labelClass}>Confirmation target</p>
<p className="text-sm font-semibold text-ink">
{invoice.confirmation_target}
</p>
</div>
<div className="grid gap-2">
<p className={labelClass}>Subaddress index</p>
<p className="text-sm font-semibold text-ink">
{invoice.subaddress_index ?? "-"}
</p>
</div>
{invoice.archived_at ? (
<div className="grid gap-2">
<p className={labelClass}>Archived at</p>
<p
className="text-sm font-semibold text-ink"
title={formatRelativeTime(invoice.archived_at) ?? undefined}
>
{formatTimestamp(invoice.archived_at)}
</p>
</div>
) : null}
{invoice.paid_after_expiry_at ? (
<div className="grid gap-2">
<p className={labelClass}>Paid after expiry at</p>
<p
className="text-sm font-semibold text-ink"
title={
formatRelativeTime(invoice.paid_after_expiry_at) ??
undefined
}
>
{formatTimestamp(invoice.paid_after_expiry_at)}
</p>
</div>
) : null}
</div>
<div className="flex flex-wrap items-center gap-3">
{isArchivable ? (
<button
className={secondaryButton}
type="button"
onClick={() => openArchiveModal(invoice.id)}
>
Archive invoice
</button>
) : (
<p className="text-sm text-ink-soft">
Invoice actions are unavailable for this status.
</p>
)}
{archiveState.error && archiveModalInvoiceId === invoice.id ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{archiveState.error}
</p>
) : null}
{archiveState.success &&
archiveState.archivedId === invoice.id ? (
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">
{archiveState.success}
</p>
) : null}
</div>
</div>
</td>
</tr>
) : null}
</Fragment>
);
})}
</tbody>
</table>
</div>
)}
</div>
<div className="mt-6 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>
{isCreateOpen ? (
<div className="fixed inset-0 z-20 flex items-center justify-center bg-ink/40 px-4 py-10">
<div className="w-full max-w-3xl rounded-3xl border border-stroke bg-white p-8 shadow-deep">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Create invoice
</p>
<h2 className="mt-2 font-serif text-2xl">Create a new invoice</h2>
</div>
<button className={secondaryButton} type="button" onClick={closeCreate}>
Close
</button>
</div>
<div className="mt-6 flex flex-wrap gap-2">
{[
{ id: 1, label: "1. Details" },
{ id: 2, label: "2. Review" },
{ id: 3, label: "3. Confirmation" },
].map((step) => (
<span
key={step.id}
className={`rounded-full border px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] ${
createStep === step.id
? "border-ink bg-ink text-cream"
: "border-stroke bg-white/70 text-ink-soft"
}`}
>
{step.label}
</span>
))}
</div>
<form className="mt-6 grid gap-6" action={formAction} key={formKey}>
{createStep === 1 ? (
<div className="grid gap-4">
<div className="grid gap-2">
<label className={labelClass} htmlFor="wizard_amount">
Amount (XMR)
</label>
<input
className={inputClass}
id="wizard_amount"
name="amount_xmr"
type="number"
step="0.000001"
min="0"
placeholder="0.10"
value={amount}
onChange={(event) => setAmount(event.target.value)}
required
/>
{draftUsdEstimate ? (
<>
<p className="text-sm text-ink-soft">
Approx. USD reference: ~{draftUsdEstimate}
</p>
<details className="w-fit text-xs text-ink-soft">
<summary className="cursor-pointer select-none underline underline-offset-4">
About this estimate
</summary>
<p className="mt-2 max-w-[46ch] leading-relaxed">
Reference only, uses CoinGecko spot rate. Not a quote or guarantee.
</p>
</details>
</>
) : null}
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="wizard_confirmation_target">
Confirmation target
</label>
<input
className={inputClass}
id="wizard_confirmation_target"
name="confirmation_target"
type="number"
min="0"
max="10"
step="1"
value={confirmationTarget}
onChange={(event) => setConfirmationTarget(event.target.value)}
required
/>
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="wizard_expires_date">
Expiry date
</label>
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_160px]">
<input
className={inputClass}
id="wizard_expires_date"
name="expires_date"
type="date"
value={expiresDate}
onChange={(event) => setExpiresDate(event.target.value)}
/>
<input
className={inputClass}
id="wizard_expires_time"
name="expires_time"
type="time"
value={expiresTime}
onChange={(event) => setExpiresTime(event.target.value)}
/>
</div>
<p className="text-sm text-ink-soft">
Leave blank to keep the default 24-hour expiry.
</p>
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="wizard_recipient">
Recipient name
</label>
<input
className={inputClass}
id="wizard_recipient"
name="recipient_name"
type="text"
placeholder="Your store"
value={recipientName}
onChange={(event) => setRecipientName(event.target.value)}
/>
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="wizard_description">
Description
</label>
<input
className={inputClass}
id="wizard_description"
name="description"
type="text"
placeholder="Order 1042"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="wizard_qr_logo_mode">
QR logo
</label>
<select
className={inputClass}
id="wizard_qr_logo_mode"
name="qr_logo_mode"
value={qrLogoMode}
onChange={(event) => {
const value = event.target.value;
if (
value === "account_default" ||
value === "monero" ||
value === "none" ||
value === "custom"
) {
setQrLogoMode(value);
}
if (value !== "custom") {
setQrLogoDataUrl(null);
}
}}
>
<option value="account_default">Account default</option>
<option value="monero">Monero logo</option>
<option value="none">No logo</option>
<option value="custom">Custom image</option>
</select>
<p className="text-sm text-ink-soft">
The logo overlays the QR center. Custom images are stored on the invoice as a
data URL.
</p>
{qrLogoMode === "custom" ? (
<input
className="w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink"
id="wizard_qr_logo_file"
type="file"
accept="image/*"
onChange={handleQrLogoFileChange}
/>
) : null}
{qrLogoMode === "custom" && qrLogoDataUrl ? (
<button
className={secondaryButton}
type="button"
onClick={() => setQrLogoDataUrl(null)}
>
Remove custom image
</button>
) : null}
</div>
</div>
) : null}
{createStep === 2 ? (
<div className="grid gap-4">
<input type="hidden" name="amount_xmr" value={amount} />
<input
type="hidden"
name="confirmation_target"
value={confirmationTarget}
/>
<input type="hidden" name="expires_at" value={expiryValue} />
<input type="hidden" name="recipient_name" value={recipientName} />
<input type="hidden" name="description" value={description} />
<input type="hidden" name="qr_logo_mode" value={qrLogoMode} />
<input
type="hidden"
name="qr_logo_data_url"
value={qrLogoMode === "custom" ? qrLogoDataUrl ?? "" : ""}
/>
<div 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">
Review details
</p>
<p className="mt-3 text-sm text-ink">
Amount:{" "}
<strong>{formattedDraftAmount || "-"} XMR</strong>
</p>
<p className="mt-2 text-sm text-ink">
Confirmations:{" "}
<strong>{confirmationTarget || "-"}</strong>
</p>
<p className="mt-2 text-sm text-ink">
Expiry:{" "}
<strong>
{expiryValue
? new Date(
expiryValue
).toLocaleString()
: "Default (24 hours)"}
</strong>
</p>
<p className="mt-2 text-sm text-ink">
QR logo: <strong>{qrLogoMode.replace("_", " ")}</strong>
</p>
<p className="mt-2 text-sm text-ink">
Recipient: <strong>{recipientName || "-"}</strong>
</p>
<p className="mt-2 text-sm text-ink">
Description: <strong>{description || "-"}</strong>
</p>
</div>
</div>
) : null}
{createStep === 3 ? (
<div className="grid gap-4">
{state.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{state.error}
</p>
) : null}
{publicInvoiceUrl ? (
<div 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">
Invoice created
</p>
<p className="mt-3 text-sm text-ink">
Your invoice has been created. You can view and share the invoice
link with your customer here:
</p>
<p className="mt-2 text-sm font-semibold text-ink">
<Link
className="underline underline-offset-4"
href={publicInvoiceUrl}
target="_blank"
rel="noreferrer"
>
{publicInvoiceUrl}
</Link>
</p>
</div>
) : null}
</div>
) : null}
{state.error && createStep !== 3 ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{state.error}
</p>
) : null}
<div className="flex flex-wrap justify-end gap-3">
{createStep === 1 ? (
<button
className={secondaryButton}
type="button"
onClick={() => setCreateStep(2)}
disabled={
!amount ||
!confirmationTarget ||
!expiryIsValid ||
(qrLogoMode === "custom" && !qrLogoDataUrl)
}
>
Continue
</button>
) : null}
{createStep === 2 ? (
<>
<button
className={secondaryButton}
type="button"
onClick={() => setCreateStep(1)}
>
Back
</button>
<button className={primaryButton} type="submit">
Create invoice
</button>
</>
) : null}
{createStep === 3 ? (
<button className={secondaryButton} type="button" onClick={closeCreate}>
Done
</button>
) : null}
</div>
</form>
</div>
</div>
) : null}
{archiveModalInvoiceId ? (
<div className="fixed inset-0 z-20 flex items-center justify-center bg-ink/40 px-4 py-10">
<div className="w-full max-w-lg rounded-3xl border border-stroke bg-white p-8 shadow-deep">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Archive invoice
</p>
<h2 className="mt-2 font-serif text-2xl">Archive this invoice?</h2>
<p className="mt-2 text-sm text-ink-soft">
Archived invoices stay available in the archive list.
</p>
</div>
<button className={secondaryButton} type="button" onClick={closeArchiveModal}>
Close
</button>
</div>
<div className="mt-6 flex flex-wrap justify-end gap-3">
<button className={secondaryButton} type="button" onClick={closeArchiveModal}>
Cancel
</button>
<form action={archiveAction}>
<input type="hidden" name="invoice_id" value={archiveModalInvoiceId} />
<button className={primaryButton} type="submit">
Archive invoice
</button>
</form>
</div>
</div>
</div>
) : null}
</div>
);
}
+256
View File
@@ -0,0 +1,256 @@
"use client";
import Image from "next/image";
import { useEffect, useMemo, useState } from "react";
import QRCode from "qrcode";
import { formatUsdAmount, formatXmrAmount } from "../lib/formatting";
import { useXmrUsdRate } from "../lib/use-xmr-usd-rate";
type InvoicePaymentDetailsProps = {
address: string;
amount: string;
hasDetectedPayment: boolean;
status: "pending" | "payment_detected" | "confirmed" | "expired" | "invalid";
confirmationTarget: number;
qrLogoMode?: "monero" | "none" | "custom" | null;
qrLogoDataUrl?: string | null;
};
const buildMoneroUri = (address: string, amount: string) => {
const params = new URLSearchParams();
params.set("tx_amount", amount);
return `monero:${address}?${params.toString()}`;
};
export default function InvoicePaymentDetails({
address,
amount,
hasDetectedPayment,
status,
confirmationTarget,
qrLogoMode,
qrLogoDataUrl,
}: InvoicePaymentDetailsProps) {
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [copiedField, setCopiedField] = useState<string | null>(null);
const { rate: usdRate } = useXmrUsdRate();
const isInvalid = status === "invalid";
const shouldPromptPayment = !hasDetectedPayment && !isInvalid;
const formattedAmount = useMemo(() => formatXmrAmount(amount), [amount]);
const uri = useMemo(
() => buildMoneroUri(address, formattedAmount),
[address, formattedAmount]
);
const amountValue = useMemo(() => {
const parsed = Number.parseFloat(amount);
return Number.isFinite(parsed) ? parsed : null;
}, [amount]);
const usdEstimate = useMemo(() => {
if (!usdRate || amountValue === null || amountValue <= 0) {
return null;
}
return formatUsdAmount(usdRate * amountValue);
}, [usdRate, amountValue]);
const handleCopy = async (value: string, field: string) => {
try {
await navigator.clipboard.writeText(value);
setCopiedField(field);
window.setTimeout(() => {
setCopiedField((current) => (current === field ? null : current));
}, 1500);
} catch {
setCopiedField(null);
}
};
useEffect(() => {
let active = true;
const size = 200;
const canvas = document.createElement("canvas");
QRCode.toCanvas(canvas, uri, {
margin: 1,
width: size,
errorCorrectionLevel: "H",
})
.then(() => {
const context = canvas.getContext("2d");
if (!context) {
return null;
}
const mode = qrLogoMode ?? "monero";
const wantsLogo = mode !== "none";
if (wantsLogo) {
const blankSize = Math.round(size * 0.28);
const blankOffset = Math.round((size - blankSize) / 2);
context.fillStyle = "#ffffff";
context.fillRect(blankOffset, blankOffset, blankSize, blankSize);
}
return canvas.toDataURL();
})
.then((url) => {
if (active) {
setQrDataUrl(url ?? null);
}
})
.catch(() => {
if (active) {
setQrDataUrl(null);
}
});
return () => {
active = false;
};
}, [uri, qrLogoMode]);
const resolvedQrLogoMode = qrLogoMode ?? "monero";
const resolvedLogoSrc =
resolvedQrLogoMode === "custom"
? qrLogoDataUrl
: resolvedQrLogoMode === "monero"
? "/monero-logo.svg"
: null;
return (
<details
className="group rounded-2xl border border-stroke bg-card p-8 shadow-card backdrop-blur"
open={shouldPromptPayment}
>
<summary className="cursor-pointer list-none [&::-webkit-details-marker]:hidden">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-clay">
Payment details
</p>
<h2 className="mt-2 font-serif text-2xl">
{isInvalid
? "Invoice marked invalid. Do not send payment."
: hasDetectedPayment
? status === "confirmed"
? `Payment confirmed on-chain at ${confirmationTarget} confirmations.`
: "Payment detected. Confirmations are in progress."
: "Send the exact amount to this address."}
</h2>
</div>
<span className="rounded-full border border-stroke bg-white/70 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink-soft transition group-open:hidden">
Show details
</span>
<span className="hidden rounded-full border border-stroke bg-white/70 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink-soft transition group-open:inline-flex">
Hide details
</span>
</div>
</summary>
<div className="mt-6 grid gap-6 lg:grid-cols-2">
<div className="rounded-2xl border border-stroke bg-white/60 p-5 shadow-soft">
<div className="flex items-center justify-between gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Amount (XMR)
</p>
<button
className="rounded-full border border-stroke bg-white/70 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:bg-white"
type="button"
onClick={() => handleCopy(formattedAmount, "amount")}
>
{copiedField === "amount" ? "Copied" : "Copy"}
</button>
</div>
<p className="mt-3 text-2xl font-semibold text-ink">{formattedAmount}</p>
<p className="mt-2 text-sm text-ink-soft">
Your wallet adds the network fee on top. Do not subtract it from the
amount shown.
</p>
{usdEstimate ? (
<>
<p className="mt-2 text-sm text-ink-soft">
Approx. USD reference: ~{usdEstimate}
</p>
<details className="mt-1 w-fit text-xs text-ink-soft">
<summary className="cursor-pointer select-none underline underline-offset-4">
About this estimate
</summary>
<p className="mt-2 max-w-[46ch] leading-relaxed">
Reference only, uses CoinGecko spot rate. Not a quote or guarantee.
</p>
</details>
</>
) : null}
</div>
<div className="rounded-2xl border border-stroke bg-white/60 p-5 shadow-soft">
<div className="flex items-center justify-between gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Payment address
</p>
<button
className="rounded-full border border-stroke bg-white/70 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:bg-white"
type="button"
onClick={() => handleCopy(address, "address")}
>
{copiedField === "address" ? "Copied" : "Copy"}
</button>
</div>
<p className="mt-3 break-words rounded-xl bg-ink/10 px-3 py-2 font-mono text-xs text-ink sm:text-sm">
{address}
</p>
</div>
</div>
<div className="mt-6 rounded-2xl border border-stroke bg-white/60 p-5 shadow-soft">
<div className="flex items-center justify-between gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Payment request URI
</p>
<button
className="rounded-full border border-stroke bg-white/70 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:bg-white"
type="button"
onClick={() => handleCopy(uri, "uri")}
>
{copiedField === "uri" ? "Copied" : "Copy"}
</button>
</div>
<p className="mt-3 break-words rounded-xl bg-ink/10 px-3 py-2 font-mono text-xs text-ink sm:text-sm">
{uri}
</p>
{shouldPromptPayment ? (
<p className="mt-4 text-sm text-ink-soft">
After you pay, this page updates automatically once the payment is
detected on-chain.
</p>
) : null}
</div>
<div className="mt-6 grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<p className="max-w-xl text-sm text-ink-soft">
{isInvalid
? "This invoice is marked invalid. Do not send a payment to this address."
: hasDetectedPayment
? "Details remain available if you need to reference the address or request URI."
: "Tip: use the QR or payment request URI to avoid copy errors."}
</p>
{qrDataUrl ? (
<div className="relative h-[200px] w-[200px] rounded-2xl border border-stroke bg-white p-2 shadow-soft">
<Image
className="h-full w-full rounded-xl border border-ink/10 bg-white"
src={qrDataUrl}
alt="Payment request QR"
width={200}
height={200}
unoptimized
/>
{resolvedLogoSrc ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-white shadow-[0_6px_12px_rgba(16,18,23,0.18)]">
<Image
src={resolvedLogoSrc}
alt="QR logo"
width={32}
height={32}
unoptimized={resolvedQrLogoMode === "custom"}
/>
</span>
</div>
) : null}
</div>
) : null}
</div>
</details>
);
}
+16
View File
@@ -0,0 +1,16 @@
"use client";
import Link from "next/link";
export default function InvoiceStatusActions() {
return (
<div className="flex flex-wrap gap-3">
<Link
className="inline-flex items-center justify-center rounded-full bg-ink px-5 py-2.5 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
href="/invoice"
>
Check another invoice
</Link>
</div>
);
}
@@ -0,0 +1,23 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
type InvoiceStatusAutoRefreshProps = {
intervalMs?: number;
};
export default function InvoiceStatusAutoRefresh({
intervalMs = 30000,
}: InvoiceStatusAutoRefreshProps) {
const router = useRouter();
useEffect(() => {
const interval = setInterval(() => {
router.refresh();
}, intervalMs);
return () => clearInterval(interval);
}, [intervalMs, router]);
return null;
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
type InvoiceStatusLookupProps = {
initialValue?: string;
compact?: boolean;
};
export default function InvoiceStatusLookup({
initialValue = "",
compact = false,
}: InvoiceStatusLookupProps) {
const router = useRouter();
const [invoiceId, setInvoiceId] = useState(initialValue);
const [error, setError] = useState<string | null>(null);
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const trimmed = invoiceId.trim();
if (!trimmed) {
setError("Enter an invoice id to continue.");
return;
}
setError(null);
router.push(`/invoice/${trimmed}`);
};
return (
<form
className={`grid gap-3 ${compact ? "text-sm" : "text-base"}`}
onSubmit={handleSubmit}
>
<label
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
htmlFor="invoice_id"
>
Invoice ID
</label>
<div className="flex flex-wrap items-center gap-3">
<input
className="min-w-[220px] flex-1 rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
id="invoice_id"
name="invoice_id"
type="text"
value={invoiceId}
onChange={(event) => setInvoiceId(event.target.value)}
placeholder="e.g. 9d2f2b3c-8a77-4c2b-b9d4-..."
autoComplete="off"
spellCheck={false}
required
/>
<button
className="inline-flex items-center justify-center rounded-full bg-ink px-5 py-3 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
type="submit"
>
Check status
</button>
</div>
{compact ? null : (
<p className="text-sm text-ink-soft">
Read-only status view. Refresh the page to see new detections.
</p>
)}
{error ? (
<p
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700"
role="alert"
>
{error}
</p>
) : null}
</form>
);
}
+122
View File
@@ -0,0 +1,122 @@
"use client";
import { useEffect, useCallback } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import LoginForm from "../app/(app)/login/login-form";
const loginParam = "login";
type LoginTriggerProps = {
className?: string;
children: React.ReactNode;
};
export function LoginTrigger({ className, children }: LoginTriggerProps) {
const router = useRouter();
const pathname = usePathname();
const openModal = useCallback(() => {
const params =
typeof window === "undefined"
? new URLSearchParams()
: new URLSearchParams(window.location.search);
params.set(loginParam, "1");
const query = params.toString();
router.push(query ? `${pathname}?${query}` : pathname);
}, [pathname, router]);
return (
<button
className={className}
type="button"
onClick={openModal}
aria-haspopup="dialog"
aria-controls="login-modal"
>
{children}
</button>
);
}
export default function LoginModal() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const isOpen = searchParams.get(loginParam) === "1";
const closeModal = useCallback(() => {
const params = new URLSearchParams(searchParams.toString());
params.delete(loginParam);
const query = params.toString();
router.replace(query ? `${pathname}?${query}` : pathname);
}, [pathname, router, searchParams]);
useEffect(() => {
if (!isOpen) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeModal();
}
};
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
window.addEventListener("keydown", handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener("keydown", handleKeyDown);
};
}, [closeModal, isOpen]);
if (!isOpen) {
return null;
}
return (
<div
className="fixed inset-0 z-40 grid place-items-center"
id="login-modal"
role="dialog"
aria-modal="true"
aria-labelledby="login-title"
>
<div className="absolute inset-0 bg-ink/60 backdrop-blur" onClick={closeModal} />
<div
className="relative w-[min(480px,92vw)] rounded-3xl border border-stroke bg-card p-7 shadow-deep"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
Merchant access
</p>
<h3 id="login-title" className="mt-2 font-serif text-xl">
Sign in to your dashboard.
</h3>
</div>
<button
className="rounded-full border border-stroke bg-white/70 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-ink transition hover:-translate-y-0.5"
type="button"
onClick={closeModal}
aria-label="Close login modal"
>
Close
</button>
</div>
<p className="mt-3 text-ink-soft">
Use your primary address and secret view key to access checkout tools.
Your primary address is the only identifier we need - no email or other
identifying information required.
<br />
Your secret view key is stored encrypted at rest and used only for payment detection.
</p>
<LoginForm />
<p className="mt-4 text-sm font-semibold text-sage">
We never require more than view-only access to keep your funds safe.
</p>
</div>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
type RelativeTimeInput = string | number | Date | null | undefined;
const relativeTimeFormatter = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
const parseRelativeTime = (value: RelativeTimeInput) => {
if (value === null || value === undefined) {
return null;
}
const date = value instanceof Date ? value : new Date(value);
const timestamp = date.getTime();
if (Number.isNaN(timestamp)) {
return null;
}
return timestamp;
};
export const formatRelativeTime = (value: RelativeTimeInput) => {
const timestamp = parseRelativeTime(value);
if (timestamp === null) {
return null;
}
const diffSeconds = Math.round((timestamp - Date.now()) / 1000);
const absSeconds = Math.abs(diffSeconds);
if (absSeconds < 60) {
return relativeTimeFormatter.format(diffSeconds, "second");
}
const diffMinutes = Math.round(diffSeconds / 60);
if (Math.abs(diffMinutes) < 60) {
return relativeTimeFormatter.format(diffMinutes, "minute");
}
const diffHours = Math.round(diffMinutes / 60);
if (Math.abs(diffHours) < 24) {
return relativeTimeFormatter.format(diffHours, "hour");
}
const diffDays = Math.round(diffHours / 24);
if (Math.abs(diffDays) < 30) {
return relativeTimeFormatter.format(diffDays, "day");
}
const diffMonths = Math.round(diffDays / 30);
if (Math.abs(diffMonths) < 12) {
return relativeTimeFormatter.format(diffMonths, "month");
}
const diffYears = Math.round(diffDays / 365);
return relativeTimeFormatter.format(diffYears, "year");
};
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { useState } from "react";
type SecretCardProps = {
label: string;
value: string;
buttonLabel: string;
};
export default function SecretCard({ label, value, buttonLabel }: SecretCardProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch {
setCopied(false);
}
};
return (
<div className="grid gap-4 rounded-2xl border border-stroke bg-white/80 p-6 shadow-soft backdrop-blur">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
{label}
</p>
<code className="mt-2 block break-all rounded-xl bg-ink/10 px-3 py-2 text-sm text-ink">
{value}
</code>
</div>
<div className="flex justify-end">
<button
className="inline-flex items-center justify-center rounded-full bg-ink px-5 py-2.5 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
type="button"
onClick={handleCopy}
>
{copied ? "Copied" : buttonLabel}
</button>
</div>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
type StatusRefreshButtonProps = {
label?: string;
className?: string;
};
export default function StatusRefreshButton({
label = "Refresh",
className = "",
}: StatusRefreshButtonProps) {
const router = useRouter();
const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefresh = () => {
setIsRefreshing(true);
router.refresh();
setTimeout(() => setIsRefreshing(false), 500);
};
return (
<button
className={`inline-flex items-center justify-center rounded-full border border-stroke bg-white/50 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink transition hover:-translate-y-0.5 disabled:cursor-not-allowed disabled:opacity-70 ${className}`}
type="button"
onClick={handleRefresh}
disabled={isRefreshing}
>
{isRefreshing ? "Refreshing..." : label}
</button>
);
}
+184
View File
@@ -0,0 +1,184 @@
"use client";
import { useEffect } from "react";
import { useFormState } from "react-dom";
import { useRouter } from "next/navigation";
import {
redeliverWebhookDeliveryAction,
type WebhookRedeliverState,
} from "../app/(app)/dashboard/actions";
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;
};
const initialState: WebhookRedeliverState = { error: null, success: null };
export default function WebhookHistoryPanel({
deliveries,
}: {
deliveries: WebhookDeliverySummary[];
}) {
const router = useRouter();
const [state, formAction] = useFormState(redeliverWebhookDeliveryAction, initialState);
useEffect(() => {
if (state.success) {
router.refresh();
}
}, [router, state.success]);
const formatTimestamp = (value: string) => new Date(value).toLocaleString();
return (
<div className="grid gap-4">
{state.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{state.error}
</p>
) : null}
{state.success ? (
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">
{state.success}
</p>
) : null}
{deliveries.length === 0 ? (
<p className="text-sm text-ink-soft">No webhook calls recorded yet.</p>
) : (
<div className="grid gap-3">
{deliveries.map((delivery) => {
const statusLabel =
delivery.http_status !== null ? delivery.http_status.toString() : "No response";
const isFailed =
delivery.http_status === null ||
(typeof delivery.http_status === "number" && delivery.http_status >= 400);
const hasInvoiceDetails =
Boolean(delivery.invoice_id) ||
Boolean(delivery.invoice_address) ||
Boolean(delivery.invoice_amount_xmr) ||
delivery.invoice_subaddress_index !== null;
return (
<div
key={delivery.id}
className="rounded-xl border border-stroke bg-white/80 px-4 py-3"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
{delivery.event}
</p>
<p className="mt-2 break-all text-sm font-semibold text-ink">
{delivery.url}
</p>
<p className="mt-2 text-xs text-ink-soft">
{formatTimestamp(delivery.created_at)}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<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">
{statusLabel}
</span>
{isFailed ? (
<form action={formAction}>
<input type="hidden" name="delivery_id" value={delivery.id} />
<button
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white/60 px-4 py-2 text-xs font-semibold uppercase tracking-[0.12em] text-ink transition hover:-translate-y-0.5"
type="submit"
>
Redeliver
</button>
</form>
) : null}
</div>
</div>
{hasInvoiceDetails ? (
<details className="mt-3 rounded-lg border border-stroke bg-white/70 px-3 py-2">
<summary className="cursor-pointer text-xs font-semibold uppercase tracking-[0.12em] text-ink">
View invoice details
</summary>
<div className="mt-3 grid gap-2 text-xs text-ink-soft">
{delivery.invoice_id ? (
<div>
<p className="text-[0.7rem] font-semibold uppercase tracking-[0.2em] text-ink-soft">
Invoice id
</p>
<p className="mt-1 break-all text-xs text-ink">
{delivery.invoice_id}
</p>
</div>
) : null}
{delivery.invoice_address ? (
<div>
<p className="text-[0.7rem] font-semibold uppercase tracking-[0.2em] text-ink-soft">
Subaddress
</p>
<p className="mt-1 break-all text-xs text-ink">
{delivery.invoice_address}
</p>
</div>
) : null}
{delivery.invoice_subaddress_index !== null ? (
<div>
<p className="text-[0.7rem] font-semibold uppercase tracking-[0.2em] text-ink-soft">
Subaddress index
</p>
<p className="mt-1 text-xs text-ink">
{delivery.invoice_subaddress_index}
</p>
</div>
) : null}
{delivery.invoice_amount_xmr ? (
<div>
<p className="text-[0.7rem] font-semibold uppercase tracking-[0.2em] text-ink-soft">
Invoice amount (XMR)
</p>
<p className="mt-1 text-xs text-ink">
{delivery.invoice_amount_xmr}
</p>
</div>
) : null}
{delivery.invoice_status ? (
<div>
<p className="text-[0.7rem] font-semibold uppercase tracking-[0.2em] text-ink-soft">
Invoice status
</p>
<p className="mt-1 text-xs text-ink">
{delivery.invoice_status}
</p>
</div>
) : null}
</div>
</details>
) : null}
{delivery.error_message ? (
<p className="mt-2 text-xs text-ink-soft">
Delivery error: {delivery.error_message}
</p>
) : null}
</div>
);
})}
</div>
)}
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { useFormState } from "react-dom";
import {
resetWebhookSecretAction,
type WebhookSecretState,
} from "../app/(app)/dashboard/actions";
import SecretCard from "./secret-card";
const initialState = (webhookSecret: string | null): WebhookSecretState => ({
webhookSecret,
error: null,
});
type WebhookSecretSectionProps = {
webhookSecret: string | null;
};
export default function WebhookSecretSection({
webhookSecret,
}: WebhookSecretSectionProps) {
const [state, formAction] = useFormState(
resetWebhookSecretAction,
initialState(webhookSecret)
);
return (
<div className="grid gap-4">
{state.webhookSecret ? (
<SecretCard
label="Your webhook secret"
value={state.webhookSecret}
buttonLabel="Copy webhook secret"
/>
) : (
<div className="rounded-2xl border border-stroke bg-white/80 p-6 text-sm text-ink-soft shadow-soft backdrop-blur">
Webhook secret unavailable. Reset to generate a new secret.
</div>
)}
{state.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{state.error}
</p>
) : null}
<form action={formAction} className="flex justify-end">
<button
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white/60 px-5 py-2.5 text-sm font-semibold text-ink transition hover:-translate-y-0.5"
type="submit"
>
Reset webhook secret
</button>
</form>
</div>
);
}
+175
View File
@@ -0,0 +1,175 @@
"use client";
import { useFormState } from "react-dom";
import {
createWebhookAction,
deleteWebhookAction,
type WebhookFormState,
} from "../app/(app)/dashboard/actions";
type WebhookSummary = {
id: string;
url: string;
events: string[];
event_urls?: Record<string, string> | null;
active: boolean;
created_at: string;
};
const initialState: WebhookFormState = { error: null, success: null };
const webhookEvents = [
{ event: "invoice.created", key: "invoice_created", label: "invoice.created" },
{
event: "invoice.payment_detected",
key: "invoice_payment_detected",
label: "invoice.payment_detected",
},
{ event: "invoice.confirmed", key: "invoice_confirmed", label: "invoice.confirmed" },
{ event: "invoice.expired", key: "invoice_expired", label: "invoice.expired" },
];
type WebhookSectionProps = {
webhooks: WebhookSummary[];
};
export default function WebhookSection({ webhooks }: WebhookSectionProps) {
const [createState, createAction] = useFormState(createWebhookAction, initialState);
const [deleteState, deleteAction] = useFormState(deleteWebhookAction, initialState);
return (
<div className="grid gap-8">
<form className="grid gap-6" action={createAction}>
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
htmlFor="default_url"
>
Default webhook URL
</label>
<input
className="w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
id="default_url"
name="default_url"
type="url"
placeholder="https://xmrcheckout.com/webhooks"
/>
<p className="text-sm text-ink-soft">
Used for any selected event without a specific override URL.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
{webhookEvents.map(({ event, key, label }) => (
<div key={event} className="rounded-xl border border-stroke bg-white/70 p-4">
<label className="flex items-center gap-3 text-sm font-semibold text-ink">
<input
className="h-4 w-4 rounded border-stroke text-ink focus:ring-ink/20"
type="checkbox"
name={`event_${key}`}
defaultChecked
/>
<span>{label}</span>
</label>
<input
className="mt-3 w-full rounded-xl border border-stroke bg-white/80 px-3 py-2 text-sm text-ink outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
id={`event_url_${key}`}
name={`event_url_${key}`}
type="url"
placeholder="Optional override URL"
/>
</div>
))}
</div>
{createState.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{createState.error}
</p>
) : null}
{createState.success ? (
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">
{createState.success}
</p>
) : null}
<div className="flex justify-end">
<button
className="inline-flex items-center justify-center rounded-full bg-ink px-5 py-2.5 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
type="submit"
>
Save webhook
</button>
</div>
</form>
<div 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">
Hint
</p>
<p className="mt-2 text-sm text-ink-soft">
Webhooks are optional. You can poll{" "}
<code>/api/core/public/invoice/&lt;invoice_id&gt;</code> without
authentication to read invoice status.
</p>
</div>
<div className="grid gap-4">
<div>
<h3 className="font-serif text-xl">Active webhooks</h3>
</div>
{deleteState.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{deleteState.error}
</p>
) : null}
{deleteState.success ? (
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">
{deleteState.success}
</p>
) : null}
{webhooks.length === 0 ? (
<p className="text-sm font-semibold text-ink-soft">
No webhooks configured yet.
</p>
) : (
<form className="grid gap-4" action={deleteAction}>
{webhooks.map((hook) => (
<div
key={hook.id}
className="flex flex-wrap items-start justify-between gap-4 rounded-2xl border border-stroke bg-white/70 p-5 shadow-soft backdrop-blur"
>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Webhook
</p>
<code className="mt-2 block break-all rounded-lg bg-ink/10 px-3 py-2 text-sm text-ink">
{hook.url || "Per-event URLs only"}
</code>
<div className="mt-4 grid gap-3">
{hook.events.map((event) => (
<div key={event} className="grid gap-1">
<span className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
{event}
</span>
<code className="break-all rounded-lg bg-ink/10 px-3 py-2 text-sm text-ink">
{hook.event_urls?.[event] ?? hook.url}
</code>
</div>
))}
</div>
</div>
<div className="flex">
<button
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white/60 px-5 py-2.5 text-sm font-semibold text-ink transition hover:-translate-y-0.5"
type="submit"
name="webhook_id"
value={hook.id}
>
Remove
</button>
</div>
</div>
))}
</form>
)}
</div>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
export const formatXmrAmount = (value: string | number): string => {
const raw = typeof value === "number" ? value.toString() : value;
if (!raw) {
return "";
}
const [whole, fraction] = raw.split(".");
if (!fraction) {
return raw;
}
const trimmedFraction = fraction.replace(/0+$/, "");
return trimmedFraction ? `${whole}.${trimmedFraction}` : whole;
};
export const formatUsdAmount = (value: number): string => {
if (!Number.isFinite(value)) {
return "";
}
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 2,
minimumFractionDigits: 2,
}).format(value);
};
+70
View File
@@ -0,0 +1,70 @@
import { useEffect, useState } from "react";
const RATE_TTL_MS = 5 * 60 * 1000;
const RATE_URL =
"https://api.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=usd";
let cachedRate: number | null = null;
let cachedAt: number | null = null;
let inflight: Promise<number | null> | null = null;
const fetchRate = async (): Promise<number | null> => {
try {
const response = await fetch(RATE_URL, { cache: "no-store" });
if (!response.ok) {
return null;
}
const data = (await response.json()) as {
monero?: { usd?: number };
};
const rate = data?.monero?.usd;
if (typeof rate !== "number" || !Number.isFinite(rate)) {
return null;
}
cachedRate = rate;
cachedAt = Date.now();
return rate;
} catch {
return null;
}
};
export const useXmrUsdRate = () => {
const [rate, setRate] = useState<number | null>(cachedRate);
const [updatedAt, setUpdatedAt] = useState<number | null>(cachedAt);
useEffect(() => {
let active = true;
const now = Date.now();
const hasFreshRate =
cachedRate !== null && cachedAt !== null && now - cachedAt < RATE_TTL_MS;
if (hasFreshRate) {
setRate(cachedRate);
setUpdatedAt(cachedAt);
return;
}
const request =
inflight ??
(inflight = fetchRate().finally(() => {
inflight = null;
}));
request.then((nextRate) => {
if (!active) {
return;
}
if (nextRate !== null) {
setRate(nextRate);
setUpdatedAt(cachedAt);
}
});
return () => {
active = false;
};
}, []);
return { rate, updatedAt, source: "coingecko" as const };
};
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
};
export default nextConfig;
+6339
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "xmrcheckout-ui",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "15.5.9",
"qrcode": "1.5.4",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@types/node": "20.14.12",
"@types/qrcode": "1.5.5",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"autoprefixer": "^10.4.23",
"eslint": "9.9.0",
"eslint-config-next": "15.5.9",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.14",
"typescript": "5.5.4"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

+136
View File
@@ -0,0 +1,136 @@
(function () {
var state = {
baseUrl: "",
modal: null,
iframe: null,
handlers: [],
};
function normalizeBaseUrl(url) {
if (!url) {
return "";
}
try {
var parsed = new URL(url, window.location.href);
var path = parsed.pathname.replace(/\/api\/v1\/?$/, "").replace(/\/api\/?$/, "");
var normalized = parsed.origin + path;
return normalized.replace(/\/$/, "");
} catch (error) {
return String(url).replace(/\/$/, "");
}
}
function resolveInvoiceUrl(invoiceId) {
var base = state.baseUrl || "";
if (!base) {
base = "";
}
base = base.replace(/\/$/, "");
return base + "/i/" + encodeURIComponent(invoiceId);
}
function ensureModal() {
if (state.modal) {
return;
}
var overlay = document.createElement("div");
overlay.setAttribute("data-btcpay-modal", "true");
overlay.style.position = "fixed";
overlay.style.inset = "0";
overlay.style.display = "none";
overlay.style.alignItems = "center";
overlay.style.justifyContent = "center";
overlay.style.background = "rgba(16, 18, 23, 0.6)";
overlay.style.zIndex = "2147483647";
overlay.style.padding = "24px";
var container = document.createElement("div");
container.style.position = "relative";
container.style.width = "min(92vw, 520px)";
container.style.height = "min(86vh, 720px)";
container.style.background = "#f8f2e9";
container.style.borderRadius = "20px";
container.style.boxShadow = "0 24px 50px rgba(16, 18, 23, 0.35)";
container.style.overflow = "hidden";
var iframe = document.createElement("iframe");
iframe.setAttribute("title", "Payment request");
iframe.style.width = "100%";
iframe.style.height = "100%";
iframe.style.border = "0";
iframe.style.background = "transparent";
iframe.allow = "clipboard-read; clipboard-write";
container.appendChild(iframe);
overlay.appendChild(container);
document.body.appendChild(overlay);
overlay.addEventListener("click", function (event) {
if (event.target === overlay) {
hideFrame();
}
});
state.modal = overlay;
state.iframe = iframe;
}
function showInvoice(invoiceId) {
if (!invoiceId) {
return;
}
ensureModal();
if (!state.modal || !state.iframe) {
return;
}
state.iframe.src = resolveInvoiceUrl(invoiceId);
state.modal.style.display = "flex";
}
function hideFrame() {
if (!state.modal) {
return;
}
state.modal.style.display = "none";
}
function onModalReceiveMessage(callback) {
if (typeof callback !== "function") {
return;
}
state.handlers.push(callback);
}
function allowMessage(event) {
if (!state.baseUrl) {
return true;
}
try {
var origin = new URL(state.baseUrl, window.location.href).origin;
return event.origin === origin;
} catch (error) {
return false;
}
}
window.addEventListener("message", function (event) {
if (!allowMessage(event)) {
return;
}
state.handlers.forEach(function (handler) {
try {
handler(event.data);
} catch (error) {
return;
}
});
});
window.btcpay = window.btcpay || {};
window.btcpay.setApiUrlPrefix = function (baseUrl) {
state.baseUrl = normalizeBaseUrl(baseUrl);
};
window.btcpay.showInvoice = showInvoice;
window.btcpay.hideFrame = hideFrame;
window.btcpay.onModalReceiveMessage = onModalReceiveMessage;
})();
+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" role="img" aria-label="Monero">
<defs>
<clipPath id="monero-circle">
<circle cx="50" cy="50" r="48" />
</clipPath>
</defs>
<circle cx="50" cy="50" r="48" fill="#f26822" />
<rect x="0" y="50" width="100" height="50" fill="#4a4a4a" clip-path="url(#monero-circle)" />
<path
fill="#ffffff"
d="M 23 70 L 23 35 L 36 35 L 50 58 L 64 35 L 77 35 L 77 70 L 66 70 L 66 45 L 50 66 L 34 45 L 34 70 Z"
/>
</svg>

After

Width:  |  Height:  |  Size: 494 B

+41
View File
@@ -0,0 +1,41 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
ink: "#101217",
"ink-soft": "#1f2430",
cream: "#f8f2e9",
sand: "#f1e5d2",
clay: "#c0733f",
monero: "#f26822",
sage: "#5d7a6a",
fog: "rgba(16, 18, 23, 0.08)",
card: "rgba(248, 242, 233, 0.88)",
stroke: "rgba(16, 18, 23, 0.18)",
},
fontFamily: {
sans: ["Space Grotesk", "Helvetica Neue", "Arial", "sans-serif"],
serif: ["Crimson Pro", "Georgia", "serif"],
},
boxShadow: {
soft: "0 18px 36px rgba(16, 18, 23, 0.08)",
card: "0 24px 50px rgba(16, 18, 23, 0.12)",
deep: "0 24px 60px rgba(16, 18, 23, 0.26)",
},
borderRadius: {
xl: "1.2rem",
"2xl": "1.6rem",
"3xl": "1.8rem",
},
},
},
plugins: [],
};
export default config;
+39
View File
@@ -0,0 +1,39 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"types": [
"node"
],
"plugins": [
{
"name": "next"
}
]
},
"include": [
"**/*.ts",
"**/*.tsx",
"next-env.d.ts",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}