Skip to content
campaiDeveloper
campai.com

Track what happens after your club accepts a member: who welcomes them, when the first visit is due, and which steps are still open. You will build a board with three columns, a shared checklist, and progress that survives closing the widget.

This is a Developer App hosted inside campai for your administrative team. An admission form on your website belongs in the external API track. This board starts after acceptance and records work your team carries out; it does not accept applications, send messages, or change member records.

What you’ll have at the end

A shared welcome board for accepted members, with an owner, due date, and persistent task checklist.

  1. Administrator adds an accepted member
  2. Widget sends a named server action
  3. App database saves the checklist atomically
  4. Board shows saved progress
Before you start
  • Permission to create a Developer App with an Admin-scoped Widget
  • Access to the new app’s database
  • A welcome process and synthetic member labels for the first test
On this page

Pick one person responsible for welcoming new members and agree on five tasks: review contact details, send welcome information, arrange the group or department, offer the Community App invitation, and confirm the first visit. The supplied code includes these tasks. Adjust their titles and descriptions in TASKS before collecting real progress; stable task IDs identify stored completions.

Use synthetic names and membership numbers for the first test. The board stores its own copies of those labels, an owner, a due date, and completed task IDs. It does not read contact records. Copy actual member numbers carefully when you later use it for your club.

Download the complete source before opening the editor. If you are new to the hosted runtime, start with the built-in objects.

  1. Open More → Developers → Apps and create New-member onboarding.
  2. Open Edit → Add group, select Widget, and name it Welcome board.
  3. Select Admin Scope and require Members → Read. This means visitors must already have that permission; it does not grant it to the app.
  4. Open Settings and enable Database access. Leave Server permissions empty and leave Outbound hosts unset.

Everyone allowed to use this widget can see and change its entire board. A checklist owner's name is a responsibility label, not an access restriction. Use a separate app for a different audience, and keep this one unpublished during testing.

Replace the complete contents of each destination file:

Downloaded file Destination
server.ts The group's server index.ts
widget.tsx Widget → index.tsx
widget.css Widget → index.css

Save the files, select the Widget's index.tsx, and choose Welcome board: Simulate. The board loads automatically. An empty board shows zero totals and empty columns. A database-access failure shows an error instead of pretending the empty state is a successful result.

The client calls action('loadBoard') and action('changeBoard', input). Server code uses the built-in storage object. There is no external server, API key, package installation, or helper file to configure.

Select Add member, enter a synthetic name, a unique number such as GUIDE-WELCOME-01, the checklist owner's name, and a due date. Select Create checklist. The new card appears under To welcome, with zero of five tasks complete.

The number is the checklist's identity. A second checklist for the same number is rejected. The app does not verify it against campai: for real members, compare the name and number with their accepted member record first. Use one membership-number namespace per app; if mandates reuse numbers, keep separate boards or extend the key design before combining them.

Create one checklist for each accepted member. The number identifies the checklist.

Create one checklist for each accepted member. The number identifies the checklist.

Add-member dialog with name, membership number, checklist owner, and due date
  1. Open the member's card.
  2. Complete a real welcome step through your normal club process.
  3. Check the corresponding task. Wait for Task progress saved.
  4. Close the checklist. The card moves to In progress and its progress bar advances.
  5. Reopen it, change the owner or due date, and select Save owner and date.
  6. Finish the remaining tasks. The card moves to Ready to join in.

A checkbox records a completed task. It does not send the welcome message or invitation, create a group membership, or schedule the visit. Carry out those actions in the appropriate campai screen or your club's usual process before checking them off.

Each saved task updates the shared checklist. Owner and date can be changed separately.

Each saved task updates the shared checklist. Owner and date can be changed separately.

Welcome checklist with two completed tasks and editable owner and due date
The board derives each stage from completed tasks. Reloading preserves the saved progress.

The board derives each stage from completed tasks. Reloading preserves the saved progress.

Welcome board with members in To welcome, In progress, and Ready to join in

Due dates are calendar dates. Past due counts incomplete checklists whose due date is before today in the viewer's local time zone. A checklist due today is not past due. The owner is a manually entered name; this example does not look up staff accounts or send assignment notifications.

Close and restart the simulation, or reload the board. The saved tasks, owner, and due date should return. They are stored in the app database at ['member-onboarding', 'board-v1'], shared by this app's groups and available across browsers. They are not browser preferences in widgetStorage.

Test a conflict using two open views: load the same board in both, save a task in one, then try a change from the older view. The older save must ask you to reload. The server compares the displayed version, then checks it again inside the atomic commit so a change during the save is also protected.

If a response is lost, the change may already exist. The widget disables further edits until you reload and inspect the latest board. It never automatically repeats a save. Failed loads leave the last visible board on screen with an error; they do not present those rows as fresh data.

Completed checklists remain in the last column. When your club no longer needs one, open it, expand Remove this checklist, type its exact member number, and select Remove checklist. This deletes the app's checklist and progress only. The campai member stays unchanged.

The example keeps at most 100 checklists in one board entry. Remove finished items according to your retention rules. For a larger workflow, design separately stored checklists and a paginated listing before raising this bound. This example has no historical audit trail or restore command for removed checklists.

Try this Expected result
Restart the widget Saved progress returns from the app database.
Add an existing member number Duplicate rejected; existing checklist unchanged.
Save from an old view Conflict reported; reload required.
Enter an impossible date through the server action Validation rejects it before writing.
Remove one test checklist Other checklists remain; no campai contact changes.
Disable Database access Loading fails with a visible error.

Review the permitted audience before publishing. Test the published link with an authorized administrator and a signed-out visitor; owner simulation alone does not verify that boundary. Remove test checklists before introducing actual member names. See Publish and share an app for the final audience check.

Copy each file below or download the complete ZIP. The source viewer and download contain the same files. Follow the destination table above and replace each editor file completely.

The complete new-member onboarding app

Copy server.ts into the group’s index.ts, widget.tsx into Widget/index.tsx, and widget.css into Widget/index.css. Enable the app database and keep server API permissions empty.

Download ZIP
View and copy the complete source

The complete new-member onboarding app

// Hosted Widget server. This app uses only its own database, never the contact API.
const BOARD_KEY = ['member-onboarding', 'board-v1'] as const;
const MAX_CHECKLISTS = 100;
const TASKS = [
    { id: 'details', title: 'Review contact details', detail: 'Check the accepted member’s details in campai.' },
    { id: 'welcome', title: 'Send the welcome information', detail: 'Use your club’s usual communication process.' },
    { id: 'group', title: 'Arrange the right group', detail: 'Confirm the department, team, or introductory session.' },
    { id: 'app', title: 'Offer the Community App invitation', detail: 'Share the invitation through the member’s campai record.' },
    { id: 'meeting', title: 'Confirm the first visit', detail: 'Agree a date and explain who will welcome the member.' },
] as const;
type Checklist = { number: string; name: string; owner: string; due: string; done: string[]; createdAt: string; updatedAt: string };
type Board = { schema: 1; members: Checklist[] };

function text(value: unknown, label: string, max: number) {
    if (typeof value !== 'string' || !value.trim() || value.trim().length > max) throw new Error(`${label} is required (maximum ${max} characters).`);
    return value.trim();
}
function dateOnly(value: unknown) {
    const date = text(value, 'Due date', 10);
    if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !Number.isFinite(Date.parse(`${date}T00:00:00Z`)) || new Date(`${date}T00:00:00Z`).toISOString().slice(0, 10) !== date) throw new Error('Enter a real due date as YYYY-MM-DD.');
    return date;
}
function parseBoard(value: unknown): Board {
    if (!value || typeof value !== 'object') throw new Error('Unexpected board data. Do not overwrite it; review the saved schema.');
    const board = value as Board;
    if (board.schema !== 1 || !Array.isArray(board.members) || board.members.length > MAX_CHECKLISTS) throw new Error('Unexpected board schema or size.');
    const numbers = new Set<string>();
    for (const member of board.members) {
        if (!member || typeof member !== 'object') throw new Error('Unexpected checklist data.');
        const number = text(member.number, 'Member number', 60);
        text(member.name, 'Member name', 100); text(member.owner, 'Owner', 100); dateOnly(member.due);
        if (numbers.has(number) || !Array.isArray(member.done) || new Set(member.done).size !== member.done.length || member.done.some(id => !TASKS.some(task => task.id === id))) throw new Error('Unexpected duplicate member or task data.');
        if (![member.createdAt, member.updatedAt].every(date => typeof date === 'string' && Number.isFinite(Date.parse(date)))) throw new Error('Unexpected checklist timestamp.');
        numbers.add(number);
    }
    return board;
}
async function read() {
    const entry = await storage.get<unknown>(BOARD_KEY);
    return { version: entry.versionstamp, board: entry.versionstamp === null ? { schema: 1 as const, members: [] } : parseBoard(entry.value) };
}
export async function loadBoard() {
    const current = await read();
    return { ...current, tasks: TASKS, limit: MAX_CHECKLISTS };
}
export async function changeBoard(input: unknown) {
    if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Invalid checklist request.');
    const request = input as Record<string, unknown>;
    if (request.version !== null && (typeof request.version !== 'string' || request.version.length > 100)) throw new Error('Reload the board before saving.');
    const current = await read();
    if (request.version !== current.version) throw new Error('Someone changed the board. Reload and review before saving again.');
    const number = text(request.number, 'Member number', 60);
    const members = current.board.members.map(member => ({ ...member, done: [...member.done] }));
    const index = members.findIndex(member => member.number === number);
    const now = new Date().toISOString();
    if (request.operation === 'add') {
        if (index !== -1) throw new Error('This member number already has a checklist. Open the existing one.');
        if (members.length >= MAX_CHECKLISTS) throw new Error('This board is full. Remove finished checklists before adding another.');
        members.push({ number, name: text(request.name, 'Member name', 100), owner: text(request.owner, 'Owner', 100), due: dateOnly(request.due), done: [], createdAt: now, updatedAt: now });
    } else {
        if (index === -1) throw new Error('This checklist no longer exists. Reload the board.');
        if (request.operation === 'task') {
            if (typeof request.done !== 'boolean' || !TASKS.some(task => task.id === request.task)) throw new Error('Unknown task or state.');
            const taskId = request.task as string;
            members[index].done = members[index].done.filter(id => id !== taskId);
            if (request.done) members[index].done.push(taskId);
            members[index].updatedAt = now;
        } else if (request.operation === 'assign') {
            members[index].owner = text(request.owner, 'Owner', 100);
            members[index].due = dateOnly(request.due);
            members[index].updatedAt = now;
        } else if (request.operation === 'remove') {
            if (request.confirmNumber !== number) throw new Error('Type the member number to remove this checklist.');
            members.splice(index, 1);
        } else throw new Error('Unknown checklist operation.');
    }
    // A single conditional commit protects the complete board from concurrent lost updates.
    let committed: { ok: boolean; versionstamp?: string };
    try {
        committed = await storage.atomic().check({ key: BOARD_KEY, versionstamp: current.version }).set(BOARD_KEY, { schema: 1, members }).commit();
    } catch {
        throw new Error('Save was not confirmed. Reload the board and inspect it before trying again.');
    }
    if (!committed.ok) throw new Error('Someone changed the board while you saved. Reload and review the latest checklist.');
    // Reload also catches changes made by another administrator immediately after our commit.
    try { return await loadBoard(); }
    catch { throw new Error('The save committed, but the board could not be reloaded. Reload before making another change.'); }
}

Check your result

Restart the widget
Saved tasks, owner, and due date return.
Duplicate member number
The existing checklist stays unchanged.
Conflicting or unconfirmed save
Further edits wait for a reload and fresh review.
Continue with Publish and share an app