# apphaven example apps

## 1. Personal to-do list (static, no build)

Apphaven supplies the accounts and database for this example. In preview, mail to anyone other
than the owner's Apphaven account email address is captured in `read_emails`. Test the form's
sign-up, confirmation link and sign-in without `as` using "Testing sign-in in preview" in
`get_docs` topic `sdk`; `as: "alice"` already has a confirmed address and is for checking tasks
and access rules afterward.

`apphaven/app.yaml`
```yaml
app: { name: My Tasks }
models:
  Task:
    fields:
      title: { type: string, required: true, max_length: 200 }
      done: { type: boolean, default: false }
      due: date
      owner: { type: user, default: $user.id, readonly: true, required: true }
    access:
      read: owner == $user.id
      create: $user != null
      update: owner == $user.id
      delete: owner == $user.id
```

`index.html`
```html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>My Tasks</title>
  <link rel="stylesheet" href="/style.css" />
</head>
<body>
  <main id="root"></main>
  <script type="module" src="/app.js"></script>
</body>
</html>
```

`app.js`
```js
import { createClient } from "/_apphaven/sdk.js";
const app = createClient();
const root = document.getElementById("root");

app.auth.onChange((user) => (user ? showTasks(user) : showSignIn()));

function showSignIn(message = "") {
  root.innerHTML = `
    <h1>My Tasks</h1>
    <form id="f">
      <input name="email" type="email" placeholder="Email" required />
      <input name="password" type="password" placeholder="Password (8+ characters)" required minlength="8" />
      <button name="mode" value="in">Sign in</button>
      <button name="mode" value="up">Create account</button>
      <p class="message"></p>
    </form>`;
  root.querySelector(".message").textContent = message;
  const form = root.querySelector("#f");
  form.addEventListener("submit", async (e) => {
    e.preventDefault();
    const { email, password } = Object.fromEntries(new FormData(form));
    try {
      if (e.submitter.value === "up") {
        await app.auth.signUp({ email, password });   // no session yet: the address is confirmed first
        showSignIn("Check your email for a confirmation link, then sign in.");
      } else await app.auth.signIn({ email, password });
    } catch (err) {
      showSignIn(err.message);
    }
  });
}

async function showTasks(user) {
  const { items } = await app.db.Task.list({ sort: "done,-created_at" });
  root.innerHTML = `
    <h1>${escape(user.name)}'s tasks <button id="out">Sign out</button></h1>
    <form id="add"><input name="title" placeholder="New task" required /><button>Add</button></form>
    <ul>${items.map((t) => `<li><label><input type="checkbox" data-id="${t.id}" ${t.done ? "checked" : ""}/> ${escape(t.title)}</label></li>`).join("")}</ul>`;
  root.querySelector("#out").onclick = () => app.auth.signOut();
  root.querySelector("#add").onsubmit = async (e) => {
    e.preventDefault();
    await app.db.Task.create({ title: new FormData(e.target).get("title") });
    showTasks(user);
  };
  root.querySelectorAll("input[type=checkbox]").forEach((box) => {
    box.onchange = () => app.db.Task.update(box.dataset.id, { done: box.checked });
  });
}

const escape = (s) => String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
```

## 2. Vite + React project

`package.json`
```json
{
  "name": "recipes",
  "private": true,
  "type": "module",
  "scripts": { "build": "vite build" },
  "dependencies": { "@apphaven/client": "*", "react": "^19.0.0", "react-dom": "^19.0.0" },
  "devDependencies": { "@vitejs/plugin-react": "^5.0.0", "vite": "^7.0.0" }
}
```

`vite.config.js`
```js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({ plugins: [react()] });
```

`index.html` (project root) loads `/src/main.jsx`; in components:
```jsx
import { createClient } from "@apphaven/client";
export const app = createClient();

function useUser() {
  const [user, setUser] = useState(undefined);       // undefined = loading
  useEffect(() => app.auth.onChange(setUser), []);
  return user;
}
```

Tailwind: add `tailwindcss` and `@tailwindcss/vite` as devDependencies, `tailwindcss()` to the
Vite plugins, and `@import "tailwindcss";` in your CSS.

### Installable (on the user's phone)

Add `"vite-plugin-pwa": "^1.0.0"` to devDependencies and extend `vite.config.js`:

```js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { VitePWA } from "vite-plugin-pwa";
export default defineConfig({
  plugins: [
    react(),
    VitePWA({
      registerType: "autoUpdate",                  // a deploy reaches installed phones on their next open
      manifest: {
        name: "Kiln Club", short_name: "Kiln", description: "Bookings and firing schedule",
        start_url: "/", display: "standalone", background_color: "#ffffff", theme_color: "#1a1a1a",
        icons: [
          { src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
          { src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any maskable" },
        ],
      },
      workbox: {
        navigateFallback: "/index.html",
        navigateFallbackDenylist: [/^\/api/, /^\/_apphaven/],   // sign-in and data never go through the cache
      },
    }),
  ],
});
```

The icons live in `public/icons/` (served at `/icons/...`): square PNGs made from the user's logo
(ask for it through the Add files page) or a simple drawing of your own. The build emits
`manifest.webmanifest`, `sw.js` and `registerSW.js` into `dist/`; nothing else to do. Check it
with `view_page` on a phone, then tell the user to install it from the live address: iPhone,
Safari, Share, "Add to Home Screen"; Android, "Install app" in the browser's menu.

### Plain pages next to the app

`public/about.html` and `public/prices.html` are served as-is at `/about` and `/prices` (fast,
readable by search engines) while the Vite app answers `/`. Link between them with plain paths.

## 3. Public guestbook with moderation

```yaml
auth:
  roles: [user, admin]
models:
  Entry:
    fields:
      name: { type: string, required: true, max_length: 60 }
      message: { type: text, required: true, max_length: 1000 }
      approved: { type: boolean, default: false, readonly: true }
    access:
      create: true                        # anyone, even signed out
      read: approved == true || $user.role == 'admin'
      delete: $user.role == 'admin'
operations:
  approveEntry:
    model: Entry
    action: update
    params: { id: string }
    where: id == $params.id
    set: { approved: true }
    access: $user.role == 'admin'
```

After the owner signs up in the app, make them an admin with `manage_users` (`action: set_role`).
