mirror of
https://github.com/Such-Software/wowcheckout.git
synced 2026-10-17 05:51:45 +02:00
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:
@@ -0,0 +1,35 @@
|
|||||||
|
# Database
|
||||||
|
POSTGRES_USER=wowcheckout
|
||||||
|
POSTGRES_PASSWORD=wowcheckout
|
||||||
|
POSTGRES_DB=wowcheckout
|
||||||
|
DATABASE_URL=postgresql://wowcheckout:wowcheckout@db:5432/wowcheckout
|
||||||
|
|
||||||
|
# Optional: Postgres backups (used by the `db-backup` Compose profile)
|
||||||
|
BACKUP_RETENTION_DAYS=7
|
||||||
|
|
||||||
|
# API authentication
|
||||||
|
API_KEYS=dev-key-1
|
||||||
|
API_KEY_ENCRYPTION_KEY=replace-with-fernet-key
|
||||||
|
RATE_PROVIDER=nonlogs
|
||||||
|
INVOICE_RECONCILE_INTERVAL_SECONDS=30
|
||||||
|
|
||||||
|
# Founder donation wallet (view-only)
|
||||||
|
FOUNDER_PAYMENT_ADDRESS=
|
||||||
|
FOUNDER_VIEW_KEY=
|
||||||
|
DONATIONS_ENABLED=false
|
||||||
|
|
||||||
|
# Wownero wallet RPC (view-only wallet generation + subaddresses)
|
||||||
|
MONERO_WALLET_RPC_URLS=http://wallet-rpc-reconciler-1:18083,http://wallet-rpc-reconciler-2:18083,http://wallet-rpc-reconciler-3:18083
|
||||||
|
MONERO_WALLET_RPC_USER=
|
||||||
|
MONERO_WALLET_RPC_PASSWORD=
|
||||||
|
MONERO_WALLET_RPC_WALLET_PASSWORD=
|
||||||
|
MONERO_WALLET_RPC_WALLET_DIR=/wallets
|
||||||
|
|
||||||
|
# Wownero daemon
|
||||||
|
# - Default: uses a local daemon
|
||||||
|
# - Local: run the bundled `wownerod` service and set MONERO_DAEMON_URL=http://wownerod:34568
|
||||||
|
MONERO_DAEMON_URL=http://192.168.88.235:34568
|
||||||
|
|
||||||
|
# UI
|
||||||
|
API_BASE_URL=http://api:8000
|
||||||
|
SITE_URL=https://wowcheckout.such.software
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
.env
|
||||||
|
ui/.env
|
||||||
|
|
||||||
|
ui/.next/
|
||||||
|
ui/node_modules/
|
||||||
|
|
||||||
|
**/__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|
||||||
|
nginx/certs/
|
||||||
|
|
||||||
|
backups/
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 xmrcheckout contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="ui/public/logo.png" width="120" alt="XMR Checkout logo" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
# wowcheckout
|
||||||
|
|
||||||
|
Non-custodial Wownero checkout software for merchants. Payments go directly from the customer to the merchant wallet.
|
||||||
|
|
||||||
|
wowcheckout is open source and self-hostable. It creates invoices, shows payment instructions, and watches the chain for incoming payments using view-only wallet access (address + secret view key). It can also notify your systems via API and webhooks.
|
||||||
|
|
||||||
|
Hard rules:
|
||||||
|
- It never requests or stores private spend keys.
|
||||||
|
- It never signs transactions.
|
||||||
|
- It never moves funds on behalf of users.
|
||||||
|
- View-only access (wallet address + private view key) is the maximum trust boundary.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [Who it's for](#who-its-for)
|
||||||
|
- [What it does](#what-it-does)
|
||||||
|
- [Trust model](#trust-model)
|
||||||
|
- [Repository layout](#repository-layout)
|
||||||
|
- [Screenshots](#screenshots)
|
||||||
|
- [Quick start](#quick-start)
|
||||||
|
- [Self-hosted deployment](#self-hosted-deployment-docker-compose)
|
||||||
|
- [Self-hosted deployment (no Docker)](#self-hosted-deployment-no-docker)
|
||||||
|
- [Development (API)](#development-api-python)
|
||||||
|
|
||||||
|
## Who it's for
|
||||||
|
|
||||||
|
If you want Wownero payments to go straight to your own wallet, this is for you.
|
||||||
|
|
||||||
|
Common fits:
|
||||||
|
- A merchant who wants a clean hosted checkout UI (self-hosted by you).
|
||||||
|
- A team that wants API + webhooks to plug into an existing order flow.
|
||||||
|
- Anyone who prefers a conservative trust model (view-only access for detection).
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
Typical flow:
|
||||||
|
1. Your integration creates an invoice (defined in WOW).
|
||||||
|
2. The UI shows payment instructions (address + amount).
|
||||||
|
3. The system observes the chain using view-only wallet access to detect payments and update invoice status.
|
||||||
|
4. Optional integrations (for example webhooks) can be used to trigger your internal order flow.
|
||||||
|
|
||||||
|
Not included (by design):
|
||||||
|
- It does not provide custody, refunds, or any fund-moving automation.
|
||||||
|
- It does not act as a financial intermediary.
|
||||||
|
- It does not touch fiat rails in the core system.
|
||||||
|
|
||||||
|
## Trust model
|
||||||
|
|
||||||
|
- Funds always move from the customer to the merchant wallet; wowcheckout only observes the chain and reports status.
|
||||||
|
- You keep spend authority. The maximum permission level wowcheckout uses is view-only wallet access (wallet address + private view key).
|
||||||
|
- If any configuration or integration implies spend authority, treat it as a misconfiguration and stop.
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
- `ui/`: web UI
|
||||||
|
- `api/`: API service (Python)
|
||||||
|
- `docker-compose.yml`: local stack and self-hosted deployment
|
||||||
|
- `nginx/`: optional reverse proxy / TLS termination (used by Docker Compose)
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
Post-login UI:
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Invoices
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Create invoice
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Public invoice page
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
### Homepage only (Docker)
|
||||||
|
|
||||||
|
```
|
||||||
|
docker build -t wowcheckout-home .
|
||||||
|
docker run --rm -p 8080:80 wowcheckout-home
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:8080`.
|
||||||
|
|
||||||
|
### Full stack (Docker Compose)
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:8180` for the UI.
|
||||||
|
In the default Compose configuration, only `nginx` is published to the host. The API, Postgres, and wallet-rpc services are reachable only inside the Docker network.
|
||||||
|
If you prefer not to run `nginx`, you can publish `ui` and `api` ports directly instead (you will also need to serve `qr/` at `/qr/` on your site URL).
|
||||||
|
|
||||||
|
## Self-hosted deployment (Docker Compose)
|
||||||
|
|
||||||
|
1. Copy the environment template and fill in required values:
|
||||||
|
|
||||||
|
```
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Set required values in `.env`:
|
||||||
|
- `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`
|
||||||
|
- `API_KEYS`, `API_KEY_ENCRYPTION_KEY`
|
||||||
|
- `SITE_URL` (public URL for the UI)
|
||||||
|
- Wownero view-only wallet settings (`MONERO_WALLET_RPC_*`)
|
||||||
|
- `MONERO_DAEMON_URL` (choose one of the options below)
|
||||||
|
|
||||||
|
3. Choose a Wownero daemon source:
|
||||||
|
- **Use an existing daemon (default):**
|
||||||
|
- Leave `MONERO_DAEMON_URL` as-is (the default points at a local Wownero daemon).
|
||||||
|
- **Run your own daemon via Docker Compose:**
|
||||||
|
- Set `MONERO_DAEMON_URL=http://wownerod:34568`
|
||||||
|
- Start the stack with the `local-daemon` profile (see step 5)
|
||||||
|
- Note: initial sync can take a long time and uses significant disk; payment detection won’t be reliable until the daemon is synced.
|
||||||
|
|
||||||
|
4. Choose a wallet-rpc target and provision view-only wallets:
|
||||||
|
- Use the bundled wallet-rpc containers:
|
||||||
|
- Set `MONERO_WALLET_RPC_URLS=http://wallet-rpc-reconciler-1:18083,http://wallet-rpc-reconciler-2:18083,http://wallet-rpc-reconciler-3:18083`
|
||||||
|
- Or point to an external wallet-rpc service:
|
||||||
|
- Set `MONERO_WALLET_RPC_URLS`, `MONERO_WALLET_RPC_USER`, `MONERO_WALLET_RPC_PASSWORD`, and `MONERO_WALLET_RPC_WALLET_PASSWORD`
|
||||||
|
|
||||||
|
5. Start the stack:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
If you’re running the bundled `wownerod` service:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose --profile local-daemon up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional: Postgres backups (disabled by default)
|
||||||
|
|
||||||
|
This repository includes an optional `db-backup` service that runs `pg_dump` hourly and writes backups to `./backups/postgres` on the host.
|
||||||
|
|
||||||
|
Enable it by starting Compose with the `db-backup` profile:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose --profile db-backup up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Retention defaults to 7 days. To override:
|
||||||
|
- Set `BACKUP_RETENTION_DAYS` in `.env`
|
||||||
|
|
||||||
|
### Optional: donations (disabled by default)
|
||||||
|
|
||||||
|
Donation endpoints and UI are off by default for self-hosted deployments.
|
||||||
|
To enable donations:
|
||||||
|
- Set `DONATIONS_ENABLED=true`
|
||||||
|
- Set `FOUNDER_PAYMENT_ADDRESS` and `FOUNDER_VIEW_KEY`
|
||||||
|
|
||||||
|
The UI uses the same flag (via Compose), so `/donate` stays unavailable unless donations are explicitly enabled.
|
||||||
|
|
||||||
|
## Self-hosted deployment (no Docker)
|
||||||
|
|
||||||
|
This section describes running the services directly on a host (or VMs) without Docker.
|
||||||
|
|
||||||
|
You will run:
|
||||||
|
- Postgres (external service)
|
||||||
|
- API (`gunicorn` / `uvicorn`)
|
||||||
|
- Reconciler worker (a separate process)
|
||||||
|
- UI (Next.js)
|
||||||
|
- A reverse proxy is optional, but strongly recommended for TLS, serving `/qr/`, and providing a single origin for UI + API.
|
||||||
|
|
||||||
|
### 1. Install prerequisites
|
||||||
|
|
||||||
|
- Postgres 16+
|
||||||
|
- Python 3.12+
|
||||||
|
- Node.js 20+ (for the UI)
|
||||||
|
- A Wownero daemon endpoint (`MONERO_DAEMON_URL`) and at least one `wownero-wallet-rpc` instance reachable by the API
|
||||||
|
|
||||||
|
### 2. Configure environment variables
|
||||||
|
|
||||||
|
Start from `.env.example` and adjust for your host. For non-Docker deployments you will typically set:
|
||||||
|
- `DATABASE_URL=postgresql://...@127.0.0.1:5432/...`
|
||||||
|
- `QR_STORAGE_DIR` to a persistent directory on disk (for example `/var/lib/wowcheckout/qr`)
|
||||||
|
- `SITE_URL` to your public URL (for example `https://example.com`)
|
||||||
|
- `API_BASE_URL` for the UI:
|
||||||
|
- If using a reverse proxy that routes `/api/` to the API on the same origin, set `API_BASE_URL=https://example.com`
|
||||||
|
- If not using a reverse proxy, set `API_BASE_URL=http://127.0.0.1:8000` (see the notes in step 6)
|
||||||
|
|
||||||
|
If you need a new `API_KEY_ENCRYPTION_KEY` value:
|
||||||
|
|
||||||
|
```
|
||||||
|
python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Create the Postgres database
|
||||||
|
|
||||||
|
Create a database and user matching your `DATABASE_URL`. On first startup, the API will create tables automatically.
|
||||||
|
|
||||||
|
### 4. Run the API
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m venv api/.venv
|
||||||
|
api/.venv/bin/pip install -r api/requirements.txt
|
||||||
|
|
||||||
|
# Export env vars (or use your process manager to load them)
|
||||||
|
set -a
|
||||||
|
source .env
|
||||||
|
set +a
|
||||||
|
|
||||||
|
api/.venv/bin/gunicorn app.main:app -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8000 -w "${GUNICORN_WORKERS:-2}" --chdir api
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Run the reconciler worker
|
||||||
|
|
||||||
|
Run this as a separate long-running process (same environment variables as the API):
|
||||||
|
|
||||||
|
```
|
||||||
|
set -a
|
||||||
|
source .env
|
||||||
|
set +a
|
||||||
|
|
||||||
|
api/.venv/bin/python -m app.reconciler
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Build and run the UI
|
||||||
|
|
||||||
|
```
|
||||||
|
cd ui
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
# Optional but recommended to keep these explicit in non-Docker deployments
|
||||||
|
export API_BASE_URL="${API_BASE_URL:-http://127.0.0.1:8000}"
|
||||||
|
export NEXT_PUBLIC_SITE_URL="${SITE_URL:-http://127.0.0.1:3000}"
|
||||||
|
export NEXT_PUBLIC_DONATIONS_ENABLED="${DONATIONS_ENABLED:-false}"
|
||||||
|
|
||||||
|
npm run build
|
||||||
|
npm run start -- -p 3000 -H 127.0.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- The UI makes some in-browser requests to `/api/...` on the same origin. In production, prefer a reverse proxy that serves the UI and forwards `/api/` to the API.
|
||||||
|
- The QR PNGs are written to `QR_STORAGE_DIR` and must be reachable at `https://<your-site>/qr/<invoice_id>.png`.
|
||||||
|
|
||||||
|
### Optional: reverse proxy (Nginx)
|
||||||
|
|
||||||
|
Nginx is optional. Any reverse proxy that can:
|
||||||
|
- route `/api/` to the API,
|
||||||
|
- route `/` to the UI,
|
||||||
|
- and serve the QR directory at `/qr/`
|
||||||
|
|
||||||
|
is fine.
|
||||||
|
|
||||||
|
Example (adjust `server_name`, TLS, and paths to match your host):
|
||||||
|
|
||||||
|
```
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name example.com;
|
||||||
|
|
||||||
|
# ssl_certificate ...;
|
||||||
|
# ssl_certificate_key ...;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:8000/api/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /qr/ {
|
||||||
|
alias /var/lib/wowcheckout/qr/;
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3000/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development (API, Python)
|
||||||
|
|
||||||
|
1. Set environment variables (see `api/.env.example`):
|
||||||
|
|
||||||
|
```
|
||||||
|
export DATABASE_URL=postgresql://wowcheckout:wowcheckout@localhost:5432/wowcheckout
|
||||||
|
export API_KEYS=change-me-1
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install dependencies:
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r api/requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Start the API:
|
||||||
|
|
||||||
|
```
|
||||||
|
cd api
|
||||||
|
uvicorn app.main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
The API listens on `http://127.0.0.1:8000`.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
DATABASE_URL=postgresql://wowcheckout:wowcheckout@localhost:5432/wowcheckout
|
||||||
|
API_KEYS=change-me-1,change-me-2
|
||||||
|
RATE_PROVIDER=nonlogs
|
||||||
|
INVOICE_RECONCILE_INTERVAL_SECONDS=30
|
||||||
|
FOUNDER_PAYMENT_ADDRESS=
|
||||||
|
FOUNDER_VIEW_KEY=
|
||||||
|
DONATIONS_ENABLED=false
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt /app/requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r /app/requirements.txt
|
||||||
|
|
||||||
|
COPY app /app/app
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["/bin/sh", "-c", "gunicorn app.main:app -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000 -w ${GUNICORN_WORKERS:-2}"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""API package."""
|
||||||
@@ -0,0 +1,704 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from decimal import Decimal, ROUND_DOWN
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .btcpay_schemas import (
|
||||||
|
BtcpayInvoiceCreate,
|
||||||
|
BtcpayInvoiceStatusUpdate,
|
||||||
|
BtcpayWebhookCreate,
|
||||||
|
BtcpayWebhookUpdate,
|
||||||
|
)
|
||||||
|
from .btcpay_webhooks import dispatch_btcpay_webhooks
|
||||||
|
from .config import INVOICE_DEFAULT_EXPIRY_HOURS
|
||||||
|
from .config import QR_STORAGE_DIR
|
||||||
|
from .db import get_db
|
||||||
|
from .formatting import format_xmr_amount
|
||||||
|
from .models import BtcpayWebhook, Invoice, InvoiceTransfer, User
|
||||||
|
from .rates import get_wow_rate
|
||||||
|
from .security import (
|
||||||
|
decrypt_api_key,
|
||||||
|
encrypt_secret,
|
||||||
|
generate_webhook_secret,
|
||||||
|
hash_api_key,
|
||||||
|
require_api_key,
|
||||||
|
)
|
||||||
|
from .subaddress_allocator import create_subaddress_for_user
|
||||||
|
from .webhooks import dispatch_webhooks
|
||||||
|
from .qr_codes import ensure_invoice_qr_png, resolve_qr_settings
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
BTCPAY_PAYMENT_METHOD = "WOW-CHAIN"
|
||||||
|
BTCPAY_PAYMENT_METHOD_ALIASES = {BTCPAY_PAYMENT_METHOD, "WOW", "WOW_CHAIN"}
|
||||||
|
BTCPAY_WEBHOOK_EVENTS = {
|
||||||
|
"InvoiceCreated",
|
||||||
|
"InvoiceReceivedPayment",
|
||||||
|
"InvoicePaidInFull",
|
||||||
|
"InvoicePaymentSettled",
|
||||||
|
"InvoiceProcessing",
|
||||||
|
"InvoiceExpired",
|
||||||
|
"InvoiceSettled",
|
||||||
|
"InvoiceInvalid",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _require_btcpay_user(
|
||||||
|
api_key: str = Depends(require_api_key),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> User:
|
||||||
|
user = db.query(User).filter(User.api_key_hash == hash_api_key(api_key)).first()
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="User API key required",
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _require_store(store_id: str, user: User) -> str:
|
||||||
|
if store_id != str(user.id):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Store not found",
|
||||||
|
)
|
||||||
|
return store_id
|
||||||
|
|
||||||
|
|
||||||
|
def _store_payload(user: User) -> dict[str, Any]:
|
||||||
|
expiry_minutes = max(1, INVOICE_DEFAULT_EXPIRY_HOURS * 60)
|
||||||
|
return {
|
||||||
|
"id": str(user.id),
|
||||||
|
"name": "WOW Checkout",
|
||||||
|
"website": "",
|
||||||
|
"defaultCurrency": "WOW",
|
||||||
|
"invoiceExpiration": expiry_minutes,
|
||||||
|
"displayExpirationTimer": True,
|
||||||
|
"monitoringExpiration": expiry_minutes,
|
||||||
|
"speedPolicy": "MediumSpeed",
|
||||||
|
"paymentTolerance": 0,
|
||||||
|
"anyoneCanCreateInvoice": False,
|
||||||
|
"requiresRefundEmail": False,
|
||||||
|
"lightningAmountInSatoshi": False,
|
||||||
|
"lightningPrivateRouteHints": False,
|
||||||
|
"onChainWithLnInvoiceFallback": False,
|
||||||
|
"redirectAutomatically": False,
|
||||||
|
"showRecommendedFee": False,
|
||||||
|
"recommendedFeeBlockTarget": 0,
|
||||||
|
"defaultLang": "en",
|
||||||
|
"customLogo": "",
|
||||||
|
"customCSS": "",
|
||||||
|
"htmlTitle": "WOW Checkout",
|
||||||
|
"networkFeeMode": "Never",
|
||||||
|
"payJoinEnabled": False,
|
||||||
|
"lazyPaymentMethods": False,
|
||||||
|
"defaultPaymentMethod": BTCPAY_PAYMENT_METHOD,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _invoice_checkout_link(invoice: Invoice, request: Request) -> str:
|
||||||
|
proto = request.headers.get("x-forwarded-proto", request.url.scheme)
|
||||||
|
host = request.headers.get("x-forwarded-host", request.headers.get("host", ""))
|
||||||
|
if not host:
|
||||||
|
return f"/i/{invoice.id}"
|
||||||
|
return f"{proto}://{host}/i/{invoice.id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_btcpay_payment_method(value: str) -> str:
|
||||||
|
normalized = value.strip().upper()
|
||||||
|
if normalized in BTCPAY_PAYMENT_METHOD_ALIASES:
|
||||||
|
return BTCPAY_PAYMENT_METHOD
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _epoch_seconds(value: datetime | None) -> int:
|
||||||
|
if value is None:
|
||||||
|
return 0
|
||||||
|
if value.tzinfo is None:
|
||||||
|
value = value.replace(tzinfo=timezone.utc)
|
||||||
|
return int(value.timestamp())
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_btcpay_amount(
|
||||||
|
amount: Decimal,
|
||||||
|
currency: str,
|
||||||
|
) -> tuple[Decimal, dict[str, str] | None]:
|
||||||
|
if currency.upper() == "WOW":
|
||||||
|
return amount, None
|
||||||
|
try:
|
||||||
|
quote = get_wow_rate(currency)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Fiat quote service unavailable",
|
||||||
|
) from exc
|
||||||
|
amount_xmr = (Decimal(amount) / quote.rate).quantize(
|
||||||
|
Decimal("0.000000000001"), rounding=ROUND_DOWN
|
||||||
|
)
|
||||||
|
quote_payload = {
|
||||||
|
"fiat_amount": str(amount),
|
||||||
|
"fiat_currency": quote.currency,
|
||||||
|
"rate": str(quote.rate),
|
||||||
|
"source": quote.source,
|
||||||
|
"quoted_at": quote.quoted_at.isoformat(),
|
||||||
|
}
|
||||||
|
return amount_xmr, quote_payload
|
||||||
|
|
||||||
|
|
||||||
|
def _btcpay_status(invoice: Invoice) -> tuple[str, str]:
|
||||||
|
additional_status = _btcpay_additional_status(invoice)
|
||||||
|
if invoice.status == "payment_detected":
|
||||||
|
return "Processing", additional_status
|
||||||
|
if invoice.status == "confirmed":
|
||||||
|
return "Settled", additional_status
|
||||||
|
if invoice.status == "expired":
|
||||||
|
return "Expired", additional_status
|
||||||
|
if invoice.status == "invalid":
|
||||||
|
return "Invalid", additional_status
|
||||||
|
return "New", additional_status
|
||||||
|
|
||||||
|
|
||||||
|
def _btcpay_additional_status(invoice: Invoice) -> str:
|
||||||
|
if invoice.status == "invalid":
|
||||||
|
return "Marked"
|
||||||
|
paid_atomic = invoice.total_paid_atomic or 0
|
||||||
|
required_atomic = _xmr_to_atomic(invoice.amount_xmr)
|
||||||
|
if paid_atomic > required_atomic:
|
||||||
|
return "PaidOver"
|
||||||
|
if 0 < paid_atomic < required_atomic:
|
||||||
|
return "PaidPartial"
|
||||||
|
if _after_expiration(invoice):
|
||||||
|
return "PaidLate"
|
||||||
|
return "None"
|
||||||
|
|
||||||
|
|
||||||
|
def _after_expiration(invoice: Invoice) -> bool:
|
||||||
|
expires_at = invoice.expires_at
|
||||||
|
detected_at = invoice.detected_at
|
||||||
|
if expires_at is None or detected_at is None:
|
||||||
|
return False
|
||||||
|
if expires_at.tzinfo is None:
|
||||||
|
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||||
|
if detected_at.tzinfo is None:
|
||||||
|
detected_at = detected_at.replace(tzinfo=timezone.utc)
|
||||||
|
return detected_at > expires_at
|
||||||
|
|
||||||
|
|
||||||
|
def _xmr_to_atomic(amount: Decimal) -> int:
|
||||||
|
quantized = (Decimal(amount) * Decimal("1000000000000")).to_integral_value(
|
||||||
|
rounding=ROUND_DOWN
|
||||||
|
)
|
||||||
|
return int(quantized)
|
||||||
|
|
||||||
|
|
||||||
|
def _btcpay_amount_currency(invoice: Invoice) -> tuple[str, str, dict[str, Any]]:
|
||||||
|
metadata = invoice.metadata_json or {}
|
||||||
|
btcpay_data = metadata.get("btcpay") if isinstance(metadata, dict) else None
|
||||||
|
if isinstance(btcpay_data, dict):
|
||||||
|
amount = btcpay_data.get("amount")
|
||||||
|
currency = btcpay_data.get("currency")
|
||||||
|
if isinstance(amount, str) and isinstance(currency, str):
|
||||||
|
return amount, currency, btcpay_data
|
||||||
|
return format_xmr_amount(invoice.amount_xmr), "WOW", {}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_xmr_fixed(value: Decimal) -> str:
|
||||||
|
return f"{value.quantize(Decimal('0.000000000001')):.12f}"
|
||||||
|
|
||||||
|
|
||||||
|
def _atomic_to_xmr(value: int) -> Decimal:
|
||||||
|
return (Decimal(value) / Decimal("1000000000000")).quantize(
|
||||||
|
Decimal("0.000000000001"), rounding=ROUND_DOWN
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/stores")
|
||||||
|
def list_stores(user: User = Depends(_require_btcpay_user)):
|
||||||
|
return [_store_payload(user)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/stores/{store_id}")
|
||||||
|
def get_store(store_id: str, user: User = Depends(_require_btcpay_user)):
|
||||||
|
_require_store(store_id, user)
|
||||||
|
return _store_payload(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/stores/{store_id}/payment-methods")
|
||||||
|
def list_payment_methods(store_id: str, user: User = Depends(_require_btcpay_user)):
|
||||||
|
_require_store(store_id, user)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"paymentMethodId": BTCPAY_PAYMENT_METHOD,
|
||||||
|
"paymentMethod": BTCPAY_PAYMENT_METHOD,
|
||||||
|
"enabled": True,
|
||||||
|
"cryptoCode": "WOW",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/stores/{store_id}/invoices", status_code=200)
|
||||||
|
def create_invoice(
|
||||||
|
store_id: str,
|
||||||
|
payload: BtcpayInvoiceCreate,
|
||||||
|
request: Request,
|
||||||
|
user: User = Depends(_require_btcpay_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
_require_store(store_id, user)
|
||||||
|
checkout = payload.checkout
|
||||||
|
payment_methods = (
|
||||||
|
[_normalize_btcpay_payment_method(method) for method in checkout.paymentMethods]
|
||||||
|
if checkout and checkout.paymentMethods
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if payment_methods and BTCPAY_PAYMENT_METHOD not in payment_methods:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Unsupported payment method",
|
||||||
|
)
|
||||||
|
amount_xmr, quote_payload = _resolve_btcpay_amount(payload.amount, payload.currency)
|
||||||
|
expires_at = None
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
expiration_minutes = None
|
||||||
|
monitoring_minutes = None
|
||||||
|
if checkout and checkout.expirationMinutes:
|
||||||
|
expiration_minutes = int(checkout.expirationMinutes)
|
||||||
|
if expiration_minutes:
|
||||||
|
expires_at = now + timedelta(minutes=expiration_minutes)
|
||||||
|
else:
|
||||||
|
expires_at = now + timedelta(hours=INVOICE_DEFAULT_EXPIRY_HOURS)
|
||||||
|
if checkout and checkout.monitoringMinutes is not None:
|
||||||
|
monitoring_minutes = int(checkout.monitoringMinutes)
|
||||||
|
metadata: dict[str, Any] = dict(payload.metadata or {})
|
||||||
|
btcpay_data = metadata.get("btcpay") if isinstance(metadata.get("btcpay"), dict) else {}
|
||||||
|
btcpay_data = dict(btcpay_data)
|
||||||
|
btcpay_data.update(
|
||||||
|
{
|
||||||
|
"amount": str(payload.amount),
|
||||||
|
"currency": payload.currency,
|
||||||
|
"checkout": checkout.model_dump(mode="json") if checkout else None,
|
||||||
|
"expiration_minutes": expiration_minutes,
|
||||||
|
"monitoring_minutes": monitoring_minutes,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
metadata["btcpay"] = btcpay_data
|
||||||
|
if quote_payload and "quote" not in metadata:
|
||||||
|
metadata["quote"] = quote_payload
|
||||||
|
qr = metadata.get("qr")
|
||||||
|
if not isinstance(qr, dict):
|
||||||
|
qr = {"logo": user.default_qr_logo}
|
||||||
|
if user.default_qr_logo == "custom" and user.default_qr_logo_data_url:
|
||||||
|
qr["logo_data_url"] = user.default_qr_logo_data_url
|
||||||
|
metadata["qr"] = qr
|
||||||
|
address, subaddress_index = create_subaddress_for_user(db, user=user)
|
||||||
|
invoice = Invoice(
|
||||||
|
user_id=user.id,
|
||||||
|
wallet_address=user.payment_address,
|
||||||
|
address=address,
|
||||||
|
subaddress_index=subaddress_index,
|
||||||
|
amount_xmr=amount_xmr,
|
||||||
|
status="pending",
|
||||||
|
confirmation_target=user.default_confirmation_target,
|
||||||
|
total_paid_atomic=0,
|
||||||
|
metadata_json=metadata,
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
db.add(invoice)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(invoice)
|
||||||
|
try:
|
||||||
|
ensure_invoice_qr_png(
|
||||||
|
invoice=invoice,
|
||||||
|
storage_dir=QR_STORAGE_DIR,
|
||||||
|
settings=resolve_qr_settings(invoice),
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
pass
|
||||||
|
dispatch_webhooks(db, str(user.id), "invoice.created", invoice)
|
||||||
|
dispatch_btcpay_webhooks(db, str(user.id), "InvoiceCreated", invoice)
|
||||||
|
status_name, additional_status = _btcpay_status(invoice)
|
||||||
|
amount, currency, _ = _btcpay_amount_currency(invoice)
|
||||||
|
expiration_time = _epoch_seconds(invoice.expires_at)
|
||||||
|
monitoring_time = expiration_time
|
||||||
|
if monitoring_minutes is None:
|
||||||
|
monitoring_minutes = 60
|
||||||
|
monitoring_time = expiration_time + int(monitoring_minutes * 60)
|
||||||
|
return {
|
||||||
|
"id": str(invoice.id),
|
||||||
|
"amount": amount,
|
||||||
|
"currency": currency,
|
||||||
|
"type": "Standard",
|
||||||
|
"checkoutLink": _invoice_checkout_link(invoice, request),
|
||||||
|
"createdTime": _epoch_seconds(invoice.created_at),
|
||||||
|
"expirationTime": expiration_time,
|
||||||
|
"monitoringTime": monitoring_time,
|
||||||
|
"archived": False,
|
||||||
|
"status": status_name,
|
||||||
|
"additionalStatus": additional_status,
|
||||||
|
"availableStatusesForManualMarking": ["Invalid"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/stores/{store_id}/invoices/{invoice_id}")
|
||||||
|
def get_invoice(
|
||||||
|
store_id: str,
|
||||||
|
invoice_id: str,
|
||||||
|
request: Request,
|
||||||
|
user: User = Depends(_require_btcpay_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
_require_store(store_id, user)
|
||||||
|
try:
|
||||||
|
invoice_uuid = uuid.UUID(invoice_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Invoice not found",
|
||||||
|
)
|
||||||
|
invoice = (
|
||||||
|
db.query(Invoice)
|
||||||
|
.filter(Invoice.id == invoice_uuid, Invoice.user_id == user.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if invoice is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Invoice not found",
|
||||||
|
)
|
||||||
|
status_name, additional_status = _btcpay_status(invoice)
|
||||||
|
amount, currency, btcpay_data = _btcpay_amount_currency(invoice)
|
||||||
|
expiration_time = _epoch_seconds(invoice.expires_at)
|
||||||
|
monitoring_minutes = (
|
||||||
|
btcpay_data.get("monitoring_minutes")
|
||||||
|
if isinstance(btcpay_data, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if monitoring_minutes is None:
|
||||||
|
monitoring_minutes = 60
|
||||||
|
monitoring_time = expiration_time + int(int(monitoring_minutes) * 60)
|
||||||
|
return {
|
||||||
|
"id": str(invoice.id),
|
||||||
|
"amount": amount,
|
||||||
|
"currency": currency,
|
||||||
|
"type": "Standard",
|
||||||
|
"checkoutLink": _invoice_checkout_link(invoice, request),
|
||||||
|
"createdTime": _epoch_seconds(invoice.created_at),
|
||||||
|
"expirationTime": expiration_time,
|
||||||
|
"monitoringTime": monitoring_time,
|
||||||
|
"archived": invoice.archived_at is not None,
|
||||||
|
"status": status_name,
|
||||||
|
"additionalStatus": additional_status,
|
||||||
|
"availableStatusesForManualMarking": ["Invalid"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/stores/{store_id}/invoices/{invoice_id}/payment-methods")
|
||||||
|
def get_invoice_payment_methods(
|
||||||
|
store_id: str,
|
||||||
|
invoice_id: str,
|
||||||
|
user: User = Depends(_require_btcpay_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
_require_store(store_id, user)
|
||||||
|
try:
|
||||||
|
invoice_uuid = uuid.UUID(invoice_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Invoice not found",
|
||||||
|
)
|
||||||
|
invoice = (
|
||||||
|
db.query(Invoice)
|
||||||
|
.filter(Invoice.id == invoice_uuid, Invoice.user_id == user.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if invoice is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Invoice not found",
|
||||||
|
)
|
||||||
|
transfers = (
|
||||||
|
db.query(InvoiceTransfer)
|
||||||
|
.filter(InvoiceTransfer.invoice_id == invoice.id)
|
||||||
|
.order_by(InvoiceTransfer.created_at.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
total_atomic = sum(
|
||||||
|
transfer.amount_atomic for transfer in transfers if transfer.amount_atomic > 0
|
||||||
|
)
|
||||||
|
total_paid = _atomic_to_xmr(total_atomic)
|
||||||
|
due = max(Decimal("0"), invoice.amount_xmr - total_paid)
|
||||||
|
metadata = invoice.metadata_json or {}
|
||||||
|
quote = metadata.get("quote") if isinstance(metadata, dict) else None
|
||||||
|
rate_value = "0"
|
||||||
|
if isinstance(quote, dict) and "rate" in quote:
|
||||||
|
rate_value = str(quote.get("rate", "0"))
|
||||||
|
payments = []
|
||||||
|
created_fallback = _epoch_seconds(invoice.created_at)
|
||||||
|
for transfer in transfers:
|
||||||
|
if transfer.amount_atomic <= 0:
|
||||||
|
continue
|
||||||
|
confirmations = transfer.confirmations
|
||||||
|
status_label = (
|
||||||
|
"confirmed"
|
||||||
|
if confirmations >= max(0, invoice.confirmation_target)
|
||||||
|
else "pending"
|
||||||
|
)
|
||||||
|
payments.append(
|
||||||
|
{
|
||||||
|
"id": f"{transfer.txid}-0",
|
||||||
|
"value": _format_xmr_fixed(_atomic_to_xmr(transfer.amount_atomic)),
|
||||||
|
"fee": "0",
|
||||||
|
"destination": transfer.address or invoice.address,
|
||||||
|
"status": status_label,
|
||||||
|
"receivedDate": transfer.timestamp or created_fallback,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"paymentMethodId": BTCPAY_PAYMENT_METHOD,
|
||||||
|
"paymentMethod": BTCPAY_PAYMENT_METHOD,
|
||||||
|
"cryptoCode": "WOW",
|
||||||
|
"destination": invoice.address,
|
||||||
|
"rate": rate_value,
|
||||||
|
"amount": _format_xmr_fixed(invoice.amount_xmr),
|
||||||
|
"due": _format_xmr_fixed(due),
|
||||||
|
"totalPaid": _format_xmr_fixed(total_paid),
|
||||||
|
"paymentMethodPaid": _format_xmr_fixed(total_paid),
|
||||||
|
"networkFee": "0",
|
||||||
|
"payments": payments,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/stores/{store_id}/invoices/{invoice_id}/status", status_code=200)
|
||||||
|
def mark_invoice_status(
|
||||||
|
store_id: str,
|
||||||
|
invoice_id: str,
|
||||||
|
payload: BtcpayInvoiceStatusUpdate,
|
||||||
|
user: User = Depends(_require_btcpay_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
_require_store(store_id, user)
|
||||||
|
if payload.status.strip().lower() != "invalid":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Unsupported status",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
invoice_uuid = uuid.UUID(invoice_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Invoice not found",
|
||||||
|
)
|
||||||
|
invoice = (
|
||||||
|
db.query(Invoice)
|
||||||
|
.filter(Invoice.id == invoice_uuid, Invoice.user_id == user.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if invoice is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Invoice not found",
|
||||||
|
)
|
||||||
|
if invoice.status == "confirmed":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Confirmed invoices cannot be marked invalid",
|
||||||
|
)
|
||||||
|
invoice.status = "invalid"
|
||||||
|
db.add(invoice)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(invoice)
|
||||||
|
dispatch_btcpay_webhooks(
|
||||||
|
db,
|
||||||
|
str(user.id),
|
||||||
|
"InvoiceInvalid",
|
||||||
|
invoice,
|
||||||
|
manually_marked=True,
|
||||||
|
)
|
||||||
|
return {"status": "Invalid"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/stores/{store_id}/webhooks", status_code=200)
|
||||||
|
def create_webhook(
|
||||||
|
store_id: str,
|
||||||
|
payload: BtcpayWebhookCreate,
|
||||||
|
user: User = Depends(_require_btcpay_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
_require_store(store_id, user)
|
||||||
|
_validate_webhook_events(payload.authorizedEvents.specificEvents)
|
||||||
|
secret = generate_webhook_secret()
|
||||||
|
webhook = BtcpayWebhook(
|
||||||
|
user_id=user.id,
|
||||||
|
url=str(payload.url),
|
||||||
|
enabled=payload.enabled,
|
||||||
|
automatic_redelivery=payload.automaticRedelivery,
|
||||||
|
authorized_events=payload.authorizedEvents.model_dump(),
|
||||||
|
secret_encrypted=encrypt_secret(secret),
|
||||||
|
)
|
||||||
|
db.add(webhook)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(webhook)
|
||||||
|
return _webhook_response(webhook, include_secret=True, secret=secret)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/stores/{store_id}/webhooks")
|
||||||
|
def list_webhooks(
|
||||||
|
store_id: str,
|
||||||
|
user: User = Depends(_require_btcpay_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
_require_store(store_id, user)
|
||||||
|
hooks = (
|
||||||
|
db.query(BtcpayWebhook)
|
||||||
|
.filter(BtcpayWebhook.user_id == user.id)
|
||||||
|
.order_by(BtcpayWebhook.created_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [_webhook_response(hook) for hook in hooks]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/stores/{store_id}/webhooks/{webhook_id}")
|
||||||
|
def get_webhook(
|
||||||
|
store_id: str,
|
||||||
|
webhook_id: str,
|
||||||
|
user: User = Depends(_require_btcpay_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
_require_store(store_id, user)
|
||||||
|
hook = _get_webhook(db, webhook_id, user)
|
||||||
|
return _webhook_response(hook)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/v1/stores/{store_id}/webhooks/{webhook_id}")
|
||||||
|
def update_webhook(
|
||||||
|
store_id: str,
|
||||||
|
webhook_id: str,
|
||||||
|
payload: BtcpayWebhookUpdate,
|
||||||
|
user: User = Depends(_require_btcpay_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
_require_store(store_id, user)
|
||||||
|
hook = _get_webhook(db, webhook_id, user)
|
||||||
|
if payload.authorizedEvents and payload.authorizedEvents.specificEvents:
|
||||||
|
_validate_webhook_events(payload.authorizedEvents.specificEvents)
|
||||||
|
if payload.enabled is not None:
|
||||||
|
hook.enabled = payload.enabled
|
||||||
|
if payload.automaticRedelivery is not None:
|
||||||
|
hook.automatic_redelivery = payload.automaticRedelivery
|
||||||
|
if payload.url is not None:
|
||||||
|
hook.url = str(payload.url)
|
||||||
|
if payload.authorizedEvents is not None:
|
||||||
|
hook.authorized_events = payload.authorizedEvents.model_dump()
|
||||||
|
db.add(hook)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(hook)
|
||||||
|
return _webhook_response(hook)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/api/v1/stores/{store_id}/webhooks/{webhook_id}",
|
||||||
|
status_code=204,
|
||||||
|
)
|
||||||
|
def delete_webhook(
|
||||||
|
store_id: str,
|
||||||
|
webhook_id: str,
|
||||||
|
user: User = Depends(_require_btcpay_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
_require_store(store_id, user)
|
||||||
|
hook = _get_webhook(db, webhook_id, user)
|
||||||
|
db.delete(hook)
|
||||||
|
db.commit()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/server/info")
|
||||||
|
def server_info():
|
||||||
|
return {
|
||||||
|
"version": "1.7.5",
|
||||||
|
"onion": None,
|
||||||
|
"fullySynched": True,
|
||||||
|
"supportedPaymentMethods": [BTCPAY_PAYMENT_METHOD],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/api-keys/current")
|
||||||
|
def api_key_current(
|
||||||
|
user: User = Depends(_require_btcpay_user),
|
||||||
|
):
|
||||||
|
store_id = str(user.id)
|
||||||
|
return {
|
||||||
|
"apiKey": decrypt_api_key(user.api_key_encrypted),
|
||||||
|
"label": "WOW Checkout",
|
||||||
|
"permissions": [
|
||||||
|
f"btcpay.store.canviewinvoices:{store_id}",
|
||||||
|
f"btcpay.store.cancreateinvoice:{store_id}",
|
||||||
|
f"btcpay.store.canviewstoresettings:{store_id}",
|
||||||
|
f"btcpay.store.canmodifyinvoices:{store_id}",
|
||||||
|
f"btcpay.store.webhooks.canmodifywebhooks:{store_id}",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_webhook(db: Session, webhook_id: str, user: User) -> BtcpayWebhook:
|
||||||
|
try:
|
||||||
|
webhook_uuid = uuid.UUID(webhook_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Webhook not found",
|
||||||
|
)
|
||||||
|
hook = (
|
||||||
|
db.query(BtcpayWebhook)
|
||||||
|
.filter(BtcpayWebhook.id == webhook_uuid, BtcpayWebhook.user_id == user.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if hook is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Webhook not found",
|
||||||
|
)
|
||||||
|
return hook
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_webhook_events(events: list[str] | None) -> None:
|
||||||
|
if not events:
|
||||||
|
return
|
||||||
|
for event in events:
|
||||||
|
if event not in BTCPAY_WEBHOOK_EVENTS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Webhook event is not supported",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _webhook_response(
|
||||||
|
hook: BtcpayWebhook,
|
||||||
|
*,
|
||||||
|
include_secret: bool = False,
|
||||||
|
secret: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
payload = {
|
||||||
|
"id": str(hook.id),
|
||||||
|
"enabled": hook.enabled,
|
||||||
|
"automaticRedelivery": hook.automatic_redelivery,
|
||||||
|
"url": hook.url,
|
||||||
|
"authorizedEvents": hook.authorized_events,
|
||||||
|
}
|
||||||
|
if include_secret and secret:
|
||||||
|
payload["secret"] = secret
|
||||||
|
return payload
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import (
|
||||||
|
AnyUrl,
|
||||||
|
BaseModel,
|
||||||
|
Field,
|
||||||
|
conint,
|
||||||
|
field_validator,
|
||||||
|
model_validator,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BtcpayCheckout(BaseModel):
|
||||||
|
redirectURL: AnyUrl | None = None
|
||||||
|
speedPolicy: str | None = None
|
||||||
|
paymentMethods: list[str] | None = None
|
||||||
|
expirationMinutes: conint(ge=1, le=10080) | None = None
|
||||||
|
monitoringMinutes: conint(ge=0, le=10080) | None = None
|
||||||
|
paymentTolerance: Decimal | None = None
|
||||||
|
redirectAutomatically: bool | None = None
|
||||||
|
defaultLanguage: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BtcpayInvoiceCreate(BaseModel):
|
||||||
|
amount: Decimal = Field(gt=0)
|
||||||
|
currency: str = Field(min_length=1)
|
||||||
|
metadata: dict[str, Any] | None = None
|
||||||
|
checkout: BtcpayCheckout | None = None
|
||||||
|
|
||||||
|
@field_validator("currency")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_currency(cls, value: str) -> str:
|
||||||
|
return value.strip().upper()
|
||||||
|
|
||||||
|
|
||||||
|
class BtcpayWebhookEvents(BaseModel):
|
||||||
|
everything: bool = False
|
||||||
|
specificEvents: list[str] | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _validate_selection(self) -> "BtcpayWebhookEvents":
|
||||||
|
if not self.everything and not self.specificEvents:
|
||||||
|
raise ValueError("Select at least one event")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class BtcpayWebhookCreate(BaseModel):
|
||||||
|
enabled: bool = True
|
||||||
|
automaticRedelivery: bool = True
|
||||||
|
url: AnyUrl
|
||||||
|
authorizedEvents: BtcpayWebhookEvents
|
||||||
|
|
||||||
|
|
||||||
|
class BtcpayWebhookUpdate(BaseModel):
|
||||||
|
enabled: bool | None = None
|
||||||
|
automaticRedelivery: bool | None = None
|
||||||
|
url: AnyUrl | None = None
|
||||||
|
authorizedEvents: BtcpayWebhookEvents | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BtcpayInvoiceStatusUpdate(BaseModel):
|
||||||
|
status: str = Field(min_length=1)
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from requests import RequestException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .models import BtcpayWebhook, Invoice
|
||||||
|
from .security import decrypt_secret
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_REDIRECT_STATUSES = {301, 302, 303, 307, 308}
|
||||||
|
|
||||||
|
|
||||||
|
def dispatch_btcpay_webhooks(
|
||||||
|
db: Session,
|
||||||
|
user_id: str,
|
||||||
|
event_type: str,
|
||||||
|
invoice: Invoice,
|
||||||
|
*,
|
||||||
|
manually_marked: bool = False,
|
||||||
|
) -> None:
|
||||||
|
hooks = (
|
||||||
|
db.query(BtcpayWebhook)
|
||||||
|
.filter(
|
||||||
|
BtcpayWebhook.user_id == user_id,
|
||||||
|
BtcpayWebhook.enabled.is_(True),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if not hooks:
|
||||||
|
return
|
||||||
|
payload = _build_payload(
|
||||||
|
event_type=event_type,
|
||||||
|
user_id=user_id,
|
||||||
|
invoice=invoice,
|
||||||
|
manually_marked=manually_marked,
|
||||||
|
)
|
||||||
|
body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||||
|
for hook in hooks:
|
||||||
|
if not _event_allowed(hook.authorized_events, event_type):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
secret = decrypt_secret(hook.secret_encrypted)
|
||||||
|
signature = _sign_payload(body, secret)
|
||||||
|
headers = {
|
||||||
|
"BTCPay-Sig": f"sha256={signature}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "wowcheckout-btcpay-webhook/1.0",
|
||||||
|
}
|
||||||
|
response = _post_with_redirects(
|
||||||
|
hook.url,
|
||||||
|
data=body,
|
||||||
|
headers=headers,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if response is None:
|
||||||
|
continue
|
||||||
|
if response.status_code >= 400:
|
||||||
|
logger.warning(
|
||||||
|
"BTCPay webhook delivered non-success status",
|
||||||
|
extra={
|
||||||
|
"webhook_id": str(hook.id),
|
||||||
|
"event": event_type,
|
||||||
|
"http_status": response.status_code,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except RequestException as exc:
|
||||||
|
logger.warning(
|
||||||
|
"BTCPay webhook delivery failed",
|
||||||
|
extra={"webhook_id": str(hook.id), "event": event_type},
|
||||||
|
)
|
||||||
|
logger.debug("BTCPay webhook delivery error: %s", exc)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"BTCPay webhook dispatch failed",
|
||||||
|
extra={"webhook_id": str(hook.id), "event": event_type, "error": str(exc)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _event_allowed(authorized_events: object, event_type: str) -> bool:
|
||||||
|
if not isinstance(authorized_events, dict):
|
||||||
|
return False
|
||||||
|
if authorized_events.get("everything") is True:
|
||||||
|
return True
|
||||||
|
specific = authorized_events.get("specificEvents") or []
|
||||||
|
return event_type in specific
|
||||||
|
|
||||||
|
|
||||||
|
def _build_payload(
|
||||||
|
*,
|
||||||
|
event_type: str,
|
||||||
|
user_id: str,
|
||||||
|
invoice: Invoice,
|
||||||
|
manually_marked: bool,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"type": event_type,
|
||||||
|
"timestamp": int(time.time()),
|
||||||
|
"storeId": user_id,
|
||||||
|
"invoiceId": str(invoice.id),
|
||||||
|
"manuallyMarked": manually_marked,
|
||||||
|
"overPaid": False,
|
||||||
|
"partiallyPaid": False,
|
||||||
|
"afterExpiration": _after_expiration(invoice),
|
||||||
|
"metadata": invoice.metadata_json or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _after_expiration(invoice: Invoice) -> bool:
|
||||||
|
expires_at = invoice.expires_at
|
||||||
|
detected_at = invoice.detected_at
|
||||||
|
if expires_at is None or detected_at is None:
|
||||||
|
return False
|
||||||
|
if expires_at.tzinfo is None:
|
||||||
|
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||||
|
if detected_at.tzinfo is None:
|
||||||
|
detected_at = detected_at.replace(tzinfo=timezone.utc)
|
||||||
|
return detected_at > expires_at
|
||||||
|
|
||||||
|
|
||||||
|
def _sign_payload(body: bytes, secret: str) -> str:
|
||||||
|
return hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _post_with_redirects(
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
data: bytes,
|
||||||
|
headers: dict[str, str],
|
||||||
|
timeout: int,
|
||||||
|
max_redirects: int = 3,
|
||||||
|
) -> requests.Response | None:
|
||||||
|
current_url = url
|
||||||
|
for _ in range(max_redirects + 1):
|
||||||
|
response = requests.post(
|
||||||
|
current_url,
|
||||||
|
data=data,
|
||||||
|
headers=headers,
|
||||||
|
timeout=timeout,
|
||||||
|
allow_redirects=False,
|
||||||
|
)
|
||||||
|
if response.status_code not in _REDIRECT_STATUSES:
|
||||||
|
return response
|
||||||
|
location = response.headers.get("Location")
|
||||||
|
if not location:
|
||||||
|
return response
|
||||||
|
current_url = urljoin(current_url, location)
|
||||||
|
return None
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def _get_bool_env(name: str, default: bool = False) -> bool:
|
||||||
|
value = os.getenv(name)
|
||||||
|
if value is None or value == "":
|
||||||
|
return default
|
||||||
|
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_env(name: str, default: str | None = None) -> str:
|
||||||
|
value = os.getenv(name, default)
|
||||||
|
if value is None or value == "":
|
||||||
|
raise RuntimeError(f"Missing required environment variable: {name}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
DATABASE_URL = _get_env("DATABASE_URL")
|
||||||
|
|
||||||
|
API_KEYS = [
|
||||||
|
key.strip()
|
||||||
|
for key in os.getenv("API_KEYS", "").split(",")
|
||||||
|
if key.strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
API_KEY_ENCRYPTION_KEY = _get_env("API_KEY_ENCRYPTION_KEY")
|
||||||
|
INVOICE_RECONCILE_INTERVAL_SECONDS = int(os.getenv("INVOICE_RECONCILE_INTERVAL_SECONDS", "30"))
|
||||||
|
INVOICE_DEFAULT_EXPIRY_HOURS = int(os.getenv("INVOICE_DEFAULT_EXPIRY_HOURS", "1"))
|
||||||
|
LATE_PAYMENT_LOOKBACK_HOURS = int(os.getenv("LATE_PAYMENT_LOOKBACK_HOURS", "48"))
|
||||||
|
DONATION_EXPIRY_MINUTES = int(os.getenv("DONATION_EXPIRY_MINUTES", "30"))
|
||||||
|
DONATION_ACTIVE_INVOICE_LIMIT = int(os.getenv("DONATION_ACTIVE_INVOICE_LIMIT", "25"))
|
||||||
|
RATE_PROVIDER = os.getenv("RATE_PROVIDER", "kraken")
|
||||||
|
DONATIONS_ENABLED = _get_bool_env("DONATIONS_ENABLED", False)
|
||||||
|
|
||||||
|
MONERO_WALLET_RPC_URLS = [
|
||||||
|
url.strip()
|
||||||
|
for url in os.getenv("MONERO_WALLET_RPC_URLS", "").split(",")
|
||||||
|
if url.strip()
|
||||||
|
]
|
||||||
|
MONERO_WALLET_RPC_USER = os.getenv("MONERO_WALLET_RPC_USER", "")
|
||||||
|
MONERO_WALLET_RPC_PASSWORD = os.getenv("MONERO_WALLET_RPC_PASSWORD", "")
|
||||||
|
MONERO_WALLET_RPC_WALLET_PASSWORD = os.getenv("MONERO_WALLET_RPC_WALLET_PASSWORD", "")
|
||||||
|
MONERO_DAEMON_URL = os.getenv("MONERO_DAEMON_URL")
|
||||||
|
MONERO_WALLET_RPC_WALLET_DIR = os.getenv("MONERO_WALLET_RPC_WALLET_DIR", "")
|
||||||
|
FOUNDER_PAYMENT_ADDRESS = os.getenv("FOUNDER_PAYMENT_ADDRESS", "")
|
||||||
|
FOUNDER_VIEW_KEY = os.getenv("FOUNDER_VIEW_KEY", "")
|
||||||
|
QR_STORAGE_DIR = os.getenv("QR_STORAGE_DIR", "/qr")
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||||
|
|
||||||
|
from .config import DATABASE_URL
|
||||||
|
|
||||||
|
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
||||||
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
|
||||||
|
def format_xmr_amount(value: Decimal) -> str:
|
||||||
|
normalized = value.normalize()
|
||||||
|
formatted = format(normalized, "f")
|
||||||
|
if "." in formatted:
|
||||||
|
formatted = formatted.rstrip("0").rstrip(".")
|
||||||
|
return formatted or "0"
|
||||||
+265
@@ -0,0 +1,265 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.openapi.utils import get_openapi
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from .db import Base, engine
|
||||||
|
from .config import DONATIONS_ENABLED
|
||||||
|
from .btcpay_routes import router as btcpay_router
|
||||||
|
from .routes import router
|
||||||
|
|
||||||
|
app = FastAPI(title="wowcheckout.com API", version="0.1.0")
|
||||||
|
|
||||||
|
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO").upper())
|
||||||
|
|
||||||
|
|
||||||
|
def custom_openapi():
|
||||||
|
if app.openapi_schema:
|
||||||
|
return app.openapi_schema
|
||||||
|
openapi_schema = get_openapi(
|
||||||
|
title=app.title,
|
||||||
|
version=app.version,
|
||||||
|
description=app.description,
|
||||||
|
routes=app.routes,
|
||||||
|
)
|
||||||
|
openapi_schema.get("paths", {}).pop("/api/core/donations", None)
|
||||||
|
if not DONATIONS_ENABLED:
|
||||||
|
openapi_schema.get("paths", {}).pop("/api/core/public/donation/{invoice_id}", None)
|
||||||
|
openapi_schema.get("paths", {}).pop("/api/core/auth/validate", None)
|
||||||
|
openapi_schema.get("paths", {}).pop("/api/core/profile", None)
|
||||||
|
app.openapi_schema = openapi_schema
|
||||||
|
return app.openapi_schema
|
||||||
|
|
||||||
|
|
||||||
|
app.openapi = custom_openapi
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def startup():
|
||||||
|
lock_acquired = False
|
||||||
|
lock_id = 894221741
|
||||||
|
with engine.begin() as connection:
|
||||||
|
try:
|
||||||
|
connection.execute(text("SELECT pg_advisory_lock(:lock_id)"), {"lock_id": lock_id})
|
||||||
|
lock_acquired = True
|
||||||
|
except Exception:
|
||||||
|
lock_acquired = False
|
||||||
|
try:
|
||||||
|
Base.metadata.create_all(bind=connection)
|
||||||
|
connection.execute(
|
||||||
|
text("ALTER TABLE invoices ADD COLUMN IF NOT EXISTS user_id UUID")
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text("ALTER TABLE invoices ADD COLUMN IF NOT EXISTS wallet_address VARCHAR")
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text("ALTER TABLE invoices ADD COLUMN IF NOT EXISTS subaddress_index INTEGER")
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE invoices "
|
||||||
|
"ADD COLUMN IF NOT EXISTS confirmations INTEGER DEFAULT 0"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE invoices "
|
||||||
|
"ADD COLUMN IF NOT EXISTS total_paid_atomic BIGINT"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE invoices "
|
||||||
|
"ADD COLUMN IF NOT EXISTS paid_after_expiry BOOLEAN DEFAULT false"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE invoices "
|
||||||
|
"ADD COLUMN IF NOT EXISTS paid_after_expiry_at TIMESTAMP WITH TIME ZONE"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE invoices "
|
||||||
|
"ADD COLUMN IF NOT EXISTS archived_at TIMESTAMP WITH TIME ZONE"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text("UPDATE invoices SET confirmations = 0 WHERE confirmations IS NULL")
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text("CREATE INDEX IF NOT EXISTS ix_invoices_user_id ON invoices (user_id)")
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_invoices_wallet_address ON invoices (wallet_address)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(text("ALTER TABLE webhooks ADD COLUMN IF NOT EXISTS user_id UUID"))
|
||||||
|
connection.execute(
|
||||||
|
text("ALTER TABLE webhooks ADD COLUMN IF NOT EXISTS event_urls JSON")
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text("ALTER TABLE webhooks ALTER COLUMN url DROP NOT NULL")
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text("CREATE INDEX IF NOT EXISTS ix_webhooks_user_id ON webhooks (user_id)")
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_webhook_deliveries_user_id "
|
||||||
|
"ON webhook_deliveries (user_id)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_webhook_deliveries_webhook_id "
|
||||||
|
"ON webhook_deliveries (webhook_id)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE webhook_deliveries "
|
||||||
|
"ADD COLUMN IF NOT EXISTS invoice_id UUID"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE webhook_deliveries "
|
||||||
|
"ADD COLUMN IF NOT EXISTS invoice_address VARCHAR"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE webhook_deliveries "
|
||||||
|
"ADD COLUMN IF NOT EXISTS invoice_subaddress_index INTEGER"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE webhook_deliveries "
|
||||||
|
"ADD COLUMN IF NOT EXISTS invoice_amount_xmr NUMERIC(18, 12)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE webhook_deliveries "
|
||||||
|
"ADD COLUMN IF NOT EXISTS invoice_status VARCHAR"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE webhook_deliveries "
|
||||||
|
"ADD COLUMN IF NOT EXISTS payload JSON"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text("ALTER TABLE users ADD COLUMN IF NOT EXISTS payment_address VARCHAR")
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text("ALTER TABLE users ADD COLUMN IF NOT EXISTS view_key_encrypted VARCHAR")
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE users "
|
||||||
|
"ADD COLUMN IF NOT EXISTS webhook_secret_encrypted VARCHAR"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE users "
|
||||||
|
"ADD COLUMN IF NOT EXISTS next_subaddress_index INTEGER DEFAULT 1"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE users "
|
||||||
|
"ADD COLUMN IF NOT EXISTS subaddress_start_index INTEGER DEFAULT 0"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE users "
|
||||||
|
"ADD COLUMN IF NOT EXISTS default_confirmation_target INTEGER DEFAULT 1"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE users "
|
||||||
|
"ALTER COLUMN default_confirmation_target SET DEFAULT 1"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE users "
|
||||||
|
"ADD COLUMN IF NOT EXISTS default_qr_logo VARCHAR DEFAULT 'wownero'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE users "
|
||||||
|
"ADD COLUMN IF NOT EXISTS default_qr_logo_data_url VARCHAR"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE users "
|
||||||
|
"ADD COLUMN IF NOT EXISTS btcpay_checkout_style VARCHAR DEFAULT 'btcpay_classic'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE users "
|
||||||
|
"ALTER COLUMN btcpay_checkout_style SET DEFAULT 'btcpay_classic'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE users "
|
||||||
|
"ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE users SET btcpay_checkout_style = 'btcpay_classic' "
|
||||||
|
"WHERE btcpay_checkout_style IS NULL"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE users "
|
||||||
|
"SET btcpay_checkout_style = 'btcpay_classic' "
|
||||||
|
"WHERE btcpay_checkout_style = 'standard' "
|
||||||
|
"AND NOT EXISTS ("
|
||||||
|
" SELECT 1 FROM profile_history ph "
|
||||||
|
" WHERE ph.user_id = users.id AND ph.field_name = 'btcpay_checkout_style'"
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE users SET default_confirmation_target = 1 "
|
||||||
|
"WHERE default_confirmation_target IS NULL"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE users SET default_qr_logo = 'wownero' "
|
||||||
|
"WHERE default_qr_logo IS NULL"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text("CREATE INDEX IF NOT EXISTS ix_users_payment_address ON users (payment_address)")
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if lock_acquired:
|
||||||
|
connection.execute(
|
||||||
|
text("SELECT pg_advisory_unlock(:lock_id)"), {"lock_id": lock_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
app.include_router(router)
|
||||||
|
app.include_router(btcpay_router)
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
BigInteger,
|
||||||
|
Boolean,
|
||||||
|
Column,
|
||||||
|
DateTime,
|
||||||
|
Integer,
|
||||||
|
Numeric,
|
||||||
|
String,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import ARRAY, UUID
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from sqlalchemy.types import JSON
|
||||||
|
|
||||||
|
from .db import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Invoice(Base):
|
||||||
|
__tablename__ = "invoices"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id = Column(UUID(as_uuid=True), nullable=True, index=True)
|
||||||
|
wallet_address = Column(String, nullable=True, index=True)
|
||||||
|
address = Column(String, nullable=False)
|
||||||
|
subaddress_index = Column(Integer, nullable=True)
|
||||||
|
amount_xmr = Column(Numeric(18, 12), nullable=False)
|
||||||
|
status = Column(String, nullable=False, index=True)
|
||||||
|
confirmation_target = Column(Integer, nullable=False)
|
||||||
|
confirmations = Column(Integer, nullable=False, server_default="0")
|
||||||
|
total_paid_atomic = Column(BigInteger, nullable=True)
|
||||||
|
paid_after_expiry = Column(Boolean, nullable=False, server_default="false")
|
||||||
|
paid_after_expiry_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
metadata_json = Column("metadata", JSON, nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
archived_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
expires_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
detected_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
confirmed_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceTransfer(Base):
|
||||||
|
__tablename__ = "invoice_transfers"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"invoice_id",
|
||||||
|
"txid",
|
||||||
|
name="uq_invoice_transfers_invoice_txid",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
invoice_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||||
|
txid = Column(String, nullable=False)
|
||||||
|
amount_atomic = Column(BigInteger, nullable=False)
|
||||||
|
confirmations = Column(Integer, nullable=False, server_default="0")
|
||||||
|
timestamp = Column(BigInteger, nullable=True)
|
||||||
|
address = Column(String, nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Webhook(Base):
|
||||||
|
__tablename__ = "webhooks"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id = Column(UUID(as_uuid=True), nullable=True, index=True)
|
||||||
|
url = Column(String, nullable=True)
|
||||||
|
events = Column(ARRAY(String), nullable=False)
|
||||||
|
event_urls = Column(JSON, nullable=True)
|
||||||
|
active = Column(Boolean, nullable=False, default=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class BtcpayWebhook(Base):
|
||||||
|
__tablename__ = "btcpay_webhooks"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||||
|
url = Column(String, nullable=False)
|
||||||
|
enabled = Column(Boolean, nullable=False, default=True)
|
||||||
|
automatic_redelivery = Column(Boolean, nullable=False, default=True)
|
||||||
|
authorized_events = Column(JSON, nullable=False)
|
||||||
|
secret_encrypted = Column(String, nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookDelivery(Base):
|
||||||
|
__tablename__ = "webhook_deliveries"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||||
|
webhook_id = Column(UUID(as_uuid=True), nullable=True, index=True)
|
||||||
|
event = Column(String, nullable=False)
|
||||||
|
url = Column(String, nullable=False)
|
||||||
|
invoice_id = Column(UUID(as_uuid=True), nullable=True)
|
||||||
|
invoice_address = Column(String, nullable=True)
|
||||||
|
invoice_subaddress_index = Column(Integer, nullable=True)
|
||||||
|
invoice_amount_xmr = Column(Numeric(18, 12), nullable=True)
|
||||||
|
invoice_status = Column(String, nullable=True)
|
||||||
|
payload_json = Column("payload", JSON, nullable=True)
|
||||||
|
http_status = Column(Integer, nullable=True)
|
||||||
|
error_message = Column(String, nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
payment_address = Column(String, nullable=False, unique=True, index=True)
|
||||||
|
view_key_encrypted = Column(String, nullable=False)
|
||||||
|
api_key_hash = Column(String, nullable=False, unique=True, index=True)
|
||||||
|
api_key_encrypted = Column(String, nullable=False)
|
||||||
|
webhook_secret_encrypted = Column(String, nullable=True)
|
||||||
|
next_subaddress_index = Column(Integer, nullable=False, server_default="1")
|
||||||
|
subaddress_start_index = Column(Integer, nullable=False, server_default="0")
|
||||||
|
default_confirmation_target = Column(Integer, nullable=False, server_default="1")
|
||||||
|
default_qr_logo = Column(String, nullable=False, server_default="wownero")
|
||||||
|
default_qr_logo_data_url = Column(String, nullable=True)
|
||||||
|
btcpay_checkout_style = Column(String, nullable=False, server_default="btcpay_classic")
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileHistory(Base):
|
||||||
|
__tablename__ = "profile_history"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||||
|
field_name = Column(String, nullable=False)
|
||||||
|
old_value = Column(String, nullable=True)
|
||||||
|
new_value = Column(String, nullable=True)
|
||||||
|
value_encrypted = Column(Boolean, nullable=False, default=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from monero.backends.jsonrpc import JSONRPCWallet
|
||||||
|
from monero.backends.jsonrpc.exceptions import RPCError
|
||||||
|
import requests
|
||||||
|
from requests import RequestException
|
||||||
|
from requests.auth import HTTPDigestAuth
|
||||||
|
|
||||||
|
from .config import (
|
||||||
|
MONERO_DAEMON_URL,
|
||||||
|
MONERO_WALLET_RPC_PASSWORD,
|
||||||
|
MONERO_WALLET_RPC_URLS,
|
||||||
|
MONERO_WALLET_RPC_USER,
|
||||||
|
MONERO_WALLET_RPC_WALLET_PASSWORD,
|
||||||
|
MONERO_WALLET_RPC_WALLET_DIR,
|
||||||
|
)
|
||||||
|
from .models import User
|
||||||
|
from .security import decrypt_secret
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_daemon_address(value: str | None) -> str | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
parsed = urlparse(value)
|
||||||
|
if parsed.scheme:
|
||||||
|
address = parsed.netloc
|
||||||
|
else:
|
||||||
|
address = value.split("/", 1)[0]
|
||||||
|
return address.rstrip("/") if address else None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_daemon_url(value: str | None) -> str | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
parsed = urlparse(value)
|
||||||
|
if parsed.scheme:
|
||||||
|
return value.rstrip("/")
|
||||||
|
return f"http://{value.rstrip('/')}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SubaddressResult:
|
||||||
|
address: str
|
||||||
|
address_index: int | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TransferDetail:
|
||||||
|
txid: str
|
||||||
|
amount_atomic: int
|
||||||
|
confirmations: int
|
||||||
|
timestamp: int | None
|
||||||
|
address: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WalletBackend:
|
||||||
|
client: JSONRPCWallet
|
||||||
|
url: str
|
||||||
|
current_wallet: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MoneroWalletService:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
urls = MONERO_WALLET_RPC_URLS
|
||||||
|
if not urls:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Monero wallet RPC is not configured",
|
||||||
|
)
|
||||||
|
self._backends: list[WalletBackend] = []
|
||||||
|
for url in urls:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
host = parsed.hostname or "127.0.0.1"
|
||||||
|
port = parsed.port or 18082
|
||||||
|
client = JSONRPCWallet(
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
user=MONERO_WALLET_RPC_USER or None,
|
||||||
|
password=MONERO_WALLET_RPC_PASSWORD or None,
|
||||||
|
)
|
||||||
|
if MONERO_WALLET_RPC_USER and MONERO_WALLET_RPC_PASSWORD:
|
||||||
|
# monero-wallet-rpc uses digest auth when rpc-login is set.
|
||||||
|
client.session.auth = HTTPDigestAuth(
|
||||||
|
MONERO_WALLET_RPC_USER, MONERO_WALLET_RPC_PASSWORD
|
||||||
|
)
|
||||||
|
self._backends.append(WalletBackend(client=client, url=url))
|
||||||
|
self._daemon_url = _normalize_daemon_url(MONERO_DAEMON_URL)
|
||||||
|
self._daemon_address = _normalize_daemon_address(MONERO_DAEMON_URL)
|
||||||
|
self._wallet_dir = MONERO_WALLET_RPC_WALLET_DIR.strip()
|
||||||
|
|
||||||
|
def get_status(self) -> dict[str, str]:
|
||||||
|
for backend in self._backends:
|
||||||
|
try:
|
||||||
|
backend.client.raw_request("get_version")
|
||||||
|
except Exception:
|
||||||
|
return {"wallet_rpc": "unreachable"}
|
||||||
|
|
||||||
|
if self._daemon_address:
|
||||||
|
try:
|
||||||
|
self._backends[0].client.raw_request(
|
||||||
|
"set_daemon",
|
||||||
|
{"address": self._daemon_address},
|
||||||
|
)
|
||||||
|
except RPCError as exc:
|
||||||
|
message = str(exc)
|
||||||
|
if "no connection to daemon" in message or "no_connection_to_daemon" in message:
|
||||||
|
return {"wallet_rpc": "ok", "daemon": "unreachable"}
|
||||||
|
return {"wallet_rpc": "ok", "daemon": "ok" if self._daemon_address else "unknown"}
|
||||||
|
|
||||||
|
def create_subaddress(self, user: User, label: str) -> SubaddressResult:
|
||||||
|
start_total = time.monotonic()
|
||||||
|
view_key = decrypt_secret(user.view_key_encrypted)
|
||||||
|
wallet_name = self._wallet_name(user, user.payment_address, view_key)
|
||||||
|
backend = self._backend_for_wallet_name(wallet_name)
|
||||||
|
start_wallet = time.monotonic()
|
||||||
|
self._ensure_wallet_open(
|
||||||
|
backend=backend,
|
||||||
|
wallet_name=wallet_name,
|
||||||
|
payment_address=user.payment_address,
|
||||||
|
view_key=view_key,
|
||||||
|
)
|
||||||
|
wallet_elapsed = time.monotonic() - start_wallet
|
||||||
|
start_daemon = time.monotonic()
|
||||||
|
self._ensure_daemon(backend)
|
||||||
|
daemon_elapsed = time.monotonic() - start_daemon
|
||||||
|
|
||||||
|
start_create = time.monotonic()
|
||||||
|
try:
|
||||||
|
response = backend.client.raw_request(
|
||||||
|
"create_address",
|
||||||
|
{"account_index": 0, "label": label},
|
||||||
|
)
|
||||||
|
except (RPCError, RequestException) as exc:
|
||||||
|
self._raise_wallet_rpc_error(exc)
|
||||||
|
create_elapsed = time.monotonic() - start_create
|
||||||
|
address = response.get("address")
|
||||||
|
if not address:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="Monero wallet RPC did not return a subaddress",
|
||||||
|
)
|
||||||
|
start_store = time.monotonic()
|
||||||
|
try:
|
||||||
|
backend.client.raw_request("store")
|
||||||
|
except RPCError:
|
||||||
|
# Storing can fail if the wallet RPC is in an odd state; the
|
||||||
|
# subaddress is still valid, but we prefer to surface issues later.
|
||||||
|
pass
|
||||||
|
store_elapsed = time.monotonic() - start_store
|
||||||
|
total_elapsed = time.monotonic() - start_total
|
||||||
|
logger.info(
|
||||||
|
"create_subaddress timing wallet=%.3fs daemon=%.3fs create=%.3fs store=%.3fs total=%.3fs",
|
||||||
|
wallet_elapsed,
|
||||||
|
daemon_elapsed,
|
||||||
|
create_elapsed,
|
||||||
|
store_elapsed,
|
||||||
|
total_elapsed,
|
||||||
|
)
|
||||||
|
return SubaddressResult(
|
||||||
|
address=address,
|
||||||
|
address_index=response.get("address_index"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_received_atomic(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
address: str,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
view_key = decrypt_secret(user.view_key_encrypted)
|
||||||
|
wallet_name = self._wallet_name(user, user.payment_address, view_key)
|
||||||
|
backend = self._backend_for_wallet_name(wallet_name)
|
||||||
|
self._ensure_wallet_open(
|
||||||
|
backend=backend,
|
||||||
|
wallet_name=wallet_name,
|
||||||
|
payment_address=user.payment_address,
|
||||||
|
view_key=view_key,
|
||||||
|
)
|
||||||
|
self._ensure_daemon(backend)
|
||||||
|
try:
|
||||||
|
index_response = backend.client.raw_request(
|
||||||
|
"get_address_index",
|
||||||
|
{"address": address},
|
||||||
|
)
|
||||||
|
except (RPCError, RequestException) as exc:
|
||||||
|
self._raise_wallet_rpc_error(exc)
|
||||||
|
index = index_response.get("index") if isinstance(index_response, dict) else None
|
||||||
|
major = index.get("major") if isinstance(index, dict) else None
|
||||||
|
minor = index.get("minor") if isinstance(index, dict) else None
|
||||||
|
if major is None or minor is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="Monero wallet RPC did not return an address index",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
transfers = backend.client.raw_request(
|
||||||
|
"get_transfers",
|
||||||
|
{
|
||||||
|
"in": True,
|
||||||
|
"pool": True,
|
||||||
|
"account_index": major,
|
||||||
|
"subaddr_indices": [minor],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except (RPCError, RequestException) as exc:
|
||||||
|
self._raise_wallet_rpc_error(exc)
|
||||||
|
incoming = transfers.get("in", []) if isinstance(transfers, dict) else []
|
||||||
|
pool = transfers.get("pool", []) if isinstance(transfers, dict) else []
|
||||||
|
total_atomic = 0
|
||||||
|
max_confirmations = 0
|
||||||
|
seen_txids: set[str] = set()
|
||||||
|
for item in [*incoming, *pool]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
txid = item.get("txid")
|
||||||
|
if isinstance(txid, str):
|
||||||
|
if txid in seen_txids:
|
||||||
|
continue
|
||||||
|
seen_txids.add(txid)
|
||||||
|
total_atomic += int(item.get("amount", 0) or 0)
|
||||||
|
confirmations = int(item.get("confirmations", 0) or 0)
|
||||||
|
if confirmations > max_confirmations:
|
||||||
|
max_confirmations = confirmations
|
||||||
|
return total_atomic, max_confirmations
|
||||||
|
|
||||||
|
def get_transfers_for_address(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
address: str,
|
||||||
|
) -> list[TransferDetail]:
|
||||||
|
view_key = decrypt_secret(user.view_key_encrypted)
|
||||||
|
wallet_name = self._wallet_name(user, user.payment_address, view_key)
|
||||||
|
backend = self._backend_for_wallet_name(wallet_name)
|
||||||
|
self._ensure_wallet_open(
|
||||||
|
backend=backend,
|
||||||
|
wallet_name=wallet_name,
|
||||||
|
payment_address=user.payment_address,
|
||||||
|
view_key=view_key,
|
||||||
|
)
|
||||||
|
self._ensure_daemon(backend)
|
||||||
|
try:
|
||||||
|
index_response = backend.client.raw_request(
|
||||||
|
"get_address_index",
|
||||||
|
{"address": address},
|
||||||
|
)
|
||||||
|
except (RPCError, RequestException) as exc:
|
||||||
|
self._raise_wallet_rpc_error(exc)
|
||||||
|
index = index_response.get("index") if isinstance(index_response, dict) else None
|
||||||
|
major = index.get("major") if isinstance(index, dict) else None
|
||||||
|
minor = index.get("minor") if isinstance(index, dict) else None
|
||||||
|
if major is None or minor is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="Monero wallet RPC did not return an address index",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
transfers = backend.client.raw_request(
|
||||||
|
"get_transfers",
|
||||||
|
{
|
||||||
|
"in": True,
|
||||||
|
"pool": True,
|
||||||
|
"account_index": major,
|
||||||
|
"subaddr_indices": [minor],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except (RPCError, RequestException) as exc:
|
||||||
|
self._raise_wallet_rpc_error(exc)
|
||||||
|
incoming = transfers.get("in", []) if isinstance(transfers, dict) else []
|
||||||
|
pool = transfers.get("pool", []) if isinstance(transfers, dict) else []
|
||||||
|
details: list[TransferDetail] = []
|
||||||
|
seen_txids: set[str] = set()
|
||||||
|
for item in [*incoming, *pool]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
txid = item.get("txid")
|
||||||
|
if not isinstance(txid, str) or not txid:
|
||||||
|
continue
|
||||||
|
if txid in seen_txids:
|
||||||
|
continue
|
||||||
|
seen_txids.add(txid)
|
||||||
|
amount_atomic = int(item.get("amount", 0) or 0)
|
||||||
|
confirmations = int(item.get("confirmations", 0) or 0)
|
||||||
|
timestamp = item.get("timestamp")
|
||||||
|
timestamp_value = int(timestamp) if isinstance(timestamp, (int, float)) else None
|
||||||
|
details.append(
|
||||||
|
TransferDetail(
|
||||||
|
txid=txid,
|
||||||
|
amount_atomic=amount_atomic,
|
||||||
|
confirmations=confirmations,
|
||||||
|
timestamp=timestamp_value,
|
||||||
|
address=item.get("address") if isinstance(item.get("address"), str) else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return details
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _rpc_error_message(exc: RPCError) -> str:
|
||||||
|
message = str(exc)
|
||||||
|
error = getattr(exc, "error", None)
|
||||||
|
if isinstance(error, dict):
|
||||||
|
detail = error.get("message")
|
||||||
|
if detail:
|
||||||
|
message = f"{message} {detail}"
|
||||||
|
return message
|
||||||
|
|
||||||
|
def _daemon_height(self) -> int | None:
|
||||||
|
if not self._daemon_url:
|
||||||
|
return None
|
||||||
|
url = self._daemon_url.rstrip("/")
|
||||||
|
payload = {"jsonrpc": "2.0", "id": "0", "method": "get_height"}
|
||||||
|
try:
|
||||||
|
response = requests.post(f"{url}/json_rpc", json=payload, timeout=5)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
result = data.get("result") if isinstance(data, dict) else None
|
||||||
|
height = result.get("height") if isinstance(result, dict) else None
|
||||||
|
if isinstance(height, int):
|
||||||
|
return height
|
||||||
|
except (requests.RequestException, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(f"{url}/get_height", timeout=5)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
except (requests.RequestException, ValueError):
|
||||||
|
return None
|
||||||
|
height = data.get("height") if isinstance(data, dict) else None
|
||||||
|
return height if isinstance(height, int) else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _wallet_name(user: User, payment_address: str, view_key: str) -> str:
|
||||||
|
fingerprint = hashlib.sha256(
|
||||||
|
f"{payment_address}:{view_key}".encode("utf-8")
|
||||||
|
).hexdigest()[:12]
|
||||||
|
return f"user-{user.id}-{fingerprint}"
|
||||||
|
|
||||||
|
def _backend_for_wallet_name(self, wallet_name: str) -> WalletBackend:
|
||||||
|
digest = hashlib.sha256(wallet_name.encode("utf-8")).hexdigest()
|
||||||
|
index = int(digest, 16) % len(self._backends)
|
||||||
|
return self._backends[index]
|
||||||
|
|
||||||
|
def _ensure_wallet_open(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
backend: WalletBackend,
|
||||||
|
wallet_name: str,
|
||||||
|
payment_address: str,
|
||||||
|
view_key: str,
|
||||||
|
) -> None:
|
||||||
|
if backend.current_wallet == wallet_name:
|
||||||
|
return
|
||||||
|
close_start = time.monotonic()
|
||||||
|
try:
|
||||||
|
backend.client.raw_request("close_wallet")
|
||||||
|
except (RPCError, RequestException):
|
||||||
|
pass
|
||||||
|
close_elapsed = time.monotonic() - close_start
|
||||||
|
open_start = time.monotonic()
|
||||||
|
try:
|
||||||
|
backend.client.raw_request(
|
||||||
|
"open_wallet",
|
||||||
|
{"filename": wallet_name, "password": MONERO_WALLET_RPC_WALLET_PASSWORD},
|
||||||
|
)
|
||||||
|
backend.current_wallet = wallet_name
|
||||||
|
open_elapsed = time.monotonic() - open_start
|
||||||
|
logger.info(
|
||||||
|
"wallet open timing close=%.3fs open=%.3fs",
|
||||||
|
close_elapsed,
|
||||||
|
open_elapsed,
|
||||||
|
)
|
||||||
|
except (RPCError, RequestException):
|
||||||
|
try:
|
||||||
|
restore_height = self._daemon_height() or 0
|
||||||
|
gen_start = time.monotonic()
|
||||||
|
# generate_from_keys opens the wallet when successful.
|
||||||
|
backend.client.raw_request(
|
||||||
|
"generate_from_keys",
|
||||||
|
{
|
||||||
|
"restore_height": restore_height,
|
||||||
|
"filename": wallet_name,
|
||||||
|
"address": payment_address,
|
||||||
|
"viewkey": view_key,
|
||||||
|
"password": MONERO_WALLET_RPC_WALLET_PASSWORD,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
backend.current_wallet = wallet_name
|
||||||
|
gen_elapsed = time.monotonic() - gen_start
|
||||||
|
open_elapsed = time.monotonic() - open_start
|
||||||
|
logger.info(
|
||||||
|
"wallet generate timing close=%.3fs open_fail=%.3fs generate=%.3fs",
|
||||||
|
close_elapsed,
|
||||||
|
open_elapsed,
|
||||||
|
gen_elapsed,
|
||||||
|
)
|
||||||
|
except (RPCError, RequestException) as gen_exc:
|
||||||
|
if isinstance(gen_exc, RequestException):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Monero wallet RPC is unreachable",
|
||||||
|
) from gen_exc
|
||||||
|
gen_message = self._rpc_error_message(gen_exc).lower()
|
||||||
|
if "wallet_files_doesnt_correspond" in gen_message or "file_exists" in gen_message:
|
||||||
|
expected_path = (
|
||||||
|
f"{self._wallet_dir.rstrip('/')}/{wallet_name}"
|
||||||
|
if self._wallet_dir
|
||||||
|
else wallet_name
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=(
|
||||||
|
"Wallet file exists but could not be opened. Verify the wallet "
|
||||||
|
"password, wallet directory, and that the cache file matches the "
|
||||||
|
".keys file. If needed, remove or rename the wallet cache file "
|
||||||
|
f"to rebuild it. Expected filename: {expected_path}"
|
||||||
|
),
|
||||||
|
) from gen_exc
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _ensure_daemon(self, backend: WalletBackend) -> None:
|
||||||
|
if not self._daemon_address:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
backend.client.raw_request(
|
||||||
|
"set_daemon",
|
||||||
|
{"address": self._daemon_address},
|
||||||
|
)
|
||||||
|
except (RPCError, RequestException) as exc:
|
||||||
|
self._raise_wallet_rpc_error(exc)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _raise_wallet_rpc_error(exc: Exception) -> None:
|
||||||
|
if isinstance(exc, RequestException):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Monero wallet RPC is unreachable",
|
||||||
|
) from exc
|
||||||
|
message = str(exc)
|
||||||
|
if "no connection to daemon" in message or "no_connection_to_daemon" in message:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Wallet RPC cannot reach the Monero daemon",
|
||||||
|
) from exc
|
||||||
|
raise
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
import qrcode
|
||||||
|
from qrcode.constants import ERROR_CORRECT_H
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from .formatting import format_xmr_amount
|
||||||
|
from .models import Invoice
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class QrSettings:
|
||||||
|
logo: str # "monero" | "none" | "custom"
|
||||||
|
logo_data_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def invoice_qr_url(invoice_id: str) -> str:
|
||||||
|
return f"/qr/{invoice_id}.png"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_invoice_qr_png(
|
||||||
|
*,
|
||||||
|
invoice: Invoice,
|
||||||
|
storage_dir: str,
|
||||||
|
settings: QrSettings,
|
||||||
|
) -> str:
|
||||||
|
if not storage_dir:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="QR storage is not configured",
|
||||||
|
)
|
||||||
|
os.makedirs(storage_dir, exist_ok=True)
|
||||||
|
try:
|
||||||
|
os.chmod(storage_dir, 0o755)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
filename = f"{invoice.id}.png"
|
||||||
|
path = os.path.join(storage_dir, filename)
|
||||||
|
if os.path.exists(path):
|
||||||
|
try:
|
||||||
|
os.chmod(path, 0o644)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return path
|
||||||
|
png_bytes = build_invoice_qr_png_bytes(invoice=invoice, settings=settings)
|
||||||
|
_atomic_write(path, png_bytes)
|
||||||
|
try:
|
||||||
|
os.chmod(path, 0o644)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def build_invoice_qr_png_bytes(*, invoice: Invoice, settings: QrSettings) -> bytes:
|
||||||
|
uri = build_monero_uri(invoice)
|
||||||
|
qr = qrcode.QRCode(
|
||||||
|
error_correction=ERROR_CORRECT_H,
|
||||||
|
border=2,
|
||||||
|
box_size=10,
|
||||||
|
)
|
||||||
|
qr.add_data(uri)
|
||||||
|
qr.make(fit=True)
|
||||||
|
image = qr.make_image(fill_color="black", back_color="white").convert("RGBA")
|
||||||
|
|
||||||
|
if settings.logo == "custom" and settings.logo_data_url:
|
||||||
|
logo = _load_logo_from_data_url(settings.logo_data_url)
|
||||||
|
image = _overlay_logo(image, logo)
|
||||||
|
|
||||||
|
out = tempfile.SpooledTemporaryFile(max_size=1024 * 1024)
|
||||||
|
image.save(out, format="PNG", optimize=True)
|
||||||
|
out.seek(0)
|
||||||
|
return out.read()
|
||||||
|
|
||||||
|
|
||||||
|
def build_monero_uri(invoice: Invoice) -> str:
|
||||||
|
amount = format_xmr_amount(invoice.amount_xmr)
|
||||||
|
params: dict[str, str] = {"tx_amount": amount}
|
||||||
|
metadata = invoice.metadata_json or {}
|
||||||
|
if isinstance(metadata, dict):
|
||||||
|
recipient_name = metadata.get("recipient_name")
|
||||||
|
description = metadata.get("description")
|
||||||
|
if isinstance(recipient_name, str) and recipient_name.strip():
|
||||||
|
params["recipient_name"] = recipient_name.strip()
|
||||||
|
if isinstance(description, str) and description.strip():
|
||||||
|
params["tx_description"] = description.strip()
|
||||||
|
query = "&".join(f"{_url_escape(k)}={_url_escape(v)}" for k, v in params.items())
|
||||||
|
return f"wownero:{invoice.address}?{query}"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_qr_settings(invoice: Invoice) -> QrSettings:
|
||||||
|
metadata: Any = invoice.metadata_json or {}
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
return QrSettings(logo="wownero", logo_data_url=None)
|
||||||
|
qr_meta = metadata.get("qr")
|
||||||
|
if not isinstance(qr_meta, dict):
|
||||||
|
return QrSettings(logo="wownero", logo_data_url=None)
|
||||||
|
logo = qr_meta.get("logo")
|
||||||
|
logo_data_url = qr_meta.get("logo_data_url")
|
||||||
|
if not isinstance(logo, str) or logo not in ("wownero", "none", "custom"):
|
||||||
|
return QrSettings(logo="wownero", logo_data_url=None)
|
||||||
|
if logo == "custom" and isinstance(logo_data_url, str) and logo_data_url.strip():
|
||||||
|
return QrSettings(logo="custom", logo_data_url=logo_data_url.strip())
|
||||||
|
return QrSettings(logo=logo, logo_data_url=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _atomic_write(path: str, data: bytes) -> None:
|
||||||
|
directory = os.path.dirname(path) or "."
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
fd, tmp_path = tempfile.mkstemp(prefix=".tmp-", dir=directory)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "wb") as tmp_file:
|
||||||
|
tmp_file.write(data)
|
||||||
|
tmp_file.flush()
|
||||||
|
os.fsync(tmp_file.fileno())
|
||||||
|
os.replace(tmp_path, path)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if os.path.exists(tmp_path):
|
||||||
|
os.remove(tmp_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _load_logo_from_data_url(data_url: str) -> Image.Image:
|
||||||
|
# Expected: data:image/png;base64,<...>
|
||||||
|
try:
|
||||||
|
header, encoded = data_url.split(",", 1)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="QR logo must be a data URL",
|
||||||
|
) from exc
|
||||||
|
if ";base64" not in header:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="QR logo must be base64 encoded",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode(encoded, validate=True)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="QR logo could not be decoded",
|
||||||
|
) from exc
|
||||||
|
try:
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
logo = Image.open(BytesIO(raw)).convert("RGBA")
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="QR logo image format is not supported",
|
||||||
|
) from exc
|
||||||
|
return logo
|
||||||
|
|
||||||
|
|
||||||
|
def _overlay_logo(qr_image: Image.Image, logo: Image.Image) -> Image.Image:
|
||||||
|
# Center a small logo with a white backing.
|
||||||
|
size = min(qr_image.size)
|
||||||
|
target_logo_size = int(size * 0.22)
|
||||||
|
if target_logo_size < 24:
|
||||||
|
return qr_image
|
||||||
|
logo = logo.copy()
|
||||||
|
logo.thumbnail((target_logo_size, target_logo_size), Image.LANCZOS)
|
||||||
|
|
||||||
|
overlay = Image.new("RGBA", qr_image.size, (255, 255, 255, 0))
|
||||||
|
box_size = int(size * 0.28)
|
||||||
|
box_offset = int((size - box_size) / 2)
|
||||||
|
box = Image.new("RGBA", (box_size, box_size), (255, 255, 255, 255))
|
||||||
|
overlay.paste(box, (box_offset, box_offset))
|
||||||
|
|
||||||
|
logo_offset = (int((size - logo.size[0]) / 2), int((size - logo.size[1]) / 2))
|
||||||
|
overlay.paste(logo, logo_offset, mask=logo)
|
||||||
|
|
||||||
|
return Image.alpha_composite(qr_image, overlay)
|
||||||
|
|
||||||
|
|
||||||
|
def _url_escape(value: str) -> str:
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
return quote(value, safe="")
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
NONLOGS_MARKETS_URL = "https://api.nonlogs.io/api/markets"
|
||||||
|
KRAKEN_TICKER_URL = "https://api.kraken.com/0/public/Ticker"
|
||||||
|
RATE_SOURCE = "nonlogs"
|
||||||
|
RATE_TTL_SECONDS = 60
|
||||||
|
|
||||||
|
_cache_lock = threading.Lock()
|
||||||
|
_cached_quote: QuoteResult | None = None
|
||||||
|
_cached_at: float | None = None
|
||||||
|
|
||||||
|
_btc_cache_lock = threading.Lock()
|
||||||
|
_cached_btc_usd: Decimal | None = None
|
||||||
|
_cached_btc_at: float | None = None
|
||||||
|
BTC_CACHE_TTL = 30
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class QuoteResult:
|
||||||
|
rate: Decimal
|
||||||
|
currency: str
|
||||||
|
source: str
|
||||||
|
quoted_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
def _get_btc_usd() -> Decimal:
|
||||||
|
global _cached_btc_usd, _cached_btc_at
|
||||||
|
with _btc_cache_lock:
|
||||||
|
if _cached_btc_usd and _cached_btc_at:
|
||||||
|
if time.monotonic() - _cached_btc_at < BTC_CACHE_TTL:
|
||||||
|
return _cached_btc_usd
|
||||||
|
|
||||||
|
resp = requests.get(KRAKEN_TICKER_URL, params={"pair": "XXBTZUSD"}, timeout=5)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
if data.get("error"):
|
||||||
|
raise RuntimeError(f"Kraken API error: {data['error']}")
|
||||||
|
price = data["result"]["XXBTZUSD"]["c"][0]
|
||||||
|
result = Decimal(str(price))
|
||||||
|
with _btc_cache_lock:
|
||||||
|
_cached_btc_usd = result
|
||||||
|
_cached_btc_at = time.monotonic()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_wow_rate(currency: str) -> QuoteResult:
|
||||||
|
global _cached_quote, _cached_at
|
||||||
|
normalized_currency = currency.strip().lower()
|
||||||
|
if normalized_currency != "usd":
|
||||||
|
raise ValueError(f"Unsupported fiat currency: {currency} (supported: usd)")
|
||||||
|
|
||||||
|
with _cache_lock:
|
||||||
|
if _cached_quote and _cached_at:
|
||||||
|
if time.monotonic() - _cached_at < RATE_TTL_SECONDS:
|
||||||
|
return _cached_quote
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"User-Agent": "wowcheckout/1.0",
|
||||||
|
}
|
||||||
|
resp = requests.get(NONLOGS_MARKETS_URL, headers=headers, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data: dict[str, Any] = resp.json()
|
||||||
|
markets = data.get("markets", {})
|
||||||
|
|
||||||
|
# Use WOW-BTC price converted via Kraken BTC/USD
|
||||||
|
wow_btc = markets.get("WOW-BTC", {})
|
||||||
|
if not wow_btc.get("last_price"):
|
||||||
|
raise ValueError("Could not get WOW-BTC rate from Nonlogs")
|
||||||
|
|
||||||
|
btc_usd = _get_btc_usd()
|
||||||
|
rate = Decimal(str(wow_btc["last_price"])) * btc_usd
|
||||||
|
|
||||||
|
quote = QuoteResult(
|
||||||
|
rate=rate,
|
||||||
|
currency="USD",
|
||||||
|
source=RATE_SOURCE,
|
||||||
|
quoted_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
with _cache_lock:
|
||||||
|
_cached_quote = quote
|
||||||
|
_cached_at = time.monotonic()
|
||||||
|
return quote
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from decimal import Decimal, ROUND_DOWN
|
||||||
|
|
||||||
|
from sqlalchemy import and_, or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .btcpay_webhooks import dispatch_btcpay_webhooks
|
||||||
|
from .config import INVOICE_RECONCILE_INTERVAL_SECONDS, LATE_PAYMENT_LOOKBACK_HOURS
|
||||||
|
from .db import SessionLocal
|
||||||
|
from .models import Invoice, InvoiceTransfer, User
|
||||||
|
from .monero_service import MoneroWalletService, TransferDetail
|
||||||
|
from .webhooks import dispatch_webhooks
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
level_name = "INFO"
|
||||||
|
try:
|
||||||
|
level_name = __import__("os").getenv("LOG_LEVEL", "INFO")
|
||||||
|
except Exception:
|
||||||
|
level_name = "INFO"
|
||||||
|
level = getattr(logging, level_name.upper(), logging.INFO)
|
||||||
|
logging.basicConfig(level=level)
|
||||||
|
service = MoneroWalletService()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
_reconcile_invoices(service)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Invoice reconcile failed: %s", exc)
|
||||||
|
time.sleep(INVOICE_RECONCILE_INTERVAL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
def _reconcile_invoices(service: MoneroWalletService) -> None:
|
||||||
|
db: Session = SessionLocal()
|
||||||
|
try:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
late_cutoff = now - timedelta(hours=max(0, LATE_PAYMENT_LOOKBACK_HOURS))
|
||||||
|
invoices = (
|
||||||
|
db.query(Invoice)
|
||||||
|
.filter(
|
||||||
|
or_(
|
||||||
|
Invoice.status.in_(["pending", "payment_detected"]),
|
||||||
|
and_(
|
||||||
|
Invoice.status == "expired",
|
||||||
|
Invoice.expires_at.is_not(None),
|
||||||
|
Invoice.expires_at >= late_cutoff,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(Invoice.created_at.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
user_groups: dict[object, list[Invoice]] = {}
|
||||||
|
for invoice in invoices:
|
||||||
|
if invoice.user_id is None:
|
||||||
|
logger.debug(
|
||||||
|
"Skipping invoice without user",
|
||||||
|
extra={"invoice_id": str(invoice.id)},
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
user_groups.setdefault(invoice.user_id, []).append(invoice)
|
||||||
|
logger.debug(
|
||||||
|
"Reconciling %d invoices across %d users",
|
||||||
|
len(invoices),
|
||||||
|
len(user_groups),
|
||||||
|
)
|
||||||
|
for user_id, user_invoices in user_groups.items():
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if user is None:
|
||||||
|
logger.debug(
|
||||||
|
"Skipping invoices with missing user",
|
||||||
|
extra={"user_id": str(user_id)},
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if not user.payment_address or not user.view_key_encrypted:
|
||||||
|
logger.debug(
|
||||||
|
"Skipping invoices without payment address",
|
||||||
|
extra={"user_id": str(user.id)},
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
for invoice in user_invoices:
|
||||||
|
try:
|
||||||
|
transfers = service.get_transfers_for_address(
|
||||||
|
user=user,
|
||||||
|
address=invoice.address,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Skipping invoice reconcile due to wallet RPC error",
|
||||||
|
extra={"invoice_id": str(invoice.id), "user_id": str(user.id)},
|
||||||
|
)
|
||||||
|
logger.debug("Wallet RPC error: %s", exc)
|
||||||
|
continue
|
||||||
|
total_atomic = 0
|
||||||
|
max_confirmations = 0
|
||||||
|
for transfer in transfers:
|
||||||
|
if transfer.amount_atomic <= 0:
|
||||||
|
continue
|
||||||
|
total_atomic += transfer.amount_atomic
|
||||||
|
if transfer.confirmations > max_confirmations:
|
||||||
|
max_confirmations = transfer.confirmations
|
||||||
|
logger.debug(
|
||||||
|
"Invoice totals",
|
||||||
|
extra={
|
||||||
|
"invoice_id": str(invoice.id),
|
||||||
|
"received_atomic": total_atomic,
|
||||||
|
"confirmations": max_confirmations,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
previous_confirmations = invoice.confirmations or 0
|
||||||
|
total_changed = invoice.total_paid_atomic != total_atomic
|
||||||
|
confirmations_changed = previous_confirmations != max_confirmations
|
||||||
|
transfers_changed = _sync_invoice_transfers(
|
||||||
|
db,
|
||||||
|
invoice=invoice,
|
||||||
|
transfers=transfers,
|
||||||
|
)
|
||||||
|
if total_changed or confirmations_changed or transfers_changed:
|
||||||
|
if confirmations_changed:
|
||||||
|
invoice.confirmations = max_confirmations
|
||||||
|
if total_changed:
|
||||||
|
invoice.total_paid_atomic = total_atomic
|
||||||
|
db.add(invoice)
|
||||||
|
db.commit()
|
||||||
|
required_atomic = _xmr_to_atomic(invoice.amount_xmr)
|
||||||
|
is_paid = total_atomic >= required_atomic
|
||||||
|
|
||||||
|
expires_at = invoice.expires_at
|
||||||
|
if expires_at is not None and expires_at.tzinfo is None:
|
||||||
|
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||||
|
is_after_expiry = bool(expires_at and now >= expires_at)
|
||||||
|
|
||||||
|
if total_atomic < required_atomic:
|
||||||
|
logger.debug(
|
||||||
|
"Payment not yet detected",
|
||||||
|
extra={
|
||||||
|
"invoice_id": str(invoice.id),
|
||||||
|
"required_atomic": required_atomic,
|
||||||
|
"received_atomic": total_atomic,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if invoice.status == "pending" and is_after_expiry:
|
||||||
|
logger.info(
|
||||||
|
"Invoice expired",
|
||||||
|
extra={"invoice_id": str(invoice.id), "user_id": str(user.id)},
|
||||||
|
)
|
||||||
|
invoice.status = "expired"
|
||||||
|
db.add(invoice)
|
||||||
|
db.commit()
|
||||||
|
dispatch_webhooks(db, str(user.id), "invoice.expired", invoice)
|
||||||
|
dispatch_btcpay_webhooks(
|
||||||
|
db, str(user.id), "InvoiceExpired", invoice
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if is_paid and invoice.status in ("pending", "expired"):
|
||||||
|
logger.info(
|
||||||
|
"Invoice marked payment detected",
|
||||||
|
extra={"invoice_id": str(invoice.id), "user_id": str(user.id)},
|
||||||
|
)
|
||||||
|
previous_status = invoice.status
|
||||||
|
invoice.status = "payment_detected"
|
||||||
|
if invoice.detected_at is None:
|
||||||
|
invoice.detected_at = now
|
||||||
|
if previous_status == "expired" or (previous_status == "pending" and is_after_expiry):
|
||||||
|
invoice.paid_after_expiry = True
|
||||||
|
if invoice.paid_after_expiry_at is None:
|
||||||
|
invoice.paid_after_expiry_at = now
|
||||||
|
db.add(invoice)
|
||||||
|
db.commit()
|
||||||
|
dispatch_webhooks(db, str(user.id), "invoice.payment_detected", invoice)
|
||||||
|
dispatch_btcpay_webhooks(
|
||||||
|
db, str(user.id), "InvoiceReceivedPayment", invoice
|
||||||
|
)
|
||||||
|
dispatch_btcpay_webhooks(db, str(user.id), "InvoicePaidInFull", invoice)
|
||||||
|
dispatch_btcpay_webhooks(
|
||||||
|
db, str(user.id), "InvoiceProcessing", invoice
|
||||||
|
)
|
||||||
|
if max_confirmations >= invoice.confirmation_target and invoice.status != "confirmed":
|
||||||
|
logger.info(
|
||||||
|
"Invoice confirmed",
|
||||||
|
extra={"invoice_id": str(invoice.id), "user_id": str(user.id)},
|
||||||
|
)
|
||||||
|
invoice.status = "confirmed"
|
||||||
|
if invoice.confirmed_at is None:
|
||||||
|
invoice.confirmed_at = now
|
||||||
|
db.add(invoice)
|
||||||
|
db.commit()
|
||||||
|
dispatch_webhooks(db, str(user.id), "invoice.confirmed", invoice)
|
||||||
|
dispatch_btcpay_webhooks(db, str(user.id), "InvoiceSettled", invoice)
|
||||||
|
dispatch_btcpay_webhooks(
|
||||||
|
db, str(user.id), "InvoicePaymentSettled", invoice
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_invoice_transfers(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
invoice: Invoice,
|
||||||
|
transfers: list[TransferDetail],
|
||||||
|
) -> bool:
|
||||||
|
existing = (
|
||||||
|
db.query(InvoiceTransfer)
|
||||||
|
.filter(InvoiceTransfer.invoice_id == invoice.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
existing_by_txid = {transfer.txid: transfer for transfer in existing if transfer.txid}
|
||||||
|
seen_txids: set[str] = set()
|
||||||
|
changed = False
|
||||||
|
for transfer in transfers:
|
||||||
|
txid = transfer.txid
|
||||||
|
if not txid:
|
||||||
|
continue
|
||||||
|
seen_txids.add(txid)
|
||||||
|
stored = existing_by_txid.get(txid)
|
||||||
|
if stored is None:
|
||||||
|
db.add(
|
||||||
|
InvoiceTransfer(
|
||||||
|
invoice_id=invoice.id,
|
||||||
|
txid=txid,
|
||||||
|
amount_atomic=transfer.amount_atomic,
|
||||||
|
confirmations=transfer.confirmations,
|
||||||
|
timestamp=transfer.timestamp,
|
||||||
|
address=transfer.address,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
changed = True
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
stored.amount_atomic != transfer.amount_atomic
|
||||||
|
or stored.confirmations != transfer.confirmations
|
||||||
|
or stored.timestamp != transfer.timestamp
|
||||||
|
or stored.address != transfer.address
|
||||||
|
):
|
||||||
|
stored.amount_atomic = transfer.amount_atomic
|
||||||
|
stored.confirmations = transfer.confirmations
|
||||||
|
stored.timestamp = transfer.timestamp
|
||||||
|
stored.address = transfer.address
|
||||||
|
changed = True
|
||||||
|
for stored in existing:
|
||||||
|
if stored.txid not in seen_txids:
|
||||||
|
db.delete(stored)
|
||||||
|
changed = True
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _xmr_to_atomic(amount: Decimal) -> int:
|
||||||
|
quantized = (Decimal(amount) * Decimal("1000000000000")).to_integral_value(
|
||||||
|
rounding=ROUND_DOWN
|
||||||
|
)
|
||||||
|
return int(quantized)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+1308
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,279 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any, Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import (
|
||||||
|
AnyUrl,
|
||||||
|
BaseModel,
|
||||||
|
ConfigDict,
|
||||||
|
Field,
|
||||||
|
conint,
|
||||||
|
field_serializer,
|
||||||
|
field_validator,
|
||||||
|
model_validator,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .formatting import format_xmr_amount
|
||||||
|
|
||||||
|
InvoiceStatus = Literal["pending", "payment_detected", "confirmed", "expired", "invalid"]
|
||||||
|
BtcpayCheckoutStyle = Literal["standard", "btcpay_classic"]
|
||||||
|
QrLogoMode = Literal["wownero", "none", "custom"]
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceCreate(BaseModel):
|
||||||
|
amount_xmr: Decimal | None = Field(default=None, gt=0)
|
||||||
|
amount_fiat: Decimal | None = Field(default=None, gt=0)
|
||||||
|
currency: str | None = None
|
||||||
|
confirmation_target: conint(ge=0, le=10) = 1
|
||||||
|
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
|
||||||
|
expires_at: datetime | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Defaults to 60 minutes from creation if omitted.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("currency")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_currency(cls, value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return value.strip().upper()
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _validate_amounts(self) -> "InvoiceCreate":
|
||||||
|
if self.amount_xmr is None and self.amount_fiat is None:
|
||||||
|
raise ValueError("amount_xmr or amount_fiat is required")
|
||||||
|
if self.amount_xmr is not None and self.amount_fiat is not None:
|
||||||
|
raise ValueError("Provide either amount_xmr or amount_fiat, not both")
|
||||||
|
if self.amount_fiat is not None and not self.currency:
|
||||||
|
raise ValueError("currency is required when amount_fiat is provided")
|
||||||
|
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):
|
||||||
|
amount_xmr: Decimal = Field(gt=0)
|
||||||
|
confirmation_target: conint(ge=0, le=10) = 1
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
address: str
|
||||||
|
subaddress_index: int | None = None
|
||||||
|
amount_xmr: Decimal
|
||||||
|
status: InvoiceStatus
|
||||||
|
confirmation_target: int
|
||||||
|
confirmations: int = 0
|
||||||
|
paid_after_expiry: bool = False
|
||||||
|
paid_after_expiry_at: datetime | None = None
|
||||||
|
qr_logo: QrLogoMode | None = None
|
||||||
|
qr_logo_data_url: str | None = None
|
||||||
|
metadata: dict[str, Any] | None = Field(default=None, validation_alias="metadata_json")
|
||||||
|
warnings: list[str] | None = None
|
||||||
|
quote: dict[str, Any] | None = None
|
||||||
|
invoice_url: str | None = None
|
||||||
|
qr_url: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
archived_at: datetime | None = None
|
||||||
|
expires_at: datetime | None
|
||||||
|
detected_at: datetime | None
|
||||||
|
confirmed_at: datetime | None
|
||||||
|
|
||||||
|
@field_serializer("amount_xmr")
|
||||||
|
def _serialize_amount_xmr(self, value: Decimal) -> str:
|
||||||
|
return format_xmr_amount(value)
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceCreateUser(BaseModel):
|
||||||
|
amount_xmr: Decimal | None = Field(default=None, gt=0)
|
||||||
|
amount_fiat: Decimal | None = Field(default=None, gt=0)
|
||||||
|
currency: str | None = None
|
||||||
|
confirmation_target: conint(ge=0, le=10) = 1
|
||||||
|
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
|
||||||
|
expires_at: datetime | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Defaults to 60 minutes from creation if omitted.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("currency")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_currency(cls, value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return value.strip().upper()
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _validate_amounts(self) -> "InvoiceCreateUser":
|
||||||
|
if self.amount_xmr is None and self.amount_fiat is None:
|
||||||
|
raise ValueError("amount_xmr or amount_fiat is required")
|
||||||
|
if self.amount_xmr is not None and self.amount_fiat is not None:
|
||||||
|
raise ValueError("Provide either amount_xmr or amount_fiat, not both")
|
||||||
|
if self.amount_fiat is not None and not self.currency:
|
||||||
|
raise ValueError("currency is required when amount_fiat is provided")
|
||||||
|
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):
|
||||||
|
items: list[InvoiceResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceStatusResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
address: str
|
||||||
|
subaddress_index: int | None = None
|
||||||
|
amount_xmr: Decimal
|
||||||
|
amount_paid_xmr: str | None = None
|
||||||
|
status: InvoiceStatus
|
||||||
|
confirmation_target: int
|
||||||
|
confirmations: int = 0
|
||||||
|
paid_after_expiry: bool = False
|
||||||
|
paid_after_expiry_at: datetime | None = None
|
||||||
|
qr_logo: QrLogoMode | None = None
|
||||||
|
qr_logo_data_url: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
expires_at: datetime | None
|
||||||
|
detected_at: datetime | None
|
||||||
|
confirmed_at: datetime | None
|
||||||
|
btcpay_amount: str | None = None
|
||||||
|
btcpay_currency: str | None = None
|
||||||
|
btcpay_checkout_style: BtcpayCheckoutStyle | None = None
|
||||||
|
btcpay_redirect_url: str | None = None
|
||||||
|
btcpay_redirect_automatically: bool | None = None
|
||||||
|
btcpay_order_id: str | None = None
|
||||||
|
btcpay_order_number: str | None = None
|
||||||
|
checkout_continue_available: bool = False
|
||||||
|
quote: dict[str, Any] | None = None
|
||||||
|
qr_url: str | None = None
|
||||||
|
|
||||||
|
@field_serializer("amount_xmr")
|
||||||
|
def _serialize_amount_xmr(self, value: Decimal) -> str:
|
||||||
|
return format_xmr_amount(value)
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
payment_address: str
|
||||||
|
default_confirmation_target: conint(ge=0, le=10) = 1
|
||||||
|
default_qr_logo: QrLogoMode = "wownero"
|
||||||
|
default_qr_logo_data_url: str | None = None
|
||||||
|
btcpay_checkout_style: BtcpayCheckoutStyle = "btcpay_classic"
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileUpdate(BaseModel):
|
||||||
|
btcpay_checkout_style: BtcpayCheckoutStyle | None = None
|
||||||
|
default_confirmation_target: conint(ge=0, le=10) | None = None
|
||||||
|
default_qr_logo: QrLogoMode | None = None
|
||||||
|
default_qr_logo_data_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookCreate(BaseModel):
|
||||||
|
url: AnyUrl | None = None
|
||||||
|
events: list[
|
||||||
|
Literal[
|
||||||
|
"invoice.created",
|
||||||
|
"invoice.payment_detected",
|
||||||
|
"invoice.confirmed",
|
||||||
|
"invoice.expired",
|
||||||
|
]
|
||||||
|
] | None = None
|
||||||
|
event_urls: dict[
|
||||||
|
Literal[
|
||||||
|
"invoice.created",
|
||||||
|
"invoice.payment_detected",
|
||||||
|
"invoice.confirmed",
|
||||||
|
"invoice.expired",
|
||||||
|
],
|
||||||
|
AnyUrl,
|
||||||
|
] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
url: AnyUrl | None = None
|
||||||
|
events: list[str]
|
||||||
|
event_urls: dict[str, AnyUrl] | None = None
|
||||||
|
active: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookDeliveryResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
webhook_id: UUID | None = None
|
||||||
|
event: str
|
||||||
|
url: str
|
||||||
|
invoice_id: UUID | None = None
|
||||||
|
invoice_address: str | None = None
|
||||||
|
invoice_subaddress_index: int | None = None
|
||||||
|
invoice_amount_xmr: Decimal | None = None
|
||||||
|
invoice_status: InvoiceStatus | None = None
|
||||||
|
http_status: int | None = None
|
||||||
|
error_message: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
@field_serializer("invoice_amount_xmr")
|
||||||
|
def _serialize_invoice_amount_xmr(self, value: Decimal | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return format_xmr_amount(value)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
payment_address: str = Field(min_length=1)
|
||||||
|
view_key: str = Field(min_length=1, max_length=256)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginResponse(BaseModel):
|
||||||
|
api_key: str
|
||||||
|
webhook_secret: str
|
||||||
|
store_id: UUID
|
||||||
|
|
||||||
|
|
||||||
|
class ApiCredentialsResetRequest(BaseModel):
|
||||||
|
reset_api_key: bool = False
|
||||||
|
reset_webhook_secret: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ApiCredentialsResetResponse(BaseModel):
|
||||||
|
api_key: str | None = None
|
||||||
|
webhook_secret: str | None = None
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from fastapi import Depends, Header, HTTPException, status
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .config import (
|
||||||
|
API_KEYS,
|
||||||
|
API_KEY_ENCRYPTION_KEY,
|
||||||
|
)
|
||||||
|
from .db import get_db
|
||||||
|
from .models import User
|
||||||
|
|
||||||
|
_pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_authorization_api_key(authorization: str | None) -> str | None:
|
||||||
|
if not authorization:
|
||||||
|
return None
|
||||||
|
scheme, _, token = authorization.partition(" ")
|
||||||
|
if scheme.lower() not in ("apikey", "token"):
|
||||||
|
return None
|
||||||
|
token = token.strip()
|
||||||
|
return token or None
|
||||||
|
|
||||||
|
|
||||||
|
def require_api_key(
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
x_api_key: str | None = Header(default=None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> str:
|
||||||
|
api_key = _parse_authorization_api_key(authorization) or x_api_key
|
||||||
|
if api_key is None or api_key not in API_KEYS:
|
||||||
|
if api_key is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid API key",
|
||||||
|
)
|
||||||
|
api_key_hash = hash_api_key(api_key)
|
||||||
|
user = db.query(User).filter(User.api_key_hash == api_key_hash).first()
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid API key",
|
||||||
|
)
|
||||||
|
return api_key
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return _pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, password_hash: str) -> bool:
|
||||||
|
return _pwd_context.verify(password, password_hash)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_api_key() -> str:
|
||||||
|
return f"wowcheckout_{secrets.token_urlsafe(32)}"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_webhook_secret() -> str:
|
||||||
|
return f"whsec_{secrets.token_urlsafe(32)}"
|
||||||
|
|
||||||
|
|
||||||
|
def hash_api_key(api_key: str) -> str:
|
||||||
|
return hashlib.sha256(api_key.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_api_key(api_key: str) -> str:
|
||||||
|
return encrypt_secret(api_key)
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_api_key(api_key_encrypted: str) -> str:
|
||||||
|
return decrypt_secret(api_key_encrypted)
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_secret(value: str) -> str:
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
return Fernet(API_KEY_ENCRYPTION_KEY).encrypt(value.encode("utf-8")).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_secret(value_encrypted: str) -> str:
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
return Fernet(API_KEY_ENCRYPTION_KEY).decrypt(
|
||||||
|
value_encrypted.encode("utf-8")
|
||||||
|
).decode("utf-8")
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .models import User
|
||||||
|
from .security import decrypt_secret
|
||||||
|
from .subaddress_derivation import derive_subaddress
|
||||||
|
|
||||||
|
|
||||||
|
MAX_SUBADDRESS_INDEX = 100
|
||||||
|
|
||||||
|
|
||||||
|
def create_subaddress_for_user(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
user: User,
|
||||||
|
) -> tuple[str, int | None]:
|
||||||
|
locked = (
|
||||||
|
db.query(User)
|
||||||
|
.filter(
|
||||||
|
User.id == user.id,
|
||||||
|
)
|
||||||
|
.with_for_update()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if locked is None:
|
||||||
|
raise ValueError("User not found")
|
||||||
|
start_index = int(locked.subaddress_start_index or 0)
|
||||||
|
if start_index < 0 or start_index >= MAX_SUBADDRESS_INDEX:
|
||||||
|
start_index = 0
|
||||||
|
range_start = start_index + 1
|
||||||
|
range_end = MAX_SUBADDRESS_INDEX
|
||||||
|
next_index = int(locked.next_subaddress_index or range_start)
|
||||||
|
if next_index < range_start or next_index > range_end:
|
||||||
|
next_index = range_start
|
||||||
|
next_next_index = next_index + 1
|
||||||
|
if next_next_index > range_end:
|
||||||
|
next_next_index = range_start
|
||||||
|
locked.next_subaddress_index = next_next_index
|
||||||
|
db.flush()
|
||||||
|
view_key = decrypt_secret(locked.view_key_encrypted)
|
||||||
|
address = derive_subaddress(
|
||||||
|
payment_address=locked.payment_address,
|
||||||
|
view_key=view_key,
|
||||||
|
account_index=0,
|
||||||
|
address_index=next_index,
|
||||||
|
)
|
||||||
|
return address, int(next_index)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from binascii import hexlify, unhexlify
|
||||||
|
import struct
|
||||||
|
|
||||||
|
from monero import base58, const, ed25519
|
||||||
|
from monero.address import Address
|
||||||
|
from monero.keccak import keccak_256
|
||||||
|
|
||||||
|
# Override monero library network constants for Wownero
|
||||||
|
from monero import const as _mc
|
||||||
|
_mc.NETS = ('main',)
|
||||||
|
_mc.MASTERADDR_NETBYTES = (53,) # Wownero mainnet address prefix
|
||||||
|
_mc.SUBADDR_NETBYTES = (63,) # Wownero subaddress prefix
|
||||||
|
_mc.INTADDRR_NETBYTES = (54,) # Wownero integrated address prefix
|
||||||
|
|
||||||
|
|
||||||
|
def derive_subaddress(
|
||||||
|
*,
|
||||||
|
payment_address: str,
|
||||||
|
view_key: str,
|
||||||
|
account_index: int,
|
||||||
|
address_index: int,
|
||||||
|
) -> str:
|
||||||
|
address = Address(payment_address)
|
||||||
|
view_key_bytes = unhexlify(view_key)
|
||||||
|
spend_pub_bytes = unhexlify(address.spend_key())
|
||||||
|
|
||||||
|
data = (
|
||||||
|
b"SubAddr\0"
|
||||||
|
+ view_key_bytes
|
||||||
|
+ struct.pack("<II", account_index, address_index)
|
||||||
|
)
|
||||||
|
m = ed25519.scalar_reduce(keccak_256(data).digest())
|
||||||
|
mG = ed25519.scalarmult_B(m)
|
||||||
|
D = ed25519.edwards_add(spend_pub_bytes, mG)
|
||||||
|
C = ed25519.scalarmult(view_key_bytes, D)
|
||||||
|
prefix = const.SUBADDR_NETBYTES[const.NETS.index(address.net)]
|
||||||
|
payload = bytearray([prefix]) + D + C
|
||||||
|
checksum = keccak_256(payload).digest()[:4]
|
||||||
|
return base58.encode(hexlify(payload + checksum))
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from requests import RequestException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .formatting import format_xmr_amount
|
||||||
|
from .models import Invoice, User, Webhook, WebhookDelivery
|
||||||
|
from .security import decrypt_secret, encrypt_secret, generate_webhook_secret
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def build_webhook_payload(event: str, invoice: Invoice) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"event": event,
|
||||||
|
"invoice": {
|
||||||
|
"id": str(invoice.id),
|
||||||
|
"address": invoice.address,
|
||||||
|
"subaddress_index": invoice.subaddress_index,
|
||||||
|
"amount_xmr": format_xmr_amount(invoice.amount_xmr),
|
||||||
|
"status": invoice.status,
|
||||||
|
"confirmation_target": invoice.confirmation_target,
|
||||||
|
"confirmations": invoice.confirmations or 0,
|
||||||
|
"paid_after_expiry": bool(invoice.paid_after_expiry),
|
||||||
|
"paid_after_expiry_at": _isoformat(invoice.paid_after_expiry_at),
|
||||||
|
"metadata": invoice.metadata_json,
|
||||||
|
"created_at": _isoformat(invoice.created_at),
|
||||||
|
"expires_at": _isoformat(invoice.expires_at),
|
||||||
|
"detected_at": _isoformat(invoice.detected_at),
|
||||||
|
"confirmed_at": _isoformat(invoice.confirmed_at),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def dispatch_webhooks(
|
||||||
|
db: Session,
|
||||||
|
user_id: str,
|
||||||
|
event: str,
|
||||||
|
invoice: Invoice,
|
||||||
|
) -> None:
|
||||||
|
hooks = (
|
||||||
|
db.query(Webhook)
|
||||||
|
.filter(
|
||||||
|
Webhook.user_id == user_id,
|
||||||
|
Webhook.active.is_(True),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
webhook_secret = None
|
||||||
|
if user is not None:
|
||||||
|
webhook_secret = _ensure_webhook_secret(db, user)
|
||||||
|
payload = build_webhook_payload(event, invoice)
|
||||||
|
deliveries: list[WebhookDelivery] = []
|
||||||
|
user_uuid = uuid.UUID(user_id)
|
||||||
|
for hook in hooks:
|
||||||
|
if event not in hook.events:
|
||||||
|
continue
|
||||||
|
hook_urls = hook.event_urls or {}
|
||||||
|
target_url = hook_urls.get(event, hook.url)
|
||||||
|
if not target_url:
|
||||||
|
logger.warning(
|
||||||
|
"Webhook URL missing for event",
|
||||||
|
extra={"webhook_id": str(hook.id), "event": event},
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
status_code = None
|
||||||
|
error_message = None
|
||||||
|
try:
|
||||||
|
headers = {"X-Webhook-Secret": webhook_secret} if webhook_secret else None
|
||||||
|
response = requests.post(target_url, json=payload, headers=headers, timeout=5)
|
||||||
|
status_code = response.status_code
|
||||||
|
except RequestException as exc:
|
||||||
|
error_message = str(exc)
|
||||||
|
logger.warning(
|
||||||
|
"Webhook delivery failed",
|
||||||
|
extra={"webhook_id": str(hook.id), "event": event},
|
||||||
|
)
|
||||||
|
logger.debug("Webhook delivery error: %s", exc)
|
||||||
|
deliveries.append(
|
||||||
|
WebhookDelivery(
|
||||||
|
user_id=user_uuid,
|
||||||
|
webhook_id=hook.id,
|
||||||
|
event=event,
|
||||||
|
url=target_url,
|
||||||
|
invoice_id=invoice.id,
|
||||||
|
invoice_address=invoice.address,
|
||||||
|
invoice_subaddress_index=invoice.subaddress_index,
|
||||||
|
invoice_amount_xmr=invoice.amount_xmr,
|
||||||
|
invoice_status=invoice.status,
|
||||||
|
payload_json=payload,
|
||||||
|
http_status=status_code,
|
||||||
|
error_message=error_message,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if deliveries:
|
||||||
|
try:
|
||||||
|
db.add_all(deliveries)
|
||||||
|
db.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
db.rollback()
|
||||||
|
logger.warning("Failed to save webhook delivery history", extra={"error": str(exc)})
|
||||||
|
|
||||||
|
|
||||||
|
def _isoformat(value: datetime | None) -> str | None:
|
||||||
|
return value.isoformat() if value is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_webhook_secret(db: Session, user: User) -> str:
|
||||||
|
if user.webhook_secret_encrypted:
|
||||||
|
return decrypt_secret(user.webhook_secret_encrypted)
|
||||||
|
secret = generate_webhook_secret()
|
||||||
|
user.webhook_secret_encrypted = encrypt_secret(secret)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return secret
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
fastapi==0.115.0
|
||||||
|
gunicorn==23.0.0
|
||||||
|
uvicorn[standard]==0.30.6
|
||||||
|
SQLAlchemy==2.0.36
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
|
pydantic==2.9.2
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
itsdangerous==2.2.0
|
||||||
|
cryptography==42.0.8
|
||||||
|
email-validator==2.2.0
|
||||||
|
monero==1.1.1
|
||||||
|
qrcode==7.4.2
|
||||||
|
Pillow==11.1.0
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
build:
|
||||||
|
context: ./nginx
|
||||||
|
ports:
|
||||||
|
- "8180:80"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/conf.d:/etc/nginx/conf.d:ro
|
||||||
|
- ./nginx/certs:/etc/nginx/certs
|
||||||
|
- ./qr:/qr:ro
|
||||||
|
depends_on:
|
||||||
|
ui:
|
||||||
|
condition: service_started
|
||||||
|
api:
|
||||||
|
condition: service_started
|
||||||
|
restart: unless-stopped
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ./api
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
API_KEYS: ${API_KEYS}
|
||||||
|
API_KEY_ENCRYPTION_KEY: ${API_KEY_ENCRYPTION_KEY}
|
||||||
|
RATE_PROVIDER: ${RATE_PROVIDER:-kraken}
|
||||||
|
FOUNDER_PAYMENT_ADDRESS: ${FOUNDER_PAYMENT_ADDRESS}
|
||||||
|
FOUNDER_VIEW_KEY: ${FOUNDER_VIEW_KEY}
|
||||||
|
DONATIONS_ENABLED: ${DONATIONS_ENABLED:-false}
|
||||||
|
QR_STORAGE_DIR: ${QR_STORAGE_DIR:-/qr}
|
||||||
|
MONERO_WALLET_RPC_URLS: ${MONERO_WALLET_RPC_URLS:-http://wallet-rpc-reconciler-1:18083,http://wallet-rpc-reconciler-2:18083,http://wallet-rpc-reconciler-3:18083}
|
||||||
|
MONERO_WALLET_RPC_USER: ${MONERO_WALLET_RPC_USER}
|
||||||
|
MONERO_WALLET_RPC_PASSWORD: ${MONERO_WALLET_RPC_PASSWORD}
|
||||||
|
MONERO_WALLET_RPC_WALLET_PASSWORD: ${MONERO_WALLET_RPC_WALLET_PASSWORD}
|
||||||
|
MONERO_WALLET_RPC_WALLET_DIR: ${MONERO_WALLET_RPC_WALLET_DIR:-/wallets}
|
||||||
|
MONERO_DAEMON_URL: ${MONERO_DAEMON_URL:-http://192.168.88.235:34568}
|
||||||
|
GUNICORN_WORKERS: ${GUNICORN_WORKERS:-5}
|
||||||
|
volumes:
|
||||||
|
- ./qr:/qr
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
reconciler:
|
||||||
|
build:
|
||||||
|
context: ./api
|
||||||
|
command: ["python", "-m", "app.reconciler"]
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
API_KEYS: ${API_KEYS}
|
||||||
|
API_KEY_ENCRYPTION_KEY: ${API_KEY_ENCRYPTION_KEY}
|
||||||
|
INVOICE_RECONCILE_INTERVAL_SECONDS: ${INVOICE_RECONCILE_INTERVAL_SECONDS}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
|
MONERO_WALLET_RPC_URLS: ${MONERO_WALLET_RPC_URLS:-http://wallet-rpc-reconciler-1:18083,http://wallet-rpc-reconciler-2:18083,http://wallet-rpc-reconciler-3:18083}
|
||||||
|
MONERO_WALLET_RPC_USER: ${MONERO_WALLET_RPC_USER}
|
||||||
|
MONERO_WALLET_RPC_PASSWORD: ${MONERO_WALLET_RPC_PASSWORD}
|
||||||
|
MONERO_WALLET_RPC_WALLET_PASSWORD: ${MONERO_WALLET_RPC_WALLET_PASSWORD}
|
||||||
|
MONERO_WALLET_RPC_WALLET_DIR: ${MONERO_WALLET_RPC_WALLET_DIR:-/wallets}
|
||||||
|
MONERO_DAEMON_URL: ${MONERO_DAEMON_URL:-http://192.168.88.235:34568}
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_started
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
wownerod:
|
||||||
|
build:
|
||||||
|
context: ./docker/monero
|
||||||
|
args:
|
||||||
|
MONERO_VERSION: ${MONERO_VERSION:-0.11.3.0}
|
||||||
|
image: wownero-local:${MONERO_VERSION:-0.11.3.0}
|
||||||
|
entrypoint: ["wownerod"]
|
||||||
|
command:
|
||||||
|
[
|
||||||
|
"--non-interactive",
|
||||||
|
"--rpc-bind-ip",
|
||||||
|
"0.0.0.0",
|
||||||
|
"--rpc-bind-port",
|
||||||
|
"34568",
|
||||||
|
"--confirm-external-bind",
|
||||||
|
"--prune-blockchain",
|
||||||
|
"--data-dir",
|
||||||
|
"/data",
|
||||||
|
"--log-level",
|
||||||
|
"0"
|
||||||
|
]
|
||||||
|
volumes:
|
||||||
|
- wownerod_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:34568/get_height >/dev/null || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 60s
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles: ["local-daemon"]
|
||||||
|
wallet-rpc-reconciler-1:
|
||||||
|
build:
|
||||||
|
context: ./docker/monero
|
||||||
|
args:
|
||||||
|
MONERO_VERSION: ${MONERO_VERSION:-0.11.3.0}
|
||||||
|
image: wownero-local:${MONERO_VERSION:-0.11.3.0}
|
||||||
|
command:
|
||||||
|
[
|
||||||
|
"wownero-wallet-rpc",
|
||||||
|
"--non-interactive",
|
||||||
|
"--rpc-bind-ip",
|
||||||
|
"0.0.0.0",
|
||||||
|
"--rpc-bind-port",
|
||||||
|
"18083",
|
||||||
|
"--confirm-external-bind",
|
||||||
|
"--daemon-address",
|
||||||
|
"${MONERO_DAEMON_URL:-http://192.168.88.235:34568}",
|
||||||
|
"--rpc-login",
|
||||||
|
"${MONERO_WALLET_RPC_USER}:${MONERO_WALLET_RPC_PASSWORD}",
|
||||||
|
"--wallet-dir",
|
||||||
|
"${MONERO_WALLET_RPC_WALLET_DIR:-/wallets}",
|
||||||
|
"--password",
|
||||||
|
"${MONERO_WALLET_RPC_WALLET_PASSWORD}",
|
||||||
|
"--log-level",
|
||||||
|
"3"
|
||||||
|
]
|
||||||
|
volumes:
|
||||||
|
- ./wallets/reconciler:/wallets
|
||||||
|
restart: unless-stopped
|
||||||
|
wallet-rpc-reconciler-2:
|
||||||
|
build:
|
||||||
|
context: ./docker/monero
|
||||||
|
args:
|
||||||
|
MONERO_VERSION: ${MONERO_VERSION:-0.11.3.0}
|
||||||
|
image: wownero-local:${MONERO_VERSION:-0.11.3.0}
|
||||||
|
command:
|
||||||
|
[
|
||||||
|
"wownero-wallet-rpc",
|
||||||
|
"--non-interactive",
|
||||||
|
"--rpc-bind-ip",
|
||||||
|
"0.0.0.0",
|
||||||
|
"--rpc-bind-port",
|
||||||
|
"18083",
|
||||||
|
"--confirm-external-bind",
|
||||||
|
"--daemon-address",
|
||||||
|
"${MONERO_DAEMON_URL:-http://192.168.88.235:34568}",
|
||||||
|
"--rpc-login",
|
||||||
|
"${MONERO_WALLET_RPC_USER}:${MONERO_WALLET_RPC_PASSWORD}",
|
||||||
|
"--wallet-dir",
|
||||||
|
"${MONERO_WALLET_RPC_WALLET_DIR:-/wallets}",
|
||||||
|
"--password",
|
||||||
|
"${MONERO_WALLET_RPC_WALLET_PASSWORD}",
|
||||||
|
"--log-level",
|
||||||
|
"3"
|
||||||
|
]
|
||||||
|
volumes:
|
||||||
|
- ./wallets/reconciler:/wallets
|
||||||
|
restart: unless-stopped
|
||||||
|
wallet-rpc-reconciler-3:
|
||||||
|
build:
|
||||||
|
context: ./docker/monero
|
||||||
|
args:
|
||||||
|
MONERO_VERSION: ${MONERO_VERSION:-0.11.3.0}
|
||||||
|
image: wownero-local:${MONERO_VERSION:-0.11.3.0}
|
||||||
|
command:
|
||||||
|
[
|
||||||
|
"wownero-wallet-rpc",
|
||||||
|
"--non-interactive",
|
||||||
|
"--rpc-bind-ip",
|
||||||
|
"0.0.0.0",
|
||||||
|
"--rpc-bind-port",
|
||||||
|
"18083",
|
||||||
|
"--confirm-external-bind",
|
||||||
|
"--daemon-address",
|
||||||
|
"${MONERO_DAEMON_URL:-http://192.168.88.235:34568}",
|
||||||
|
"--rpc-login",
|
||||||
|
"${MONERO_WALLET_RPC_USER}:${MONERO_WALLET_RPC_PASSWORD}",
|
||||||
|
"--wallet-dir",
|
||||||
|
"${MONERO_WALLET_RPC_WALLET_DIR:-/wallets}",
|
||||||
|
"--password",
|
||||||
|
"${MONERO_WALLET_RPC_WALLET_PASSWORD}",
|
||||||
|
"--log-level",
|
||||||
|
"3"
|
||||||
|
]
|
||||||
|
volumes:
|
||||||
|
- ./wallets/reconciler:/wallets
|
||||||
|
restart: unless-stopped
|
||||||
|
ui:
|
||||||
|
build:
|
||||||
|
context: ./ui
|
||||||
|
environment:
|
||||||
|
API_BASE_URL: ${API_BASE_URL:-http://nginx/api}
|
||||||
|
NEXT_PUBLIC_SITE_URL: ${SITE_URL:-http://localhost}
|
||||||
|
NEXT_PUBLIC_DONATIONS_ENABLED: ${DONATIONS_ENABLED:-false}
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_started
|
||||||
|
restart: unless-stopped
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
db-backup:
|
||||||
|
build:
|
||||||
|
context: ./docker/pg_backup
|
||||||
|
environment:
|
||||||
|
POSTGRES_HOST: db
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-7}
|
||||||
|
volumes:
|
||||||
|
- ./backups/postgres:/backups
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles: ["db-backup"]
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
wownerod_data:
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
ARG MONERO_VERSION=0.11.3.0
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates curl bzip2 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN set -eux; \
|
||||||
|
curl -fsSL -o /tmp/wownero.tar.bz2 \
|
||||||
|
"https://codeberg.org/wownero/wownero/releases/download/v${MONERO_VERSION}/wownero-x86_64-linux-gnu-v${MONERO_VERSION}.tar.bz2"; \
|
||||||
|
tar -xjf /tmp/wownero.tar.bz2 -C /tmp; \
|
||||||
|
cp /tmp/wownero-*/wownerod /usr/local/bin/; \
|
||||||
|
cp /tmp/wownero-*/wownero-wallet-rpc /usr/local/bin/; \
|
||||||
|
rm -rf /tmp/wownero* /tmp/wownero.tar.bz2
|
||||||
|
|
||||||
|
COPY entrypoint.sh /usr/local/bin/monero-wallet-rpc-entrypoint
|
||||||
|
RUN chmod +x /usr/local/bin/monero-wallet-rpc-entrypoint
|
||||||
|
|
||||||
|
EXPOSE 34568 18083
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/monero-wallet-rpc-entrypoint"]
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
normalize_address() {
|
||||||
|
value="$1"
|
||||||
|
if [ -z "$value" ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
case "$value" in
|
||||||
|
http://*)
|
||||||
|
value="${value#http://}"
|
||||||
|
;;
|
||||||
|
https://*)
|
||||||
|
value="${value#https://}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
value="${value%%/*}"
|
||||||
|
printf '%s' "$value"
|
||||||
|
}
|
||||||
|
|
||||||
|
args=""
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--daemon-address)
|
||||||
|
shift
|
||||||
|
normalized="$(normalize_address "$1")"
|
||||||
|
args="$args --daemon-address $normalized"
|
||||||
|
;;
|
||||||
|
--daemon-address=*)
|
||||||
|
value="${1#--daemon-address=}"
|
||||||
|
normalized="$(normalize_address "$value")"
|
||||||
|
args="$args --daemon-address=$normalized"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
args="$args $1"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
exec $args
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
FROM postgres:16-alpine
|
||||||
|
|
||||||
|
RUN apk add --no-cache bash
|
||||||
|
|
||||||
|
COPY backup.sh /usr/local/bin/backup.sh
|
||||||
|
COPY pg_backup.cron /etc/cron.d/pg_backup
|
||||||
|
|
||||||
|
RUN chmod +x /usr/local/bin/backup.sh \
|
||||||
|
&& chmod 0644 /etc/cron.d/pg_backup \
|
||||||
|
&& crontab /etc/cron.d/pg_backup
|
||||||
|
|
||||||
|
CMD ["crond", "-f", "-l", "8"]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
: "${POSTGRES_HOST:?}"
|
||||||
|
: "${POSTGRES_USER:?}"
|
||||||
|
: "${POSTGRES_PASSWORD:?}"
|
||||||
|
: "${POSTGRES_DB:?}"
|
||||||
|
|
||||||
|
backup_retention_days="${BACKUP_RETENTION_DAYS:-7}"
|
||||||
|
|
||||||
|
export PGPASSWORD="$POSTGRES_PASSWORD"
|
||||||
|
|
||||||
|
backup_dir="/backups"
|
||||||
|
timestamp="$(date +%Y%m%d_%H%M%S)"
|
||||||
|
backup_path="${backup_dir}/${POSTGRES_DB}_${timestamp}.dump"
|
||||||
|
|
||||||
|
mkdir -p "$backup_dir"
|
||||||
|
|
||||||
|
pg_dump -h "$POSTGRES_HOST" -U "$POSTGRES_USER" -d "$POSTGRES_DB" -F c -f "$backup_path"
|
||||||
|
|
||||||
|
find "$backup_dir" -type f -name "*.dump" -mtime "+${backup_retention_days}" -delete
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0 * * * * /usr/local/bin/backup.sh >> /var/log/pg_backup.log 2>&1
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
|
RUN apk add --no-cache openssl
|
||||||
|
|
||||||
|
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
limit_req_zone $binary_remote_addr zone=global_rate_limit:10m rate=50r/s;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name wowcheckout.such.software localhost;
|
||||||
|
|
||||||
|
limit_req zone=global_rate_limit burst=200 nodelay;
|
||||||
|
|
||||||
|
if ($request_uri ~* "//+") {
|
||||||
|
return 308 $scheme://$host$uri$is_args$args;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:8000/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api {
|
||||||
|
return 301 /api/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /openapi.json {
|
||||||
|
proxy_pass http://api:8000/openapi.json;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /qr/ {
|
||||||
|
alias /qr/;
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://ui:3000/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
cert_dir="/etc/nginx/certs"
|
||||||
|
cert_path="${cert_dir}/dev.crt"
|
||||||
|
key_path="${cert_dir}/dev.key"
|
||||||
|
|
||||||
|
if [ ! -f "$cert_path" ] || [ ! -f "$key_path" ]; then
|
||||||
|
mkdir -p "$cert_dir"
|
||||||
|
rm -f "$cert_path" "$key_path"
|
||||||
|
openssl req \
|
||||||
|
-x509 \
|
||||||
|
-nodes \
|
||||||
|
-newkey rsa:2048 \
|
||||||
|
-days 365 \
|
||||||
|
-keyout "$key_path" \
|
||||||
|
-out "$cert_path" \
|
||||||
|
-subj "/CN=localhost"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 168 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 145 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 436 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 533 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
API_BASE_URL=http://localhost:8000
|
||||||
|
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||||
|
NEXT_PUBLIC_SITE_URL=http://localhost
|
||||||
|
NEXT_PUBLIC_DONATIONS_ENABLED=false
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module.exports = {
|
||||||
|
extends: ["next/core-web-vitals"],
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
FROM node:20-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||||
|
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
COPY --from=builder /app/.next ./.next
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["npm", "start"]
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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("wowcheckout_session")?.value;
|
||||||
|
if (!session) {
|
||||||
|
redirect("/?login=1");
|
||||||
|
}
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
@@ -0,0 +1,558 @@
|
|||||||
|
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 a WOW 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>
|
||||||
|
Optional: include{" "}
|
||||||
|
<span className="font-mono text-ink">checkout_continue_url</span> to show a
|
||||||
|
customer-facing Continue button after confirmation.
|
||||||
|
</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">
|
||||||
|
WOWCHECKOUT_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">
|
||||||
|
WOWCHECKOUT_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">
|
||||||
|
WOWCHECKOUT_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 WOW 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 WOW Checkout API key.</li>
|
||||||
|
<li>Payment method: WOW-CHAIN (shows as WOW_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 WOWCHECKOUT_BTCPAY_URL="${normalizedSiteUrl}/api/v1"
|
||||||
|
export WOWCHECKOUT_API_KEY="wowcheckout_..."
|
||||||
|
|
||||||
|
curl -sS "$WOWCHECKOUT_BTCPAY_URL/stores" \\
|
||||||
|
-H "Authorization: token $WOWCHECKOUT_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 WOWCHECKOUT_API_BASE_URL="${publicApiBaseUrl}"
|
||||||
|
export WOWCHECKOUT_API_KEY="wowcheckout_..."
|
||||||
|
|
||||||
|
curl -sS -X POST "$WOWCHECKOUT_API_BASE_URL/invoices" \\
|
||||||
|
-H "Authorization: ApiKey $WOWCHECKOUT_API_KEY" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{
|
||||||
|
"amount_xmr": "0.15",
|
||||||
|
"confirmation_target": 2,
|
||||||
|
"metadata": { "order_id": "ORDER-1234" }
|
||||||
|
}'`}</code>
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<pre className={codeBlockClass}>
|
||||||
|
<code>{`# 1b) Create invoice from fiat (non-binding conversion)
|
||||||
|
curl -sS -X POST "$WOWCHECKOUT_API_BASE_URL/invoices" \\
|
||||||
|
-H "Authorization: ApiKey $WOWCHECKOUT_API_KEY" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{
|
||||||
|
"amount_fiat": "100.00",
|
||||||
|
"currency": "USD",
|
||||||
|
"confirmation_target": 2,
|
||||||
|
"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 "$WOWCHECKOUT_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.WOWCHECKOUT_API_BASE_URL;
|
||||||
|
const API_KEY = process.env.WOWCHECKOUT_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(\`wowcheckout 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.WOWCHECKOUT_WEBHOOK_SECRET;
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json({ type: "application/json" }));
|
||||||
|
|
||||||
|
app.post("/wowcheckout/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/wowcheckout/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("WOWCHECKOUT_API_BASE_URL");
|
||||||
|
$apiKey = getenv("WOWCHECKOUT_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 "wowcheckout error: HTTP " . $status;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$invoice = json_decode($body, true);
|
||||||
|
echo $invoice["invoice_url"];`}</code>
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<pre className={codeBlockClass}>
|
||||||
|
<code>{`<?php
|
||||||
|
// webhook.php
|
||||||
|
|
||||||
|
$webhookSecret = getenv("WOWCHECKOUT_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["WOWCHECKOUT_API_BASE_URL"].rstrip("/")
|
||||||
|
api_key = os.environ["WOWCHECKOUT_API_KEY"]
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
f"{api_base_url}/invoices",
|
||||||
|
headers={"Authorization": f"ApiKey {api_key}"},
|
||||||
|
json={
|
||||||
|
"amount_xmr": "0.15",
|
||||||
|
"confirmation_target": 2,
|
||||||
|
"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["WOWCHECKOUT_WEBHOOK_SECRET"]
|
||||||
|
|
||||||
|
@app.post("/wowcheckout/webhook", status_code=204)
|
||||||
|
async def wowcheckout_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("WOWCHECKOUT_API_BASE_URL"), "/")
|
||||||
|
apiKey := os.Getenv("WOWCHECKOUT_API_KEY")
|
||||||
|
|
||||||
|
payload := map[string]any{
|
||||||
|
"amount_xmr": "0.15",
|
||||||
|
"confirmation_target": 2,
|
||||||
|
"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("wowcheckout 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("WOWCHECKOUT_WEBHOOK_SECRET")
|
||||||
|
|
||||||
|
http.HandleFunc("/wowcheckout/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,858 @@
|
|||||||
|
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.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/core/public/invoice/{invoice_id}/continue",
|
||||||
|
description: "After confirmation, redirect to the merchant-provided Continue URL (if configured).",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 (WOW-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/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",
|
||||||
|
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 2, 0-10)",
|
||||||
|
"checkout_continue_url (https URL, optional post-confirmation Continue button)",
|
||||||
|
"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 WOW-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 WOW 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]">
|
||||||
|
Wownero checkout documentation & API reference.
|
||||||
|
</h1>
|
||||||
|
<p className="text-[1.05rem] leading-relaxed text-ink-soft">
|
||||||
|
wowcheckout.com generates invoices, detects Wownero payments using view-only access,
|
||||||
|
and relays status events. Setup is explicit: 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 WOW using the current rate and returns a non-binding quote in the
|
||||||
|
response. The invoice is always stored in WOW.
|
||||||
|
</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": 2,
|
||||||
|
"checkout_continue_url": "https://merchant.example/thanks",
|
||||||
|
"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>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 WOW; 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-wownero/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">
|
||||||
|
Make a donation
|
||||||
|
</p>
|
||||||
|
<h1 className="font-serif text-[clamp(2.2rem,2rem+1.4vw,3.4rem)] leading-[1.1]">
|
||||||
|
Make a donation
|
||||||
|
</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 WOW 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,278 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "FAQ",
|
||||||
|
description:
|
||||||
|
"Frequently asked questions about WOW Checkout: non-custodial Wownero 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 WOW Checkout custodial?",
|
||||||
|
answer: (
|
||||||
|
<>
|
||||||
|
<p className="text-ink-soft">
|
||||||
|
No. Payments go directly from the customer to your Wownero wallet. WOW
|
||||||
|
Checkout only observes the blockchain to detect payments and update
|
||||||
|
invoice status.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "What wallet access does WOW 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: "What does setup require?",
|
||||||
|
answer: (
|
||||||
|
<>
|
||||||
|
<p className="text-ink-soft">
|
||||||
|
Setup is 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: "What does WOW Checkout cost?",
|
||||||
|
answer: (
|
||||||
|
<>
|
||||||
|
<p className="text-ink-soft">
|
||||||
|
Currently, wowcheckout.com does not charge a service fee. You can also self-host the open source
|
||||||
|
stack if you prefer.
|
||||||
|
</p>
|
||||||
|
{donationsEnabled ? (
|
||||||
|
<p className="text-ink-soft">
|
||||||
|
If you find WOW Checkout useful, you can support development with{" "}
|
||||||
|
<Link className="underline underline-offset-4" href="/donate">
|
||||||
|
donations
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Can WOW Checkout move funds, issue refunds, or reverse payments?",
|
||||||
|
answer: (
|
||||||
|
<>
|
||||||
|
<p className="text-ink-soft">
|
||||||
|
No. WOW 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 WOW 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: "How does WOW Checkout differ from hosted services (e.g. NOWPayments)?",
|
||||||
|
answer: (
|
||||||
|
<>
|
||||||
|
<p className="text-ink-soft">
|
||||||
|
The main differences are scope, custody model, and data collection: WOW Checkout is Wownero-only,
|
||||||
|
does not require an account or email, and uses view-only access for payment detection.
|
||||||
|
</p>
|
||||||
|
<ul className="mt-4 grid gap-2 text-sm text-ink-soft">
|
||||||
|
<li>
|
||||||
|
<span className="font-semibold text-ink">Wownero-only:</span> a focused
|
||||||
|
product without a multi-asset stack.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="font-semibold text-ink">No account/email:</span> your primary 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 use a third-party service, review what keys and data it requires, and whether it ever takes custody
|
||||||
|
or intermediates funds.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "How does WOW Checkout differ from BTCPay Server?",
|
||||||
|
answer: (
|
||||||
|
<>
|
||||||
|
<p className="text-ink-soft">
|
||||||
|
BTCPay Server is a general-purpose, self-hosted payment server with a primary focus on Bitcoin
|
||||||
|
and optional integrations for other assets (including Wownero via a plugin). WOW Checkout is intentionally
|
||||||
|
focused on Wownero checkout with a view-only trust boundary and a minimal, deterministic core.
|
||||||
|
</p>
|
||||||
|
<p className="text-ink-soft">
|
||||||
|
Some hosted BTCPay deployments may not include the Wownero plugin by default. WOW Checkout provides a
|
||||||
|
small compatibility layer so teams can integrate without running a full BTCPay deployment.
|
||||||
|
</p>
|
||||||
|
<ul className="mt-4 grid gap-2 text-sm text-ink-soft">
|
||||||
|
<li>
|
||||||
|
<span className="font-semibold text-ink">Wownero-first UX:</span>{" "}
|
||||||
|
invoice and confirmation language is tailored to Wownero.
|
||||||
|
</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 WOW Checkout?",
|
||||||
|
answer: (
|
||||||
|
<>
|
||||||
|
<p className="text-ink-soft">
|
||||||
|
Yes. WOW Checkout is open source, and you can self-host it from the
|
||||||
|
official{" "}
|
||||||
|
<Link
|
||||||
|
className="underline underline-offset-4"
|
||||||
|
href="https://github.com/wowcheckout/wowcheckout"
|
||||||
|
>
|
||||||
|
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-wownero">
|
||||||
|
FAQ
|
||||||
|
</p>
|
||||||
|
<h1 className="font-serif text-3xl">Direct answers.</h1>
|
||||||
|
<p className="max-w-[46rem] text-[1.02rem] leading-relaxed text-ink-soft">
|
||||||
|
WOW Checkout is non-custodial checkout software for accepting Wownero
|
||||||
|
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,345 @@
|
|||||||
|
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;
|
||||||
|
checkout_continue_available?: boolean;
|
||||||
|
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 checkoutContinueAvailable =
|
||||||
|
invoice.status === "confirmed" && Boolean(invoice.checkout_continue_available);
|
||||||
|
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">
|
||||||
|
{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 />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
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 Wownero invoice status.
|
||||||
|
</h1>
|
||||||
|
<p className="text-[1.05rem] leading-relaxed text-ink-soft">
|
||||||
|
Paste the invoice id to see detection and confirmation state.
|
||||||
|
</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-wownero"></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-wownero/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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
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 NavLink from "../../components/nav-link";
|
||||||
|
import { logoutAction } from "../(app)/dashboard/actions";
|
||||||
|
|
||||||
|
export default async function MarketingLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const apiKey = cookieStore.get("wowcheckout_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="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="/tour">Tour</NavLink>
|
||||||
|
<NavLink href="/docs">Documentation</NavLink>
|
||||||
|
<NavLink href="/faq">FAQ</NavLink>
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<>
|
||||||
|
<NavLink href="/dashboard">Dashboard</NavLink>
|
||||||
|
<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>
|
||||||
|
wowcheckout.com - Non-custodial Wownero checkout software. Open source. Self-hostable.
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,294 @@
|
|||||||
|
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]">
|
||||||
|
Accept Wownero payments. Funds go straight to your wallet.
|
||||||
|
</h1>
|
||||||
|
<p className="text-[1.08rem] leading-relaxed text-ink-soft">
|
||||||
|
Create invoices and detect on-chain payments using your primary address + secret view key.
|
||||||
|
Get a clear status as payments are seen and confirmed.
|
||||||
|
</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-wownero"></span>
|
||||||
|
<span>
|
||||||
|
View-only access: we can only detect incoming payments.
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-3">
|
||||||
|
<span className="mt-1.5 h-2 w-2 rounded-full bg-wownero"></span>
|
||||||
|
<span>
|
||||||
|
No account or email: 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-wownero"></span>
|
||||||
|
<span>BTCPay-compatible invoice endpoints for existing integrations.</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<p className="inline-flex w-fit items-center rounded-full bg-wownero 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
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-ink-soft">
|
||||||
|
We can’t spend from your wallet. Payments go straight to your wallet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-semibold text-ink-soft">
|
||||||
|
Open source. Self-hostable.
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
Log in
|
||||||
|
</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>
|
||||||
|
<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="https://github.com/wowcheckout/wowcheckout#self-hosted-deployment-docker-compose"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Self-host
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-ink-soft">
|
||||||
|
Prefer to look around first?{" "}
|
||||||
|
<Link className="font-semibold text-ink underline underline-offset-4" href="/tour">
|
||||||
|
Take the tour
|
||||||
|
</Link>{" "}
|
||||||
|
(simulated, no login).
|
||||||
|
</p>
|
||||||
|
</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-wownero/0 via-wownero/60 to-wownero/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-wownero/15 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-wownero">
|
||||||
|
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-wownero"></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-wownero/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">
|
||||||
|
First seen on-chain, then confirmed when it reaches your target.
|
||||||
|
</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">
|
||||||
|
Watch-only by design.
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-xs text-ink-soft sm:text-sm">
|
||||||
|
View key access lets us detect payments - nothing more.
|
||||||
|
</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 via API + webhooks.
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-xs text-ink-soft sm:text-sm">
|
||||||
|
Keep your systems 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-wownero">
|
||||||
|
When someone pays
|
||||||
|
</p>
|
||||||
|
<h2 className="font-serif text-3xl">
|
||||||
|
What happens, step by step.
|
||||||
|
</h2>
|
||||||
|
<p className="text-ink-soft">
|
||||||
|
You stay in control of the wallet. This just detects and reports what is on-chain.
|
||||||
|
</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-wownero/15 text-sm font-semibold text-wownero">
|
||||||
|
01
|
||||||
|
</span>
|
||||||
|
<h3 className="mb-2 font-serif text-xl">
|
||||||
|
Connect with a view key
|
||||||
|
</h3>
|
||||||
|
<p className="text-ink-soft">
|
||||||
|
Use your primary address and secret view key. No spend keys.
|
||||||
|
</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-wownero/15 text-sm font-semibold text-wownero">
|
||||||
|
02
|
||||||
|
</span>
|
||||||
|
<h3 className="mb-2 font-serif text-xl">Create an invoice</h3>
|
||||||
|
<p className="text-ink-soft">
|
||||||
|
A unique subaddress is generated per invoice.
|
||||||
|
</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-wownero/15 text-sm font-semibold text-wownero">
|
||||||
|
03
|
||||||
|
</span>
|
||||||
|
<h3 className="mb-2 font-serif text-xl">Wait for payment</h3>
|
||||||
|
<p className="text-ink-soft">
|
||||||
|
We watch the chain (view-only) for incoming payments.
|
||||||
|
</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-wownero/15 text-sm font-semibold text-wownero">
|
||||||
|
04
|
||||||
|
</span>
|
||||||
|
<h3 className="mb-2 font-serif text-xl">Send status updates</h3>
|
||||||
|
<p className="text-ink-soft">
|
||||||
|
Webhooks fire when a payment is detected and when it reaches your confirmation target.
|
||||||
|
</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-wownero">
|
||||||
|
Trust
|
||||||
|
</p>
|
||||||
|
<h2 className="font-serif text-3xl">
|
||||||
|
What we can (and can’t) do.
|
||||||
|
</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>Create invoices and generate subaddresses.</li>
|
||||||
|
<li>Detect on-chain payments with view-only keys.</li>
|
||||||
|
<li>Send status updates 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">
|
||||||
|
We only ask for a secret view key. That lets us detect payments - nothing more.
|
||||||
|
</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">
|
||||||
|
Ready to try it?
|
||||||
|
</h2>
|
||||||
|
<p className="text-cream/80">
|
||||||
|
Log in to create an invoice, or read the docs to integrate via API and webhooks.
|
||||||
|
</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>
|
||||||
|
<p className="text-sm text-cream/70">
|
||||||
|
Want to preview the dashboard first?{" "}
|
||||||
|
<Link className="font-semibold text-cream underline underline-offset-4" href="/tour">
|
||||||
|
Take the tour
|
||||||
|
</Link>{" "}
|
||||||
|
(simulated).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import InvoicePanel from "../../../components/invoice-panel";
|
||||||
|
import WebhookHistoryPanel from "../../../components/webhook-history-panel";
|
||||||
|
import WebhookSection from "../../../components/webhook-section";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Tour",
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabs = ["overview", "invoices", "webhooks"] as const;
|
||||||
|
const webhookTabs = ["settings", "history"] as const;
|
||||||
|
type TourTab = (typeof tabs)[number];
|
||||||
|
type WebhookTab = (typeof webhookTabs)[number];
|
||||||
|
|
||||||
|
type TourSearchParams = 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;
|
||||||
|
metadata?: Record<string, string> | null;
|
||||||
|
created_at: string;
|
||||||
|
archived_at: string | null;
|
||||||
|
detected_at: string | null;
|
||||||
|
confirmed_at: string | null;
|
||||||
|
expires_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildNowFixture = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const iso = (offsetMs: number) => new Date(now + offsetMs).toISOString();
|
||||||
|
const exampleAddress =
|
||||||
|
"4DEMO_ADDRESS_EXAMPLE_ONLY_DO_NOT_USE_FOR_PAYMENTS_XXXXXXXXXXXXXXXXXXXXXX";
|
||||||
|
|
||||||
|
const invoices: InvoiceSummary[] = [
|
||||||
|
{
|
||||||
|
id: "demo_inv_pending",
|
||||||
|
address: `${exampleAddress}_001`,
|
||||||
|
subaddress_index: 12,
|
||||||
|
amount_xmr: "0.125",
|
||||||
|
status: "pending",
|
||||||
|
confirmation_target: 2,
|
||||||
|
created_at: iso(-35 * 60 * 1000),
|
||||||
|
archived_at: null,
|
||||||
|
detected_at: null,
|
||||||
|
confirmed_at: null,
|
||||||
|
expires_at: iso(25 * 60 * 1000),
|
||||||
|
metadata: { recipient_name: "Example customer", description: "Order #1027" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "demo_inv_detected",
|
||||||
|
address: `${exampleAddress}_002`,
|
||||||
|
subaddress_index: 13,
|
||||||
|
amount_xmr: "0.420",
|
||||||
|
status: "payment_detected",
|
||||||
|
confirmation_target: 2,
|
||||||
|
created_at: iso(-2 * 60 * 60 * 1000),
|
||||||
|
archived_at: null,
|
||||||
|
detected_at: iso(-18 * 60 * 1000),
|
||||||
|
confirmed_at: null,
|
||||||
|
expires_at: iso(55 * 60 * 1000),
|
||||||
|
metadata: { description: "Subscription renewal" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "demo_inv_confirmed",
|
||||||
|
address: `${exampleAddress}_003`,
|
||||||
|
subaddress_index: 14,
|
||||||
|
amount_xmr: "1.000",
|
||||||
|
status: "confirmed",
|
||||||
|
confirmation_target: 1,
|
||||||
|
created_at: iso(-26 * 60 * 60 * 1000),
|
||||||
|
archived_at: null,
|
||||||
|
detected_at: iso(-25.5 * 60 * 60 * 1000),
|
||||||
|
confirmed_at: iso(-25.3 * 60 * 60 * 1000),
|
||||||
|
expires_at: iso(-25 * 60 * 60 * 1000),
|
||||||
|
metadata: { recipient_name: "ACME Inc.", description: "Invoice #8841" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "demo_inv_expired",
|
||||||
|
address: `${exampleAddress}_004`,
|
||||||
|
subaddress_index: 15,
|
||||||
|
amount_xmr: "0.050",
|
||||||
|
status: "expired",
|
||||||
|
confirmation_target: 2,
|
||||||
|
created_at: iso(-5 * 24 * 60 * 60 * 1000),
|
||||||
|
archived_at: iso(-4.5 * 24 * 60 * 60 * 1000),
|
||||||
|
detected_at: null,
|
||||||
|
confirmed_at: null,
|
||||||
|
expires_at: iso(-4.8 * 24 * 60 * 60 * 1000),
|
||||||
|
metadata: { description: "Abandoned checkout" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const webhooks: WebhookSummary[] = [
|
||||||
|
{
|
||||||
|
id: "demo_hook_1",
|
||||||
|
url: "https://example.com/wowcheckout/webhook",
|
||||||
|
events: ["invoice.created", "invoice.payment_detected", "invoice.confirmed"],
|
||||||
|
event_urls: { "invoice.expired": "https://example.com/wowcheckout/invoice-expired" },
|
||||||
|
active: true,
|
||||||
|
created_at: iso(-9 * 24 * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const deliveries: WebhookDeliverySummary[] = [
|
||||||
|
{
|
||||||
|
id: "demo_delivery_1",
|
||||||
|
webhook_id: "demo_hook_1",
|
||||||
|
event: "invoice.payment_detected",
|
||||||
|
url: "https://example.com/wowcheckout/webhook",
|
||||||
|
invoice_id: "demo_inv_detected",
|
||||||
|
invoice_address: `${exampleAddress}_002`,
|
||||||
|
invoice_subaddress_index: 13,
|
||||||
|
invoice_amount_xmr: "0.420",
|
||||||
|
invoice_status: "payment_detected",
|
||||||
|
http_status: 200,
|
||||||
|
error_message: null,
|
||||||
|
created_at: iso(-17 * 60 * 1000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "demo_delivery_2",
|
||||||
|
webhook_id: "demo_hook_1",
|
||||||
|
event: "invoice.confirmed",
|
||||||
|
url: "https://example.com/wowcheckout/webhook",
|
||||||
|
invoice_id: "demo_inv_confirmed",
|
||||||
|
invoice_address: `${exampleAddress}_003`,
|
||||||
|
invoice_subaddress_index: 14,
|
||||||
|
invoice_amount_xmr: "1.000",
|
||||||
|
invoice_status: "confirmed",
|
||||||
|
http_status: 500,
|
||||||
|
error_message: "Upstream returned 500",
|
||||||
|
created_at: iso(-25.2 * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
paymentAddress: exampleAddress,
|
||||||
|
defaultConfirmationTarget: 2,
|
||||||
|
invoices,
|
||||||
|
webhooks,
|
||||||
|
deliveries,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function TourPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<TourSearchParams>;
|
||||||
|
}) {
|
||||||
|
const resolvedSearchParams = await searchParams;
|
||||||
|
const tabParam = resolvedSearchParams?.tab;
|
||||||
|
const activeTabValue = Array.isArray(tabParam) ? tabParam[0] : tabParam;
|
||||||
|
const activeTab: TourTab = tabs.includes(activeTabValue as TourTab)
|
||||||
|
? (activeTabValue as TourTab)
|
||||||
|
: "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";
|
||||||
|
|
||||||
|
const fixture = buildNowFixture();
|
||||||
|
const invoices = includeArchived
|
||||||
|
? fixture.invoices
|
||||||
|
: fixture.invoices.filter((invoice) => !invoice.archived_at);
|
||||||
|
|
||||||
|
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 = invoices.filter((invoice) => isToday(invoice.created_at)).length;
|
||||||
|
const awaitingConfirmationCount = invoices.filter(
|
||||||
|
(invoice) => invoice.status === "payment_detected"
|
||||||
|
).length;
|
||||||
|
const confirmedCount = invoices.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-4">
|
||||||
|
<div className="rounded-2xl border border-stroke bg-white/80 px-5 py-3 shadow-soft backdrop-blur">
|
||||||
|
<p className="text-sm font-semibold text-ink">
|
||||||
|
Tour mode uses simulated data. No wallet connection. No changes are saved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="mx-auto mt-6 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="Tour sections">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<Link
|
||||||
|
key={tab}
|
||||||
|
className={`${tabBaseClass} ${activeTab === tab ? tabActiveClass : ""}`}
|
||||||
|
href={`/tour?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 (example)
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 break-all font-mono text-sm text-ink">
|
||||||
|
{fixture.paymentAddress}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-ink-soft">
|
||||||
|
This is an example address shown for the tour.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-4 lg:grid-cols-3">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
title: "Invoices today",
|
||||||
|
value: invoicesTodayCount.toString(),
|
||||||
|
detail: "Totals update as 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 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>
|
||||||
|
{fixture.webhooks.length === 0 ? (
|
||||||
|
<p className="mt-3 text-sm text-ink-soft">
|
||||||
|
No webhooks configured yet.{" "}
|
||||||
|
<Link className="font-semibold text-ink underline" href="/tour?tab=webhooks">
|
||||||
|
Add a webhook endpoint.
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-3 grid gap-3 text-sm text-ink">
|
||||||
|
{fixture.webhooks.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
|
||||||
|
mode="tour"
|
||||||
|
basePath="/tour"
|
||||||
|
activeInvoices={invoices}
|
||||||
|
includeArchived={includeArchived}
|
||||||
|
searchQuery={invoiceSearchQuery}
|
||||||
|
sort={invoiceSort}
|
||||||
|
order={invoiceOrder}
|
||||||
|
defaultConfirmationTarget={fixture.defaultConfirmationTarget}
|
||||||
|
/>
|
||||||
|
) : 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={`/tour?tab=webhooks&webhook_tab=${tab}`}
|
||||||
|
>
|
||||||
|
{tab}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
{activeWebhookTab === "settings" ? (
|
||||||
|
<div className="mt-6">
|
||||||
|
<WebhookSection mode="tour" webhooks={fixture.webhooks} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{activeWebhookTab === "history" ? (
|
||||||
|
<div className="mt-6">
|
||||||
|
<WebhookHistoryPanel mode="tour" deliveries={fixture.deliveries} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</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,295 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
import BtcpayModalBridge from "../../../components/btcpay-modal-bridge";
|
||||||
|
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;
|
||||||
|
amount_paid_xmr?: string | null;
|
||||||
|
status: InvoiceStatus;
|
||||||
|
confirmation_target: number;
|
||||||
|
confirmations: number;
|
||||||
|
checkout_continue_available?: boolean;
|
||||||
|
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;
|
||||||
|
btcpay_redirect_url?: string | null;
|
||||||
|
btcpay_redirect_automatically?: boolean | null;
|
||||||
|
btcpay_order_id?: string | null;
|
||||||
|
btcpay_order_number?: string | 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>
|
||||||
|
</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>
|
||||||
|
</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";
|
||||||
|
const shouldShowBtcpayActions = isBtcpayInvoice && invoice.status === "confirmed";
|
||||||
|
const checkoutContinueAvailable =
|
||||||
|
!isBtcpayInvoice &&
|
||||||
|
invoice.status === "confirmed" &&
|
||||||
|
Boolean(invoice.checkout_continue_available);
|
||||||
|
|
||||||
|
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" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<BtcpayClassicCheckout
|
||||||
|
invoiceId={invoiceId}
|
||||||
|
address={invoice.address}
|
||||||
|
amountXmr={invoice.amount_xmr}
|
||||||
|
amountPaidXmr={invoice.amount_paid_xmr ?? null}
|
||||||
|
btcpayAmount={invoice.btcpay_amount ?? null}
|
||||||
|
btcpayCurrency={invoice.btcpay_currency ?? null}
|
||||||
|
quote={invoice.quote ?? null}
|
||||||
|
status={invoice.status}
|
||||||
|
confirmationTarget={confirmationTarget}
|
||||||
|
redirectUrl={invoice.btcpay_redirect_url ?? null}
|
||||||
|
redirectAutomatically={invoice.btcpay_redirect_automatically ?? null}
|
||||||
|
orderId={invoice.btcpay_order_id ?? null}
|
||||||
|
orderNumber={invoice.btcpay_order_number ?? null}
|
||||||
|
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" />
|
||||||
|
</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>
|
||||||
|
{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 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>
|
||||||
|
{shouldShowBtcpayActions ? (
|
||||||
|
<div className="mt-6 grid gap-3">
|
||||||
|
<a
|
||||||
|
className="inline-flex items-center justify-center rounded-full bg-sage px-6 py-3 text-sm font-semibold text-cream shadow-[0_14px_22px_rgba(93,122,106,0.25)] transition hover:-translate-y-0.5"
|
||||||
|
href={`/i/${encodeURIComponent(invoiceId)}/receipt`}
|
||||||
|
>
|
||||||
|
View receipt
|
||||||
|
</a>
|
||||||
|
{invoice.btcpay_redirect_url ? (
|
||||||
|
<a
|
||||||
|
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white px-6 py-3 text-sm font-semibold text-sage transition hover:bg-cream"
|
||||||
|
href={invoice.btcpay_redirect_url}
|
||||||
|
target="_top"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Return to store
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
import BtcpayModalBridge from "../../../../components/btcpay-modal-bridge";
|
||||||
|
import CopyIconButton from "../../../../components/copy-icon-button";
|
||||||
|
import PrintButton from "../../../../components/print-button";
|
||||||
|
import { formatRelativeTime } from "../../../../components/relative-time";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Receipt",
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:8000";
|
||||||
|
|
||||||
|
type InvoiceStatus =
|
||||||
|
| "pending"
|
||||||
|
| "payment_detected"
|
||||||
|
| "confirmed"
|
||||||
|
| "expired"
|
||||||
|
| "invalid";
|
||||||
|
|
||||||
|
type InvoiceStatusResponse = {
|
||||||
|
id: string;
|
||||||
|
address: string;
|
||||||
|
amount_xmr: string;
|
||||||
|
amount_paid_xmr?: string | null;
|
||||||
|
status: InvoiceStatus;
|
||||||
|
confirmation_target: number;
|
||||||
|
confirmations: number;
|
||||||
|
created_at: string;
|
||||||
|
confirmed_at: string | null;
|
||||||
|
btcpay_amount?: string | null;
|
||||||
|
btcpay_currency?: string | null;
|
||||||
|
btcpay_redirect_url?: string | null;
|
||||||
|
btcpay_order_id?: string | null;
|
||||||
|
btcpay_order_number?: string | null;
|
||||||
|
quote?: {
|
||||||
|
fiat_amount: string;
|
||||||
|
fiat_currency: string;
|
||||||
|
rate: string;
|
||||||
|
source: string;
|
||||||
|
quoted_at: string;
|
||||||
|
} | null;
|
||||||
|
qr_url?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFiatAmount = (value: string, currency: string) => {
|
||||||
|
const amount = Number.parseFloat(value);
|
||||||
|
if (!Number.isFinite(amount)) {
|
||||||
|
return `${value} ${currency}`;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
}).format(amount);
|
||||||
|
} catch {
|
||||||
|
return `${amount.toFixed(2)} ${currency}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const receiptTitle = (redirectUrl: string | null | undefined) => {
|
||||||
|
if (!redirectUrl) {
|
||||||
|
return "Receipt";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = new URL(redirectUrl);
|
||||||
|
return parsed.hostname ? `${parsed.hostname}` : "Receipt";
|
||||||
|
} catch {
|
||||||
|
return "Receipt";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveQrSrc = (invoiceId: string, qrUrl: string | null | undefined) => {
|
||||||
|
if (!qrUrl) {
|
||||||
|
return `/qr/${encodeURIComponent(invoiceId)}.png`;
|
||||||
|
}
|
||||||
|
if (qrUrl.startsWith("/qr/")) {
|
||||||
|
return qrUrl;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = new URL(qrUrl);
|
||||||
|
if (parsed.pathname.startsWith("/qr/")) {
|
||||||
|
return parsed.pathname;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed URLs and fall back to the provided value.
|
||||||
|
}
|
||||||
|
return qrUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeLinkClassName =
|
||||||
|
"rounded-md border border-neutral-200 bg-white px-3 py-2 text-xs font-semibold text-neutral-700 transition hover:bg-neutral-50";
|
||||||
|
|
||||||
|
export default async function BtcpayReceiptPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ invoiceId: string }>;
|
||||||
|
}) {
|
||||||
|
const { invoiceId } = await params;
|
||||||
|
const requestHeaders = await headers();
|
||||||
|
const forwardedProto = requestHeaders.get("x-forwarded-proto");
|
||||||
|
const forwardedHost = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host");
|
||||||
|
const response = await fetch(
|
||||||
|
`${apiBaseUrl}/api/core/public/invoice/${encodeURIComponent(invoiceId)}`,
|
||||||
|
{
|
||||||
|
cache: "no-store",
|
||||||
|
headers: {
|
||||||
|
...(forwardedProto ? { "x-forwarded-proto": forwardedProto } : {}),
|
||||||
|
...(forwardedHost ? { "x-forwarded-host": forwardedHost } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-white px-4 py-10 text-ink">
|
||||||
|
<BtcpayModalBridge invoiceId={invoiceId} status="invalid" />
|
||||||
|
<div className="mx-auto w-full max-w-4xl">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<a className={closeLinkClassName} href={`/i/${encodeURIComponent(invoiceId)}`}>
|
||||||
|
Close
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<h1 className="text-3xl font-semibold">Invoice not found</h1>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-white px-4 py-10 text-ink">
|
||||||
|
<BtcpayModalBridge invoiceId={invoiceId} status="invalid" />
|
||||||
|
<div className="mx-auto w-full max-w-4xl">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<a className={closeLinkClassName} href={`/i/${encodeURIComponent(invoiceId)}`}>
|
||||||
|
Close
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<h1 className="text-3xl font-semibold">Status unavailable</h1>
|
||||||
|
<p className="mt-3 text-sm text-ink-soft">
|
||||||
|
We could not load this invoice status. Refresh the page or try again later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoice = (await response.json()) as InvoiceStatusResponse;
|
||||||
|
const orderId = invoice.btcpay_order_number ?? invoice.btcpay_order_id ?? null;
|
||||||
|
const receiptTimestamp = invoice.confirmed_at ?? invoice.created_at;
|
||||||
|
const timestampLabel = new Date(receiptTimestamp).toLocaleString();
|
||||||
|
const timestampRelative = formatRelativeTime(receiptTimestamp);
|
||||||
|
const totalFiat =
|
||||||
|
invoice.btcpay_amount && invoice.btcpay_currency && invoice.btcpay_currency !== "WOW"
|
||||||
|
? formatFiatAmount(invoice.btcpay_amount, invoice.btcpay_currency)
|
||||||
|
: invoice.btcpay_amount && invoice.btcpay_currency
|
||||||
|
? `${invoice.btcpay_amount} ${invoice.btcpay_currency}`
|
||||||
|
: null;
|
||||||
|
const paidXmr = invoice.amount_paid_xmr ?? invoice.amount_xmr;
|
||||||
|
const qrSrc = resolveQrSrc(invoiceId, invoice.qr_url);
|
||||||
|
const storeTitle = receiptTitle(invoice.btcpay_redirect_url);
|
||||||
|
const amountPaidLabel = totalFiat ? totalFiat : `${paidXmr} WOW`;
|
||||||
|
const returnLabel = invoice.btcpay_redirect_url ? `Return to ${storeTitle}` : "Return to invoice";
|
||||||
|
const returnHref = invoice.btcpay_redirect_url ?? `/i/${encodeURIComponent(invoiceId)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-neutral-100 px-4 py-10 text-neutral-900">
|
||||||
|
<BtcpayModalBridge invoiceId={invoiceId} status={invoice.status} />
|
||||||
|
<div className="mx-auto w-full max-w-4xl">
|
||||||
|
<header className="text-center">
|
||||||
|
<h1 className="text-3xl font-semibold tracking-tight">{storeTitle}</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{invoice.status !== "confirmed" ? (
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
{orderId ? <p className="mt-3 text-lg">Order ID: {orderId}</p> : null}
|
||||||
|
<p className="mt-1 text-base text-neutral-600" title={timestampRelative ?? undefined}>
|
||||||
|
{timestampLabel}
|
||||||
|
</p>
|
||||||
|
<p className="mt-10 text-sm text-neutral-600">
|
||||||
|
This receipt is available after the payment is confirmed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mt-8 rounded-xl bg-white p-6 shadow-soft">
|
||||||
|
<div className="grid gap-6 md:grid-cols-[auto_minmax(0,1fr)] md:items-start">
|
||||||
|
<div className="rounded-lg bg-neutral-100 p-4">
|
||||||
|
{qrSrc ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img alt="Payment QR" src={qrSrc} className="h-56 w-56" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-5 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div className="grid gap-5">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-neutral-500">Amount Paid</p>
|
||||||
|
<p className="mt-1 text-3xl font-semibold">{amountPaidLabel}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-neutral-500">Date</p>
|
||||||
|
<p className="mt-1 text-base" title={timestampRelative ?? undefined}>
|
||||||
|
{timestampLabel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{orderId ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-neutral-500">Order ID</p>
|
||||||
|
<p className="mt-1 text-base">{orderId}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:self-start">
|
||||||
|
<PrintButton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="mt-8 rounded-xl bg-white p-6 shadow-soft">
|
||||||
|
<h2 className="text-xl font-semibold">Payment Details</h2>
|
||||||
|
|
||||||
|
<div className="mt-6 text-sm text-neutral-500">
|
||||||
|
<div className="grid grid-cols-3 gap-6 border-b border-neutral-200 pb-3">
|
||||||
|
<div>Date</div>
|
||||||
|
<div className="text-right">Paid</div>
|
||||||
|
<div className="text-right">Payment</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-6 pt-4 text-neutral-900">
|
||||||
|
<div title={timestampRelative ?? undefined}>{timestampLabel}</div>
|
||||||
|
<div className="text-right">{totalFiat ?? "-"}</div>
|
||||||
|
<div className="text-right">{paidXmr} WOW</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-7 flex flex-col gap-3 text-sm md:flex-row md:items-center">
|
||||||
|
<p className="font-semibold text-neutral-500 md:w-28">Destination</p>
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className="min-w-0 truncate rounded-md bg-neutral-100 px-3 py-2 font-mono text-xs">
|
||||||
|
{invoice.address}
|
||||||
|
</span>
|
||||||
|
<CopyIconButton value={invoice.address} label="Copy destination address" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="mt-10 flex justify-center">
|
||||||
|
<a
|
||||||
|
className="inline-flex items-center justify-center rounded-full border border-emerald-200 bg-white px-10 py-3 text-sm font-semibold text-emerald-700 shadow-soft transition hover:bg-emerald-50"
|
||||||
|
href={returnHref}
|
||||||
|
target={invoice.btcpay_redirect_url ? "_top" : undefined}
|
||||||
|
rel={invoice.btcpay_redirect_url ? "noreferrer" : undefined}
|
||||||
|
>
|
||||||
|
{returnLabel}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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: "WOW Checkout | Home",
|
||||||
|
template: "WOW Checkout | %s",
|
||||||
|
},
|
||||||
|
description: "Non-custodial Wownero 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
type ApiKeyCardProps = {
|
||||||
|
apiKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ApiKeyCard({ apiKey }: ApiKeyCardProps) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(apiKey);
|
||||||
|
setCopied(true);
|
||||||
|
window.setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
setCopied(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 rounded-2xl border border-stroke bg-white/80 p-6 shadow-soft backdrop-blur">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
||||||
|
Your API key
|
||||||
|
</p>
|
||||||
|
<code className="mt-2 block break-all rounded-xl bg-ink/10 px-3 py-2 text-sm text-ink">
|
||||||
|
{apiKey}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center justify-center rounded-full bg-ink px-5 py-2.5 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
>
|
||||||
|
{copied ? "Copied" : "Copy API key"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useFormState } from "react-dom";
|
||||||
|
|
||||||
|
import { resetApiKeyAction, type ApiKeyState } from "../app/(app)/dashboard/actions";
|
||||||
|
import ApiKeyCard from "./api-key-card";
|
||||||
|
|
||||||
|
const initialState = (apiKey: string): ApiKeyState => ({
|
||||||
|
apiKey,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
type ApiKeySectionProps = {
|
||||||
|
apiKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ApiKeySection({ apiKey }: ApiKeySectionProps) {
|
||||||
|
const [state, formAction] = useFormState(resetApiKeyAction, initialState(apiKey));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<ApiKeyCard apiKey={state.apiKey} />
|
||||||
|
{state.error ? (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
|
||||||
|
{state.error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<form action={formAction} className="flex justify-end">
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white/60 px-5 py-2.5 text-sm font-semibold text-ink transition hover:-translate-y-0.5"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Reset API key
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useFormState } from "react-dom";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import {
|
||||||
|
updateBtcpayCheckoutStyleAction,
|
||||||
|
type BtcpayCheckoutPreferenceState,
|
||||||
|
} from "../app/(app)/dashboard/actions";
|
||||||
|
|
||||||
|
type BtcpayCheckoutStyle = "standard" | "btcpay_classic";
|
||||||
|
|
||||||
|
type BtcpayCheckoutStyleSectionProps = {
|
||||||
|
initialStyle: BtcpayCheckoutStyle;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialState = (
|
||||||
|
style: BtcpayCheckoutStyle
|
||||||
|
): BtcpayCheckoutPreferenceState => ({
|
||||||
|
style,
|
||||||
|
error: null,
|
||||||
|
success: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function BtcpayCheckoutStyleSection({
|
||||||
|
initialStyle,
|
||||||
|
}: BtcpayCheckoutStyleSectionProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [state, formAction] = useFormState(
|
||||||
|
updateBtcpayCheckoutStyleAction,
|
||||||
|
initialState(initialStyle)
|
||||||
|
);
|
||||||
|
const [style, setStyle] = useState<BtcpayCheckoutStyle>(initialStyle);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.success) {
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}, [router, state.success]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setStyle(initialStyle);
|
||||||
|
}, [initialStyle]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="grid gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-serif text-2xl">BTCPay checkout layout</h3>
|
||||||
|
<p className="mt-2 text-sm text-ink-soft">
|
||||||
|
This setting only applies to BTCPay checkouts (invoices created via the
|
||||||
|
BTCPay compatibility endpoints).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="grid gap-2 text-sm font-semibold text-ink">
|
||||||
|
Layout
|
||||||
|
<select
|
||||||
|
name="btcpay_checkout_style"
|
||||||
|
className="rounded-xl border border-stroke bg-white/80 px-3 py-2 text-sm text-ink"
|
||||||
|
value={style}
|
||||||
|
onChange={(event) =>
|
||||||
|
setStyle(event.target.value as BtcpayCheckoutStyle)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="standard">Standard WOW Checkout</option>
|
||||||
|
<option value="btcpay_classic">BTCPay Classic</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-ink-soft">
|
||||||
|
The classic layout mirrors the BTCPay checkout screen for drop-in migration.
|
||||||
|
</p>
|
||||||
|
{state.error ? (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
|
||||||
|
{state.error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{state.success ? (
|
||||||
|
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">
|
||||||
|
{state.success}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center justify-center rounded-full border border-stroke bg-ink px-5 py-2.5 text-sm font-semibold text-cream transition hover:-translate-y-0.5"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Save preference
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import QRCode from "qrcode";
|
||||||
|
|
||||||
|
import { formatXmrAmount } from "../lib/formatting";
|
||||||
|
|
||||||
|
type InvoiceStatus =
|
||||||
|
| "pending"
|
||||||
|
| "payment_detected"
|
||||||
|
| "confirmed"
|
||||||
|
| "expired"
|
||||||
|
| "invalid";
|
||||||
|
|
||||||
|
type QuotePayload = {
|
||||||
|
fiat_amount: string;
|
||||||
|
fiat_currency: string;
|
||||||
|
rate: string;
|
||||||
|
source: string;
|
||||||
|
quoted_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BtcpayClassicCheckoutProps = {
|
||||||
|
invoiceId: string;
|
||||||
|
address: string;
|
||||||
|
amountXmr: string;
|
||||||
|
amountPaidXmr: string | null;
|
||||||
|
btcpayAmount: string | null;
|
||||||
|
btcpayCurrency: string | null;
|
||||||
|
quote: QuotePayload | null;
|
||||||
|
status: InvoiceStatus;
|
||||||
|
confirmationTarget: number;
|
||||||
|
redirectUrl: string | null;
|
||||||
|
redirectAutomatically: boolean | null;
|
||||||
|
orderId: string | null;
|
||||||
|
orderNumber: string | null;
|
||||||
|
qrLogoMode?: "monero" | "none" | "custom" | null;
|
||||||
|
qrLogoDataUrl?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildMoneroUri = (address: string, amount: string) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("tx_amount", amount);
|
||||||
|
return `wownero:${address}?${params.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFiatAmount = (value: string, currency: string) => {
|
||||||
|
const amount = Number.parseFloat(value);
|
||||||
|
if (!Number.isFinite(amount)) {
|
||||||
|
return `${value} ${currency}`;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
}).format(amount);
|
||||||
|
} catch {
|
||||||
|
return `${amount.toFixed(2)} ${currency}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatReturnLabel = (redirectUrl: string) => {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(redirectUrl);
|
||||||
|
return parsed.hostname ? `Return to ${parsed.hostname}` : "Return to store";
|
||||||
|
} catch {
|
||||||
|
return "Return to store";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusMessage = (
|
||||||
|
status: InvoiceStatus,
|
||||||
|
confirmationTarget: number
|
||||||
|
): string => {
|
||||||
|
if (status === "invalid") {
|
||||||
|
return "Invoice marked invalid. Do not send payment.";
|
||||||
|
}
|
||||||
|
if (status === "expired") {
|
||||||
|
return "Invoice expired. Do not send payment.";
|
||||||
|
}
|
||||||
|
if (status === "confirmed") {
|
||||||
|
return `Payment confirmed at ${confirmationTarget} confirmations.`;
|
||||||
|
}
|
||||||
|
if (status === "payment_detected") {
|
||||||
|
return "Payment detected. Awaiting confirmations.";
|
||||||
|
}
|
||||||
|
return "Awaiting funds.";
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BtcpayClassicCheckout({
|
||||||
|
invoiceId,
|
||||||
|
address,
|
||||||
|
amountXmr,
|
||||||
|
amountPaidXmr,
|
||||||
|
btcpayAmount,
|
||||||
|
btcpayCurrency,
|
||||||
|
quote,
|
||||||
|
status,
|
||||||
|
confirmationTarget,
|
||||||
|
redirectUrl,
|
||||||
|
redirectAutomatically,
|
||||||
|
orderId,
|
||||||
|
orderNumber,
|
||||||
|
qrLogoMode,
|
||||||
|
qrLogoDataUrl,
|
||||||
|
}: BtcpayClassicCheckoutProps) {
|
||||||
|
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||||
|
const [copiedField, setCopiedField] = useState<string | null>(null);
|
||||||
|
const formattedAmount = useMemo(() => formatXmrAmount(amountXmr), [amountXmr]);
|
||||||
|
const formattedPaidAmount = useMemo(() => {
|
||||||
|
if (!amountPaidXmr) {
|
||||||
|
return formattedAmount;
|
||||||
|
}
|
||||||
|
return formatXmrAmount(amountPaidXmr);
|
||||||
|
}, [amountPaidXmr, formattedAmount]);
|
||||||
|
const uri = useMemo(
|
||||||
|
() => buildMoneroUri(address, formattedAmount),
|
||||||
|
[address, formattedAmount]
|
||||||
|
);
|
||||||
|
const totalFiat =
|
||||||
|
btcpayAmount && btcpayCurrency && btcpayCurrency !== "WOW"
|
||||||
|
? formatFiatAmount(btcpayAmount, btcpayCurrency)
|
||||||
|
: null;
|
||||||
|
const exchangeRate =
|
||||||
|
quote?.rate && quote.fiat_currency
|
||||||
|
? `1 WOW = ${formatFiatAmount(quote.rate, quote.fiat_currency)}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== "confirmed" || !redirectUrl || !redirectAutomatically) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
try {
|
||||||
|
window.top?.location.assign(redirectUrl);
|
||||||
|
} catch {
|
||||||
|
window.location.assign(redirectUrl);
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [redirectAutomatically, redirectUrl, status]);
|
||||||
|
|
||||||
|
const handleCopy = async (value: string, field: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
setCopiedField(field);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setCopiedField((current) => (current === field ? null : current));
|
||||||
|
}, 1500);
|
||||||
|
} catch {
|
||||||
|
setCopiedField(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
const size = 260;
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
QRCode.toCanvas(canvas, uri, {
|
||||||
|
margin: 1,
|
||||||
|
width: size,
|
||||||
|
errorCorrectionLevel: "H",
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const mode = qrLogoMode ?? "monero";
|
||||||
|
const wantsLogo = mode !== "none";
|
||||||
|
if (wantsLogo) {
|
||||||
|
const blankSize = Math.round(size * 0.28);
|
||||||
|
const blankOffset = Math.round((size - blankSize) / 2);
|
||||||
|
context.fillStyle = "#ffffff";
|
||||||
|
context.fillRect(blankOffset, blankOffset, blankSize, blankSize);
|
||||||
|
}
|
||||||
|
return canvas.toDataURL();
|
||||||
|
})
|
||||||
|
.then((url) => {
|
||||||
|
if (active) {
|
||||||
|
setQrDataUrl(url ?? null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (active) {
|
||||||
|
setQrDataUrl(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [uri, qrLogoMode]);
|
||||||
|
|
||||||
|
const isActionDisabled = status === "expired" || status === "invalid";
|
||||||
|
const resolvedQrLogoMode = qrLogoMode ?? "monero";
|
||||||
|
const resolvedLogoSrc =
|
||||||
|
resolvedQrLogoMode === "custom"
|
||||||
|
? qrLogoDataUrl
|
||||||
|
: resolvedQrLogoMode === "monero"
|
||||||
|
? "/wownero-logo.svg"
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (status === "confirmed") {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[28px] border border-stroke bg-white px-6 py-8 shadow-card">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-sage/15 text-sage">
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="h-9 w-9"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M20 6 9 17l-5-5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-sm font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
||||||
|
Invoice paid
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="mt-6 grid gap-3 text-sm text-ink">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<dt className="text-ink-soft">Invoice id</dt>
|
||||||
|
<dd className="max-w-[60%] truncate text-right font-semibold">{invoiceId}</dd>
|
||||||
|
</div>
|
||||||
|
{orderNumber || orderId ? (
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<dt className="text-ink-soft">Order id</dt>
|
||||||
|
<dd className="font-semibold">{orderNumber ?? orderId}</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<dt className="text-ink-soft">Total price</dt>
|
||||||
|
<dd className="font-semibold">{formattedAmount} WOW</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<dt className="text-ink-soft">Total fiat</dt>
|
||||||
|
<dd className="font-semibold">{totalFiat ?? "-"}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<dt className="text-ink-soft">Exchange rate</dt>
|
||||||
|
<dd className="font-semibold">{exchangeRate ?? "-"}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<dt className="text-ink-soft">Network cost</dt>
|
||||||
|
<dd className="font-semibold">Added by wallet</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<dt className="text-ink-soft">Amount paid</dt>
|
||||||
|
<dd className="font-semibold">{formattedPaidAmount} WOW</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className="mt-8 grid gap-3">
|
||||||
|
<a
|
||||||
|
className="inline-flex items-center justify-center rounded-full bg-sage px-6 py-3 text-sm font-semibold text-cream shadow-[0_14px_22px_rgba(93,122,106,0.25)] transition hover:-translate-y-0.5"
|
||||||
|
href={`/i/${encodeURIComponent(invoiceId)}/receipt`}
|
||||||
|
>
|
||||||
|
View receipt
|
||||||
|
</a>
|
||||||
|
{redirectUrl ? (
|
||||||
|
<a
|
||||||
|
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white px-6 py-3 text-sm font-semibold text-sage transition hover:bg-cream"
|
||||||
|
href={redirectUrl}
|
||||||
|
target="_top"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{formatReturnLabel(redirectUrl)}
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-[28px] border border-stroke bg-white px-6 py-8 shadow-card">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
||||||
|
Payment request
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex flex-wrap items-center justify-center gap-3">
|
||||||
|
<p className="text-[1.9rem] font-semibold text-ink">{formattedAmount} WOW</p>
|
||||||
|
<button
|
||||||
|
className="rounded-full border border-stroke bg-white/80 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:bg-white"
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCopy(formattedAmount, "amount")}
|
||||||
|
>
|
||||||
|
{copiedField === "amount" ? "Copied" : "Copy"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-sage">{statusMessage(status, confirmationTarget)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details className="group mt-5 rounded-2xl border border-stroke bg-cream/70 px-5 py-4">
|
||||||
|
<summary className="flex cursor-pointer items-center justify-center gap-2 text-sm font-semibold text-sage [&::-webkit-details-marker]:hidden">
|
||||||
|
<span className="group-open:hidden">View details</span>
|
||||||
|
<span className="hidden group-open:inline">Hide details</span>
|
||||||
|
</summary>
|
||||||
|
<dl className="mt-4 grid gap-3 text-sm text-ink">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<dt className="text-ink-soft">Total price</dt>
|
||||||
|
<dd className="font-semibold">{formattedAmount} WOW</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<dt className="text-ink-soft">Total fiat</dt>
|
||||||
|
<dd className="font-semibold">{totalFiat ?? "-"}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<dt className="text-ink-soft">Exchange rate</dt>
|
||||||
|
<dd className="font-semibold">{exchangeRate ?? "-"}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<dt className="text-ink-soft">Network cost</dt>
|
||||||
|
<dd className="font-semibold">Added by wallet</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<dt className="text-ink-soft">Amount due</dt>
|
||||||
|
<dd className="flex items-center gap-2 font-semibold">
|
||||||
|
<span>{formattedAmount} WOW</span>
|
||||||
|
<button
|
||||||
|
className="rounded-full border border-stroke bg-white/80 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:bg-white"
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCopy(formattedAmount, "amount")}
|
||||||
|
>
|
||||||
|
{copiedField === "amount" ? "Copied" : "Copy"}
|
||||||
|
</button>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div className="mt-6 flex items-center justify-center">
|
||||||
|
{qrDataUrl ? (
|
||||||
|
<div className="relative rounded-2xl border border-ink/10 bg-white p-3 shadow-soft">
|
||||||
|
<Image
|
||||||
|
className="h-[260px] w-[260px]"
|
||||||
|
src={qrDataUrl}
|
||||||
|
alt="Payment request QR"
|
||||||
|
width={260}
|
||||||
|
height={260}
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
{resolvedLogoSrc ? (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-white shadow-[0_8px_16px_rgba(16,18,23,0.18)]">
|
||||||
|
<Image
|
||||||
|
src={resolvedLogoSrc}
|
||||||
|
alt="QR logo"
|
||||||
|
width={38}
|
||||||
|
height={38}
|
||||||
|
unoptimized={resolvedQrLogoMode === "custom"}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
||||||
|
Address
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="rounded-full border border-stroke bg-white/80 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:bg-white"
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCopy(address, "address")}
|
||||||
|
>
|
||||||
|
{copiedField === "address" ? "Copied" : "Copy"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 break-all rounded-xl bg-ink/5 px-3 py-2 font-mono text-xs text-ink">
|
||||||
|
{address}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
className={`mt-6 inline-flex w-full items-center justify-center rounded-full px-6 py-3 text-sm font-semibold text-cream transition ${
|
||||||
|
isActionDisabled
|
||||||
|
? "cursor-not-allowed bg-ink/30"
|
||||||
|
: "bg-sage shadow-[0_14px_22px_rgba(93,122,106,0.25)] hover:-translate-y-0.5"
|
||||||
|
}`}
|
||||||
|
href={isActionDisabled ? undefined : uri}
|
||||||
|
aria-disabled={isActionDisabled}
|
||||||
|
>
|
||||||
|
Pay in wallet
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
type InvoiceStatus =
|
||||||
|
| "pending"
|
||||||
|
| "payment_detected"
|
||||||
|
| "confirmed"
|
||||||
|
| "expired"
|
||||||
|
| "invalid";
|
||||||
|
|
||||||
|
type BtcpayModalBridgeProps = {
|
||||||
|
invoiceId: string;
|
||||||
|
status: InvoiceStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusMap: Record<InvoiceStatus, string> = {
|
||||||
|
pending: "New",
|
||||||
|
payment_detected: "Processing",
|
||||||
|
confirmed: "Settled",
|
||||||
|
expired: "Expired",
|
||||||
|
invalid: "Invalid",
|
||||||
|
};
|
||||||
|
|
||||||
|
const postToParent = (payload: unknown) => {
|
||||||
|
if (typeof window === "undefined" || !window.parent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.parent.postMessage(payload, "*");
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BtcpayModalBridge({
|
||||||
|
invoiceId,
|
||||||
|
status,
|
||||||
|
}: BtcpayModalBridgeProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
postToParent("loaded");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
postToParent({
|
||||||
|
invoiceId,
|
||||||
|
status: statusMap[status] ?? "new",
|
||||||
|
});
|
||||||
|
}, [invoiceId, status]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
export default function BtcpayModalCloseButton() {
|
||||||
|
const handleClick = () => {
|
||||||
|
if (typeof window === "undefined" || !window.parent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.parent.postMessage("close", "*");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="rounded-full border border-stroke bg-white/80 px-3 py-1 text-[0.65rem] font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:bg-white"
|
||||||
|
type="button"
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
type CopyIconButtonProps = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CopyIconButton({ value, label, className }: CopyIconButtonProps) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
setCopied(true);
|
||||||
|
window.setTimeout(() => setCopied(false), 1500);
|
||||||
|
} catch {
|
||||||
|
setCopied(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
title={copied ? "Copied" : label}
|
||||||
|
onClick={handleCopy}
|
||||||
|
className={
|
||||||
|
className ??
|
||||||
|
"inline-flex h-7 w-7 items-center justify-center rounded-md border border-neutral-200 bg-white text-neutral-600 transition hover:bg-neutral-50"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M20 6 9 17l-5-5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path
|
||||||
|
d="M8 5h9a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2Z"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M6 17H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,525 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useFormState } from "react-dom";
|
||||||
|
import QRCode from "qrcode";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createInvoiceAction,
|
||||||
|
type InvoiceState,
|
||||||
|
} from "../app/(app)/dashboard/actions";
|
||||||
|
import { formatUsdAmount, formatXmrAmount } from "../lib/formatting";
|
||||||
|
import { useXmrUsdRate } from "../lib/use-xmr-usd-rate";
|
||||||
|
import { useXmrFiatRate } from "../lib/use-xmr-fiat-rate";
|
||||||
|
import {
|
||||||
|
FIAT_CURRENCY_SUGGESTIONS,
|
||||||
|
getCurrencyFlag,
|
||||||
|
} from "../lib/fiat-currencies";
|
||||||
|
|
||||||
|
const initialState: InvoiceState = {
|
||||||
|
error: null,
|
||||||
|
invoiceId: null,
|
||||||
|
address: null,
|
||||||
|
amount: null,
|
||||||
|
recipientName: null,
|
||||||
|
description: null,
|
||||||
|
subaddressIndex: null,
|
||||||
|
warnings: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildMoneroUri = (state: InvoiceState): string | null => {
|
||||||
|
if (!state.address || !state.amount) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("tx_amount", state.amount);
|
||||||
|
if (state.recipientName) {
|
||||||
|
params.set("recipient_name", state.recipientName);
|
||||||
|
}
|
||||||
|
if (state.description) {
|
||||||
|
params.set("tx_description", state.description);
|
||||||
|
}
|
||||||
|
return `wownero:${state.address}?${params.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CreateInvoiceCard() {
|
||||||
|
const [state, formAction] = useFormState(createInvoiceAction, initialState);
|
||||||
|
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||||
|
const [qrColor, setQrColor] = useState("#1b1b1a");
|
||||||
|
const [qrLogo, setQrLogo] = useState<string | null>(null);
|
||||||
|
const [origin, setOrigin] = useState<string | null>(null);
|
||||||
|
const [amountMode, setAmountMode] = useState<"xmr" | "fiat">("xmr");
|
||||||
|
const [amountInput, setAmountInput] = useState("");
|
||||||
|
const [fiatCurrency, setFiatCurrency] = useState("USD");
|
||||||
|
const { rate: usdRate } = useXmrUsdRate();
|
||||||
|
const { rate: fiatRate, status: fiatRateStatus } = useXmrFiatRate(
|
||||||
|
amountMode === "fiat" ? fiatCurrency : null
|
||||||
|
);
|
||||||
|
const labelClass = "text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft";
|
||||||
|
const inputClass =
|
||||||
|
"w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10";
|
||||||
|
const secondaryButton =
|
||||||
|
"inline-flex items-center justify-center rounded-full border border-stroke bg-white/60 px-5 py-2.5 text-sm font-semibold text-ink transition hover:-translate-y-0.5";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOrigin(window.location.origin);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const publicInvoiceUrl = useMemo(() => {
|
||||||
|
if (!state.invoiceId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (origin) {
|
||||||
|
return new URL(`/invoice/${state.invoiceId}`, origin).toString();
|
||||||
|
}
|
||||||
|
return `/invoice/${state.invoiceId}`;
|
||||||
|
}, [origin, state.invoiceId]);
|
||||||
|
|
||||||
|
const uri = useMemo(() => buildMoneroUri(state), [state]);
|
||||||
|
const amountValue = useMemo(() => {
|
||||||
|
if (amountMode !== "xmr") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const raw = amountInput || state.amount || "";
|
||||||
|
const parsed = Number.parseFloat(raw);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}, [amountInput, amountMode, state.amount]);
|
||||||
|
const usdEstimate = useMemo(() => {
|
||||||
|
if (!usdRate || amountValue === null || amountValue <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return formatUsdAmount(usdRate * amountValue);
|
||||||
|
}, [usdRate, amountValue]);
|
||||||
|
const fiatAmountValue = useMemo(() => {
|
||||||
|
if (amountMode !== "fiat") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = Number.parseFloat(amountInput);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}, [amountInput, amountMode]);
|
||||||
|
const xmrEstimate = useMemo(() => {
|
||||||
|
if (!fiatRate || fiatAmountValue === null || fiatAmountValue <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const estimate = fiatAmountValue / fiatRate;
|
||||||
|
return formatXmrAmount(estimate.toFixed(12));
|
||||||
|
}, [fiatAmountValue, fiatRate]);
|
||||||
|
const showFiatEstimate =
|
||||||
|
amountMode === "fiat" && fiatAmountValue !== null && fiatAmountValue > 0;
|
||||||
|
const fiatEstimateLabel = xmrEstimate
|
||||||
|
? `~${xmrEstimate} WOW`
|
||||||
|
: fiatRateStatus === "loading"
|
||||||
|
? "Fetching estimate..."
|
||||||
|
: "Estimate unavailable";
|
||||||
|
const fiatCurrencyFlag = getCurrencyFlag(fiatCurrency);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
if (!uri) {
|
||||||
|
setQrDataUrl(null);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
QRCode.toDataURL(uri, {
|
||||||
|
margin: 1,
|
||||||
|
width: 220,
|
||||||
|
errorCorrectionLevel: "H",
|
||||||
|
color: {
|
||||||
|
dark: qrColor,
|
||||||
|
light: "#ffffff",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((url) => {
|
||||||
|
if (active) {
|
||||||
|
setQrDataUrl(url);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (active) {
|
||||||
|
setQrDataUrl(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [uri, qrColor]);
|
||||||
|
|
||||||
|
const handleLogoChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) {
|
||||||
|
setQrLogo(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const result = reader.result;
|
||||||
|
if (typeof result === "string") {
|
||||||
|
setQrLogo(result);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-stroke bg-white/80 p-6 shadow-card backdrop-blur">
|
||||||
|
<h3 className="font-serif text-xl">Create test invoice</h3>
|
||||||
|
<p className="mt-2 text-ink-soft">
|
||||||
|
Generate an invoice address and draft invoice for validation.
|
||||||
|
</p>
|
||||||
|
<form className="mt-6 grid gap-4" action={formAction}>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className={labelClass} htmlFor="payment_address">
|
||||||
|
Primary address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
id="payment_address"
|
||||||
|
name="payment_address"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter your primary address"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className={labelClass} htmlFor="amount_mode">
|
||||||
|
Amount type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className={inputClass}
|
||||||
|
id="amount_mode"
|
||||||
|
name="amount_mode"
|
||||||
|
value={amountMode}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = event.target.value;
|
||||||
|
if (value === "xmr" || value === "fiat") {
|
||||||
|
setAmountMode(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="xmr">WOW</option>
|
||||||
|
<option value="fiat">Fiat</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{amountMode === "xmr" ? (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className={labelClass} htmlFor="amount_xmr">
|
||||||
|
Amount (WOW)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
id="amount_xmr"
|
||||||
|
name="amount_xmr"
|
||||||
|
type="number"
|
||||||
|
step="0.000001"
|
||||||
|
min="0"
|
||||||
|
placeholder="0.10"
|
||||||
|
required
|
||||||
|
onChange={(event) => setAmountInput(event.target.value)}
|
||||||
|
/>
|
||||||
|
{usdEstimate ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-ink-soft">
|
||||||
|
Approx. USD reference: ~{usdEstimate}
|
||||||
|
</p>
|
||||||
|
<details className="w-fit text-xs text-ink-soft">
|
||||||
|
<summary className="cursor-pointer select-none underline underline-offset-4">
|
||||||
|
About this estimate
|
||||||
|
</summary>
|
||||||
|
<p className="mt-2 max-w-[46ch] leading-relaxed">
|
||||||
|
Reference only, uses CoinGecko spot rate. Not a quote or guarantee.
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_140px]">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className={labelClass} htmlFor="amount_fiat">
|
||||||
|
Amount (fiat)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
id="amount_fiat"
|
||||||
|
name="amount_fiat"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
placeholder="100.00"
|
||||||
|
required
|
||||||
|
onChange={(event) => setAmountInput(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className={labelClass} htmlFor="currency">
|
||||||
|
Currency
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
id="currency"
|
||||||
|
name="currency"
|
||||||
|
type="text"
|
||||||
|
placeholder="USD"
|
||||||
|
list="fiat-currency-options"
|
||||||
|
value={fiatCurrency}
|
||||||
|
onChange={(event) => setFiatCurrency(event.target.value.toUpperCase())}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<datalist id="fiat-currency-options">
|
||||||
|
{FIAT_CURRENCY_SUGGESTIONS.map((code) => (
|
||||||
|
<option
|
||||||
|
key={code}
|
||||||
|
value={code}
|
||||||
|
label={`${getCurrencyFlag(code) ?? ""} ${code}`.trim()}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
<p className="text-sm text-ink-soft sm:col-span-2">
|
||||||
|
Fiat inputs create a WOW invoice using a non-binding rate at request time.
|
||||||
|
</p>
|
||||||
|
{showFiatEstimate ? (
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<p className="text-sm text-ink-soft">
|
||||||
|
Approx. WOW value: {fiatEstimateLabel}
|
||||||
|
{fiatCurrencyFlag ? ` · ${fiatCurrencyFlag} ${fiatCurrency}` : ""}
|
||||||
|
</p>
|
||||||
|
{xmrEstimate ? (
|
||||||
|
<details className="w-fit text-xs text-ink-soft">
|
||||||
|
<summary className="cursor-pointer select-none underline underline-offset-4">
|
||||||
|
About this estimate
|
||||||
|
</summary>
|
||||||
|
<p className="mt-2 max-w-[46ch] leading-relaxed">
|
||||||
|
Reference only, uses CoinGecko spot rate. Not a quote or guarantee.
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className={labelClass} htmlFor="confirmation_target">
|
||||||
|
Confirmation target
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
id="confirmation_target"
|
||||||
|
name="confirmation_target"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="10"
|
||||||
|
step="1"
|
||||||
|
defaultValue={10}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-ink-soft">Expiry defaults to 60 minutes.</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className={labelClass} htmlFor="expires_date">
|
||||||
|
Expiry date
|
||||||
|
</label>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_160px]">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
id="expires_date"
|
||||||
|
name="expires_date"
|
||||||
|
type="date"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
id="expires_time"
|
||||||
|
name="expires_time"
|
||||||
|
type="time"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-ink-soft">
|
||||||
|
Leave blank to keep the default 60-minute expiry.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 rounded-xl border border-stroke bg-white/60 px-4 py-3 text-sm font-semibold text-ink">
|
||||||
|
<label className="flex items-center gap-3" htmlFor="instant_confirmation">
|
||||||
|
<input
|
||||||
|
className="h-4 w-4 rounded border-stroke text-ink focus:ring-ink/20"
|
||||||
|
id="instant_confirmation"
|
||||||
|
name="instant_confirmation"
|
||||||
|
type="checkbox"
|
||||||
|
value="1"
|
||||||
|
/>
|
||||||
|
Instant confirmation (0 confirmations)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className={labelClass} htmlFor="recipient_name">
|
||||||
|
Recipient name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
id="recipient_name"
|
||||||
|
name="recipient_name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Your store"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className={labelClass} htmlFor="description">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
type="text"
|
||||||
|
placeholder="Order 1042"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className={labelClass} htmlFor="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">
|
||||||
|
<label className={labelClass} htmlFor="qr_color">
|
||||||
|
QR accent color
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="h-12 w-full rounded-xl border border-stroke bg-white/80 px-3 py-2"
|
||||||
|
id="qr_color"
|
||||||
|
name="qr_color"
|
||||||
|
type="color"
|
||||||
|
value={qrColor}
|
||||||
|
onChange={(event) => setQrColor(event.target.value)}
|
||||||
|
aria-label="QR accent color"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className={labelClass} htmlFor="qr_logo">
|
||||||
|
Logo overlay
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink"
|
||||||
|
id="qr_logo"
|
||||||
|
name="qr_logo"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleLogoChange}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-ink-soft">Logo overlays the QR center for branding.</p>
|
||||||
|
{qrLogo ? (
|
||||||
|
<button
|
||||||
|
className={secondaryButton}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setQrLogo(null)}
|
||||||
|
>
|
||||||
|
Remove logo
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{state.error ? (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
|
||||||
|
{state.error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{state.invoiceId ? (
|
||||||
|
<div className="grid gap-3 rounded-2xl border border-stroke bg-white/70 p-5 shadow-soft">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
||||||
|
Invoice created
|
||||||
|
</p>
|
||||||
|
<code className="break-all rounded-lg bg-ink/10 px-3 py-2 text-sm text-ink">
|
||||||
|
{state.invoiceId}
|
||||||
|
</code>
|
||||||
|
{publicInvoiceUrl ? (
|
||||||
|
<>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
||||||
|
Status link
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
className="break-all text-sm font-semibold text-ink underline"
|
||||||
|
href={publicInvoiceUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{publicInvoiceUrl}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
||||||
|
Address
|
||||||
|
</p>
|
||||||
|
<code className="break-all rounded-lg bg-ink/10 px-3 py-2 text-sm text-ink">
|
||||||
|
{state.address}
|
||||||
|
</code>
|
||||||
|
{state.subaddressIndex !== null ? (
|
||||||
|
<>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
||||||
|
Subaddress index
|
||||||
|
</p>
|
||||||
|
<code className="rounded-lg bg-ink/10 px-3 py-2 text-sm text-ink">
|
||||||
|
{state.subaddressIndex}
|
||||||
|
</code>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{state.warnings && state.warnings.length > 0 ? (
|
||||||
|
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
|
||||||
|
{state.warnings.map((warning) => (
|
||||||
|
<p key={warning}>{warning}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{uri ? (
|
||||||
|
<>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
||||||
|
Payment request
|
||||||
|
</p>
|
||||||
|
<code className="break-all rounded-lg bg-ink/10 px-3 py-2 text-sm text-ink">
|
||||||
|
{uri}
|
||||||
|
</code>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{qrDataUrl ? (
|
||||||
|
<div className="relative mx-auto mt-2 h-[220px] w-[220px] rounded-2xl border border-stroke bg-white p-2">
|
||||||
|
<Image
|
||||||
|
className="h-full w-full rounded-xl border border-ink/10 bg-white"
|
||||||
|
src={qrDataUrl}
|
||||||
|
alt="Payment request QR"
|
||||||
|
width={220}
|
||||||
|
height={220}
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
{qrLogo ? (
|
||||||
|
<Image
|
||||||
|
className="absolute left-1/2 top-1/2 h-[54px] w-[54px] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-white bg-white object-cover"
|
||||||
|
src={qrLogo}
|
||||||
|
alt="Logo overlay"
|
||||||
|
width={54}
|
||||||
|
height={54}
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button className={secondaryButton} type="submit">
|
||||||
|
Create invoice
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useFormState } from "react-dom";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import {
|
||||||
|
updateDefaultConfirmationTargetAction,
|
||||||
|
type DefaultConfirmationTargetState,
|
||||||
|
} from "../app/(app)/dashboard/actions";
|
||||||
|
|
||||||
|
const buildInitialState = (value: number): DefaultConfirmationTargetState => ({
|
||||||
|
value,
|
||||||
|
error: null,
|
||||||
|
success: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function DefaultConfirmationTargetSection({
|
||||||
|
initialValue,
|
||||||
|
}: {
|
||||||
|
initialValue: number;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [state, formAction] = useFormState(
|
||||||
|
updateDefaultConfirmationTargetAction,
|
||||||
|
buildInitialState(initialValue)
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.success) {
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}, [router, state.success]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-serif text-2xl">Default confirmations</h2>
|
||||||
|
<p className="mt-2 text-sm text-ink-soft">
|
||||||
|
Used as the default confirmation target for new invoices.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto]" action={formAction}>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label
|
||||||
|
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
|
||||||
|
htmlFor="default_confirmation_target"
|
||||||
|
>
|
||||||
|
Confirmation target
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
|
||||||
|
id="default_confirmation_target"
|
||||||
|
name="default_confirmation_target"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={10}
|
||||||
|
step={1}
|
||||||
|
defaultValue={state.value}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end justify-end">
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center justify-center rounded-full bg-ink px-6 py-3 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{state.error ? (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
|
||||||
|
{state.error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{state.success ? (
|
||||||
|
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">
|
||||||
|
{state.success}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useFormState } from "react-dom";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import {
|
||||||
|
updateDefaultQrLogoAction,
|
||||||
|
type DefaultQrLogoState,
|
||||||
|
} from "../app/(app)/dashboard/actions";
|
||||||
|
|
||||||
|
const buildInitialState = (logo: "monero" | "none" | "custom", logoDataUrl: string | null) => ({
|
||||||
|
logo,
|
||||||
|
logoDataUrl,
|
||||||
|
error: null,
|
||||||
|
success: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function DefaultQrLogoSection({
|
||||||
|
initialLogo,
|
||||||
|
initialLogoDataUrl,
|
||||||
|
}: {
|
||||||
|
initialLogo: "monero" | "none" | "custom";
|
||||||
|
initialLogoDataUrl: string | null;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [logo, setLogo] = useState<"monero" | "none" | "custom">(initialLogo);
|
||||||
|
const [logoDataUrl, setLogoDataUrl] = useState<string | null>(initialLogoDataUrl);
|
||||||
|
const [state, formAction] = useFormState(
|
||||||
|
updateDefaultQrLogoAction,
|
||||||
|
buildInitialState(initialLogo, initialLogoDataUrl)
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.success) {
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}, [router, state.success]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLogo(initialLogo);
|
||||||
|
setLogoDataUrl(initialLogoDataUrl);
|
||||||
|
}, [initialLogo, initialLogoDataUrl]);
|
||||||
|
|
||||||
|
const previewSrc = useMemo(() => {
|
||||||
|
if (logo === "monero") {
|
||||||
|
return "/wownero-logo.svg";
|
||||||
|
}
|
||||||
|
if (logo === "custom") {
|
||||||
|
return logoDataUrl;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [logo, logoDataUrl]);
|
||||||
|
|
||||||
|
const handleLogoFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) {
|
||||||
|
setLogoDataUrl(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const result = reader.result;
|
||||||
|
if (typeof result === "string") {
|
||||||
|
setLogoDataUrl(result);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-serif text-2xl">Default QR logo</h2>
|
||||||
|
<p className="mt-2 text-sm text-ink-soft">
|
||||||
|
Applies to new invoices unless overridden at creation time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form className="grid gap-4" action={formAction}>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label
|
||||||
|
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
|
||||||
|
htmlFor="default_qr_logo"
|
||||||
|
>
|
||||||
|
Logo mode
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
|
||||||
|
id="default_qr_logo"
|
||||||
|
name="default_qr_logo"
|
||||||
|
value={logo}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = event.target.value;
|
||||||
|
if (value === "none" || value === "custom" || value === "monero") {
|
||||||
|
setLogo(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="monero">Wownero logo</option>
|
||||||
|
<option value="none">No logo</option>
|
||||||
|
<option value="custom">Custom image</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{logo === "custom" ? (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label
|
||||||
|
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
|
||||||
|
htmlFor="default_qr_logo_file"
|
||||||
|
>
|
||||||
|
Custom logo image
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink"
|
||||||
|
id="default_qr_logo_file"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleLogoFileChange}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-ink-soft">
|
||||||
|
Stored as a data URL for use on public invoice pages. Keep it small.
|
||||||
|
</p>
|
||||||
|
{logoDataUrl ? (
|
||||||
|
<button
|
||||||
|
className="inline-flex w-fit items-center justify-center rounded-full border border-stroke bg-white/60 px-5 py-2.5 text-sm font-semibold text-ink transition hover:-translate-y-0.5"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLogoDataUrl(null)}
|
||||||
|
>
|
||||||
|
Remove image
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="default_qr_logo_data_url"
|
||||||
|
value={logo === "custom" ? logoDataUrl ?? "" : ""}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{previewSrc ? (
|
||||||
|
<div className="flex items-center gap-3 rounded-2xl border border-stroke bg-white/70 p-4 shadow-soft">
|
||||||
|
<span className="inline-flex h-12 w-12 items-center justify-center rounded-full bg-white shadow-[0_6px_12px_rgba(16,18,23,0.18)]">
|
||||||
|
<Image
|
||||||
|
src={previewSrc}
|
||||||
|
alt="QR logo preview"
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
unoptimized={logo === "custom"}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<p className="text-sm text-ink-soft">Preview (used in the QR center).</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{state.error ? (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
|
||||||
|
{state.error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{state.success ? (
|
||||||
|
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">
|
||||||
|
{state.success}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center justify-center rounded-full bg-ink px-6 py-3 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useFormState } from "react-dom";
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createDonationAction,
|
||||||
|
type DonationState,
|
||||||
|
} from "../app/(marketing)/donate/actions";
|
||||||
|
import { formatUsdAmount } from "../lib/formatting";
|
||||||
|
import { useXmrUsdRate } from "../lib/use-xmr-usd-rate";
|
||||||
|
|
||||||
|
const donateParam = "donate";
|
||||||
|
|
||||||
|
const initialState: DonationState = {
|
||||||
|
error: null,
|
||||||
|
invoiceId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DonateModal() {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const donationsEnabled = process.env.NEXT_PUBLIC_DONATIONS_ENABLED === "true";
|
||||||
|
const isOpen = searchParams.get(donateParam) === "1";
|
||||||
|
const [state, formAction] = useFormState(createDonationAction, initialState);
|
||||||
|
const formRef = useRef<HTMLFormElement | null>(null);
|
||||||
|
const [amountInput, setAmountInput] = useState("");
|
||||||
|
const { rate: usdRate } = useXmrUsdRate();
|
||||||
|
|
||||||
|
const openModal = useCallback(() => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.set(donateParam, "1");
|
||||||
|
const query = params.toString();
|
||||||
|
router.push(query ? `${pathname}?${query}` : pathname);
|
||||||
|
}, [pathname, router, searchParams]);
|
||||||
|
|
||||||
|
const closeModal = useCallback(() => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.delete(donateParam);
|
||||||
|
const query = params.toString();
|
||||||
|
router.replace(query ? `${pathname}?${query}` : pathname);
|
||||||
|
}, [pathname, router, searchParams]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!state.invoiceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeModal();
|
||||||
|
router.push(`/donate/${state.invoiceId}`);
|
||||||
|
}, [closeModal, router, state.invoiceId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
formRef.current?.reset();
|
||||||
|
setAmountInput("");
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const previousOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = previousOverflow;
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [closeModal, isOpen]);
|
||||||
|
const amountValue = useMemo(() => {
|
||||||
|
const parsed = Number.parseFloat(amountInput);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}, [amountInput]);
|
||||||
|
const usdEstimate = useMemo(() => {
|
||||||
|
if (!usdRate || amountValue === null || amountValue <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return formatUsdAmount(usdRate * amountValue);
|
||||||
|
}, [usdRate, amountValue]);
|
||||||
|
|
||||||
|
if (!donationsEnabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<button
|
||||||
|
className="rounded-full border border-stroke bg-white/40 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.18em] text-clay transition hover:text-ink"
|
||||||
|
type="button"
|
||||||
|
onClick={openModal}
|
||||||
|
>
|
||||||
|
Donate
|
||||||
|
</button>
|
||||||
|
{isOpen ? (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-30 grid place-items-center"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="donate-title"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-ink/60 backdrop-blur" onClick={closeModal} />
|
||||||
|
<div
|
||||||
|
className="relative w-[min(420px,92vw)] rounded-3xl border border-stroke bg-card p-7 shadow-deep"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
|
||||||
|
Support wowcheckout.com
|
||||||
|
</p>
|
||||||
|
<h3 id="donate-title" className="mt-2 font-serif text-xl">
|
||||||
|
If wowcheckout.com has been helpful, a donation is appreciated.
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="rounded-full border border-stroke bg-white/70 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-ink transition hover:-translate-y-0.5"
|
||||||
|
type="button"
|
||||||
|
onClick={closeModal}
|
||||||
|
aria-label="Close donation modal"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-ink-soft">
|
||||||
|
Choose an amount and we will open an invoice page with payment details and status.
|
||||||
|
</p>
|
||||||
|
<form className="mt-4 grid gap-4" action={formAction} ref={formRef}>
|
||||||
|
<label
|
||||||
|
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
|
||||||
|
htmlFor="donation_amount"
|
||||||
|
>
|
||||||
|
Amount (WOW)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
|
||||||
|
id="donation_amount"
|
||||||
|
name="amount_xmr"
|
||||||
|
type="number"
|
||||||
|
step="0.000001"
|
||||||
|
min="0"
|
||||||
|
placeholder="0.10"
|
||||||
|
required
|
||||||
|
onChange={(event) => setAmountInput(event.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="min-h-[2.75rem]">
|
||||||
|
{usdEstimate ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-ink-soft">
|
||||||
|
Approx. USD reference: ~{usdEstimate}
|
||||||
|
</p>
|
||||||
|
<details className="w-fit text-xs text-ink-soft">
|
||||||
|
<summary className="cursor-pointer select-none underline underline-offset-4">
|
||||||
|
About this estimate
|
||||||
|
</summary>
|
||||||
|
<p className="mt-2 max-w-[46ch] leading-relaxed">
|
||||||
|
Reference only, uses CoinGecko spot rate. Not a quote or guarantee.
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{state.error ? (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
|
||||||
|
{state.error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="flex flex-wrap justify-end gap-3">
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white/60 px-5 py-2.5 text-sm font-semibold text-ink transition hover:-translate-y-0.5"
|
||||||
|
type="button"
|
||||||
|
onClick={closeModal}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center justify-center rounded-full bg-ink px-5 py-2.5 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Donate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p className="mt-4 text-sm text-ink-soft">
|
||||||
|
Payments go directly to the founder wallet. This checkout never
|
||||||
|
moves funds.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function DonationStatusActions() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Link
|
||||||
|
className="inline-flex items-center justify-center rounded-full bg-ink px-5 py-2.5 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
|
||||||
|
href="/"
|
||||||
|
>
|
||||||
|
Back to home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,256 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import QRCode from "qrcode";
|
||||||
|
|
||||||
|
import { formatUsdAmount, formatXmrAmount } from "../lib/formatting";
|
||||||
|
import { useXmrUsdRate } from "../lib/use-xmr-usd-rate";
|
||||||
|
|
||||||
|
type InvoicePaymentDetailsProps = {
|
||||||
|
address: string;
|
||||||
|
amount: string;
|
||||||
|
hasDetectedPayment: boolean;
|
||||||
|
status: "pending" | "payment_detected" | "confirmed" | "expired" | "invalid";
|
||||||
|
confirmationTarget: number;
|
||||||
|
qrLogoMode?: "monero" | "none" | "custom" | null;
|
||||||
|
qrLogoDataUrl?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildMoneroUri = (address: string, amount: string) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("tx_amount", amount);
|
||||||
|
return `wownero:${address}?${params.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function InvoicePaymentDetails({
|
||||||
|
address,
|
||||||
|
amount,
|
||||||
|
hasDetectedPayment,
|
||||||
|
status,
|
||||||
|
confirmationTarget,
|
||||||
|
qrLogoMode,
|
||||||
|
qrLogoDataUrl,
|
||||||
|
}: InvoicePaymentDetailsProps) {
|
||||||
|
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||||
|
const [copiedField, setCopiedField] = useState<string | null>(null);
|
||||||
|
const { rate: usdRate } = useXmrUsdRate();
|
||||||
|
const isInvalid = status === "invalid";
|
||||||
|
const shouldPromptPayment = !hasDetectedPayment && !isInvalid;
|
||||||
|
const formattedAmount = useMemo(() => formatXmrAmount(amount), [amount]);
|
||||||
|
const uri = useMemo(
|
||||||
|
() => buildMoneroUri(address, formattedAmount),
|
||||||
|
[address, formattedAmount]
|
||||||
|
);
|
||||||
|
const amountValue = useMemo(() => {
|
||||||
|
const parsed = Number.parseFloat(amount);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}, [amount]);
|
||||||
|
const usdEstimate = useMemo(() => {
|
||||||
|
if (!usdRate || amountValue === null || amountValue <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return formatUsdAmount(usdRate * amountValue);
|
||||||
|
}, [usdRate, amountValue]);
|
||||||
|
|
||||||
|
const handleCopy = async (value: string, field: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
setCopiedField(field);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setCopiedField((current) => (current === field ? null : current));
|
||||||
|
}, 1500);
|
||||||
|
} catch {
|
||||||
|
setCopiedField(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
const size = 200;
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
QRCode.toCanvas(canvas, uri, {
|
||||||
|
margin: 1,
|
||||||
|
width: size,
|
||||||
|
errorCorrectionLevel: "H",
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const mode = qrLogoMode ?? "monero";
|
||||||
|
const wantsLogo = mode !== "none";
|
||||||
|
if (wantsLogo) {
|
||||||
|
const blankSize = Math.round(size * 0.28);
|
||||||
|
const blankOffset = Math.round((size - blankSize) / 2);
|
||||||
|
context.fillStyle = "#ffffff";
|
||||||
|
context.fillRect(blankOffset, blankOffset, blankSize, blankSize);
|
||||||
|
}
|
||||||
|
return canvas.toDataURL();
|
||||||
|
})
|
||||||
|
.then((url) => {
|
||||||
|
if (active) {
|
||||||
|
setQrDataUrl(url ?? null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (active) {
|
||||||
|
setQrDataUrl(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [uri, qrLogoMode]);
|
||||||
|
|
||||||
|
const resolvedQrLogoMode = qrLogoMode ?? "monero";
|
||||||
|
const resolvedLogoSrc =
|
||||||
|
resolvedQrLogoMode === "custom"
|
||||||
|
? qrLogoDataUrl
|
||||||
|
: resolvedQrLogoMode === "monero"
|
||||||
|
? "/wownero-logo.svg"
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details
|
||||||
|
className="group rounded-2xl border border-stroke bg-card p-8 shadow-card backdrop-blur"
|
||||||
|
open={shouldPromptPayment}
|
||||||
|
>
|
||||||
|
<summary className="cursor-pointer list-none [&::-webkit-details-marker]:hidden">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-clay">
|
||||||
|
Payment details
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-2 font-serif text-2xl">
|
||||||
|
{isInvalid
|
||||||
|
? "Invoice marked invalid. Do not send payment."
|
||||||
|
: hasDetectedPayment
|
||||||
|
? status === "confirmed"
|
||||||
|
? `Payment confirmed on-chain at ${confirmationTarget} confirmations.`
|
||||||
|
: "Payment detected. Confirmations are in progress."
|
||||||
|
: "Send the exact amount to this address."}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full border border-stroke bg-white/70 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink-soft transition group-open:hidden">
|
||||||
|
Show details
|
||||||
|
</span>
|
||||||
|
<span className="hidden rounded-full border border-stroke bg-white/70 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink-soft transition group-open:inline-flex">
|
||||||
|
Hide details
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</summary>
|
||||||
|
<div className="mt-6 grid gap-6 lg:grid-cols-2">
|
||||||
|
<div className="rounded-2xl border border-stroke bg-white/60 p-5 shadow-soft">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
||||||
|
Amount (WOW)
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="rounded-full border border-stroke bg-white/70 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:bg-white"
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCopy(formattedAmount, "amount")}
|
||||||
|
>
|
||||||
|
{copiedField === "amount" ? "Copied" : "Copy"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-2xl font-semibold text-ink">{formattedAmount}</p>
|
||||||
|
<p className="mt-2 text-sm text-ink-soft">
|
||||||
|
Your wallet adds the network fee on top. Do not subtract it from the
|
||||||
|
amount shown.
|
||||||
|
</p>
|
||||||
|
{usdEstimate ? (
|
||||||
|
<>
|
||||||
|
<p className="mt-2 text-sm text-ink-soft">
|
||||||
|
Approx. USD reference: ~{usdEstimate}
|
||||||
|
</p>
|
||||||
|
<details className="mt-1 w-fit text-xs text-ink-soft">
|
||||||
|
<summary className="cursor-pointer select-none underline underline-offset-4">
|
||||||
|
About this estimate
|
||||||
|
</summary>
|
||||||
|
<p className="mt-2 max-w-[46ch] leading-relaxed">
|
||||||
|
Reference only, uses CoinGecko spot rate. Not a quote or guarantee.
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-stroke bg-white/60 p-5 shadow-soft">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
||||||
|
Payment address
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="rounded-full border border-stroke bg-white/70 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:bg-white"
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCopy(address, "address")}
|
||||||
|
>
|
||||||
|
{copiedField === "address" ? "Copied" : "Copy"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 break-words rounded-xl bg-ink/10 px-3 py-2 font-mono text-xs text-ink sm:text-sm">
|
||||||
|
{address}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 rounded-2xl border border-stroke bg-white/60 p-5 shadow-soft">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
||||||
|
Payment request URI
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="rounded-full border border-stroke bg-white/70 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:bg-white"
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCopy(uri, "uri")}
|
||||||
|
>
|
||||||
|
{copiedField === "uri" ? "Copied" : "Copy"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 break-words rounded-xl bg-ink/10 px-3 py-2 font-mono text-xs text-ink sm:text-sm">
|
||||||
|
{uri}
|
||||||
|
</p>
|
||||||
|
{shouldPromptPayment ? (
|
||||||
|
<p className="mt-4 text-sm text-ink-soft">
|
||||||
|
After you pay, this page updates automatically once the payment is
|
||||||
|
detected on-chain.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
||||||
|
<p className="max-w-xl text-sm text-ink-soft">
|
||||||
|
{isInvalid
|
||||||
|
? "This invoice is marked invalid. Do not send a payment to this address."
|
||||||
|
: hasDetectedPayment
|
||||||
|
? "Details remain available if you need to reference the address or request URI."
|
||||||
|
: "Tip: use the QR or payment request URI to avoid copy errors."}
|
||||||
|
</p>
|
||||||
|
{qrDataUrl ? (
|
||||||
|
<div className="relative h-[200px] w-[200px] rounded-2xl border border-stroke bg-white p-2 shadow-soft">
|
||||||
|
<Image
|
||||||
|
className="h-full w-full rounded-xl border border-ink/10 bg-white"
|
||||||
|
src={qrDataUrl}
|
||||||
|
alt="Payment request QR"
|
||||||
|
width={200}
|
||||||
|
height={200}
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
{resolvedLogoSrc ? (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-white shadow-[0_6px_12px_rgba(16,18,23,0.18)]">
|
||||||
|
<Image
|
||||||
|
src={resolvedLogoSrc}
|
||||||
|
alt="QR logo"
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
unoptimized={resolvedQrLogoMode === "custom"}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function InvoiceStatusActions() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Link
|
||||||
|
className="inline-flex items-center justify-center rounded-full bg-ink px-5 py-2.5 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
|
||||||
|
href="/invoice"
|
||||||
|
>
|
||||||
|
Check another invoice
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
type InvoiceStatusAutoRefreshProps = {
|
||||||
|
intervalMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function InvoiceStatusAutoRefresh({
|
||||||
|
intervalMs = 30000,
|
||||||
|
}: InvoiceStatusAutoRefreshProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
router.refresh();
|
||||||
|
}, intervalMs);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [intervalMs, router]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
type InvoiceStatusLookupProps = {
|
||||||
|
initialValue?: string;
|
||||||
|
compact?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function InvoiceStatusLookup({
|
||||||
|
initialValue = "",
|
||||||
|
compact = false,
|
||||||
|
}: InvoiceStatusLookupProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [invoiceId, setInvoiceId] = useState(initialValue);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const trimmed = invoiceId.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setError("Enter an invoice id to continue.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
router.push(`/invoice/${trimmed}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className={`grid gap-3 ${compact ? "text-sm" : "text-base"}`}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
|
||||||
|
htmlFor="invoice_id"
|
||||||
|
>
|
||||||
|
Invoice ID
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<input
|
||||||
|
className="min-w-[220px] flex-1 rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
|
||||||
|
id="invoice_id"
|
||||||
|
name="invoice_id"
|
||||||
|
type="text"
|
||||||
|
value={invoiceId}
|
||||||
|
onChange={(event) => setInvoiceId(event.target.value)}
|
||||||
|
placeholder="e.g. 9d2f2b3c-8a77-4c2b-b9d4-..."
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center justify-center rounded-full bg-ink px-5 py-3 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Check status
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{compact ? null : (
|
||||||
|
<p className="text-sm text-ink-soft">
|
||||||
|
Read-only status view. Refresh the page to see new detections.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error ? (
|
||||||
|
<p
|
||||||
|
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useCallback } from "react";
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
import LoginForm from "../app/(app)/login/login-form";
|
||||||
|
|
||||||
|
const loginParam = "login";
|
||||||
|
|
||||||
|
type LoginTriggerProps = {
|
||||||
|
className?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LoginTrigger({ className, children }: LoginTriggerProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const openModal = useCallback(() => {
|
||||||
|
const params =
|
||||||
|
typeof window === "undefined"
|
||||||
|
? new URLSearchParams()
|
||||||
|
: new URLSearchParams(window.location.search);
|
||||||
|
params.set(loginParam, "1");
|
||||||
|
const query = params.toString();
|
||||||
|
router.push(query ? `${pathname}?${query}` : pathname);
|
||||||
|
}, [pathname, router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={className}
|
||||||
|
type="button"
|
||||||
|
onClick={openModal}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-controls="login-modal"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginModal() {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const isOpen = searchParams.get(loginParam) === "1";
|
||||||
|
|
||||||
|
const closeModal = useCallback(() => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.delete(loginParam);
|
||||||
|
const query = params.toString();
|
||||||
|
router.replace(query ? `${pathname}?${query}` : pathname);
|
||||||
|
}, [pathname, router, searchParams]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const previousOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = previousOverflow;
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [closeModal, isOpen]);
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40 grid place-items-center"
|
||||||
|
id="login-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="login-title"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-ink/60 backdrop-blur" onClick={closeModal} />
|
||||||
|
<div
|
||||||
|
className="relative w-[min(480px,92vw)] rounded-3xl border border-stroke bg-card p-7 shadow-deep"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-clay">
|
||||||
|
Merchant access
|
||||||
|
</p>
|
||||||
|
<h3 id="login-title" className="mt-2 font-serif text-xl">
|
||||||
|
Sign in to your dashboard.
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="rounded-full border border-stroke bg-white/70 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-ink transition hover:-translate-y-0.5"
|
||||||
|
type="button"
|
||||||
|
onClick={closeModal}
|
||||||
|
aria-label="Close login modal"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-ink-soft">
|
||||||
|
Use your primary address and secret view key to access checkout tools.
|
||||||
|
Your primary address is the only identifier we need - no email or other
|
||||||
|
identifying information required.
|
||||||
|
<br />
|
||||||
|
Your secret view key is stored encrypted at rest and used only for payment detection.
|
||||||
|
</p>
|
||||||
|
<LoginForm />
|
||||||
|
<p className="mt-4 text-sm font-semibold text-sage">
|
||||||
|
We never require more than view-only access to keep your funds safe.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
function normalizePath(path: string) {
|
||||||
|
if (!path.startsWith("/")) {
|
||||||
|
return `/${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/") {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NavLink({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
exact,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
exact?: boolean;
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname() ?? "/";
|
||||||
|
const currentPath = normalizePath(pathname);
|
||||||
|
const targetPath = normalizePath(href);
|
||||||
|
|
||||||
|
const isActive = exact
|
||||||
|
? currentPath === targetPath
|
||||||
|
: currentPath === targetPath || currentPath.startsWith(`${targetPath}/`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className={className}
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
data-active={isActive ? "true" : undefined}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
type PrintButtonProps = {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PrintButton({ className }: PrintButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => window.print()}
|
||||||
|
className={
|
||||||
|
className ??
|
||||||
|
"inline-flex items-center justify-center rounded-md border border-emerald-200 bg-white px-4 py-2 text-sm font-semibold text-emerald-600 transition hover:bg-emerald-50"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Print
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
type RelativeTimeInput = string | number | Date | null | undefined;
|
||||||
|
|
||||||
|
const relativeTimeFormatter = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
||||||
|
|
||||||
|
const parseRelativeTime = (value: RelativeTimeInput) => {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
|
const timestamp = date.getTime();
|
||||||
|
if (Number.isNaN(timestamp)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return timestamp;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatRelativeTime = (value: RelativeTimeInput) => {
|
||||||
|
const timestamp = parseRelativeTime(value);
|
||||||
|
if (timestamp === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffSeconds = Math.round((timestamp - Date.now()) / 1000);
|
||||||
|
const absSeconds = Math.abs(diffSeconds);
|
||||||
|
if (absSeconds < 60) {
|
||||||
|
return relativeTimeFormatter.format(diffSeconds, "second");
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffMinutes = Math.round(diffSeconds / 60);
|
||||||
|
if (Math.abs(diffMinutes) < 60) {
|
||||||
|
return relativeTimeFormatter.format(diffMinutes, "minute");
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffHours = Math.round(diffMinutes / 60);
|
||||||
|
if (Math.abs(diffHours) < 24) {
|
||||||
|
return relativeTimeFormatter.format(diffHours, "hour");
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffDays = Math.round(diffHours / 24);
|
||||||
|
if (Math.abs(diffDays) < 30) {
|
||||||
|
return relativeTimeFormatter.format(diffDays, "day");
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffMonths = Math.round(diffDays / 30);
|
||||||
|
if (Math.abs(diffMonths) < 12) {
|
||||||
|
return relativeTimeFormatter.format(diffMonths, "month");
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffYears = Math.round(diffDays / 365);
|
||||||
|
return relativeTimeFormatter.format(diffYears, "year");
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
type SecretCardProps = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
buttonLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SecretCard({ label, value, buttonLabel }: SecretCardProps) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
setCopied(true);
|
||||||
|
window.setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
setCopied(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 rounded-2xl border border-stroke bg-white/80 p-6 shadow-soft backdrop-blur">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<code className="mt-2 block break-all rounded-xl bg-ink/10 px-3 py-2 text-sm text-ink">
|
||||||
|
{value}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center justify-center rounded-full bg-ink px-5 py-2.5 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
>
|
||||||
|
{copied ? "Copied" : buttonLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
type StatusRefreshButtonProps = {
|
||||||
|
label?: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StatusRefreshButton({
|
||||||
|
label = "Refresh",
|
||||||
|
className = "",
|
||||||
|
}: StatusRefreshButtonProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
router.refresh();
|
||||||
|
setTimeout(() => setIsRefreshing(false), 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`inline-flex items-center justify-center rounded-full border border-stroke bg-white/50 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink transition hover:-translate-y-0.5 disabled:cursor-not-allowed disabled:opacity-70 ${className}`}
|
||||||
|
type="button"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={isRefreshing}
|
||||||
|
>
|
||||||
|
{isRefreshing ? "Refreshing..." : label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useFormState } from "react-dom";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import {
|
||||||
|
redeliverWebhookDeliveryAction,
|
||||||
|
redeliverWebhookDeliveryTourAction,
|
||||||
|
type WebhookRedeliverState,
|
||||||
|
} from "../app/(app)/dashboard/actions";
|
||||||
|
|
||||||
|
type WebhookDeliverySummary = {
|
||||||
|
id: string;
|
||||||
|
webhook_id: string | null;
|
||||||
|
event: string;
|
||||||
|
url: string;
|
||||||
|
invoice_id: string | null;
|
||||||
|
invoice_address: string | null;
|
||||||
|
invoice_subaddress_index: number | null;
|
||||||
|
invoice_amount_xmr: string | null;
|
||||||
|
invoice_status:
|
||||||
|
| "pending"
|
||||||
|
| "payment_detected"
|
||||||
|
| "confirmed"
|
||||||
|
| "expired"
|
||||||
|
| "invalid"
|
||||||
|
| null;
|
||||||
|
http_status: number | null;
|
||||||
|
error_message: string | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialState: WebhookRedeliverState = { error: null, success: null };
|
||||||
|
|
||||||
|
export default function WebhookHistoryPanel({
|
||||||
|
mode = "live",
|
||||||
|
deliveries,
|
||||||
|
}: {
|
||||||
|
mode?: "live" | "tour";
|
||||||
|
deliveries: WebhookDeliverySummary[];
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [state, formAction] = useFormState(
|
||||||
|
mode === "tour" ? redeliverWebhookDeliveryTourAction : redeliverWebhookDeliveryAction,
|
||||||
|
initialState
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.success && mode !== "tour") {
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}, [mode, router, state.success]);
|
||||||
|
|
||||||
|
const formatTimestamp = (value: string) => new Date(value).toLocaleString();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{state.error ? (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
|
||||||
|
{state.error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{state.success ? (
|
||||||
|
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">
|
||||||
|
{state.success}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{deliveries.length === 0 ? (
|
||||||
|
<p className="text-sm text-ink-soft">No webhook calls recorded yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{deliveries.map((delivery) => {
|
||||||
|
const statusLabel =
|
||||||
|
delivery.http_status !== null ? delivery.http_status.toString() : "No response";
|
||||||
|
const isFailed =
|
||||||
|
delivery.http_status === null ||
|
||||||
|
(typeof delivery.http_status === "number" && delivery.http_status >= 400);
|
||||||
|
const hasInvoiceDetails =
|
||||||
|
Boolean(delivery.invoice_id) ||
|
||||||
|
Boolean(delivery.invoice_address) ||
|
||||||
|
Boolean(delivery.invoice_amount_xmr) ||
|
||||||
|
delivery.invoice_subaddress_index !== null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={delivery.id}
|
||||||
|
className="rounded-xl border border-stroke bg-white/80 px-4 py-3"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
||||||
|
{delivery.event}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 break-all text-sm font-semibold text-ink">
|
||||||
|
{delivery.url}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs text-ink-soft">
|
||||||
|
{formatTimestamp(delivery.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="rounded-full border border-stroke bg-white/60 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-ink">
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
{isFailed ? (
|
||||||
|
<form action={formAction}>
|
||||||
|
<input type="hidden" name="delivery_id" value={delivery.id} />
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white/60 px-4 py-2 text-xs font-semibold uppercase tracking-[0.12em] text-ink transition hover:-translate-y-0.5"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Redeliver
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{hasInvoiceDetails ? (
|
||||||
|
<details className="mt-3 rounded-lg border border-stroke bg-white/70 px-3 py-2">
|
||||||
|
<summary className="cursor-pointer text-xs font-semibold uppercase tracking-[0.12em] text-ink">
|
||||||
|
View invoice details
|
||||||
|
</summary>
|
||||||
|
<div className="mt-3 grid gap-2 text-xs text-ink-soft">
|
||||||
|
{delivery.invoice_id ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-[0.7rem] font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
||||||
|
Invoice id
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 break-all text-xs text-ink">
|
||||||
|
{delivery.invoice_id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{delivery.invoice_address ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-[0.7rem] font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
||||||
|
Subaddress
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 break-all text-xs text-ink">
|
||||||
|
{delivery.invoice_address}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{delivery.invoice_subaddress_index !== null ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-[0.7rem] font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
||||||
|
Subaddress index
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-ink">
|
||||||
|
{delivery.invoice_subaddress_index}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{delivery.invoice_amount_xmr ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-[0.7rem] font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
||||||
|
Invoice amount (WOW)
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-ink">
|
||||||
|
{delivery.invoice_amount_xmr}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{delivery.invoice_status ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-[0.7rem] font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
||||||
|
Invoice status
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-ink">
|
||||||
|
{delivery.invoice_status}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
{delivery.error_message ? (
|
||||||
|
<p className="mt-2 text-xs text-ink-soft">
|
||||||
|
Delivery error: {delivery.error_message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useFormState } from "react-dom";
|
||||||
|
|
||||||
|
import {
|
||||||
|
resetWebhookSecretAction,
|
||||||
|
type WebhookSecretState,
|
||||||
|
} from "../app/(app)/dashboard/actions";
|
||||||
|
import SecretCard from "./secret-card";
|
||||||
|
|
||||||
|
const initialState = (webhookSecret: string | null): WebhookSecretState => ({
|
||||||
|
webhookSecret,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
type WebhookSecretSectionProps = {
|
||||||
|
webhookSecret: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WebhookSecretSection({
|
||||||
|
webhookSecret,
|
||||||
|
}: WebhookSecretSectionProps) {
|
||||||
|
const [state, formAction] = useFormState(
|
||||||
|
resetWebhookSecretAction,
|
||||||
|
initialState(webhookSecret)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{state.webhookSecret ? (
|
||||||
|
<SecretCard
|
||||||
|
label="Your webhook secret"
|
||||||
|
value={state.webhookSecret}
|
||||||
|
buttonLabel="Copy webhook secret"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-2xl border border-stroke bg-white/80 p-6 text-sm text-ink-soft shadow-soft backdrop-blur">
|
||||||
|
Webhook secret unavailable. Reset to generate a new secret.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{state.error ? (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
|
||||||
|
{state.error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<form action={formAction} className="flex justify-end">
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white/60 px-5 py-2.5 text-sm font-semibold text-ink transition hover:-translate-y-0.5"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Reset webhook secret
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useFormState } from "react-dom";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createWebhookAction,
|
||||||
|
createWebhookTourAction,
|
||||||
|
deleteWebhookAction,
|
||||||
|
deleteWebhookTourAction,
|
||||||
|
type WebhookFormState,
|
||||||
|
} from "../app/(app)/dashboard/actions";
|
||||||
|
|
||||||
|
type WebhookSummary = {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
events: string[];
|
||||||
|
event_urls?: Record<string, string> | null;
|
||||||
|
active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialState: WebhookFormState = { error: null, success: null };
|
||||||
|
|
||||||
|
const webhookEvents = [
|
||||||
|
{ event: "invoice.created", key: "invoice_created", label: "invoice.created" },
|
||||||
|
{
|
||||||
|
event: "invoice.payment_detected",
|
||||||
|
key: "invoice_payment_detected",
|
||||||
|
label: "invoice.payment_detected",
|
||||||
|
},
|
||||||
|
{ event: "invoice.confirmed", key: "invoice_confirmed", label: "invoice.confirmed" },
|
||||||
|
{ event: "invoice.expired", key: "invoice_expired", label: "invoice.expired" },
|
||||||
|
];
|
||||||
|
|
||||||
|
type WebhookSectionProps = {
|
||||||
|
mode?: "live" | "tour";
|
||||||
|
webhooks: WebhookSummary[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WebhookSection({ mode = "live", webhooks }: WebhookSectionProps) {
|
||||||
|
const [createState, createAction] = useFormState(
|
||||||
|
mode === "tour" ? createWebhookTourAction : createWebhookAction,
|
||||||
|
initialState
|
||||||
|
);
|
||||||
|
const [deleteState, deleteAction] = useFormState(
|
||||||
|
mode === "tour" ? deleteWebhookTourAction : deleteWebhookAction,
|
||||||
|
initialState
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-8">
|
||||||
|
{mode === "tour" ? (
|
||||||
|
<div className="rounded-2xl border border-stroke bg-white/70 p-5 text-sm text-ink-soft shadow-soft">
|
||||||
|
Tour mode: webhook changes are simulated. No endpoints are saved.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<form className="grid gap-6" action={createAction}>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label
|
||||||
|
className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"
|
||||||
|
htmlFor="default_url"
|
||||||
|
>
|
||||||
|
Default webhook URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-xl border border-stroke bg-white/80 px-4 py-3 text-sm text-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.6)] outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
|
||||||
|
id="default_url"
|
||||||
|
name="default_url"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://wowcheckout.com/webhooks"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-ink-soft">
|
||||||
|
Used for any selected event without a specific override URL.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{webhookEvents.map(({ event, key, label }) => (
|
||||||
|
<div key={event} className="rounded-xl border border-stroke bg-white/70 p-4">
|
||||||
|
<label className="flex items-center gap-3 text-sm font-semibold text-ink">
|
||||||
|
<input
|
||||||
|
className="h-4 w-4 rounded border-stroke text-ink focus:ring-ink/20"
|
||||||
|
type="checkbox"
|
||||||
|
name={`event_${key}`}
|
||||||
|
defaultChecked
|
||||||
|
/>
|
||||||
|
<span>{label}</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="mt-3 w-full rounded-xl border border-stroke bg-white/80 px-3 py-2 text-sm text-ink outline-none transition focus:border-ink/40 focus:ring-2 focus:ring-ink/10"
|
||||||
|
id={`event_url_${key}`}
|
||||||
|
name={`event_url_${key}`}
|
||||||
|
type="url"
|
||||||
|
placeholder="Optional override URL"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{createState.error ? (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
|
||||||
|
{createState.error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{createState.success ? (
|
||||||
|
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">
|
||||||
|
{createState.success}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center justify-center rounded-full bg-ink px-5 py-2.5 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Save webhook
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div className="rounded-2xl border border-stroke bg-white/70 p-5 shadow-soft">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
||||||
|
Hint
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-ink-soft">
|
||||||
|
Webhooks are optional. You can poll{" "}
|
||||||
|
<code>/api/core/public/invoice/<invoice_id></code> without
|
||||||
|
authentication to read invoice status.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-serif text-xl">Active webhooks</h3>
|
||||||
|
</div>
|
||||||
|
{deleteState.error ? (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
|
||||||
|
{deleteState.error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{deleteState.success ? (
|
||||||
|
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">
|
||||||
|
{deleteState.success}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{webhooks.length === 0 ? (
|
||||||
|
<p className="text-sm font-semibold text-ink-soft">
|
||||||
|
No webhooks configured yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<form className="grid gap-4" action={deleteAction}>
|
||||||
|
{webhooks.map((hook) => (
|
||||||
|
<div
|
||||||
|
key={hook.id}
|
||||||
|
className="flex flex-wrap items-start justify-between gap-4 rounded-2xl border border-stroke bg-white/70 p-5 shadow-soft backdrop-blur"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
||||||
|
Webhook
|
||||||
|
</p>
|
||||||
|
<code className="mt-2 block break-all rounded-lg bg-ink/10 px-3 py-2 text-sm text-ink">
|
||||||
|
{hook.url || "Per-event URLs only"}
|
||||||
|
</code>
|
||||||
|
<div className="mt-4 grid gap-3">
|
||||||
|
{hook.events.map((event) => (
|
||||||
|
<div key={event} className="grid gap-1">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
|
||||||
|
{event}
|
||||||
|
</span>
|
||||||
|
<code className="break-all rounded-lg bg-ink/10 px-3 py-2 text-sm text-ink">
|
||||||
|
{hook.event_urls?.[event] ?? hook.url}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center justify-center rounded-full border border-stroke bg-white/60 px-5 py-2.5 text-sm font-semibold text-ink transition hover:-translate-y-0.5"
|
||||||
|
type="submit"
|
||||||
|
name="webhook_id"
|
||||||
|
value={hook.id}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
export const FIAT_CURRENCY_SUGGESTIONS = [
|
||||||
|
"USD",
|
||||||
|
"EUR",
|
||||||
|
"GBP",
|
||||||
|
"CAD",
|
||||||
|
"AUD",
|
||||||
|
"NZD",
|
||||||
|
"CHF",
|
||||||
|
"JPY",
|
||||||
|
"CNY",
|
||||||
|
"HKD",
|
||||||
|
"SGD",
|
||||||
|
"INR",
|
||||||
|
"BRL",
|
||||||
|
"MXN",
|
||||||
|
"SEK",
|
||||||
|
"NOK",
|
||||||
|
"DKK",
|
||||||
|
"ZAR",
|
||||||
|
"PLN",
|
||||||
|
"KRW",
|
||||||
|
];
|
||||||
|
|
||||||
|
const CURRENCY_FLAGS: Record<string, string> = {
|
||||||
|
USD: "🇺🇸",
|
||||||
|
EUR: "🇪🇺",
|
||||||
|
GBP: "🇬🇧",
|
||||||
|
CAD: "🇨🇦",
|
||||||
|
AUD: "🇦🇺",
|
||||||
|
NZD: "🇳🇿",
|
||||||
|
CHF: "🇨🇭",
|
||||||
|
JPY: "🇯🇵",
|
||||||
|
CNY: "🇨🇳",
|
||||||
|
HKD: "🇭🇰",
|
||||||
|
SGD: "🇸🇬",
|
||||||
|
INR: "🇮🇳",
|
||||||
|
BRL: "🇧🇷",
|
||||||
|
MXN: "🇲🇽",
|
||||||
|
SEK: "🇸🇪",
|
||||||
|
NOK: "🇳🇴",
|
||||||
|
DKK: "🇩🇰",
|
||||||
|
ZAR: "🇿🇦",
|
||||||
|
PLN: "🇵🇱",
|
||||||
|
KRW: "🇰🇷",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCurrencyFlag = (
|
||||||
|
code: string | null | undefined
|
||||||
|
): string | null => {
|
||||||
|
if (!code) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return CURRENCY_FLAGS[code.trim().toUpperCase()] ?? null;
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export const formatXmrAmount = (value: string | number): string => {
|
||||||
|
const raw = typeof value === "number" ? value.toString() : value;
|
||||||
|
if (!raw) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const [whole, fraction] = raw.split(".");
|
||||||
|
if (!fraction) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
const trimmedFraction = fraction.replace(/0+$/, "");
|
||||||
|
return trimmedFraction ? `${whole}.${trimmedFraction}` : whole;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatUsdAmount = (value: number): string => {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const RATE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
const BASE_URL = "https://api.coingecko.com/api/v3/simple/price";
|
||||||
|
|
||||||
|
type CachedRate = {
|
||||||
|
rate: number;
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cachedRates = new Map<string, CachedRate>();
|
||||||
|
const inflight = new Map<string, Promise<number | null>>();
|
||||||
|
|
||||||
|
const fetchRate = async (currency: string): Promise<number | null> => {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
ids: "wownero",
|
||||||
|
vs_currencies: currency,
|
||||||
|
});
|
||||||
|
const response = await fetch(`${BASE_URL}?${params.toString()}`, {
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as {
|
||||||
|
wownero?: Record<string, number>;
|
||||||
|
};
|
||||||
|
const rate = data?.wownero?.[currency];
|
||||||
|
if (typeof rate !== "number" || !Number.isFinite(rate)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
cachedRates.set(currency, { rate, updatedAt: Date.now() });
|
||||||
|
return rate;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
type RateStatus = "idle" | "loading" | "ready" | "error";
|
||||||
|
|
||||||
|
export const useXmrFiatRate = (currency: string | null) => {
|
||||||
|
const normalized = currency?.trim().toLowerCase() ?? "";
|
||||||
|
const cached = normalized ? cachedRates.get(normalized) : null;
|
||||||
|
const [rate, setRate] = useState<number | null>(cached?.rate ?? null);
|
||||||
|
const [updatedAt, setUpdatedAt] = useState<number | null>(cached?.updatedAt ?? null);
|
||||||
|
const [status, setStatus] = useState<RateStatus>(() => {
|
||||||
|
if (!normalized) {
|
||||||
|
return "idle";
|
||||||
|
}
|
||||||
|
return cached?.rate ? "ready" : "loading";
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
if (!normalized) {
|
||||||
|
setRate(null);
|
||||||
|
setUpdatedAt(null);
|
||||||
|
setStatus("idle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const cachedEntry = cachedRates.get(normalized);
|
||||||
|
const hasFreshRate =
|
||||||
|
cachedEntry && now - cachedEntry.updatedAt < RATE_TTL_MS;
|
||||||
|
|
||||||
|
if (hasFreshRate) {
|
||||||
|
setRate(cachedEntry.rate);
|
||||||
|
setUpdatedAt(cachedEntry.updatedAt);
|
||||||
|
setStatus("ready");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("loading");
|
||||||
|
const request =
|
||||||
|
inflight.get(normalized) ??
|
||||||
|
fetchRate(normalized).finally(() => {
|
||||||
|
inflight.delete(normalized);
|
||||||
|
});
|
||||||
|
inflight.set(normalized, request);
|
||||||
|
|
||||||
|
request.then((nextRate) => {
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextCached = cachedRates.get(normalized);
|
||||||
|
if (nextRate !== null && nextCached) {
|
||||||
|
setRate(nextCached.rate);
|
||||||
|
setUpdatedAt(nextCached.updatedAt);
|
||||||
|
setStatus("ready");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [normalized]);
|
||||||
|
|
||||||
|
return { rate, updatedAt, status, source: "coingecko" as const };
|
||||||
|
};
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const RATE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
const RATE_URL =
|
||||||
|
"https://api.coingecko.com/api/v3/simple/price?ids=wownero&vs_currencies=usd";
|
||||||
|
|
||||||
|
let cachedRate: number | null = null;
|
||||||
|
let cachedAt: number | null = null;
|
||||||
|
let inflight: Promise<number | null> | null = null;
|
||||||
|
|
||||||
|
const fetchRate = async (): Promise<number | null> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(RATE_URL, { cache: "no-store" });
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as {
|
||||||
|
wownero?: { usd?: number };
|
||||||
|
};
|
||||||
|
const rate = data?.wownero?.usd;
|
||||||
|
if (typeof rate !== "number" || !Number.isFinite(rate)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
cachedRate = rate;
|
||||||
|
cachedAt = Date.now();
|
||||||
|
return rate;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useXmrUsdRate = () => {
|
||||||
|
const [rate, setRate] = useState<number | null>(cachedRate);
|
||||||
|
const [updatedAt, setUpdatedAt] = useState<number | null>(cachedAt);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
const now = Date.now();
|
||||||
|
const hasFreshRate =
|
||||||
|
cachedRate !== null && cachedAt !== null && now - cachedAt < RATE_TTL_MS;
|
||||||
|
|
||||||
|
if (hasFreshRate) {
|
||||||
|
setRate(cachedRate);
|
||||||
|
setUpdatedAt(cachedAt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request =
|
||||||
|
inflight ??
|
||||||
|
(inflight = fetchRate().finally(() => {
|
||||||
|
inflight = null;
|
||||||
|
}));
|
||||||
|
|
||||||
|
request.then((nextRate) => {
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nextRate !== null) {
|
||||||
|
setRate(nextRate);
|
||||||
|
setUpdatedAt(cachedAt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { rate, updatedAt, source: "coingecko" as const };
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user