Build the account area
Requires: A secret key with
MEMBER_AUTH, and the member's session.
This guide builds the member's account pages on your own domain: My events, a Ledger of money in and out, and a Library of what they own. They are the same pages a member sees in the community app, scoped to your community.
It builds on Embed in your own code: the browser
talks to your server, your server talks to Cobuntu, and the key never leaves the
server. It uses cobuntuForRequest() from that guide (the client, with the
member's session when there is one).
events me.events ──▶ MyEvents
ledger me.ledger, me.invoice, me.receipt ──▶ Ledger
library me.library, me.purchaseDownload ──▶ Library
settings me.profile, me.preferences, me.password,
me.account, me.friends, me.notesKey and session
All three routes need a secret key with MEMBER_AUTH and the member's session
in X-Cobuntu-Auth-Token (the client sends it for you on client.as(session)).
| Answer | Means |
|---|---|
401 INVALID_SESSION | No session, an expired one, or the user is not an accepted member. Send them to sign in. |
403 SECRET_KEY_REQUIRED | A publishable key. These routes are server only. |
403 MISSING_SCOPE | The key lacks MEMBER_AUTH. |
400 INVALID_INPUT | A bad when, role, type or date. |
The member is identified only by the session. No route takes a user id, so one member can never read another's account.
All amounts are integers in the smallest currency unit (cents for EUR).
1. My events
// app/account/events/page.tsx (server component)
import { MyEvents } from "@cobuntu/embed-react";
import { cobuntuForRequest } from "@/lib/session";
export default async function MyEventsPage() {
const events = await cobuntuForRequest().me.events();
return (
<MyEvents
events={events}
eventHref={(e) => `/events/${e.slug ?? e.id}`}
receiptHref={(e) => `/account/ledger?txn=${e.transactionId}`}
browseHref="/events"
/>
);
}me.events() answers four groups:
| Group | Holds |
|---|---|
attending | Upcoming events where the member has a place (approved or awaiting approval). |
attended | The same, in the past. |
hosting | Upcoming events the member hosts. |
hosted | The same, in the past. |
- Only events of your community appear, even for a member of several.
- Declined and cancelled places are left out.
- An event both hosted and attended is in both groups.
MyEventsshows it once. - Past means the end date (else the start date) is before now. An event with no date counts as upcoming.
- Upcoming groups are soonest first, past groups most recent first.
when(upcoming|past) androle(attending|hosting) narrow the answer; an excluded group comes back as[].transactionIdis the latest purchase for the event, for a receipt link. Free places have none.
For per-place detail (companions, whether a ticket can be refunded) use
me.tickets.
2. Ledger
The ledger lists, newest first, everything that moved money for the member in your community:
type | direction | amount |
|---|---|---|
PURCHASE | out | What they paid (gross). |
REFUND | in | What came back to them. |
SALE | in | What they earned as a seller, after fees. |
A sale also carries fees (gross, platform fee, card processing, tax fee),
payout (status, the date it becomes payable, the date it was paid out) and the
buyer as counterpart. A refunded sale keeps its entry, with status and
refund.
summary.totals has one row per currency over the filtered entries: spent,
refunded, earned and fees. Amounts in different currencies are never added
together. pendingPayouts, readyForPayout and inEscrowPeriod are the
member's payout state in your community, before filters.
Payouts to a bank account are not listed: one payout can cover sales in several communities. Each sale shows its own payout state instead.
The page
The filters live in the browser, so the ledger is a client component that asks your server for each page.
// app/api/ledger/route.ts
import { cobuntuForRequest } from "@/lib/session";
export async function GET(req: Request) {
const p = new URL(req.url).searchParams;
const ledger = await cobuntuForRequest().me.ledger({
type: (p.get("type") as "purchase" | "refund" | "sale" | null) ?? undefined,
from: p.get("from") ?? undefined,
limit: 20,
offset: Number(p.get("offset") ?? 0),
});
return Response.json(ledger);
}// app/account/ledger/LedgerPage.tsx
"use client";
import { useEffect, useState } from "react";
import { Ledger, EMPTY_LEDGER_FILTERS, toLedgerQuery, type CbLedger } from "@cobuntu/embed-react";
export function LedgerPage({ initial }: { initial: CbLedger }) {
const [ledger, setLedger] = useState(initial);
const [filters, setFilters] = useState(EMPTY_LEDGER_FILTERS);
const [offset, setOffset] = useState(0);
const [loading, setLoading] = useState(false);
useEffect(() => {
const q = new URLSearchParams(Object.entries(toLedgerQuery(filters, { offset })).map(([k, v]) => [k, String(v)]));
setLoading(true);
fetch(`/api/ledger?${q}`).then((r) => r.json()).then(setLedger).finally(() => setLoading(false));
}, [filters, offset]);
return (
<Ledger
ledger={ledger}
loading={loading}
filters={filters}
onFiltersChange={(f) => { setFilters(f); setOffset(0); }}
offset={offset}
onOffsetChange={setOffset}
invoiceHref={(e) => `/api/invoice/${e.saleId}`}
receiptHref={(e) => `/api/receipt/${e.saleId}`}
/>
);
}Invoices and receipts
invoiceUrl is set on the member's own purchases and receiptUrl on purchases
and sales. Both are API paths that need the secret key, so point the links at
small routes on your server:
// app/api/invoice/[saleId]/route.ts
export async function GET(_: Request, { params }: { params: { saleId: string } }) {
const url = await cobuntuForRequest().me.invoice(params.saleId);
return Response.redirect(url, 302);
}
// app/api/receipt/[saleId]/route.ts
export async function GET(_: Request, { params }: { params: { saleId: string } }) {
const pdf = await cobuntuForRequest().me.receipt(params.saleId);
return new Response(pdf, { headers: { "Content-Type": "application/pdf" } });
}An invoice is issued shortly after payment. Until then me.invoice answers
404 INVOICE_NOT_AVAILABLE; invoiceAvailable on the entry tells you whether it
exists yet.
3. Library
// app/account/library/page.tsx (server component)
import { cobuntuForRequest } from "@/lib/session";
import { LibraryGrid } from "./LibraryGrid";
export default async function LibraryPage() {
const { items } = await cobuntuForRequest().me.library();
return <LibraryGrid items={items} />;
}// app/account/library/LibraryGrid.tsx
"use client";
import { Library, type CbLibraryItem } from "@cobuntu/embed-react";
export function LibraryGrid({ items }: { items: CbLibraryItem[] }) {
return (
<Library
items={items}
itemHref={(i) => (i.product.sku ? `/shop/${i.product.sku}` : null)}
onDownload={async (i, d) => {
const { url } = await fetch(`/api/download/${i.saleId}/${d.attachmentId}`).then((r) => r.json());
window.location.href = url;
}}
browseHref="/shop"
/>
);
}// app/api/download/[saleId]/[attachmentId]/route.ts
export async function GET(_: Request, { params }: { params: { saleId: string; attachmentId: string } }) {
const link = await cobuntuForRequest().me.purchaseDownload(params.saleId, params.attachmentId);
return Response.json(link);
}- The library holds what the member owns in your community. Refunded purchases are not in it.
downloadslists each deliverable. ALINKopens itsurldirectly. AFILEgoes throughme.purchaseDownload, which returns a signed URL valid for about five minutes and counts one download against any download limit.awaitingApproval: truemarks a purchase the seller has not approved yet. Itsdownloadsis empty until they do, andLibraryshows it dimmed.
4. Profile and settings
The me namespace covers the account screens: profile, avatar, preferences,
password, data export, account deletion, friends and notes. Every call needs a
member session (client.as(token)) and a secret key with MEMBER_AUTH.
const me = cobuntuForRequest().me;
// Profile, including handle, socials, birthday and contact visibility
const profile = await me.profile.get();
const { available, alternatives } = await me.profile.usertagAvailable("alex");
await me.profile.update({
usertag: "alex",
pronouns: "they/them",
socials: [{ label: "Site", url: "https://example.com" }],
birthday: { month: 4, day: 12 },
emailVisibility: "CIRCLE",
});
// Avatar: a png, jpeg or webp data URL, up to 5 MB
const avatarUrl = await me.profile.avatar.upload(dataUrl);
// Preferences: notifications are this community's email settings
await me.preferences.update({ timeFormat: "H24", notifications: { postsEmail: false } });profile.bioMaxLengthis the bio limit for the account; use it for your counter.phoneVisibility: "PUBLIC"is saved asCIRCLE.- A taken usertag on update is
409 USERTAG_TAKENwithalternatives. CallusertagAvailableas the member types to show checked suggestions instead.
Password, export and deletion
const { token } = await me.password.change({ currentPassword, newPassword });
// Every session is now signed out. Save `token` as this device's session.
setSessionCookie(token);
const data = await me.export(); // JSON, offer it as a download
await me.account.delete({ password });password.changeerrors:BAD_PASSWORD(wrong current password),NO_PASSWORD(the account signs in without one; send the member to password reset),WEAK_PASSWORD,SAME_PASSWORD.account.deleteerases the whole account, not only the membership. Accounts without a password are deleted on the session alone, so ask for a typed confirmation before calling it.
Friends and notes
const { items, nextCursor } = await me.friends.list({ limit: 20 });
const notes = await me.notes.list();
const note = await me.notes.create({ content: "Looking for a cofounder", category: "LOOKING_FOR" });
await me.notes.delete(note.id);- Notes belong to the account, so the same set shows in every community. Free accounts hold up to two.
Labels and styling
The components render no CSS and fetch nothing. Every string has an English
default you can replace through labels, and every element has a cb- class for
your stylesheet. See the React page for the props and the
class map.