mirror of
https://github.com/Such-Software/xmrcheckout.git
synced 2026-10-17 05:51:34 +02:00
Add optional post-confirmation continue URL
This commit is contained in:
+55
-1
@@ -7,7 +7,7 @@ import io
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import RedirectResponse, StreamingResponse
|
||||||
import requests
|
import requests
|
||||||
from requests import RequestException
|
from requests import RequestException
|
||||||
from sqlalchemy import func, or_
|
from sqlalchemy import func, or_
|
||||||
@@ -131,6 +131,20 @@ def _is_donation_invoice(invoice: Invoice) -> bool:
|
|||||||
return isinstance(metadata, dict) and metadata.get("origin") == "donation"
|
return isinstance(metadata, dict) and metadata.get("origin") == "donation"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_checkout_continue_url(invoice: Invoice) -> str | None:
|
||||||
|
metadata = invoice.metadata_json or {}
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
return None
|
||||||
|
checkout = metadata.get("checkout")
|
||||||
|
if not isinstance(checkout, dict):
|
||||||
|
return None
|
||||||
|
value = checkout.get("continue_url")
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
trimmed = value.strip()
|
||||||
|
return trimmed or None
|
||||||
|
|
||||||
|
|
||||||
def _ensure_webhook_secret(db: Session, user: User) -> str:
|
def _ensure_webhook_secret(db: Session, user: User) -> str:
|
||||||
if user.webhook_secret_encrypted:
|
if user.webhook_secret_encrypted:
|
||||||
return decrypt_secret(user.webhook_secret_encrypted)
|
return decrypt_secret(user.webhook_secret_encrypted)
|
||||||
@@ -288,6 +302,10 @@ def _public_invoice_status_response(
|
|||||||
update["qr_logo"] = logo
|
update["qr_logo"] = logo
|
||||||
if logo == "custom" and isinstance(logo_data_url, str):
|
if logo == "custom" and isinstance(logo_data_url, str):
|
||||||
update["qr_logo_data_url"] = logo_data_url
|
update["qr_logo_data_url"] = logo_data_url
|
||||||
|
continue_url = _get_checkout_continue_url(invoice)
|
||||||
|
update["checkout_continue_available"] = bool(
|
||||||
|
invoice.status == "confirmed" and continue_url
|
||||||
|
)
|
||||||
update["qr_url"] = _qr_url(invoice, request)
|
update["qr_url"] = _qr_url(invoice, request)
|
||||||
return response.model_copy(update=update)
|
return response.model_copy(update=update)
|
||||||
|
|
||||||
@@ -455,6 +473,36 @@ def get_invoice_status(
|
|||||||
return _public_invoice_status_response(db, invoice, request)
|
return _public_invoice_status_response(db, invoice, request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/core/public/invoice/{invoice_id}/continue",
|
||||||
|
response_class=RedirectResponse,
|
||||||
|
)
|
||||||
|
def continue_invoice_after_confirmation(
|
||||||
|
invoice_id: uuid.UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first()
|
||||||
|
if invoice is None or _is_donation_invoice(invoice):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Invoice not found",
|
||||||
|
)
|
||||||
|
if invoice.status != "confirmed":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Continue link not available",
|
||||||
|
)
|
||||||
|
continue_url = _get_checkout_continue_url(invoice)
|
||||||
|
if not continue_url:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Continue link not available",
|
||||||
|
)
|
||||||
|
response = RedirectResponse(url=continue_url, status_code=status.HTTP_302_FOUND)
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/api/core/public/donation/{invoice_id}",
|
"/api/core/public/donation/{invoice_id}",
|
||||||
response_model=InvoiceStatusResponse,
|
response_model=InvoiceStatusResponse,
|
||||||
@@ -933,6 +981,12 @@ def create_invoice_for_user(
|
|||||||
metadata = {"qr": {"logo": user.default_qr_logo}}
|
metadata = {"qr": {"logo": user.default_qr_logo}}
|
||||||
if user.default_qr_logo == "custom" and user.default_qr_logo_data_url:
|
if user.default_qr_logo == "custom" and user.default_qr_logo_data_url:
|
||||||
metadata["qr"]["logo_data_url"] = user.default_qr_logo_data_url
|
metadata["qr"]["logo_data_url"] = user.default_qr_logo_data_url
|
||||||
|
if payload.checkout_continue_url is not None:
|
||||||
|
checkout = metadata.get("checkout") if isinstance(metadata, dict) else None
|
||||||
|
if not isinstance(checkout, dict):
|
||||||
|
checkout = {}
|
||||||
|
checkout = {**checkout, "continue_url": str(payload.checkout_continue_url)}
|
||||||
|
metadata = {**(metadata if isinstance(metadata, dict) else {}), "checkout": checkout}
|
||||||
address, subaddress_index = create_subaddress_for_user(db, user=user)
|
address, subaddress_index = create_subaddress_for_user(db, user=user)
|
||||||
invoice = Invoice(
|
invoice = Invoice(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ class InvoiceCreate(BaseModel):
|
|||||||
amount_fiat: Decimal | None = Field(default=None, gt=0)
|
amount_fiat: Decimal | None = Field(default=None, gt=0)
|
||||||
currency: str | None = None
|
currency: str | None = None
|
||||||
confirmation_target: conint(ge=0, le=10) = 2
|
confirmation_target: conint(ge=0, le=10) = 2
|
||||||
|
checkout_continue_url: AnyUrl | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Optional: after the invoice is confirmed, the hosted invoice page can offer a Continue button that navigates to this merchant URL.",
|
||||||
|
)
|
||||||
metadata: dict[str, Any] | None = None
|
metadata: dict[str, Any] | None = None
|
||||||
expires_at: datetime | None = Field(
|
expires_at: datetime | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
@@ -49,6 +53,17 @@ class InvoiceCreate(BaseModel):
|
|||||||
raise ValueError("currency is required when amount_fiat is provided")
|
raise ValueError("currency is required when amount_fiat is provided")
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
@field_validator("checkout_continue_url")
|
||||||
|
@classmethod
|
||||||
|
def _validate_checkout_continue_url(cls, value: AnyUrl | None) -> AnyUrl | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if value.scheme == "https":
|
||||||
|
return value
|
||||||
|
if value.scheme == "http" and value.host in ("localhost", "127.0.0.1"):
|
||||||
|
return value
|
||||||
|
raise ValueError("checkout_continue_url must use https (http is allowed for localhost)")
|
||||||
|
|
||||||
|
|
||||||
class DonationCreate(BaseModel):
|
class DonationCreate(BaseModel):
|
||||||
amount_xmr: Decimal = Field(gt=0)
|
amount_xmr: Decimal = Field(gt=0)
|
||||||
@@ -90,6 +105,10 @@ class InvoiceCreateUser(BaseModel):
|
|||||||
amount_fiat: Decimal | None = Field(default=None, gt=0)
|
amount_fiat: Decimal | None = Field(default=None, gt=0)
|
||||||
currency: str | None = None
|
currency: str | None = None
|
||||||
confirmation_target: conint(ge=0, le=10) = 2
|
confirmation_target: conint(ge=0, le=10) = 2
|
||||||
|
checkout_continue_url: AnyUrl | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Optional: after the invoice is confirmed, the hosted invoice page can offer a Continue button that navigates to this merchant URL.",
|
||||||
|
)
|
||||||
metadata: dict[str, Any] | None = None
|
metadata: dict[str, Any] | None = None
|
||||||
expires_at: datetime | None = Field(
|
expires_at: datetime | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
@@ -113,6 +132,17 @@ class InvoiceCreateUser(BaseModel):
|
|||||||
raise ValueError("currency is required when amount_fiat is provided")
|
raise ValueError("currency is required when amount_fiat is provided")
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
@field_validator("checkout_continue_url")
|
||||||
|
@classmethod
|
||||||
|
def _validate_checkout_continue_url(cls, value: AnyUrl | None) -> AnyUrl | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if value.scheme == "https":
|
||||||
|
return value
|
||||||
|
if value.scheme == "http" and value.host in ("localhost", "127.0.0.1"):
|
||||||
|
return value
|
||||||
|
raise ValueError("checkout_continue_url must use https (http is allowed for localhost)")
|
||||||
|
|
||||||
|
|
||||||
class InvoiceListResponse(BaseModel):
|
class InvoiceListResponse(BaseModel):
|
||||||
items: list[InvoiceResponse]
|
items: list[InvoiceResponse]
|
||||||
@@ -145,6 +175,7 @@ class InvoiceStatusResponse(BaseModel):
|
|||||||
btcpay_redirect_automatically: bool | None = None
|
btcpay_redirect_automatically: bool | None = None
|
||||||
btcpay_order_id: str | None = None
|
btcpay_order_id: str | None = None
|
||||||
btcpay_order_number: str | None = None
|
btcpay_order_number: str | None = None
|
||||||
|
checkout_continue_available: bool = False
|
||||||
quote: dict[str, Any] | None = None
|
quote: dict[str, Any] | None = None
|
||||||
qr_url: str | None = None
|
qr_url: str | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,37 @@ export async function createInvoiceAction(
|
|||||||
}
|
}
|
||||||
const recipientName = String(formData.get("recipient_name") ?? "").trim();
|
const recipientName = String(formData.get("recipient_name") ?? "").trim();
|
||||||
const description = String(formData.get("description") ?? "").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 expiresAtRaw = String(formData.get("expires_at") ?? "").trim();
|
||||||
const expiresDate = String(formData.get("expires_date") ?? "").trim();
|
const expiresDate = String(formData.get("expires_date") ?? "").trim();
|
||||||
const expiresTime = String(formData.get("expires_time") ?? "").trim();
|
const expiresTime = String(formData.get("expires_time") ?? "").trim();
|
||||||
@@ -216,6 +247,7 @@ export async function createInvoiceAction(
|
|||||||
currency: isFiat ? currency : null,
|
currency: isFiat ? currency : null,
|
||||||
confirmation_target: confirmationTarget,
|
confirmation_target: confirmationTarget,
|
||||||
expires_at: expiresAt,
|
expires_at: expiresAt,
|
||||||
|
checkout_continue_url: checkoutContinueUrlRaw || null,
|
||||||
metadata:
|
metadata:
|
||||||
Object.keys(metadata).length > 0 || qrPayload
|
Object.keys(metadata).length > 0 || qrPayload
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -73,6 +73,11 @@ export default function IntegrationsPage() {
|
|||||||
Create an invoice via{" "}
|
Create an invoice via{" "}
|
||||||
<span className="font-mono text-ink">POST /api/core/invoices</span>.
|
<span className="font-mono text-ink">POST /api/core/invoices</span>.
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
Optional: include{" "}
|
||||||
|
<span className="font-mono text-ink">checkout_continue_url</span> to show a
|
||||||
|
customer-facing Continue button after confirmation.
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Redirect the customer to <span className="font-mono text-ink">invoice_url</span>.
|
Redirect the customer to <span className="font-mono text-ink">invoice_url</span>.
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ const endpointGroups = [
|
|||||||
path: "/api/core/public/invoice/{invoice_id}",
|
path: "/api/core/public/invoice/{invoice_id}",
|
||||||
description: "Read invoice status and payment details without auth.",
|
description: "Read invoice status and payment details without auth.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/core/public/invoice/{invoice_id}/continue",
|
||||||
|
description: "After confirmation, redirect to the merchant-provided Continue URL (if configured).",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -241,6 +246,17 @@ const endpointRequirements = [
|
|||||||
optional: [],
|
optional: [],
|
||||||
notes: ["Returns invoice status and payment details."],
|
notes: ["Returns invoice status and payment details."],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/core/public/invoice/{invoice_id}/continue",
|
||||||
|
auth: "None",
|
||||||
|
required: ["invoice_id (path)"],
|
||||||
|
optional: [],
|
||||||
|
notes: [
|
||||||
|
"Only available after an invoice is confirmed.",
|
||||||
|
"Returns a 302 redirect to the merchant-provided checkout_continue_url (if configured).",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "/api/core/invoices",
|
path: "/api/core/invoices",
|
||||||
@@ -352,6 +368,7 @@ const endpointRequirements = [
|
|||||||
required: ["amount_xmr or amount_fiat + currency"],
|
required: ["amount_xmr or amount_fiat + currency"],
|
||||||
optional: [
|
optional: [
|
||||||
"confirmation_target (default 2, 0-10)",
|
"confirmation_target (default 2, 0-10)",
|
||||||
|
"checkout_continue_url (https URL, optional post-confirmation Continue button)",
|
||||||
"metadata",
|
"metadata",
|
||||||
"expires_at (ISO 8601)",
|
"expires_at (ISO 8601)",
|
||||||
],
|
],
|
||||||
@@ -614,6 +631,7 @@ Content-Type: application/json
|
|||||||
"amount_fiat": "100.00",
|
"amount_fiat": "100.00",
|
||||||
"currency": "USD",
|
"currency": "USD",
|
||||||
"confirmation_target": 2,
|
"confirmation_target": 2,
|
||||||
|
"checkout_continue_url": "https://merchant.example/thanks",
|
||||||
"metadata": { "order_id": "ORDER-1234" }
|
"metadata": { "order_id": "ORDER-1234" }
|
||||||
}`}</code>
|
}`}</code>
|
||||||
</pre>
|
</pre>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type InvoiceStatusResponse = {
|
|||||||
status: InvoiceStatus;
|
status: InvoiceStatus;
|
||||||
confirmation_target: number;
|
confirmation_target: number;
|
||||||
confirmations: number;
|
confirmations: number;
|
||||||
|
checkout_continue_available?: boolean;
|
||||||
qr_logo?: "monero" | "none" | "custom" | null;
|
qr_logo?: "monero" | "none" | "custom" | null;
|
||||||
qr_logo_data_url?: string | null;
|
qr_logo_data_url?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -162,6 +163,8 @@ export default async function InvoiceStatusDetailPage({
|
|||||||
const isInvalid = invoice.status === "invalid";
|
const isInvalid = invoice.status === "invalid";
|
||||||
const hasDetectedPayment =
|
const hasDetectedPayment =
|
||||||
invoice.status === "payment_detected" || invoice.status === "confirmed";
|
invoice.status === "payment_detected" || invoice.status === "confirmed";
|
||||||
|
const checkoutContinueAvailable =
|
||||||
|
invoice.status === "confirmed" && Boolean(invoice.checkout_continue_available);
|
||||||
const timelineItems: {
|
const timelineItems: {
|
||||||
label: string;
|
label: string;
|
||||||
timestamp: ReturnType<typeof formatTimestamp>;
|
timestamp: ReturnType<typeof formatTimestamp>;
|
||||||
@@ -326,6 +329,15 @@ export default async function InvoiceStatusDetailPage({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mt-6 grid gap-4">
|
<section className="mt-6 grid gap-4">
|
||||||
|
{checkoutContinueAvailable ? (
|
||||||
|
<a
|
||||||
|
className="inline-flex w-fit items-center justify-center rounded-full bg-ink px-5 py-2.5 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
|
||||||
|
href={`/api/core/public/invoice/${encodeURIComponent(invoiceId)}/continue`}
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Continue to merchant
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
<InvoiceStatusActions />
|
<InvoiceStatusActions />
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type InvoiceStatusResponse = {
|
|||||||
status: InvoiceStatus;
|
status: InvoiceStatus;
|
||||||
confirmation_target: number;
|
confirmation_target: number;
|
||||||
confirmations: number;
|
confirmations: number;
|
||||||
|
checkout_continue_available?: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
expires_at: string | null;
|
expires_at: string | null;
|
||||||
detected_at: string | null;
|
detected_at: string | null;
|
||||||
@@ -149,6 +150,10 @@ export default async function BtcpayModalInvoicePage({
|
|||||||
const useClassicCheckout =
|
const useClassicCheckout =
|
||||||
isBtcpayInvoice && invoice.btcpay_checkout_style === "btcpay_classic";
|
isBtcpayInvoice && invoice.btcpay_checkout_style === "btcpay_classic";
|
||||||
const shouldShowBtcpayActions = isBtcpayInvoice && invoice.status === "confirmed";
|
const shouldShowBtcpayActions = isBtcpayInvoice && invoice.status === "confirmed";
|
||||||
|
const checkoutContinueAvailable =
|
||||||
|
!isBtcpayInvoice &&
|
||||||
|
invoice.status === "confirmed" &&
|
||||||
|
Boolean(invoice.checkout_continue_available);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-cream px-6 py-6 text-ink">
|
<main className="min-h-screen bg-cream px-6 py-6 text-ink">
|
||||||
@@ -241,6 +246,15 @@ export default async function BtcpayModalInvoicePage({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{checkoutContinueAvailable ? (
|
||||||
|
<a
|
||||||
|
className="mt-5 inline-flex w-fit items-center justify-center rounded-full bg-ink px-5 py-2 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
|
||||||
|
href={`/api/core/public/invoice/${encodeURIComponent(invoiceId)}/continue`}
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Continue to merchant
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
|
|||||||
@@ -376,6 +376,22 @@ export default function CreateInvoiceCard() {
|
|||||||
placeholder="Order 1042"
|
placeholder="Order 1042"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className={labelClass} htmlFor="checkout_continue_url">
|
||||||
|
Continue URL (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
id="checkout_continue_url"
|
||||||
|
name="checkout_continue_url"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://merchant.example/thanks"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-ink-soft">
|
||||||
|
After confirmation, the hosted invoice page shows a "Continue" button
|
||||||
|
that navigates to this URL.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<label className={labelClass} htmlFor="qr_color">
|
<label className={labelClass} htmlFor="qr_color">
|
||||||
QR accent color
|
QR accent color
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export default function InvoicePanel({
|
|||||||
const [expiresTime, setExpiresTime] = useState("");
|
const [expiresTime, setExpiresTime] = useState("");
|
||||||
const [recipientName, setRecipientName] = useState("");
|
const [recipientName, setRecipientName] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
|
const [checkoutContinueUrl, setCheckoutContinueUrl] = useState("");
|
||||||
const [archiveModalInvoiceId, setArchiveModalInvoiceId] = useState<string | null>(null);
|
const [archiveModalInvoiceId, setArchiveModalInvoiceId] = useState<string | null>(null);
|
||||||
const [searchInput, setSearchInput] = useState(searchQuery ?? "");
|
const [searchInput, setSearchInput] = useState(searchQuery ?? "");
|
||||||
const [qrLogoMode, setQrLogoMode] = useState<
|
const [qrLogoMode, setQrLogoMode] = useState<
|
||||||
@@ -260,6 +261,7 @@ export default function InvoicePanel({
|
|||||||
setExpiresTime("");
|
setExpiresTime("");
|
||||||
setRecipientName("");
|
setRecipientName("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
|
setCheckoutContinueUrl("");
|
||||||
setQrLogoMode("account_default");
|
setQrLogoMode("account_default");
|
||||||
setQrLogoDataUrl(null);
|
setQrLogoDataUrl(null);
|
||||||
};
|
};
|
||||||
@@ -1044,6 +1046,23 @@ export default function InvoicePanel({
|
|||||||
onChange={(event) => setDescription(event.target.value)}
|
onChange={(event) => setDescription(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className={labelClass} htmlFor="wizard_checkout_continue_url">
|
||||||
|
Continue URL (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
id="wizard_checkout_continue_url"
|
||||||
|
name="checkout_continue_url"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://merchant.example/thanks"
|
||||||
|
value={checkoutContinueUrl}
|
||||||
|
onChange={(event) => setCheckoutContinueUrl(event.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-ink-soft">
|
||||||
|
After confirmation, the hosted invoice page can show a Continue button.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<label className={labelClass} htmlFor="wizard_qr_logo_mode">
|
<label className={labelClass} htmlFor="wizard_qr_logo_mode">
|
||||||
QR logo
|
QR logo
|
||||||
@@ -1118,6 +1137,11 @@ export default function InvoicePanel({
|
|||||||
<input type="hidden" name="expires_at" value={expiryValue} />
|
<input type="hidden" name="expires_at" value={expiryValue} />
|
||||||
<input type="hidden" name="recipient_name" value={recipientName} />
|
<input type="hidden" name="recipient_name" value={recipientName} />
|
||||||
<input type="hidden" name="description" value={description} />
|
<input type="hidden" name="description" value={description} />
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="checkout_continue_url"
|
||||||
|
value={checkoutContinueUrl}
|
||||||
|
/>
|
||||||
<input type="hidden" name="qr_logo_mode" value={qrLogoMode} />
|
<input type="hidden" name="qr_logo_mode" value={qrLogoMode} />
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
@@ -1177,6 +1201,9 @@ export default function InvoicePanel({
|
|||||||
<p className="mt-2 text-sm text-ink">
|
<p className="mt-2 text-sm text-ink">
|
||||||
Description: <strong>{description || "-"}</strong>
|
Description: <strong>{description || "-"}</strong>
|
||||||
</p>
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-ink">
|
||||||
|
Continue URL: <strong>{checkoutContinueUrl || "-"}</strong>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
Reference in New Issue
Block a user