mirror of
https://github.com/Such-Software/xmrcheckout.git
synced 2026-10-17 05:51:34 +02:00
Add UI
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 <api_key>.</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>
|
||||
);
|
||||
}
|
||||
@@ -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 <api_key></li>
|
||||
<li>BTCPay compatibility: Authorization: token <api_key></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/<invoice_id> 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=<hmac></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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user