Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ Complete, working example apps for [FlyCommerce](https://flycommerce.com). Each

## Examples

| Example | What it shows | Status |
| --- | --- | --- |
| [`order-export`](order-export) | Download a store's orders as a CSV file. The three things every app does: install, know who's asking (session tokens), and read the store, a page at a time. Then a column choice and a Settings page, saved per store. Pages built with `@flycommerce/ui`, no database. Comes with a four-step tutorial. **Start here.** | Preview: installs once the SDK is on npm |
| Example | What it shows | Status |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------- |
| [`order-export`](order-export) | Download a store's orders as a CSV file. The three things every app does: install, know who's asking (session tokens), and read the store, a page at a time. Then a column choice and a Settings page, saved per store, and a welcome message on the storefront. Pages built with `@flycommerce/ui`, no database. Comes with a five-step tutorial. **Start here.** | Preview: SDK from npm `@next` |

Each example is a folder you can copy out and run on its own.

Expand Down
14 changes: 11 additions & 3 deletions order-export/AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Order Export: notes for coding agents

A FlyCommerce example app. A page inside the merchant's dashboard downloads the orders placed in a period as a CSV file. The merchant chooses the columns, and a Settings page keeps the file name format and the default columns per store. Plain TypeScript on Node 22+ (ESM), `node:http`, React 19 and `@flycommerce/ui`. No framework, no database (settings live in one JSON file), no webhooks.
A FlyCommerce example app. A page inside the merchant's dashboard downloads the orders placed in a period as a CSV file. The merchant chooses the columns, and a Settings page keeps the file name format and the default columns per store. Plain TypeScript on Node 22+ (ESM), `node:http`, React 19 and `@flycommerce/ui`. No framework, no database (settings live in one JSON file), no webhooks. One storefront script, a welcome message, in plain JavaScript.

## Files

Expand All @@ -20,9 +20,12 @@ A FlyCommerce example app. A page inside the merchant's dashboard downloads the
| `src/pages/ColumnChoice.tsx` | The column checkboxes, drawn from `COLUMNS` |
| `src/pages/api.ts` | `useApi()`: JSON calls to the app's server that throw the server's message |
| `src/pages/fileName.ts` | Fills in `{store}`, `{from}`, `{to}` and replaces unsafe characters with `-`; `dates.ts` has the day maths |
| `src/dev.ts` | `npm run dev`: emulator with sample orders, the app, and the example dashboard on ports 4000-4003 |
| `src/storefront.ts` | `GET /storefront/welcome.js`: serves `storefront/welcome.js` as JavaScript |
| `storefront/welcome.js` | The storefront script: a welcome message in a shadow root, follows `flycommerce:page`, closes with × |
| `src/dev.ts` | `npm run dev`: emulator with sample orders, the app, and the example dashboard and storefront on 4000-4003 |
| `test/export.test.ts` | Against the emulator: install, session check, paging, CSV safety, a bad period, columns, settings per store |
| `app-config.json` | The app's dashboard pages, uploaded to the developer portal on each release |
| `test/storefront.test.ts` | The script is declared on the app's host, served as JavaScript, and run by the emulator's storefront |
| `app-config.json` | The app's dashboard pages and storefront script, uploaded to the developer portal on each release |

## Commands

Expand All @@ -45,6 +48,7 @@ The guide is https://developers.flycommerce.com/docs/apps; the API reference is
- **Roles:** only `owner` and `admin` open apps today. Treat any other role as the least privileged.
- **Secrets** come from the environment; store credentials are sealed at rest. Never log tokens, secrets or customer data.
- **Pages** follow the `@flycommerce/ui` guide (https://ui.flycommerce.com): `PageHeader` outside the dashboard, `Field` around inputs, tokens only, and `className` for layout, never to restyle.
- **Storefront scripts** run on the store's own pages: keep to your own element in a shadow root, never read the page's cookies, storage or forms, stay small, and never assume load order. They don't run on checkout, account or sign-in pages.
- Keep every file small, one concept each, comments only for a non-obvious why.

## Recipes
Expand All @@ -61,6 +65,10 @@ Add the field to `Settings` and `DEFAULT_SETTINGS` in `src/data.ts`, check it in

Add the store's filter to the `paginate()` query, e.g. `'filters[status]': 'completed'`, and a control for it on the page that sends it in the URL. Read it from `url.searchParams` in `exportOrders`, and accept only the values you expect.

### Change the storefront message

Edit `GREETINGS` in `storefront/welcome.js`; the keys are `pageType` values (`home`, `product`, `category`, …) and `default` covers the rest. To add a second script, put the file in `storefront/`, serve it from a route in `src/storefront.ts`, and add it to `storefront.scripts` in `app-config.json` (at most 3, on `appUrl`'s host).

### Export something else

Copy `src/export.ts` to, say, `src/products.ts`, read `/api/v1/products` with the fields you need, add `'GET /api/products': exportProducts` to the route table in `src/server.ts`, and request `catalog.read` in the developer portal. Check the API reference for which filters and sorts that list accepts.
11 changes: 7 additions & 4 deletions order-export/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ It's the smallest useful FlyCommerce app, and the place to start. It shows the t
2. **Know who's asking:** the page calls the app's server with a session token, and the server verifies it before trusting the store it names (`src/session.ts`).
3. **Read the store:** the server reads the orders as that user, a page at a time, and turns them into CSV (`src/export.ts`, `src/csv.ts`).

Then it lets the merchant choose: the columns, defined once in `src/columns.ts`, and per-store settings kept in one JSON file (`src/settings.ts`, `src/data.ts`).
Then it lets the merchant choose: the columns, defined once in `src/columns.ts`, and per-store settings kept in one JSON file (`src/settings.ts`, `src/data.ts`). And it greets the store's shoppers with a storefront script: a small welcome message with a close button (`storefront/welcome.js`).

No webhooks, no database, no background jobs. When you need those, the [Building apps](https://developers.flycommerce.com/docs/apps) guide covers them.

Expand Down Expand Up @@ -43,15 +43,18 @@ npm test # the export, end to end against the emulator
| `src/pages/Settings.tsx` | The settings page: a file name format with a live preview, and the default columns |
| `src/pages/ColumnChoice.tsx` | The column checkboxes both pages share |
| `src/pages/fileName.ts` | Fills in the file name format with the store and the merchant's local dates |
| `src/dev.ts` | `npm run dev`: the emulator, sample orders, the app and the example dashboard |
| `src/storefront.ts` | `GET /storefront/welcome.js`: serves the storefront script |
| `storefront/welcome.js` | The welcome message shown on the store's pages, with a close button |
| `src/dev.ts` | `npm run dev`: the emulator, sample orders, the app, the example dashboard and storefront |
| `test/export.test.ts` | Install, the session check, paging, CSV safety, the chosen columns, and settings kept per store |
| `test/storefront.test.ts` | The storefront script: declared on the app's host, served as JavaScript, run by the example storefront |

[TUTORIAL.md](TUTORIAL.md) builds the app in four steps, each tagged in git (`order-export-step-1` to `-4`).
[TUTORIAL.md](TUTORIAL.md) builds the app in five steps, each tagged in git (`order-export-step-1` to `-5`).

## Run it on a real store

1. In the [developer portal](https://developers.flycommerce.com), create an app, request the `orders.read` permission, and set the install URL to `https://<your-app>/auth/callback`.
2. Create a version, put your App ID, version and URL in `app-config.json`, and upload it to that version.
2. Create a version, put your App ID, version and URL in `app-config.json` (the URL goes in `appUrl` and in the script's `src`), and upload it to that version.
3. Copy `.env.example` to `.env` and fill it in. Then `npm run build && npm start`, somewhere the dashboard can reach over HTTPS.
4. Install the app on a store you own: it's under **Apps → Your apps** until it's published.

Expand Down
43 changes: 40 additions & 3 deletions order-export/TUTORIAL.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Build your first FlyCommerce app

In four steps you'll build **Order Export**: a page inside the merchant's dashboard that downloads the orders placed in a period as a CSV file. Along the way you'll use the three things every FlyCommerce app does: install on a store, know who's asking, and read the store's data. Then you'll let the merchant make the file their own.
In five steps you'll build **Order Export**: a page inside the merchant's dashboard that downloads the orders placed in a period as a CSV file. Along the way you'll use the three things every FlyCommerce app does: install on a store, know who's asking, and read the store's data. Then you'll let the merchant make the file their own, and greet the store's shoppers with a small storefront script.

Each step is one commit, tagged `order-export-step-1` to `order-export-step-4`. To see the code after a step, run `git checkout order-export-step-2`.
Each step is one commit, tagged `order-export-step-1` to `order-export-step-5`. To see the code after a step, run `git checkout order-export-step-2`.

> **Preview.** The SDK packages arrive on npm with FlyCommerce's app platform launch; until then `npm install` can't fetch them.

Expand Down Expand Up @@ -214,9 +214,46 @@ Add `{ "slug": "settings", "label": "Settings", "path": "/settings" }` to `app-c

Run `npm run dev` again. On **Settings**, change the format and watch the preview, uncheck a column or two, and **Save**. Back on **Export orders**, those columns are checked, and the file you download is named after your format.

## 5. Greet shoppers on the storefront

**Goal:** a small welcome message, the kind a live-chat app shows, appears on the store's catalogue pages, and the shopper can close it.

Everything so far runs in the merchant's dashboard. A storefront script runs on the store's own pages instead, for every shopper. Declare it in `app-config.json`, next to `dashboard`:

```json
"storefront": {
"scripts": [{ "handle": "welcome", "src": "http://localhost:4000/storefront/welcome.js", "load": "idle" }]
}
```

- `src` is on the same host as `appUrl`; the portal refuses any other.
- `idle` loads it once the browser has nothing else to do, so it never slows the store down.
- Declaring a script adds the `storefront.scripts` permission. The merchant grants it at install and can switch scripts off later.

`storefront/welcome.js` is plain JavaScript, served as it is by `GET /storefront/welcome.js` in `src/storefront.ts`. It's the store's page, not yours, so the script keeps to its own corner: one element of its own, styled inside a shadow root, so the store's CSS can't reach in and its own can't leak out.

```js
const host = document.createElement('div');
host.id = 'order-export-welcome';
const root = host.attachShadow({ mode: 'open' });
```

The storefront sets `window.FlyCommerce` before the script loads, and fires `flycommerce:page` each time the shopper moves to another page without a reload. The script reads `pageType` from both to change its greeting on a product page:

```js
text.textContent = greeting(window.FlyCommerce?.pageType);
window.addEventListener('flycommerce:page', follow);
```

The × button removes the element and stops listening. The storefront keeps the script across page changes, so a closed message stays closed while the shopper browses.

The script never reads the page's cookies, storage or forms, and it doesn't load anything heavy: it's on every catalogue page of every store that installs the app.

Run `npm run dev` again and open the **Storefront** address it prints (`http://127.0.0.1:4003/storefront`). The message appears in the corner; click **Green tea** and it asks about the product; close it and switch pages, and it stays closed.

## Test it

`test/export.test.ts` runs the whole app against the emulator: the page is framed, the install keeps a credential, a request without this app's session token is refused, more than one page of orders comes back in order and is read as the user, awkward names stay text, and a backwards period is refused. Then the choices: only the chosen columns are written, in the list's order; unknown columns and unsafe file name formats are refused; and one store never sees another's settings.
`test/export.test.ts` runs the whole app against the emulator: the page is framed, the install keeps a credential, a request without this app's session token is refused, more than one page of orders comes back in order and is read as the user, awkward names stay text, and a backwards period is refused. Then the choices: only the chosen columns are written, in the list's order; unknown columns and unsafe file name formats are refused; and one store never sees another's settings. `test/storefront.test.ts` checks the script: declared on the app's own host, served as JavaScript, and run by the emulator's example storefront.

```bash
npm test
Expand Down
3 changes: 3 additions & 0 deletions order-export/app-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,8 @@
{ "slug": "export", "label": "Export orders", "path": "/export" },
{ "slug": "settings", "label": "Settings", "path": "/settings" }
]
},
"storefront": {
"scripts": [{ "handle": "welcome", "src": "http://localhost:4000/storefront/welcome.js", "load": "idle" }]
}
}
6 changes: 3 additions & 3 deletions order-export/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@
"test": "vite build && node --import tsx --test test/*.test.ts"
},
"dependencies": {
"@flycommerce/app-bridge": "^0.1.0",
"@flycommerce/app-server": "^0.1.0",
"@flycommerce/app-bridge": "^0.1.0-next.1",
"@flycommerce/app-server": "^0.1.0-next.1",
"@flycommerce/ui": "^0.3.1",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@flycommerce/app-emulator": "^0.1.0",
"@flycommerce/app-emulator": "^0.1.0-next.1",
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^22.20.5",
"@types/react": "^19.2.0",
Expand Down
4 changes: 3 additions & 1 deletion order-export/src/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const CREDENTIALS_FILE = 'data/dev-credentials.json';
const DATA_FILE = 'data/dev-order-export.json';
const DAY = 24 * 60 * 60 * 1000;
const appUrl = `http://localhost:${PORTS.app}`;
const { appId, dashboard } = loadAppConfig('app-config.json');
const { appId, dashboard, storefront } = loadAppConfig('app-config.json');

// The emulator keeps everything in memory, so the app starts from nothing too.
fs.rmSync(CREDENTIALS_FILE, { force: true });
Expand Down Expand Up @@ -57,13 +57,15 @@ const dashboardServer = await ExampleDashboard.start({
appUrl,
store: STORE,
pages: dashboard.pages,
scripts: storefront?.scripts,
port: PORTS.dashboard,
});

console.log(`
Order Export is running for ${STORE}.

Dashboard ${dashboardServer.url}/apps/export
Storefront ${dashboardServer.url}/storefront
`);

process.on('SIGINT', async () => {
Expand Down
2 changes: 2 additions & 0 deletions order-export/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { Data } from './data.js';
import { exportOrders } from './export.js';
import { install } from './install.js';
import { saveSettings, showSettings } from './settings.js';
import { welcomeScript } from './storefront.js';

export interface App {
config: AppServerConfig;
Expand All @@ -35,6 +36,7 @@ const routes: Record<string, Route> = {
'GET /api/export': exportOrders,
'GET /api/settings': showSettings,
'PUT /api/settings': saveSettings,
'GET /storefront/welcome.js': welcomeScript,
};

export function createApp(env: NodeJS.ProcessEnv = process.env): App {
Expand Down
13 changes: 13 additions & 0 deletions order-export/src/storefront.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import fs from 'node:fs/promises';
import type { Route } from './server.js';

export const welcomeScript: Route = async (_app, _req, res) => {
const script = await fs.readFile('storefront/welcome.js');

res.writeHead(200, {
'Content-Type': 'text/javascript; charset=utf-8',
'Cache-Control': 'public, max-age=300',
'X-Content-Type-Options': 'nosniff',
});
res.end(script);
};
79 changes: 79 additions & 0 deletions order-export/storefront/welcome.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Runs on the store's own pages, so it touches nothing of theirs: its own element, styled inside a shadow root.
(() => {
const ID = 'order-export-welcome';
if (document.getElementById(ID)) return;

const GREETINGS = {
product: 'Hi! Any questions about this product?',
default: 'Hi! Are you looking for something?',
};
const greeting = (pageType) => GREETINGS[pageType] ?? GREETINGS.default;

const host = document.createElement('div');
host.id = ID;
const root = host.attachShadow({ mode: 'open' });

root.innerHTML = `
<style>
:host { all: initial; }
aside {
position: fixed;
bottom: 16px;
inset-inline-end: 16px;
z-index: 2147483000;
display: flex;
align-items: flex-start;
gap: 8px;
max-width: min(300px, calc(100vw - 32px));
padding: 12px 8px 12px 14px;
border-radius: 14px;
background: #ffffff;
color: #1f2328;
box-shadow: 0 6px 24px rgb(0 0 0 / 0.16);
font: 14px/1.4 system-ui, -apple-system, 'Segoe UI', sans-serif;
animation: rise 240ms ease-out;
}
p { margin: 0; flex: 1; }
strong { display: block; margin-bottom: 2px; }
button {
flex: none;
width: 28px;
height: 28px;
border: 0;
border-radius: 8px;
background: transparent;
color: inherit;
font: 20px/1 system-ui, sans-serif;
cursor: pointer;
}
button:hover { background: rgb(0 0 0 / 0.06); }
button:focus-visible { outline: 2px solid #2563eb; outline-offset: 1px; }
@keyframes rise { from { opacity: 0; transform: translateY(8px); } }
@media (prefers-reduced-motion: reduce) { aside { animation: none; } }
@media (prefers-color-scheme: dark) {
aside { background: #22262b; color: #f2f4f7; }
button:hover { background: rgb(255 255 255 / 0.1); }
}
</style>
<aside aria-label="Welcome message" aria-live="polite">
<p><strong>Welcome 👋</strong><span></span></p>
<button type="button" aria-label="Close">×</button>
</aside>
`;

const text = root.querySelector('span');
text.textContent = greeting(window.FlyCommerce?.pageType);

const follow = (event) => {
text.textContent = greeting(event.detail.pageType);
};
window.addEventListener('flycommerce:page', follow);

// Closed stays closed while the shopper browses: the storefront keeps this script across page changes.
root.querySelector('button').addEventListener('click', () => {
host.remove();
window.removeEventListener('flycommerce:page', follow);
});

document.body.append(host);
})();
Loading
Loading