Update readme, move frontend build to build phase in dockerfile
This commit is contained in:
+45
-18
@@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ---------- Build the Rust backend (compiled here, in the build stage) ----------
|
||||
# ---------- Build the Rust backend ----------
|
||||
FROM rust:1.94-bookworm AS backend-build
|
||||
|
||||
WORKDIR /app
|
||||
@@ -17,10 +17,30 @@ COPY backend/migrations ./migrations
|
||||
|
||||
RUN cargo build --release
|
||||
|
||||
# ---------- Runtime: prebuilt backend + frontend built at startup ----------
|
||||
# The frontend is built in this (run) stage, not at image-build time, so VITE_*
|
||||
# values provided when the container starts are inlined into the bundle.
|
||||
# ---------- Build the frontend with placeholder config ----------
|
||||
# The frontend is compiled once, here, with sentinel VITE_* values. The runtime
|
||||
# entrypoint find-and-replaces those sentinels with the real configuration when
|
||||
# the container starts, so one image can be deployed with any config — no rebuild
|
||||
# and no build step at startup.
|
||||
FROM node:20-slim AS frontend-build
|
||||
|
||||
RUN npm install -g bun
|
||||
|
||||
WORKDIR /app/frontend
|
||||
|
||||
COPY frontend/package.json frontend/bun.lock ./
|
||||
RUN bun install
|
||||
|
||||
COPY frontend ./
|
||||
|
||||
ENV VITE_API_URL=__VITE_API_URL__ \
|
||||
VITE_RELAY_DOMAIN=__VITE_RELAY_DOMAIN__ \
|
||||
VITE_PLATFORM_NAME=__VITE_PLATFORM_NAME__
|
||||
RUN bun run build
|
||||
|
||||
# ---------- Runtime ----------
|
||||
# node:20-slim is bookworm-based, so it is ABI-compatible with the backend binary.
|
||||
# No bun / frontend sources here — just the prebuilt bundle and a static server.
|
||||
FROM node:20-slim
|
||||
|
||||
RUN apt-get update \
|
||||
@@ -28,33 +48,40 @@ RUN apt-get update \
|
||||
ca-certificates \
|
||||
libsqlite3-0 \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& npm install -g bun serve
|
||||
&& npm install -g serve
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install frontend deps as a cached image layer. The actual `vite build` runs at
|
||||
# startup (see the entrypoint below) so it can pick up runtime env vars.
|
||||
COPY frontend/package.json frontend/bun.lock ./frontend/
|
||||
RUN cd frontend && bun install
|
||||
|
||||
COPY frontend ./frontend
|
||||
|
||||
# Prebuilt backend binary from the build stage.
|
||||
COPY --from=backend-build /app/target/release/backend /app/backend
|
||||
COPY --from=frontend-build /app/frontend/dist /app/dist
|
||||
|
||||
# Single entrypoint: build the frontend with the runtime config, then run both
|
||||
# processes and exit (so the orchestrator restarts us) if either one dies.
|
||||
# Single entrypoint: substitute the real config into the prebuilt bundle, then
|
||||
# run both processes and exit (so the orchestrator restarts us) if either dies.
|
||||
RUN cat > /app/entrypoint.sh <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "Building frontend with runtime configuration..."
|
||||
(cd /app/frontend && bun run build)
|
||||
# Map the provided runtime variables onto the frontend's VITE_* placeholders.
|
||||
VITE_API_URL="${SERVER_URL:-}"
|
||||
VITE_RELAY_DOMAIN="${RELAY_DOMAIN:-}"
|
||||
VITE_PLATFORM_NAME="${PLATFORM_NAME:-}"
|
||||
|
||||
# Escape characters that are special in a sed replacement.
|
||||
esc() { printf '%s' "$1" | sed -e 's/[&|\\]/\\&/g'; }
|
||||
|
||||
echo "Applying runtime configuration to the frontend bundle..."
|
||||
while IFS= read -r -d '' f; do
|
||||
sed -i \
|
||||
-e "s|__VITE_API_URL__|$(esc "$VITE_API_URL")|g" \
|
||||
-e "s|__VITE_RELAY_DOMAIN__|$(esc "$VITE_RELAY_DOMAIN")|g" \
|
||||
-e "s|__VITE_PLATFORM_NAME__|$(esc "$VITE_PLATFORM_NAME")|g" \
|
||||
"$f"
|
||||
done < <(find /app/dist -type f \( -name '*.js' -o -name '*.html' \) -print0)
|
||||
|
||||
echo "Starting backend (:2892) and frontend (:3000)..."
|
||||
/app/backend &
|
||||
backend_pid=$!
|
||||
serve -s /app/frontend/dist -l 3000 &
|
||||
serve -s /app/dist -l 3000 &
|
||||
serve_pid=$!
|
||||
|
||||
trap 'kill -TERM "$backend_pid" "$serve_pid" 2>/dev/null || true' TERM INT
|
||||
|
||||
@@ -2,7 +2,64 @@
|
||||
|
||||
A multi-tenant platform for hosting Nostr community relays, built on top of [zooid](https://github.com/coracle-social/zooid).
|
||||
|
||||
## Quick Start (Local Development)
|
||||
## Deployment
|
||||
|
||||
Caravel ships as a single Docker image, built from the repository-root [`Dockerfile`](./Dockerfile) and published by [`.gitea/workflows/docker-publish.yml`](./.gitea/workflows/docker-publish.yml) as `gitea.coracle.social/coracle/caravel`. One container runs both services: the backend API on port `2892` and the frontend on port `3000`.
|
||||
|
||||
Both the backend and the frontend are compiled into the image at build time. The frontend is built with placeholder config that the entrypoint replaces with the real values when the container starts, so one image can be deployed with any configuration — no rebuild required.
|
||||
|
||||
Caravel needs a reachable [zooid](https://github.com/coracle-social/zooid) instance (the [Local Development](#local-development) section below shows how to run one). Substitute your own values for the placeholders below:
|
||||
|
||||
```sh
|
||||
docker run -d \
|
||||
--name caravel \
|
||||
-p 2892:2892 \
|
||||
-p 3000:3000 \
|
||||
-v my-caravel-data:/app/data \
|
||||
-e PLATFORM_NAME=Caravel \
|
||||
-e RELAY_DOMAIN=example.com \
|
||||
-e APP_URL=https://example.com \
|
||||
-e ZOOID_API_URL=http://zooid:3334 \
|
||||
-e DATABASE_URL=sqlite://data/caravel.db \
|
||||
-e SERVER_URL=https://api.example.com \
|
||||
-e SERVER_PORT=2892 \
|
||||
-e SERVER_ADMIN_PUBKEYS=<your-hex-pubkey> \
|
||||
-e SERVER_ALLOW_ORIGINS=https://example.com \
|
||||
-e ROBOT_SECRET=<hex-nostr-secret-key> \
|
||||
-e ROBOT_NAME=Caravel \
|
||||
-e ROBOT_DESCRIPTION="Relay manager bot" \
|
||||
-e ROBOT_PICTURE=https://example.com/robot.png \
|
||||
-e ROBOT_WALLET=<nwc-connection-uri> \
|
||||
-e ROBOT_OUTBOX_RELAYS=wss://relay.damus.io,wss://relay.primal.net,wss://nos.lol \
|
||||
-e ROBOT_INDEXER_RELAYS=wss://purplepag.es,wss://relay.damus.io,wss://indexer.coracle.social \
|
||||
-e ROBOT_MESSAGING_RELAYS=wss://auth.nostr1.com,wss://relay.keychat.io,wss://relay.ditto.pub \
|
||||
-e BLOSSOM_S3_ENDPOINT=https://s3.example.com \
|
||||
-e BLOSSOM_S3_REGION=us-east-1 \
|
||||
-e BLOSSOM_S3_BUCKET=caravel-blossom \
|
||||
-e BLOSSOM_S3_ACCESS_KEY=<s3-access-key> \
|
||||
-e BLOSSOM_S3_SECRET_KEY=<s3-secret-key> \
|
||||
-e LIVEKIT_URL=wss://livekit.example.com \
|
||||
-e LIVEKIT_API_KEY=<livekit-api-key> \
|
||||
-e LIVEKIT_API_SECRET=<livekit-api-secret> \
|
||||
-e STRIPE_SECRET_KEY=<stripe-secret-key> \
|
||||
gitea.coracle.social/coracle/caravel
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Every backend variable above is **required** — the server exits on startup if any is missing or empty. See [`backend/.env.template`](./backend/.env.template) for what each one means.
|
||||
- `ROBOT_SECRET` is the robot account's hex nostr secret key; its pubkey must be in zooid's `API_WHITELIST` (the backend signs zooid requests with it via NIP-98).
|
||||
- `SERVER_ALLOW_ORIGINS` must include the frontend's public origin, or browsers will be blocked by CORS.
|
||||
- The frontend's `VITE_` prefixed env variables are automatically populated from the provided env variables.
|
||||
- `-v my-caravel-data:/app/data` persists the SQLite database.
|
||||
|
||||
To build the image yourself instead of pulling it:
|
||||
|
||||
```sh
|
||||
docker build -t caravel .
|
||||
```
|
||||
|
||||
## Local Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
|
||||
+14
-15
@@ -16,17 +16,16 @@ Rust backend for Caravel. It manages tenants, relays, invoices, and background w
|
||||
backend/
|
||||
migrations/
|
||||
0001_init.sql
|
||||
spec/ # Module-by-module design notes
|
||||
src/
|
||||
main.rs # App bootstrap: load Env, build services, serve + spawn workers
|
||||
env.rs # Configuration from the environment (+ NIP-44 encryption, NIP-98 signing)
|
||||
api.rs # Shared Api state, router, NIP-98 auth + authorization helpers
|
||||
web.rs # HTTP response envelope + helpers
|
||||
routes/ # HTTP route handlers (identity, plans, tenants, relays, invoices, stripe)
|
||||
routes/ # HTTP route handlers (identity, plans, tenants, relays, invoices)
|
||||
models.rs # Domain models + sqlite rows
|
||||
query.rs # Database reads
|
||||
command.rs # Database writes + activity broadcast
|
||||
pool.rs # SQLite pool + migrations
|
||||
db.rs # SQLite pool, migrations, activity broadcast channel
|
||||
billing.rs # Stripe subscription reconciliation + Lightning collection worker
|
||||
stripe.rs # Thin Stripe REST client
|
||||
wallet.rs # NWC wallet handle (NIP-47)
|
||||
@@ -43,10 +42,11 @@ All configuration is read from the environment by `Env::load()` at startup. **Ev
|
||||
|
||||
| Variable | Description |
|
||||
| ---------------------- | ------------------------------------------------------------------- |
|
||||
| `SERVER_HOST` | API bind host; also the value the NIP-98 `u` tag must contain |
|
||||
| `SERVER_PORT` | API bind port |
|
||||
| `SERVER_URL` | Public API base URL; the value the NIP-98 `u` tag must equal exactly |
|
||||
| `SERVER_PORT` | API bind port (the server always binds to `127.0.0.1`) |
|
||||
| `SERVER_ADMIN_PUBKEYS` | Comma-separated admin pubkeys (hex) |
|
||||
| `SERVER_ALLOW_ORIGINS` | Comma-separated allowed CORS origins |
|
||||
| `APP_URL` | Frontend base URL; used to build links in DMs and invoices |
|
||||
| `DATABASE_URL` | SQLite URL; relative `sqlite://` paths are resolved under `backend/` |
|
||||
|
||||
**Robot identity**
|
||||
@@ -84,18 +84,15 @@ All configuration is read from the environment by `Env::load()` at startup. **Ev
|
||||
|
||||
**Billing (Stripe)**
|
||||
|
||||
| Variable | Description |
|
||||
| ----------------------- | ----------------------------------------------------------------------- |
|
||||
| `STRIPE_SECRET_KEY` | Stripe API secret key used for billing API operations |
|
||||
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret used to verify `Stripe-Signature` headers |
|
||||
| `STRIPE_PRICE_BASIC` | Stripe price ID (`price_...`) backing the Basic plan |
|
||||
| `STRIPE_PRICE_GROWTH` | Stripe price ID (`price_...`) backing the Growth plan |
|
||||
| Variable | Description |
|
||||
| ------------------- | ----------------------------------------------------- |
|
||||
| `STRIPE_SECRET_KEY` | Stripe API secret key used for billing API operations |
|
||||
|
||||
Comma-separated list variables are split on commas and trimmed; empty entries are dropped.
|
||||
|
||||
## Schema and Architecture
|
||||
|
||||
See [spec](spec) for more details
|
||||
The database schema lives in [migrations](migrations); see the module-level doc comments in `src/` for architecture details.
|
||||
|
||||
## API Routes
|
||||
|
||||
@@ -105,7 +102,6 @@ Public exceptions:
|
||||
|
||||
- `GET /plans`
|
||||
- `GET /plans/:id`
|
||||
- `POST /stripe/webhook` (validated with Stripe signatures)
|
||||
|
||||
- `GET /identity` — get auth identity (`pubkey`, `is_admin`); side-effect-free
|
||||
- `GET /tenants` — list tenants (admin)
|
||||
@@ -122,16 +118,19 @@ Public exceptions:
|
||||
- `POST /relays/:id/deactivate` — deactivate relay (admin or relay tenant)
|
||||
- `POST /relays/:id/reactivate` — reactivate relay (admin or relay tenant)
|
||||
- `GET /tenants/:pubkey/invoices` — list tenant invoices (admin or same tenant)
|
||||
- `GET /tenants/:pubkey/invoices/draft` — get the tenant's synthetic draft invoice for the current period, or `null` if nothing is due (admin or same tenant)
|
||||
- `GET /tenants/:pubkey/invoices/draft/items` — list the draft invoice's line items (admin or same tenant)
|
||||
- `GET /invoices/:id` — get invoice (admin or same tenant)
|
||||
- `GET /invoices/:id/bolt11` — get invoice bolt11 (admin or same tenant)
|
||||
- `GET /tenants/:pubkey/stripe/session` — create Stripe customer portal session (admin or same tenant)
|
||||
- `GET /invoices/:id/items` — list invoice line items (admin or same tenant)
|
||||
- `GET /tenants/:pubkey/stripe/session` — create Stripe customer portal session (same tenant only)
|
||||
|
||||
## API Auth Model
|
||||
|
||||
Caravel intentionally uses a session-style variant of NIP-98 for client-to-backend API auth.
|
||||
|
||||
- Frontend signs one kind `27235` event with `u = VITE_API_URL` and caches that header for about 10 minutes.
|
||||
- Backend verifies event kind, signature, and that `u` contains configured `SERVER_HOST`.
|
||||
- Backend verifies event kind, signature, and that `u` equals configured `SERVER_URL`.
|
||||
- Backend intentionally does not bind auth to exact request URL/method/query, and does not enforce payload hash, timestamp freshness window, or replay cache.
|
||||
- Goal: reduce repeated wallet signing prompts and avoid cookie-based sessions.
|
||||
- Tradeoff: this is weaker request-intent binding than strict NIP-98 semantics.
|
||||
|
||||
+2
-1
@@ -32,7 +32,7 @@ use crate::models::{Relay, Tenant};
|
||||
use crate::query;
|
||||
use crate::robot::Robot;
|
||||
use crate::routes::identity::get_identity;
|
||||
use crate::routes::invoices::{get_invoice, get_invoice_bolt11, list_invoice_items};
|
||||
use crate::routes::invoices::{get_invoice, get_invoice_bolt11, list_invoice_items, list_invoices};
|
||||
use crate::routes::plans::{get_plan, list_plans};
|
||||
use crate::routes::relays::{
|
||||
create_relay, deactivate_relay, get_relay, list_relay_activity, list_relay_members,
|
||||
@@ -87,6 +87,7 @@ impl Api {
|
||||
.route("/relays/:id/activity", get(list_relay_activity))
|
||||
.route("/relays/:id/deactivate", post(deactivate_relay))
|
||||
.route("/relays/:id/reactivate", post(reactivate_relay))
|
||||
.route("/invoices", get(list_invoices))
|
||||
.route("/invoices/:id", get(get_invoice))
|
||||
.route("/invoices/:id/bolt11", get(get_invoice_bolt11))
|
||||
.route("/invoices/:id/items", get(list_invoice_items))
|
||||
|
||||
@@ -131,7 +131,15 @@ pub async fn get_invoice(invoice_id: &str) -> Result<Option<Invoice>> {
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn list_invoices(tenant_pubkey: &str) -> Result<Vec<Invoice>> {
|
||||
pub async fn list_invoices() -> Result<Vec<Invoice>> {
|
||||
Ok(
|
||||
sqlx::query_as::<_, Invoice>("SELECT * FROM invoice ORDER BY created_at DESC")
|
||||
.fetch_all(pool())
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn list_invoices_for_tenant(tenant_pubkey: &str) -> Result<Vec<Invoice>> {
|
||||
Ok(sqlx::query_as::<_, Invoice>(
|
||||
"SELECT * FROM invoice WHERE tenant_pubkey = ? ORDER BY created_at DESC",
|
||||
)
|
||||
|
||||
@@ -6,6 +6,15 @@ use crate::api::{Api, AuthedPubkey};
|
||||
use crate::query;
|
||||
use crate::web::{ApiResult, internal, not_found, ok};
|
||||
|
||||
pub async fn list_invoices(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
) -> ApiResult {
|
||||
api.require_admin(&auth)?;
|
||||
|
||||
ok(query::list_invoices().await.map_err(internal)?)
|
||||
}
|
||||
|
||||
pub async fn get_invoice(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
|
||||
@@ -174,7 +174,9 @@ pub async fn list_tenant_invoices(
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
|
||||
let invoices = query::list_invoices(&pubkey).await.map_err(internal)?;
|
||||
let invoices = query::list_invoices_for_tenant(&pubkey)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
|
||||
ok(invoices)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import AdminTenantDetail from "@/pages/admin/AdminTenantDetail"
|
||||
import AdminRelayList from "@/pages/admin/AdminRelayList"
|
||||
import AdminRelayDetail from "@/pages/admin/AdminRelayDetail"
|
||||
import AdminRelayEdit from "@/pages/admin/AdminRelayEdit"
|
||||
import AdminInvoiceList from "@/pages/admin/AdminInvoiceList"
|
||||
import AdminInvoiceDetail from "@/pages/admin/AdminInvoiceDetail"
|
||||
import { account, eventStore, identity, pool } from "@/lib/state"
|
||||
import { NostrProvider } from "@/lib/nostr"
|
||||
|
||||
@@ -72,6 +74,8 @@ export default function App() {
|
||||
<Route path="/admin/relays" component={requireAdmin(AdminRelayList)} />
|
||||
<Route path="/admin/relays/:id" component={requireAdmin(AdminRelayDetail)} />
|
||||
<Route path="/admin/relays/:id/edit" component={requireAdmin(AdminRelayEdit)} />
|
||||
<Route path="/admin/invoices" component={requireAdmin(AdminInvoiceList)} />
|
||||
<Route path="/admin/invoices/:id" component={requireAdmin(AdminInvoiceDetail)} />
|
||||
</Router>
|
||||
</NostrProvider>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { A } from "@solidjs/router"
|
||||
import { Show } from "solid-js"
|
||||
import { getProfilePicture } from "applesauce-core/helpers/profile"
|
||||
import { invoiceStatus, type Invoice } from "@/lib/api"
|
||||
import { useProfileMetadata } from "@/lib/hooks"
|
||||
import { formatPeriod, formatUsd } from "@/lib/format"
|
||||
import { shortenPubkey } from "@/lib/pubkey"
|
||||
|
||||
const invoiceStatusStyles: Record<string, string> = {
|
||||
open: "bg-yellow-50 text-yellow-700 border-yellow-200",
|
||||
paid: "bg-green-50 text-green-700 border-green-200",
|
||||
void: "bg-gray-100 text-gray-500 border-gray-200",
|
||||
}
|
||||
|
||||
type AdminInvoiceListItemProps = {
|
||||
invoice: Invoice
|
||||
href: string
|
||||
}
|
||||
|
||||
export default function AdminInvoiceListItem(props: AdminInvoiceListItemProps) {
|
||||
// Resolve the owning tenant's profile from the event store. AdminInvoiceList
|
||||
// primes these profiles in one batch, so this subscription does not prime.
|
||||
const metadata = useProfileMetadata(() => props.invoice.tenant_pubkey, { prime: false })
|
||||
|
||||
const status = () => invoiceStatus(props.invoice)
|
||||
|
||||
return (
|
||||
<li>
|
||||
<A href={props.href} class="block rounded-lg border border-gray-200 bg-white p-4 hover:border-gray-300">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium text-gray-900">{formatUsd(props.invoice.amount)}</p>
|
||||
<p class="text-xs text-gray-500">{formatPeriod(props.invoice.period_start, props.invoice.period_end)}</p>
|
||||
<div class="mt-1.5 flex items-center gap-2">
|
||||
<Show
|
||||
when={getProfilePicture(metadata())}
|
||||
fallback={
|
||||
<div class="h-5 w-5 flex-shrink-0 rounded-full bg-gray-200 flex items-center justify-center text-[10px] text-gray-500">
|
||||
{((metadata()?.name || metadata()?.display_name) || props.invoice.tenant_pubkey).slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<img src={getProfilePicture(metadata())} alt="" class="h-5 w-5 flex-shrink-0 rounded-full object-cover" />
|
||||
</Show>
|
||||
<span class="text-xs text-gray-500 truncate">{(metadata()?.name || metadata()?.display_name) || shortenPubkey(props.invoice.tenant_pubkey)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${invoiceStatusStyles[status()] ?? "bg-gray-100 text-gray-500 border-gray-200"}`}>
|
||||
{status()}
|
||||
</span>
|
||||
</div>
|
||||
</A>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -47,6 +47,7 @@ export default function AppShell(props: { children?: any }) {
|
||||
const adminResources = [
|
||||
{ href: "/admin/tenants", label: "Tenants" },
|
||||
{ href: "/admin/relays", label: "Relays" },
|
||||
{ href: "/admin/invoices", label: "Invoices" },
|
||||
]
|
||||
|
||||
const navItemClass = (href: string) => {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { A } from "@solidjs/router"
|
||||
import { Show } from "solid-js"
|
||||
import { getProfilePicture } from "applesauce-core/helpers/profile"
|
||||
import { invoiceStatus, type Invoice } from "@/lib/api"
|
||||
import Field from "@/components/Field"
|
||||
import { useProfileMetadata } from "@/lib/hooks"
|
||||
import { formatPeriod, formatUsd } from "@/lib/format"
|
||||
import { shortenPubkey } from "@/lib/pubkey"
|
||||
|
||||
const invoiceStatusStyles: Record<string, string> = {
|
||||
open: "bg-yellow-50 text-yellow-700 border-yellow-200",
|
||||
paid: "bg-green-50 text-green-700 border-green-200",
|
||||
void: "bg-gray-100 text-gray-500 border-gray-200",
|
||||
}
|
||||
|
||||
type InvoiceDetailCardProps = {
|
||||
invoice: Invoice
|
||||
}
|
||||
|
||||
export default function InvoiceDetailCard(props: InvoiceDetailCardProps) {
|
||||
// Resolve the owning tenant's profile so the Tenant field can show a name and
|
||||
// avatar instead of a raw pubkey, like the admin list item.
|
||||
const metadata = useProfileMetadata(() => props.invoice.tenant_pubkey, { prime: false })
|
||||
|
||||
const status = () => invoiceStatus(props.invoice)
|
||||
|
||||
return (
|
||||
<div class="bg-white border border-gray-200 rounded-xl p-6 space-y-6">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-2xl font-bold text-gray-900">{formatUsd(props.invoice.amount)}</p>
|
||||
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${invoiceStatusStyles[status()] ?? "bg-gray-100 text-gray-500 border-gray-200"}`}>
|
||||
{status()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<hr class="border-gray-200" />
|
||||
|
||||
<dl class="grid gap-x-6 gap-y-4 sm:grid-cols-2">
|
||||
<Field label="Billing period">
|
||||
<span class="text-gray-900">{formatPeriod(props.invoice.period_start, props.invoice.period_end)}</span>
|
||||
</Field>
|
||||
<Field label="Created">
|
||||
<span class="text-gray-900">{new Date(props.invoice.created_at * 1000).toLocaleDateString()}</span>
|
||||
</Field>
|
||||
<Show when={props.invoice.method}>
|
||||
<Field label="Payment method">
|
||||
<span class="uppercase text-gray-900">{props.invoice.method}</span>
|
||||
</Field>
|
||||
</Show>
|
||||
<Field label="Tenant">
|
||||
<A href={`/admin/tenants/${props.invoice.tenant_pubkey}`} class="group flex w-fit items-center gap-2 min-w-0">
|
||||
<Show
|
||||
when={getProfilePicture(metadata())}
|
||||
fallback={
|
||||
<div class="h-5 w-5 flex-shrink-0 rounded-full bg-gray-200 flex items-center justify-center text-[10px] text-gray-500">
|
||||
{((metadata()?.name || metadata()?.display_name) || props.invoice.tenant_pubkey).slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<img src={getProfilePicture(metadata())} alt="" class="h-5 w-5 flex-shrink-0 rounded-full object-cover" />
|
||||
</Show>
|
||||
<span class="truncate text-blue-600 group-hover:underline">{(metadata()?.name || metadata()?.display_name) || shortenPubkey(props.invoice.tenant_pubkey)}</span>
|
||||
</A>
|
||||
</Field>
|
||||
</dl>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -285,6 +285,10 @@ export function listRelays() {
|
||||
return callApi<undefined, Relay[]>("GET", "/relays")
|
||||
}
|
||||
|
||||
export function listInvoices() {
|
||||
return callApi<undefined, Invoice[]>("GET", "/invoices")
|
||||
}
|
||||
|
||||
export function getRelay(id: string) {
|
||||
return callApi<undefined, Relay>("GET", `/relays/${id}`)
|
||||
}
|
||||
|
||||
@@ -10,9 +10,11 @@ import {
|
||||
createRelay,
|
||||
deactivateRelay,
|
||||
reactivateRelay,
|
||||
getInvoice,
|
||||
getRelay,
|
||||
getTenant,
|
||||
invoiceStatus,
|
||||
listInvoices,
|
||||
listRelayActivity,
|
||||
listRelays,
|
||||
listTenantInvoices,
|
||||
@@ -145,6 +147,10 @@ export const useAdminTenants = () => createResource(listTenants)
|
||||
|
||||
export const useAdminRelays = () => createResource(listRelays)
|
||||
|
||||
export const useAdminInvoices = () => createResource(listInvoices)
|
||||
|
||||
export const useInvoice = (id: () => string) => createResource(id, getInvoice)
|
||||
|
||||
export const useAdminTenant = (pubkey: () => string) => createResource(pubkey, getTenant)
|
||||
|
||||
export const useAdminTenantRelays = (pubkey: () => string) => createResource(pubkey, listTenantRelays)
|
||||
|
||||
@@ -25,7 +25,7 @@ export type EventSigner = {
|
||||
signEvent(event: UnsignedEvent): Promise<SignedEvent>
|
||||
}
|
||||
|
||||
export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME || "Caravel"
|
||||
export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME
|
||||
|
||||
export const eventStore = new EventStore()
|
||||
export const pool = new RelayPool()
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { createResource, Show } from "solid-js"
|
||||
import BackLink from "@/components/BackLink"
|
||||
import InvoiceDetailCard from "@/components/InvoiceDetailCard"
|
||||
import InvoiceItemsList from "@/components/payment/InvoiceItemsList"
|
||||
import PageContainer from "@/components/PageContainer"
|
||||
import ResourceState from "@/components/ResourceState"
|
||||
import useMinLoading from "@/lib/useMinLoading"
|
||||
import { listInvoiceItems } from "@/lib/api"
|
||||
import { useInvoice } from "@/lib/hooks"
|
||||
|
||||
export default function AdminInvoiceDetail() {
|
||||
const params = useParams()
|
||||
const invoiceId = () => params.id ?? ""
|
||||
const [invoice] = useInvoice(invoiceId)
|
||||
const [items] = createResource(invoiceId, async (id) => {
|
||||
if (!id) return []
|
||||
|
||||
try {
|
||||
return await listInvoiceItems(id)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
const loading = useMinLoading(() => invoice.loading && !invoice())
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<BackLink href="/admin/invoices" label="Invoices" />
|
||||
<ResourceState loading={loading()} error={invoice.error} loadingText="Loading invoice..." errorText="Failed to load invoice." class="mb-4" />
|
||||
<Show when={!loading() && invoice()}>
|
||||
{(i) => (
|
||||
<div class="space-y-6 mb-6">
|
||||
<InvoiceDetailCard invoice={i()} />
|
||||
<Show when={(items()?.length ?? 0) > 0}>
|
||||
<InvoiceItemsList items={items() ?? []} />
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</PageContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
|
||||
import { fuzzySearch } from "@/lib/search"
|
||||
import AdminInvoiceListItem from "@/components/AdminInvoiceListItem"
|
||||
import PageContainer from "@/components/PageContainer"
|
||||
import ResourceState from "@/components/ResourceState"
|
||||
import SearchInput from "@/components/SearchInput"
|
||||
import useMinLoading from "@/lib/useMinLoading"
|
||||
import { primeProfiles, useAdminInvoices } from "@/lib/hooks"
|
||||
|
||||
export default function AdminInvoiceList() {
|
||||
const [query, setQuery] = createSignal("")
|
||||
const [invoices] = useAdminInvoices()
|
||||
const loading = useMinLoading(() => invoices.loading)
|
||||
|
||||
// Each list item shows its tenant's profile; prime them all in one batch so
|
||||
// we don't open a separate outbox subscription per invoice.
|
||||
createEffect(() => {
|
||||
const list = invoices() ?? []
|
||||
if (!list.length) return
|
||||
const sub = primeProfiles(list.map(i => i.tenant_pubkey))
|
||||
onCleanup(() => sub.unsubscribe())
|
||||
})
|
||||
|
||||
const filtered = createMemo(() => {
|
||||
const list = invoices() ?? []
|
||||
const q = query().trim()
|
||||
if (!q) return list
|
||||
return fuzzySearch(list, ["tenant_pubkey", "id"], q)
|
||||
})
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-6 py-2">Invoices</h1>
|
||||
<div class="mb-6">
|
||||
<SearchInput value={query()} onInput={setQuery} placeholder="Search invoices..." />
|
||||
</div>
|
||||
<ResourceState loading={loading()} error={invoices.error} loadingText="Loading invoices..." errorText="Failed to load invoices." />
|
||||
<Show when={!loading()}>
|
||||
<Show when={filtered().length > 0} fallback={<p class="py-20 text-center text-gray-500">No invoices found.</p>}>
|
||||
<ul class="space-y-3">
|
||||
<For each={filtered()}>
|
||||
{(invoice) => <AdminInvoiceListItem invoice={invoice} href={`/admin/invoices/${invoice.id}`} />}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
</Show>
|
||||
</PageContainer>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user