forked from coracle/caravel
Update readme, move frontend build to build phase in dockerfile
This commit is contained in:
@@ -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