Products · Build a dot
Tutorial: a ticket dot
Build Salone Sounds Live, a dot for reserving tickets to shows in Freetown, Bo and Kenema, then test it and release it.
You're going to build Salone Sounds Live, a dot for reserving tickets to shows in Freetown, Bo and Kenema. People can browse the shows, open one by scanning its poster, share it with a friend, and add a photo to a VIP pass.
It takes about 45 minutes. You need the Monime CLI and Node.js 22.12 or newer. If monime dot is new to you, do the quickstart first.
Create the dot
monime dot create salone-tickets --name "Salone Sounds Live" --template starterWhen create asks for a package name, give it a reverse domain you own. This tutorial uses sl.salonesounds.tickets. When it offers to install and start the dot, either answer is fine. If you say yes, leave it running and open a second terminal for the rest.
Add the other two pages:
cd salone-tickets
npm install
monime dot create page book
monime dot create page donecreate page adds each page to dot.ts. Then add your server to network.hosts in dot.json:
{
"$schema": "https://unpkg.com/@monimesl/dot-runtime/dot.schema.json",
"package": "sl.salonesounds.tickets",
"name": "Salone Sounds Live",
"version": "0.1.0",
"network": { "hosts": ["api.salonesounds.sl"] }
}Put your server's address in .env. The dot reads it as env.API_URL:
API_URL=https://api.salonesounds.slIf the dot isn't running yet, run monime dot dev in the salone-tickets folder and leave it running. Each step then shows up in the simulator as you save. Add --device to try it on your phone.
List the shows
The first page's state holds the shows. The book page needs SHOWS later, so export it:
export interface Show {
id: string;
name: string;
town: string;
date: string;
}
// In a real dot, you'd fetch the shows from your server in onLoad.
export const SHOWS: Show[] = [
{ id: 'freetown', name: 'Salone Sounds Live', town: 'Freetown', date: 'Sat 12 Dec' },
{ id: 'bo', name: 'Palm-wine evening', town: 'Bo', date: 'Sat 19 Dec' },
{ id: 'kenema', name: 'Gospel night', town: 'Kenema', date: 'Sun 27 Dec' },
];
export default {
shows: SHOWS,
};Each row's data-id reaches the tap handler as event.data.id:
<View>
<Text variant="title">Shows</Text>
<Text tone="muted">Reserve tickets, and pay at the door.</Text>
<List>
<For each="{{ shows }}" item="show" key="id">
<ListItem
icon="ticket"
title="{{ show.name }}"
subtitle="{{ show.town }} · {{ show.date }}"
data-id="{{ show.id }}"
onTap="choose"
/>
</For>
</List>
</View>choose opens the book page with the show's id as a launch option:
import state from './state';
export default definePage({
initialState: state,
actions: {
choose(_state, event: DataEvent<{ id: string }>) {
this.navigation.push('book', { show: event.data.id });
},
},
});Open a show from its poster
Every poster has a QR code with the show's address. For the Bo show, that's https://salonesounds.sl/shows/bo. Scanning needs the camera permission, and the VIP photo later needs photoLibrary. Add both now:
{
"permissions": {
"camera": "Scan a show's poster, or take a photo for your VIP pass",
"photoLibrary": "Choose a photo for your VIP pass"
}
}Add a note to the state. The page shows it when a scanned code isn't for one of the shows:
export default {
shows: SHOWS,
note: '',
};Then add the note and a scan button to the view:
<View>
<Text variant="title">Shows</Text>
<Text tone="muted">Reserve tickets, and pay at the door.</Text>
<List>
<For each="{{ shows }}" item="show" key="id">
<ListItem
icon="ticket"
title="{{ show.name }}"
subtitle="{{ show.town }} · {{ show.date }}"
data-id="{{ show.id }}"
onTap="choose"
/>
</For>
</List>
<If condition="{{ note }}">
<Notice tone="warning" title="Not a show poster">{{ note }}</Notice>
</If>
<Footer>
<Button variant="secondary" onClick="scanPoster">Scan a show's poster</Button>
</Footer>
</View>scanPoster reads the show's id off the end of the scanned address:
import { errorMessage, unlessCancelled } from '@monimesl/dot-runtime';
import state from './state';
const SHOW_ADDRESS = /^https:\/\/salonesounds\.sl\/shows\/([\w-]+)$/;
export default definePage({
initialState: state,
actions: {
choose(_state, event: DataEvent<{ id: string }>) {
this.navigation.push('book', { show: event.data.id });
},
async scanPoster(state) {
try {
const code = await unlessCancelled(this.dash.media.scanCode());
if (!code) {
return;
}
const id = SHOW_ADDRESS.exec(code.text)?.[1];
const show = state.shows.find((item) => item.id === id);
if (!show) {
this.setState({ note: "That code isn't for one of these shows." });
return;
}
this.setState({ note: '' });
this.navigation.push('book', { show: show.id });
} catch (error) {
this.setState({ note: errorMessage(error) });
}
},
},
});If the person closes the scanner, unlessCancelled gives undefined and the action quietly stops. In the simulator, you type the code's text instead. See QR codes.
Choose tickets
Prices are whole amounts, in SLE:
export type TicketType = 'regular' | 'vip';
export const PRICES: Record<TicketType, number> = { regular: 150, vip: 400 };
export default {
show: '',
showName: '',
ticket: 'regular' as TicketType,
count: 1,
phone: '',
total: 'SLE 150',
error: '',
sending: false,
};<View>
<Text variant="title">{{ showName }}</Text>
<View variant="card">
<RadioGroup label="Ticket" value="{{ ticket }}" onChange="setTicket">
<Radio value="regular" label="Regular · SLE 150" />
<Radio value="vip" label="VIP, near the stage · SLE 400" />
</RadioGroup>
<Stepper label="How many" value="{{ count }}" min="1" max="10" hint="At most 10 for each person" onChange="setCount" />
<Input label="Phone" type="phone" prefix="+232" placeholder="76 123456" value="{{ phone }}" onChange="setPhone" />
</View>
<View variant="card" gap="none">
<DetailRow label="Total" value="{{ total }}" weight="bold" />
</View>
<If condition="{{ error }}">
<Notice tone="danger" title="Not reserved">{{ error }}</Notice>
</If>
<Footer>
<Button onClick="reserve" loading="{{ sending }}" disabled="{{ !show }}">Reserve tickets</Button>
</Footer>
</View>The Stepper keeps the count between 1 and 10, and the button stays off until the page knows its show.
In the controller, onLoad looks up the show, and each field has a mutation that returns what changes:
import { errorMessage } from '@monimesl/dot-runtime';
import { SHOWS } from '../index/state';
import type { TicketType } from './state';
import state, { PRICES } from './state';
function total(count: number, ticket: TicketType) {
return `SLE ${(count * PRICES[ticket]).toLocaleString('en-US')}`;
}
export default definePage({
initialState: state,
hooks: {
onLoad(options) {
const show = SHOWS.find((item) => item.id === options.show);
if (!show) {
this.setState({ showName: 'Show not found', error: "This link is for a show that isn't on sale." });
return;
}
this.setState({ show: show.id, showName: `${show.name} · ${show.town}` });
},
},
mutations: {
setTicket: (state, event: ValueEvent<TicketType>) => ({
ticket: event.detail.value,
total: total(state.count, event.detail.value),
}),
setCount: (state, event: ValueEvent<number>) => ({
count: event.detail.value,
total: total(event.detail.value, state.ticket),
}),
setPhone: (_state, event: ValueEvent) => ({ phone: event.detail.value }),
},
actions: {
async reserve(state) {
const phone = state.phone.replace(/\D/g, '');
if (phone.length !== 8) {
this.setState({ error: 'Enter a phone number, such as 76 123456.' });
return;
}
this.setState({ sending: true, error: '' });
try {
const response = await this.dash.network.http.request<{ reference: string }>({
url: `${env.API_URL}/reservations`,
method: 'POST',
body: { show: state.show, ticket: state.ticket, count: state.count, phone: `+232${phone}` },
as: 'json',
});
if (!response.ok) {
throw new Error('The box office is busy. Try again in a moment.');
}
this.navigation.replace('done', {
reference: response.body.reference,
summary: `${state.count} × ${state.ticket === 'vip' ? 'VIP' : 'Regular'}, ${state.showName}`,
vip: state.ticket === 'vip' ? 'yes' : 'no',
});
} catch (error) {
this.setState({ error: errorMessage(error) });
} finally {
this.setState({ sending: false });
}
},
},
});reserve is an action because it waits for your server. replace swaps this page for the confirmation, so Back doesn't return to the sent form. VIP goes as 'yes' or 'no' because launch options are strings. Until your server is up, Reserve tickets shows an error.
Let friends share a show
The dot's menu has a Share item. Add a share line to the book page. Share then gives a link to this page, with its show option:
export default definePage({
initialState: state,
share: { options: ['show'] },
// hooks, mutations and actions stay as they are
});For the Bo show, that's https://dashit.to/d/k7Pq2/book?show=bo. Anyone can type a link, so onLoad checks the id instead of trusting it. The done page doesn't share, so a reference never ends up in a link. See Share links.
Confirm the reservation
export default {
reference: '',
summary: '',
vip: false,
photoUrl: '',
photoNote: '',
uploading: false,
};<View>
<EmptyState icon="success" title="Reserved" text="Show this reference at the door, and pay there." />
<View variant="card" gap="none">
<DetailRow label="Reference" value="{{ reference }}" weight="bold" />
<DetailRow label="Tickets" value="{{ summary }}" />
</View>
<If condition="{{ vip }}">
<View variant="card" gap="small">
<Text variant="subtitle">Your VIP pass</Text>
<If condition="{{ photoUrl }}">
<Image src="{{ photoUrl }}" alt="Your photo for the VIP pass" aspect="1:1" fit="cover" shape="rounded" />
<Text tone="muted">The door staff see this photo with your reference.</Text>
</If>
<If condition="{{ !photoUrl }}">
<Text tone="muted">Add a photo, so the door staff know you at the VIP entrance.</Text>
<View direction="row" gap="small" wrap>
<Button variant="secondary" data-source="camera" disabled="{{ uploading }}" onClick="addPhoto">Take a photo</Button>
<Button variant="secondary" data-source="library" disabled="{{ uploading }}" onClick="addPhoto">Choose a photo</Button>
</View>
<If condition="{{ uploading }}">
<Spinner />
</If>
</If>
<If condition="{{ photoNote }}">
<Text variant="caption" tone="danger">{{ photoNote }}</Text>
</If>
</View>
</If>
<Footer>
<Button variant="secondary" onClick="share">Share the reference</Button>
<Button onClick="home">Back to shows</Button>
</Footer>
</View>Add a photo to a VIP pass
Both photo buttons run addPhoto, and data-source says where the photo comes from. addPhoto gets it and uploads it to your server:
import { errorMessage, unlessCancelled } from '@monimesl/dot-runtime';
import state from './state';
export default definePage({
initialState: state,
hooks: {
onLoad(options) {
this.setState({
reference: options.reference ?? '',
summary: options.summary ?? '',
vip: options.vip === 'yes',
});
},
},
actions: {
async addPhoto(state, event: DataEvent<{ source: 'camera' | 'library' }>) {
try {
const photo = await unlessCancelled(this.dash.media.pickPhoto({ source: event.data.source }));
if (!photo) {
return;
}
this.setState({ uploading: true, photoNote: '' });
const response = await this.dash.network.http.upload({
url: `${env.API_URL}/reservations/${state.reference}/photo`,
files: [{ field: 'photo', file: photo }],
});
if (!response.ok) {
throw new Error("The photo didn't upload. Try again.");
}
this.setState({ photoUrl: photo.url });
} catch (error) {
this.setState({ photoNote: errorMessage(error) });
} finally {
this.setState({ uploading: false });
}
},
async share(state) {
await this.dash.system.share({ text: `Salone Sounds Live: ${state.summary}. Reference ${state.reference}` });
},
home() {
this.navigation.reset('index');
},
},
});As with the scanner, unlessCancelled gives undefined if the person closes the picker. In the simulator, you pick a picture from your computer.
Test the booking page
testPage runs a page without Dash, with a fake answer for each Dash call. See Testing.
import { testPage } from '@monimesl/dot-runtime/testing';
import { expect, it } from 'vitest';
import book from './controller';
it('reserves tickets and opens the confirmation', async () => {
const page = await testPage(book, {
options: { show: 'freetown' },
host: {
'dash.network.http.request': () => ({
ok: true,
status: 201,
statusText: 'Created',
headers: {},
body: JSON.stringify({ reference: 'SSL-4821' }),
}),
},
});
await page.tap('setTicket', { value: 'vip' });
await page.tap('setCount', { value: 2 });
await page.tap('setPhone', { value: '76 123456' });
expect(page.state.total).toBe('SLE 800');
await page.tap('reserve');
expect(page.navigations).toEqual([
{
action: 'replace',
page: 'done',
options: { reference: 'SSL-4821', summary: '2 × VIP, Salone Sounds Live · Freetown', vip: 'yes' },
},
]);
});
it("won't reserve from a link to a show that isn't on sale", async () => {
const page = await testPage(book, { options: { show: 'makeni' } });
expect(page.state).toMatchObject({ show: '', showName: 'Show not found' });
});A fake returns what Dash would send, so the response's body is a JSON string. tap sends a field's detail, here { value: 'vip' }.
You can fake the scanner and the photo picker the same way, with dash.media.scanCode and dash.media.pickPhoto. addPhoto reads event.data, so its test needs dispatch instead of tap. See Test taps with data.
Install Vitest with npm install --save-dev vitest, then run npx vitest run.
Release it
monime login
monime dot register
monime dot publish --subtitle "Tickets for Salone shows" --wait
monime dot tester add @aminata_kmonime login opens your browser, then asks which Space to work in. To change it later, run monime space use. register adds the dot to that Space, which you only do once. publish sends this version to Monime for checks. Once it passes, your testers get it, and tester add makes Aminata one of them.
When Aminata has tried it in her Dash, give it to everyone:
monime dot releaseSee Publish and release.
Your server
Your server does the real work. It needs two endpoints:
| Request | Answer |
|---|---|
POST /reservations with { show, ticket, count, phone } | 201 with { reference }, or an error status |
POST /reservations/{reference}/photo, a multipart form with the JPEG in its photo field | 204, or an error status |
Check every request. Work out the price on your server, limit how often one number can reserve, and take a photo only for a VIP reservation. See Security.