Fork xmrcheckout for Wownero (WOW)

Minimal changes from upstream xmrcheckout to support Wownero:
- Rate provider: Nonlogs WOW-BTC via Kraken BTC/USD
- Subaddress derivation: Wownero network byte overrides (53/63/54)
- Payment URI scheme: wownero:
- Dockerfile: Wownero binaries from Codeberg
- Display text: Monero→Wownero, XMR→WOW throughout UI
- Default daemon: 192.168.88.235:34568
- Added wownero-logo.svg

File and folder structure kept identical to upstream for easy sync.
This commit is contained in:
jwinterm
2026-03-09 11:08:55 -04:00
commit f8f9b4424c
111 changed files with 23058 additions and 0 deletions
+812
View File
@@ -0,0 +1,812 @@
"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 tourNoopMessage = "Tour mode only: no changes are saved.";
export async function createInvoiceTourAction(
_prevState: InvoiceState,
_formData: FormData
): Promise<InvoiceState> {
return {
error: tourNoopMessage,
invoiceId: null,
address: null,
amount: null,
recipientName: null,
description: null,
subaddressIndex: null,
warnings: null,
};
}
export async function archiveInvoiceTourAction(
_prevState: ArchiveInvoiceState,
formData: FormData
): Promise<ArchiveInvoiceState> {
const archivedId = String(formData.get("invoice_id") ?? "").trim() || null;
return {
error: null,
success: tourNoopMessage,
archivedId,
};
}
export async function createWebhookTourAction(
_prevState: WebhookFormState,
_formData: FormData
): Promise<WebhookFormState> {
return { error: null, success: tourNoopMessage };
}
export async function deleteWebhookTourAction(
_prevState: WebhookFormState,
_formData: FormData
): Promise<WebhookFormState> {
return { error: null, success: tourNoopMessage };
}
export async function redeliverWebhookDeliveryTourAction(
_prevState: WebhookRedeliverState,
_formData: FormData
): Promise<WebhookRedeliverState> {
return { error: null, success: tourNoopMessage };
}
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 amountMode = String(formData.get("amount_mode") ?? "xmr").trim();
const isFiat = amountMode === "fiat";
const instantConfirmation = formData.get("instant_confirmation") === "1";
const confirmationRaw = String(formData.get("confirmation_target") ?? "").trim();
let confirmationTarget: number | null = null;
if (instantConfirmation) {
confirmationTarget = 0;
} else if (confirmationRaw) {
const parsed = Number(confirmationRaw);
if (Number.isNaN(parsed) || parsed < 0 || parsed > 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,
};
}
confirmationTarget = parsed;
}
const amountRaw = String(
formData.get(isFiat ? "amount_fiat" : "amount_xmr") ?? ""
).trim();
const amount = Number(amountRaw);
if (Number.isNaN(amount) || amount <= 0) {
return {
error: isFiat ? "Enter a valid fiat amount." : "Enter a valid WOW amount.",
invoiceId: null,
address: null,
amount: null,
recipientName: null,
description: null,
subaddressIndex: null,
warnings: null,
};
}
const currency = String(formData.get("currency") ?? "").trim().toUpperCase();
if (isFiat && !currency) {
return {
error: "Currency is required when using a fiat amount.",
invoiceId: null,
address: null,
amount: null,
recipientName: null,
description: null,
subaddressIndex: null,
warnings: null,
};
}
const recipientName = String(formData.get("recipient_name") ?? "").trim();
const description = String(formData.get("description") ?? "").trim();
const checkoutContinueUrlRaw = String(formData.get("checkout_continue_url") ?? "").trim();
if (checkoutContinueUrlRaw) {
try {
const parsed = new URL(checkoutContinueUrlRaw);
const isLocalhost =
parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLocalhost)) {
return {
error: "Continue URL must use https (http is allowed for localhost).",
invoiceId: null,
address: null,
amount: null,
recipientName: null,
description: null,
subaddressIndex: null,
warnings: null,
};
}
} catch {
return {
error: "Continue URL must be a valid URL.",
invoiceId: null,
address: null,
amount: null,
recipientName: null,
description: null,
subaddressIndex: null,
warnings: null,
};
}
}
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("wowcheckout_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: isFiat ? null : amountRaw,
amount_fiat: isFiat ? amountRaw : null,
currency: isFiat ? currency : null,
...(confirmationTarget !== null ? { confirmation_target: confirmationTarget } : {}),
expires_at: expiresAt,
checkout_continue_url: checkoutContinueUrlRaw || null,
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;
amount_xmr: string;
warnings?: string[] | null;
};
return {
error: null,
invoiceId: data.id,
address: data.address,
amount: data.amount_xmr,
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("wowcheckout_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("wowcheckout_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("wowcheckout_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("wowcheckout_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("wowcheckout_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("wowcheckout_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("wowcheckout_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("wowcheckout_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("wowcheckout_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("wowcheckout_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("wowcheckout_api_key", "", {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 0,
});
cookieStore.set("wowcheckout_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("wowcheckout_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("wowcheckout_api_key")?.value;
if (!apiKey) {
return new Response("Unauthorized", { status: 401 });
}
const sourceUrl = new URL(request.url);
const targetUrl = new URL(`${apiBaseUrl}/api/core/invoices/export.csv`);
sourceUrl.searchParams.forEach((value, key) => {
targetUrl.searchParams.set(key, value);
});
const response = await fetch(targetUrl.toString(), {
headers: { Authorization: `ApiKey ${apiKey}` },
cache: "no-store",
});
if (!response.ok) {
const message = await response.text().catch(() => "Unable to export invoices.");
return new Response(message, { status: response.status });
}
const headers = new Headers();
headers.set("Content-Type", response.headers.get("content-type") ?? "text/csv");
const disposition = response.headers.get("content-disposition");
headers.set(
"Content-Disposition",
disposition ?? 'attachment; filename="invoices.csv"'
);
return new Response(response.body, { status: 200, headers });
}
+481
View File
@@ -0,0 +1,481 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import Link from "next/link";
import { redirect } from "next/navigation";
import ApiKeySection from "../../../components/api-key-section";
import BtcpayCheckoutStyleSection from "../../../components/btcpay-checkout-style-section";
import DefaultConfirmationTargetSection from "../../../components/default-confirmation-target-section";
import DefaultQrLogoSection from "../../../components/default-qr-logo-section";
import InvoicePanel from "../../../components/invoice-panel";
import WebhookHistoryPanel from "../../../components/webhook-history-panel";
import WebhookSecretSection from "../../../components/webhook-secret-section";
import WebhookSection from "../../../components/webhook-section";
export const metadata: Metadata = {
title: "Dashboard",
};
const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:8000";
const tabs = ["overview", "invoices", "webhooks", "profile"] as const;
const webhookTabs = ["settings", "history"] as const;
type DashboardTab = (typeof tabs)[number];
type WebhookTab = (typeof webhookTabs)[number];
type DashboardSearchParams = Record<string, string | string[] | undefined>;
type InvoiceSummary = {
id: string;
address: string;
subaddress_index?: number | null;
amount_xmr: string;
status: "pending" | "payment_detected" | "confirmed" | "expired" | "invalid";
confirmation_target: number;
paid_after_expiry?: boolean;
paid_after_expiry_at?: string | null;
created_at: string;
archived_at: string | null;
detected_at: string | null;
confirmed_at: string | null;
expires_at: string | null;
};
type WebhookSummary = {
id: string;
url: string;
events: string[];
event_urls?: Record<string, string> | null;
active: boolean;
created_at: string;
};
type WebhookDeliverySummary = {
id: string;
webhook_id: string | null;
event: string;
url: string;
invoice_id: string | null;
invoice_address: string | null;
invoice_subaddress_index: number | null;
invoice_amount_xmr: string | null;
invoice_status:
| "pending"
| "payment_detected"
| "confirmed"
| "expired"
| "invalid"
| null;
http_status: number | null;
error_message: string | null;
created_at: string;
};
type ProfileSummary = {
id: string;
payment_address: string;
default_confirmation_target: number;
default_qr_logo: "monero" | "none" | "custom";
default_qr_logo_data_url: string | null;
btcpay_checkout_style?: "standard" | "btcpay_classic";
created_at: string;
};
export default async function DashboardPage({
searchParams,
}: {
searchParams: Promise<DashboardSearchParams>;
}) {
const apiKey = (await cookies()).get("wowcheckout_api_key")?.value;
const webhookSecret = (await cookies()).get("wowcheckout_webhook_secret")?.value ?? null;
if (!apiKey) {
redirect("/?login=1");
}
const resolvedSearchParams = await searchParams;
const tabParam = resolvedSearchParams?.tab;
const activeTabValue = Array.isArray(tabParam) ? tabParam[0] : tabParam;
const activeTab: DashboardTab = tabs.includes(activeTabValue as DashboardTab)
? (activeTabValue as DashboardTab)
: "overview";
const webhookTabParam = resolvedSearchParams?.webhook_tab;
const activeWebhookTabValue = Array.isArray(webhookTabParam)
? webhookTabParam[0]
: webhookTabParam;
const activeWebhookTab: WebhookTab = webhookTabs.includes(
activeWebhookTabValue as WebhookTab
)
? (activeWebhookTabValue as WebhookTab)
: "settings";
const includeArchived =
activeTab === "invoices" &&
(resolvedSearchParams?.archived === "1" ||
resolvedSearchParams?.archived === "true");
const qParam = resolvedSearchParams?.q;
const queryValue = Array.isArray(qParam) ? qParam[0] : qParam;
const invoiceSearchQuery = activeTab === "invoices" ? (queryValue ?? "") : "";
const sortParam = resolvedSearchParams?.sort;
const sortValue = Array.isArray(sortParam) ? sortParam[0] : sortParam;
const invoiceSort =
activeTab === "invoices" && typeof sortValue === "string" && sortValue
? sortValue
: "created_at";
const orderParam = resolvedSearchParams?.order;
const orderValue = Array.isArray(orderParam) ? orderParam[0] : orderParam;
const invoiceOrder =
activeTab === "invoices" && typeof orderValue === "string" && orderValue
? orderValue
: "desc";
// API key is already available from the cookie
const apiKeyData = activeTab === "profile" ? { api_key: apiKey } : null;
let webhooksData: WebhookSummary[] = [];
let webhookHistory: WebhookDeliverySummary[] = [];
const invoiceLimit = 100;
let invoicesData: { items: InvoiceSummary[]; total: number } | null = null;
let offset = 0;
let total = 0;
const items: InvoiceSummary[] = [];
do {
const invoiceUrl = new URL(`${apiBaseUrl}/api/core/invoices`);
invoiceUrl.searchParams.set("limit", invoiceLimit.toString());
invoiceUrl.searchParams.set("offset", offset.toString());
if (includeArchived) {
invoiceUrl.searchParams.set("include_archived", "true");
}
if (invoiceSearchQuery) {
invoiceUrl.searchParams.set("q", invoiceSearchQuery);
}
if (invoiceSort) {
invoiceUrl.searchParams.set("sort", invoiceSort);
}
if (invoiceOrder) {
invoiceUrl.searchParams.set("order", invoiceOrder);
}
const response = await fetch(invoiceUrl.toString(), {
headers: {
Authorization: `ApiKey ${apiKey}`,
},
cache: "no-store",
});
if (response.status === 401) {
redirect("/?login=1");
}
if (!response.ok) {
break;
}
const data = (await response.json()) as {
items: InvoiceSummary[];
total: number;
};
total = data.total;
items.push(...data.items);
offset += invoiceLimit;
} while (offset < total);
invoicesData = { items, total };
if (
activeTab === "overview" ||
(activeTab === "webhooks" && activeWebhookTab === "settings")
) {
const response = await fetch(`${apiBaseUrl}/api/core/webhooks`, {
headers: {
Authorization: `ApiKey ${apiKey}`,
},
cache: "no-store",
});
if (response.status === 401) {
redirect("/?login=1");
}
if (response.ok) {
webhooksData = (await response.json()) as WebhookSummary[];
}
}
if (activeTab === "webhooks" && activeWebhookTab === "history") {
const historyUrl = new URL(`${apiBaseUrl}/api/core/webhooks/history`);
historyUrl.searchParams.set("limit", "50");
const response = await fetch(historyUrl.toString(), {
headers: {
Authorization: `ApiKey ${apiKey}`,
},
cache: "no-store",
});
if (response.status === 401) {
redirect("/?login=1");
}
if (response.ok) {
webhookHistory = (await response.json()) as WebhookDeliverySummary[];
}
}
const allInvoices = invoicesData?.items ?? [];
let profileData: ProfileSummary | null = null;
if (activeTab === "overview" || activeTab === "profile" || activeTab === "invoices") {
const response = await fetch(`${apiBaseUrl}/api/core/profile`, {
headers: {
Authorization: `ApiKey ${apiKey}`,
},
cache: "no-store",
});
if (response.status === 401) {
redirect("/?login=1");
}
if (response.ok) {
profileData = (await response.json()) as ProfileSummary;
}
}
const isToday = (value: string) => {
const date = new Date(value);
const today = new Date();
return (
date.getFullYear() === today.getFullYear() &&
date.getMonth() === today.getMonth() &&
date.getDate() === today.getDate()
);
};
const invoicesTodayCount = allInvoices.filter((invoice) =>
isToday(invoice.created_at)
).length;
const awaitingConfirmationCount = allInvoices.filter(
(invoice) => invoice.status === "payment_detected"
).length;
const confirmedCount = allInvoices.filter(
(invoice) => invoice.status === "confirmed"
).length;
const tabBaseClass =
"inline-flex items-center rounded-xl border border-stroke bg-white/70 px-4 py-2 text-xs font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:-translate-y-0.5";
const tabActiveClass =
"border-ink bg-ink text-cream shadow-[0_12px_24px_rgba(16,18,23,0.18)]";
const subTabBaseClass =
"inline-flex items-center rounded-full border border-stroke bg-white/70 px-4 py-2 text-xs font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:-translate-y-0.5";
const subTabActiveClass =
"border-ink bg-ink text-cream shadow-[0_10px_18px_rgba(16,18,23,0.18)]";
return (
<main className="px-[6vw] py-12 text-ink">
<section className="mx-auto grid w-full max-w-6xl gap-8 lg:grid-cols-[260px_minmax(0,1fr)]">
<aside className="grid gap-6">
<nav className="flex flex-col gap-2" aria-label="Dashboard sections">
{tabs.map((tab) => (
<Link
key={tab}
className={`${tabBaseClass} ${activeTab === tab ? tabActiveClass : ""}`}
href={`/dashboard?tab=${tab}`}
>
{tab}
</Link>
))}
</nav>
</aside>
<div className="grid gap-6">
{activeTab === "overview" ? (
<div className="rounded-2xl border border-stroke bg-white/80 p-8 shadow-card backdrop-blur">
<h1 className="font-serif text-3xl">Operational overview.</h1>
<p className="mt-2 text-ink-soft">
Track invoice flow and confirmation progress at a glance.
</p>
<div className="mt-6 rounded-2xl border border-stroke bg-white/70 p-5 shadow-soft">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Primary address
</p>
<p className="mt-3 break-all font-mono text-sm text-ink">
{profileData?.payment_address ?? "Unavailable"}
</p>
<p className="mt-2 text-sm text-ink-soft">
This is the wallet address used for your invoices.
</p>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-3">
{[
{
title: "Invoices today",
value: invoicesTodayCount.toString(),
detail: "Totals will appear once invoices are created.",
},
{
title: "Invoices awaiting confirmation",
value: awaitingConfirmationCount.toString(),
detail: "Waiting for the configured confirmation target.",
},
{
title: "Confirmed invoices",
value: confirmedCount.toString(),
detail: "Confirmed on-chain invoices will display here.",
},
].map((item) => (
<div
key={item.title}
className="rounded-2xl border border-stroke bg-white/70 p-5 shadow-soft"
>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
{item.title}
</p>
<h2 className="mt-3 text-2xl font-semibold">{item.value}</h2>
<p className="mt-2 text-sm text-ink-soft">{item.detail}</p>
</div>
))}
</div>
<div className="mt-4 rounded-2xl border border-stroke bg-white/70 p-5 shadow-soft">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Webhook endpoints
</p>
{webhooksData.length === 0 ? (
<p className="mt-3 text-sm text-ink-soft">
No webhooks configured yet.{" "}
<Link className="font-semibold text-ink underline" href="/dashboard?tab=webhooks">
Add a webhook endpoint.
</Link>
</p>
) : (
<div className="mt-3 grid gap-3 text-sm text-ink">
{webhooksData.map((webhook) => (
<div
className="rounded-xl border border-stroke bg-white/80 px-4 py-3"
key={webhook.id}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="break-all font-semibold">{webhook.url}</p>
<span className="rounded-full border border-stroke bg-white/60 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink">
{webhook.active ? "Active" : "Paused"}
</span>
</div>
<p className="mt-2 text-xs text-ink-soft">
Events: {webhook.events.join(", ")}
</p>
</div>
))}
</div>
)}
</div>
<div className="mt-5 rounded-2xl border border-ink/10 bg-ink/10 px-4 py-3 text-sm font-semibold text-ink">
We never hold funds. All payments move from the customer to your
wallet.
</div>
</div>
) : null}
{activeTab === "invoices" ? (
<InvoicePanel
activeInvoices={allInvoices}
includeArchived={includeArchived}
searchQuery={invoiceSearchQuery}
sort={invoiceSort}
order={invoiceOrder}
defaultConfirmationTarget={profileData?.default_confirmation_target ?? 1}
/>
) : null}
{activeTab === "profile" ? (
<>
{apiKeyData ? (
<div className="rounded-2xl border border-stroke bg-white/80 p-8 shadow-card backdrop-blur">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="font-serif text-3xl">Profile settings.</h1>
<p className="mt-2 text-ink-soft">
Manage credentials and delivery settings for your integration.
</p>
</div>
</div>
<div className="mt-6 grid gap-6 lg:grid-cols-2">
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft">
<h2 className="font-serif text-2xl">Store id</h2>
<p className="mt-2 min-h-[2.5rem] text-sm text-ink-soft">
Single-store per primary address. Use this id in the BTCPay
WooCommerce plugin store field.
</p>
<div className="mt-4">
<p className="break-all rounded-xl bg-ink/5 px-3 py-2 font-mono text-xs text-ink sm:text-sm">
{profileData?.id ?? "Unavailable"}
</p>
</div>
</div>
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft">
<h2 className="font-serif text-2xl">API key</h2>
<p className="mt-2 min-h-[2.5rem] text-sm text-ink-soft">
Use this key with authenticated endpoints.
</p>
<div className="mt-4">
<ApiKeySection apiKey={apiKeyData.api_key} />
</div>
</div>
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft">
<h2 className="font-serif text-2xl">Webhook secret</h2>
<p className="mt-2 min-h-[2.5rem] text-sm text-ink-soft">
Sent with each delivery as the{" "}
<code>X-Webhook-Secret</code> header.
</p>
<div className="mt-4">
<WebhookSecretSection webhookSecret={webhookSecret} />
</div>
</div>
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft">
<BtcpayCheckoutStyleSection
initialStyle={
profileData?.btcpay_checkout_style === "btcpay_classic"
? "btcpay_classic"
: "btcpay_classic"
}
/>
</div>
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft">
<DefaultConfirmationTargetSection
initialValue={profileData?.default_confirmation_target ?? 1}
/>
</div>
<div className="rounded-2xl border border-stroke bg-white/70 p-6 shadow-soft">
<DefaultQrLogoSection
initialLogo={profileData?.default_qr_logo ?? "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>
);
}
+62
View File
@@ -0,0 +1,62 @@
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 NavLink from "../../components/nav-link";
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="WOW Checkout home">
<Image
className="brand-logo"
src="/logo.png"
alt="WOW Checkout logo"
width={128}
height={128}
priority
/>
</Link>
<Link className="name" href="/">
WOW Checkout
</Link>
</div>
<nav className="nav">
<NavLink href="/invoice">Check Invoice</NavLink>
<NavLink href="/docs">Documentation</NavLink>
<NavLink href="/faq">FAQ</NavLink>
<NavLink href="/dashboard">Dashboard</NavLink>
<form action={logoutAction}>
<button type="submit">Log out</button>
</form>
</nav>
</header>
{children}
<footer className="site-footer">
<div className="site-footer-copy">
<p>wowcheckout.com - Non-custodial Wownero checkout software.</p>
<p>
<Link
className="underline underline-offset-4"
href="https://github.com/wowcheckout/wowcheckout"
>
GitHub
</Link>
</p>
</div>
<Suspense fallback={null}>
<DonateModal />
</Suspense>
</footer>
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:8000";
export type LoginState = {
error: string | null;
};
export async function loginAction(
_prevState: LoginState,
formData: FormData
): Promise<LoginState> {
const paymentAddress = String(formData.get("payment_address") ?? "").trim();
const viewKey = String(formData.get("view_key") ?? "").trim();
if (!paymentAddress || !viewKey) {
return { error: "Primary address and secret view key are required." };
}
const response = await fetch(`${apiBaseUrl}/api/core/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ payment_address: paymentAddress, view_key: viewKey }),
cache: "no-store",
});
if (!response.ok) {
const detail = await response.json().catch(() => null);
const message = detail?.detail ?? "Unable to sign in.";
return { error: message };
}
const data = (await response.json()) as {
api_key: string;
webhook_secret: string;
store_id?: string;
};
const cookieStore = await cookies();
cookieStore.set("wowcheckout_api_key", data.api_key, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
});
cookieStore.set("wowcheckout_webhook_secret", data.webhook_secret, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
});
redirect("/dashboard");
}
+198
View File
@@ -0,0 +1,198 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useFormState } from "react-dom";
import { loginAction, type LoginState } from "./actions";
const initialState: LoginState = { error: null };
const apiBaseUrl =
typeof window === "undefined" ? process.env.NEXT_PUBLIC_API_BASE_URL ?? "" : "";
const base58Pattern = /^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$/;
const viewKeyPattern = /^[0-9a-fA-F]{64}$/;
const validationDelayMs = 450;
const isLikelyPrimaryAddress = (value: string) => {
const trimmed = value.trim();
if (trimmed.length !== 95) {
return false;
}
if (!trimmed.startsWith("4")) {
return false;
}
return base58Pattern.test(trimmed);
};
const isLikelySecretViewKey = (value: string) => viewKeyPattern.test(value.trim());
export default function LoginForm() {
const [state, formAction] = useFormState(loginAction, initialState);
const [paymentAddress, setPaymentAddress] = useState("");
const [viewKey, setViewKey] = useState("");
const [validationStatus, setValidationStatus] = useState<
"idle" | "checking" | "valid" | "invalid" | "error"
>("idle");
const [validationMessage, setValidationMessage] = useState<string | null>(null);
const canSubmit = useMemo(() => {
if (!isLikelyPrimaryAddress(paymentAddress) || !isLikelySecretViewKey(viewKey)) {
return false;
}
return validationStatus !== "checking" && validationStatus !== "invalid";
}, [paymentAddress, validationStatus, viewKey]);
useEffect(() => {
if (!isLikelyPrimaryAddress(paymentAddress) || !isLikelySecretViewKey(viewKey)) {
setValidationStatus("idle");
setValidationMessage(null);
return;
}
const controller = new AbortController();
const timeoutId = window.setTimeout(async () => {
setValidationStatus("checking");
setValidationMessage(null);
try {
const response = await fetch(`${apiBaseUrl}/api/core/auth/validate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
payment_address: paymentAddress.trim(),
view_key: viewKey.trim(),
}),
cache: "no-store",
signal: controller.signal,
});
if (!response.ok) {
const detail = await response.json().catch(() => null);
const message =
detail?.detail ?? "Address and view key did not pass validation.";
setValidationStatus("invalid");
setValidationMessage(message);
return;
}
setValidationStatus("valid");
} catch (error) {
if (controller.signal.aborted) {
return;
}
setValidationStatus("error");
setValidationMessage(
"Unable to validate the address and view key yet. You can still sign in to confirm."
);
}
}, validationDelayMs);
return () => {
controller.abort();
window.clearTimeout(timeoutId);
};
}, [paymentAddress, viewKey]);
return (
<form className="mt-6 grid gap-5" action={formAction}>
<div className="grid gap-2">
<div className="flex items-center justify-between gap-3">
<label
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
htmlFor="payment_address"
>
Primary address
</label>
{isLikelyPrimaryAddress(paymentAddress) ? (
<span className="inline-flex items-center gap-1 text-[0.65rem] font-semibold uppercase tracking-[0.16em] text-emerald-600">
<svg
className="h-3.5 w-3.5"
viewBox="0 0 16 16"
aria-hidden="true"
>
<path
d="M6.4 11.4 3.2 8.2l1.1-1.1 2.1 2.1 5.2-5.2 1.1 1.1-6.3 6.3z"
fill="currentColor"
/>
</svg>
Valid
</span>
) : null}
</div>
<textarea
className="min-h-[96px] w-full resize-none rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
id="payment_address"
name="payment_address"
rows={3}
autoComplete="off"
required
value={paymentAddress}
onChange={(event) => setPaymentAddress(event.target.value)}
/>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between gap-3">
<label
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
htmlFor="view_key"
>
Secret view key
</label>
{isLikelySecretViewKey(viewKey) ? (
<span className="inline-flex items-center gap-1 text-[0.65rem] font-semibold uppercase tracking-[0.16em] text-emerald-600">
<svg
className="h-3.5 w-3.5"
viewBox="0 0 16 16"
aria-hidden="true"
>
<path
d="M6.4 11.4 3.2 8.2l1.1-1.1 2.1 2.1 5.2-5.2 1.1 1.1-6.3 6.3z"
fill="currentColor"
/>
</svg>
Valid
</span>
) : null}
</div>
<input
className="min-h-[48px] w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
id="view_key"
name="view_key"
type="password"
autoComplete="off"
required
value={viewKey}
onChange={(event) => setViewKey(event.target.value)}
/>
</div>
{validationStatus === "checking" ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-amber-700">
Checking address and view key
</p>
) : null}
{validationStatus === "valid" ? (
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-emerald-700">
Address and view key match
</p>
) : null}
{validationStatus === "invalid" || validationStatus === "error" ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{validationMessage}
</p>
) : null}
{state.error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{state.error}
</p>
) : null}
<div className="flex items-center justify-end">
<button
className="inline-flex items-center justify-center rounded-full bg-ink px-6 py-3 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5 disabled:cursor-not-allowed disabled:bg-ink/60 disabled:text-cream/70 disabled:shadow-none disabled:hover:translate-y-0"
type="submit"
disabled={!canSubmit}
>
Sign in
</button>
</div>
</form>
);
}
+10
View File
@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Login",
};
export default function LoginPage() {
redirect("/?login=1");
}
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Welcome",
};
export default async function WelcomePage() {
const session = (await cookies()).get("wowcheckout_session")?.value;
if (!session) {
redirect("/?login=1");
}
redirect("/dashboard");
}