# Tutorial: a ticket dot (/docs/products/dash-dots/developers/tutorial)

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](/docs/developer-resources/cli/install) and Node.js 22.12 or newer. If `monime dot` is new to you, do the [quickstart](/docs/products/dash-dots/developers/quickstart) first.

1. **Create the dot**

   ```bash
   monime dot create salone-tickets --name "Salone Sounds Live" --template starter
   ```

   When `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:

   ```bash
   cd salone-tickets
   npm install
   monime dot create page book
   monime dot create page done
   ```

   `create page` adds each page to `dot.ts`. Then add your server to `network.hosts` in `dot.json`:

   ```json title="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`:

   ```bash title=".env"
   API_URL=https://api.salonesounds.sl
   ```

   If 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](/docs/products/dash-dots/developers/run-on-a-phone).

2. **List the shows**

   The first page's state holds the shows. The `book` page needs `SHOWS` later, so export it:

   ```ts title="pages/index/state.ts"
   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`:

   ```xml title="pages/index/view.dml"
   <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](/docs/products/dash-dots/developers/navigation#pass-data-with-launch-options):

   ```ts title="pages/index/controller.ts"
   import state from './state';

   export default definePage({
     initialState: state,
     actions: {
       choose(_state, event: DataEvent<{ id: string }>) {
         this.navigation.push('book', { show: event.data.id });
       },
     },
   });
   ```

3. **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:

   ```json title="dot.json"
   {
     "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:

   ```ts title="pages/index/state.ts"
   export default {
     shows: SHOWS,
     note: '',
   };
   ```

   Then add the note and a scan button to the view:

   ```xml title="pages/index/view.dml"
   <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:

   ```ts title="pages/index/controller.ts"
   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](/docs/products/dash-dots/developers/dash-apis#qr-codes).

4. **Choose tickets**

   Prices are whole amounts, in SLE:

   ```ts title="pages/book/state.ts"
   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,
   };
   ```

   ```xml title="pages/book/view.dml"
   <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:

   ```ts title="pages/book/controller.ts"
   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.

5. **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:

   ```ts title="pages/book/controller.ts"
   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](/docs/products/dash-dots/developers/navigation#share-links).

6. **Confirm the reservation**

   ```ts title="pages/done/state.ts"
   export default {
     reference: '',
     summary: '',
     vip: false,
     photoUrl: '',
     photoNote: '',
     uploading: false,
   };
   ```

   ```xml title="pages/done/view.dml"
   <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>
   ```

7. **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:

   ```ts title="pages/done/controller.ts"
   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.

8. **Test the booking page**

   `testPage` runs a page without Dash, with a fake answer for each Dash call. See [Testing](/docs/products/dash-dots/developers/testing).

   ```ts title="pages/book/controller.test.ts"
   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](/docs/products/dash-dots/developers/testing#test-taps-with-data).

   Install Vitest with `npm install --save-dev vitest`, then run `npx vitest run`.

9. **Release it**

   ```bash
   monime login
   monime dot register
   monime dot publish --subtitle "Tickets for Salone shows" --wait
   monime dot tester add @aminata_k
   ```

   `monime 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:

   ```bash
   monime dot release
   ```

   See [Publish and release](/docs/products/dash-dots/developers/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](/docs/products/dash-dots/developers/security#protect-your-server).
