Plugin Development Guide
A feature plugin is the second kind of Tours add-on (the first being channel integrations — see the Add-on Development Guide). Where a channel add-on connects an external provider, a feature plugin adds a whole capability to the marketplace: its own admin pages, customer- and operator-portal pages, public pages, API endpoints, navigation entries, event hooks, and its own database tables. Announcements, Support Desk, an Operator Payout Ledger, a Departures Calendar, and a Featured-Tours home widget all ship this way.
Feature plugins live alongside channel add-ons under src/product/addons/<id>/, are declared with "feature": true in their manifest, and are activated by license entitlement + an admin toggle — there are no files to upload. This guide uses an Operator Payout Ledger (src/product/addons/payouts/) as the worked example. The plugin contract is defined in src/core/plugins/types.ts (FeaturePlugin).
Anatomy of a feature plugin
src/product/addons/payouts/
addon.json manifest (feature: true, premium, category, templates…)
index.ts exports `plugin` (the FeaturePlugin object)
schema.ts ensureSchema — idempotent CREATE TABLE
api.ts API handlers (operator ledger, admin release, mark-paid)
admin.tsx admin page component
operator.tsx operator-portal page component
notify-payout.ts best-effort email / WhatsApp / SMS notifications
installation-guide.md operator-facing docs shown in admin
The folder is self-contained. Core never imports from it directly; it discovers the plugin through the generated FEATURES index and renders it through catch-all routes (below).
The manifest
{
"id": "payouts",
"name": "Operator Payout Ledger",
"type": "code",
"feature": true,
"products": ["*"],
"version": "1.0.0",
"premium": true,
"vendor": "creative-cape.com",
"category": "Finance",
"templates": [
{
"slug": "payout_released",
"channel": "EMAIL",
"name": "Payout released",
"subject": "Your payout is on the way — {{amount}}",
"body": "Hi {{name}}, we've released a payout of {{amount}} for {{count}} completed bookings…"
}
]
}
"feature": true marks it a plugin (versus a channel add-on). "products": ["*"] makes it universal — it works with any product built on this core; a tours-only plugin would list ["tours"]. Any templates are seeded into the notification system on activation (idempotently), so the plugin's emails / SMS / WhatsApp messages exist the moment it is switched on. category groups the plugin under a tab on the admin Integrations page.
The FeaturePlugin object
index.ts exports a plugin matching the FeaturePlugin interface:
import type { FeaturePlugin } from "@/core/plugins/types";
import { ensureSchema } from "./schema";
import { ledger, statement, adminList, release, markPaid } from "./api";
import PayoutsOperator from "./operator";
import PayoutsAdmin from "./admin";
export const plugin: FeaturePlugin = {
id: "payouts",
name: "Operator Payout Ledger",
category: "Finance",
ensureSchema,
nav: [
{ area: "admin", label: "Payouts", icon: "Wallet", slug: "", permission: "settings" },
{ area: "operator", label: "Payouts", icon: "Wallet", slug: "" },
],
adminPages: { "": PayoutsAdmin },
operatorPages: { "": PayoutsOperator },
api: {
"GET ledger": ledger,
"GET statement/:id": statement,
"GET admin/list": adminList,
"POST admin/release": release,
"POST admin/:id/paid": markPaid,
},
hooks: {
"order.paid": async (payload) => {
const p = payload as { bookingId?: number; hostId?: number; amount?: number };
if (!p?.bookingId || !p?.hostId) return;
// accrue commission-adjusted earnings to the operator's ledger
},
},
};
The full shape (src/core/plugins/types.ts):
| Field | Purpose |
|---|---|
id, name |
Identity — id matches the folder and the license key |
category? |
Grouping label on the admin Integrations page (defaults to "Features") |
ensureSchema? |
Idempotent CREATE TABLE …, run on activation |
nav? |
Sidebar entries (area: admin / customer / operator; slug; optional permission) |
adminPages? |
sub-slug → component ("" is the plugin root); rendered by the admin catch-all |
customerPages? |
sub-slug → component; the customer-portal page surface |
operatorPages? |
sub-slug → component; the operator-portal page surface |
publicPages? |
pattern → component (e.g. "verify/:code"); rendered by the site catch-all |
api? |
"METHOD pattern" → handler (e.g. "GET statement/:id") |
hooks? |
Handlers for core domain events |
slots? |
Components injected into named core slots (e.g. home.sections, operator.dashboard.cards) |
settings? |
Per-plugin settings fields (text / secret / select / switch) edited on its admin page |
loginButtons? |
Buttons rendered on login / register (e.g. SSO) |
Schema
ensureSchema follows the project-wide ensure-schema pattern — a guarded, idempotent function that creates the plugin's tables. It runs automatically when the plugin is activated, so there are no migration files to manage. Note the domain-vs-DB rule: an operator is a hosts row, so the ledger references host_id.
import sql from "@/lib/db";
export async function ensureSchema(): Promise<void> {
await sql`CREATE TABLE IF NOT EXISTS payout_ledger (
id SERIAL PRIMARY KEY,
host_id INT NOT NULL, -- the operator (hosts row)
booking_id INT NOT NULL,
amount INT NOT NULL, -- minor units (cents)
commission INT NOT NULL DEFAULT 0, -- marketplace fee withheld
status VARCHAR(20) DEFAULT 'pending',
created_at TIMESTAMPTZ DEFAULT NOW()
)`;
await sql`CREATE TABLE IF NOT EXISTS payout_batch (
id SERIAL PRIMARY KEY,
host_id INT NOT NULL,
total INT NOT NULL,
released_at TIMESTAMPTZ DEFAULT NOW()
)`;
}
Extend existing tables the same way core does — ALTER TABLE … ADD COLUMN IF NOT EXISTS — so re-activating is always safe.
Hooks on core events
Core emits domain events at key moments; a plugin subscribes by adding a hooks handler. The neutral, marketplace-wide events every plugin can rely on are:
| Event | Fires when | Typical payload |
|---|---|---|
order.paid |
A booking is paid in full (or deposit taken) | { bookingId, hostId, customerId, amount } |
order.refunded |
A booking is refunded | { bookingId, amount } |
user.registered |
A traveler or operator signs up | { userId, role } |
user.login |
A user signs in | { userId, role } |
admin.action |
An admin performs an audited action | { actorId, action, target } |
Domain-specific Tours events (e.g. booking.created, departure.completed, review.submitted, payout.released) are emitted the same way, so a plugin can react to the exact stage it cares about. The Operator Payout Ledger reacts to a completed payment to accrue the operator's earnings:
export const plugin: FeaturePlugin = {
// …
hooks: {
"order.paid": async (payload) => {
const p = payload as { bookingId?: number; hostId?: number; amount?: number };
if (!p?.bookingId || !p?.hostId) return;
const commission = Math.round((p.amount ?? 0) * 0.15);
await sql`INSERT INTO payout_ledger (host_id, booking_id, amount, commission)
VALUES (${p.hostId}, ${p.bookingId}, ${p.amount ?? 0}, ${commission})`;
// …then send a follow-up, record analytics, etc.
},
},
};
Hooks are best-effort and must never throw into the core flow — wrap risky work and fail quietly, since a booking must still succeed even if the plugin's side-effect does not.
API handlers
Each API entry maps "METHOD pattern" to a PluginApiHandler(req, ctx), where ctx.params carries the pattern matches and ctx.query is the search params. Handlers reuse the same auth helpers as core — requirePermission() for admin endpoints, getOperatorId() for operator endpoints, getCustomerId() for customer endpoints. Auth guards first; queries are scoped to the owner (no IDOR):
import sql from "@/lib/db";
import { getOperatorId } from "@/lib/operator-auth";
import { requirePermission } from "@/lib/rbac";
import type { PluginApiHandler } from "@/core/plugins/types";
// Operator sees only their own ledger
export const ledger: PluginApiHandler = async () => {
const me = await getOperatorId();
if (!me) return Response.json({ error: "unauthorized" }, { status: 401 });
const rows = await sql`
SELECT * FROM payout_ledger WHERE host_id = ${me} ORDER BY created_at DESC`;
return Response.json(rows);
};
// Admin releases a batch — recompute the total server-side, never trust the client
export const release: PluginApiHandler = async (req) => {
const denied = await requirePermission("write", "settings");
if (denied) return denied;
const { hostId } = await req.json();
const [{ total }] = await sql`
SELECT COALESCE(SUM(amount - commission), 0) AS total
FROM payout_ledger WHERE host_id = ${Number(hostId)} AND status = 'pending'`;
// …insert a payout_batch row, flip ledger rows to 'released', notify the operator…
return Response.json({ ok: true, total });
};
How plugins are rendered (catch-all routes)
Plugins don't define their own Next.js route files. Catch-all routes plus one API dispatcher resolve active plugins at runtime:
| Surface | Route file | URL |
|---|---|---|
| Admin pages | src/app/admin/(panel)/x/[...slug]/page.tsx |
/admin/x/<id>/<slug> |
| Public pages | src/app/(site)/x/[...slug]/page.tsx |
/x/<id>/<slug> |
| API | src/app/api/v1/ext/[addon]/[...path]/route.ts |
/api/v1/ext/<id>/<path…> |
Plugin nav for every audience is served by GET /api/v1/plugins/nav?area=<admin|customer|operator>, which each portal's layout merges into its sidebar. The API dispatcher looks up the active plugin, walks its api map, matches the "METHOD pattern" key against the request path, and calls the handler:
const feature = (await getActiveFeatures()).find((f) => f.id === addon);
for (const [key, handler] of Object.entries(feature.api)) {
const [m, pattern = ""] = key.trim().split(/\s+/);
if (m.toUpperCase() !== method) continue;
const params = matchPattern(pattern, parts);
if (params) return handler(req, { params, query });
}
So an operator opening a statement hits /api/v1/ext/payouts/statement/42 and matches "GET statement/:id".
Slots: injecting UI into core surfaces
A plugin can render into named slots that core exposes without adding a whole page. A Featured-Tours widget, for instance, registers a component into the home.sections slot so it appears on the marketplace home page between core sections:
import FeaturedTours from "./FeaturedTours";
export const plugin: FeaturePlugin = {
id: "featured-tours",
name: "Featured Tours",
slots: { "home.sections": FeaturedTours },
// …ensureSchema, api to pick which listings are featured, admin page to curate…
};
Other slots include operator.dashboard.cards and customer.dashboard.cards. Slot components receive an optional ctx and must be resilient — if the plugin is deactivated, its slot simply disappears.
Activation & licensing
A plugin is usable only when it is installed (present in the generated FEATURES list, at build time) and active (a row in the plugin_state table). Activation is handled by setFeatureActive() (src/core/plugins/state.ts):
export async function setFeatureActive(id: string, active: boolean): Promise<void> {
const f = getFeature(id);
if (!f) throw new Error("Unknown feature plugin.");
if (active && f.ensureSchema) await f.ensureSchema(); // create tables
if (active && ADDON_TEMPLATES[id]) await seedAddonTemplates(id); // seed templates
await sql`INSERT INTO plugin_state (id, active, activated_at) VALUES (${id}, ${active}, NOW())
ON CONFLICT (id) DO UPDATE SET active = ${active}`;
}
On activation it runs ensureSchema, seeds declared templates, and flips the plugin_state row. getActiveFeatures() is the single resolver every catch-all and the event bus use, so deactivating a plugin instantly hides its nav, pages, and hooks (its data is kept). Settings are stored per-plugin in the same plugin_state.settings JSON column via saveFeatureSettings().
License gate
Premium plugins are gated by featureBlock(headers, featureId) (src/lib/premium.ts), mirroring the channel logic. A premium plugin activates only when the license entitlements carry the feature id (or "*" / "all"), or a per-add-on purchase code has been activated for the domain. The free/premium split lives in src/core/channels/tiers.ts (FREE_FEATURES is empty by default — every feature is premium); development / localhost hosts unlock everything.
Admins manage activation from Settings → Add-ons (/admin/settings/integrations) — toggling a feature on (which calls setFeatureActive) and, if needed, entering a purchase code to satisfy the entitlement.
Build checklist
-
addon.jsonhas"feature": true, a stableid(folder name),category,premium, andvendor. -
index.tsexports apluginimplementingFeaturePlugin; navareavalues areadmin/customer/operator. -
ensureSchemais idempotent; SQL uses the real table/column names (hosts/host_id,listings/listing_id,units/unit_id). - Every API handler guards auth first and scopes queries to the owner; money is recomputed server-side.
- Hooks are wrapped so they never break the core flow.
-
npm run addons:bundlere-run so the plugin lands inFEATURES;npx tsc --noEmitclean. - Activated and tested end-to-end on localhost across every portal it touches.
© CreativeCape Solutions · creative-cape.com · support@creative-cape.com