Overview
What it is
The user-facing tier of GKOI: three Next.js 16 (App Router) + React 19 apps on a Privy + wagmi + viem wallet stack (READMEs mentioning RainbowKit are stale — there is no RainbowKit in any manifest).
| App | Role | Stack shape |
|---|---|---|
gkoi-admin-v2 | Operator dashboard | Privy + wagmi + viem, TanStack Query + SWR, react-hook-form + Zod, Playwright e2e |
gkoi-client-v3 | Public mint site | Lean read/submit: wagmi + viem + SWR + axios |
gkoi-gallery | NFT gallery | Lean read-only browse/detail |
None of them owns any source of truth: collection/config/whitelist data is fetched
from the platform services as the same typed IResponseData<T> envelope the
services expose, and the server re-authorizes and re-enforces every mutation. The
frontends are projections.
The design move that makes chain ops legible
The admin's core idea is counter-intuitive: for almost every privileged task, the operator does not sign a raw wallet transaction. Instead the action is a labeled button that POSTs to a backend the operator is already authenticated to, and the chain/indexing work happens server-side. The REST client is a thin cookie-auth axios wrapper — no bearer token ever sits in JS-readable storage:
const axiosClient = axios.create({ withCredentials: true });
Each action drives UX through a toast.loading → toast.update(success|error)
lifecycle and surfaces the server's own error message verbatim, guarded by an
isUserLoggedIn check:
const toastId = toast.loading("Adding new collection...");
const url = `${env.MAIN_SERVICE_URL}/api/collections/add/${tokenAddressOrSlug}?chainId=${chainId}`;
const { status, data: { data } } = await api().post(url, null);
if (status !== 201 || !data) throw new Error();
toast.update(toastId, { render: "Successfully added new collection!", type: "success", isLoading: false, autoClose: 3000 });
So the operator clicks a clearly-named action, watches a live loading→result toast, and never has to reason about calldata or gas for indexing an NFT collection, reindexing it, or deleting it.
On-chain state as read-only UI
Contract state is surfaced as plain dashboard values via wagmi
useReadContracts/useReadContract — no transaction required to look. The
presale hook reads owner, stage, and stagePrices, then maps the raw stage
enum to a human stageName and a formatEther price:
const { data: contractReadsResults } = useReadContracts({
allowFailure: true,
contracts: [ { ...presaleContract, functionName: "owner" },
{ ...presaleContract, functionName: "stage" } ],
});
The one genuine chain write
There is exactly one real direct chain write in the whole admin: the swap
token redeem/claim, via wagmi useWriteContract, which submits the user's own
signed claim(...) through their connected wallet — never a server-held key. It's
the deliberate exception that proves the rule that privileged mutations are
otherwise delegated to the authenticated backend.
const result = await mutateAsync({
address: (siteConfigs?.gKoiSwapAddress as `0x${string}`) || "",
abi: gKoiMultiSwapABI, functionName: "claim",
args: [payload.signatureData?.signature || "", id, payload.amountWei, payload.deadline],
});
Auth gate and operator breadth
Login is Privy (SIWE-style) → an HttpOnly session cookie (/api/login|verify|logout
route handlers) → an admin-role/permission verify, with a tri-state gate
(documented in the app's own flicker-design doc) built so the connect/sign dialog
never dead-ends when unauthenticated. Admin role is fetched from
gkoi-authentications — the frontend never decides authorization itself — and RBAC
hooks (useAdminAccessControl, useHasPermission, useIsSuperAdmin) gate a broad
operator surface: manage_collections, certified_nfts, art_contest_*,
communities, gcoin, guardians, power, quests, permissions,
announcements, admin_audit_log, gallery_whitelist, arenas, fighters, and
more.
Testing & security posture
The admin ships Playwright e2e (playwright.config.ts, tests/) over the operator
routes; all three apps run Biome + ts.check + lint-staged and use
patch-package. Authorization is server-side by construction — cookie auth keeps
tokens out of JS, and the few chain writes go through the operator's own wallet.
Honest note
On the public mint site, the actual on-chain buyPresale submit path is currently
commented out behind a "SOLD OUT" state — the code even carries a note that it
was left in place "so the presale can be re-enabled without rebuilding it." So a
live mint flow is not claimed as active here; what ships today is the eligibility
read, the claim-signature plumbing, and a SOLD-OUT UI.