# Capstone: Build Meera's Kitchen

Course: [Vue.js](https://www.devtutorials.ai/learn/vue) · Section: Capstone Project
Source: https://www.devtutorials.ai/learn/vue/project/capstone-build-meeras-kitchen

> Putting the whole course together in one app: a folder plan, a typed API service, a Pinia cart store, routed pages with loading and error states, a validated checkout form that posts an order, and tests for the store and a component.

## The Project

- You have learned each part of Vue one lesson at a time. Now you build one app with all of them.
- **Meera's Kitchen** lets a customer see the menu, add dishes to a cart, pick a table and place an order.
- It uses components, props and events, Vue Router, Pinia, HTTP requests, form validation and tests.
- Each step below adds one part. The first lab is the finished app, so you can see where you are going.

**Simple lesson:** small, clear parts, each with one job, make a big app easy to change.

### A Real-World Example

- Click **Add** on a few dishes, open **Cart**, pick a table and place the order.
- Watch the Console: every request to the pretend API is listed.

defineProps<{ dish: Dish }>();
const emit = defineEmits<{ add: [dish: Dish] }>();
</script>

<template>
    <li class="dish">
      <span>{{ dish.veg ? "🟢" : "🔴" }} {{ dish.name }} ₹{{ dish.price }}</span>
      <button @click="emit('add', dish)">Add</button>
    </li>
</template>
`,
    "views/MenuView.vue": `<script setup lang="ts">

const cart = useCartStore();
const dishes = ref<Dish[]>([]);
const loading = ref<boolean>(true);
const error = ref<string>("");

onMounted(async () => {
    try {
      dishes.value = await getDishes();
    } catch {
      error.value = "Could not load the menu";
    } finally {
      loading.value = false;
    }
});
</script>

<template>
    <h3>Menu</h3>
    <p v-if="loading">Loading…</p>
    <p v-else-if="error">{{ error }}</p>
    <ul v-else>
    </ul>
</template>
`,
    "views/CartView.vue": `<script setup lang="ts">

const cart = useCartStore();
const table = ref<number | null>(null);
const sending = ref<boolean>(false);
const message = ref<string>("");

const problem = computed<string>(() => {
    if (cart.count === 0) return "Your cart is empty";
    if (table.value === null || table.value < 1 || table.value > 12) return "Pick a table from 1 to 12";
    return "";
});

async function submit(): Promise<void> {
    if (problem.value || table.value === null) return;
    sending.value = true;
    try {
      const id = await placeOrder({
        table: table.value,
        items: cart.lines.map((l) => \`\${l.qty} × \${l.dish.name}\`),
        total: cart.total,
      });
      message.value = \`Order #\${id} placed for table \${table.value}!\`;
      cart.clear();
    } catch {
      message.value = "Sorry, the order failed. Try again.";
    } finally {
      sending.value = false;
    }
}
</script>

<template>
    <h3>Cart</h3>
    <ul>
      <li v-for="l in cart.lines" :key="l.dish.id">{{ l.qty }} × {{ l.dish.name }}</li>
    </ul>
    <p>Total: ₹{{ cart.total }}</p>
    <form @submit.prevent="submit">
      <input v-model.number="table" type="number" placeholder="Table (1–12)" />
      <button :disabled="!!problem || sending">{{ sending ? "Sending…" : "Place order" }}</button>
    </form>
    <p><small>{{ problem }}</small></p>
    <p>{{ message }}</p>
</template>
`,
    "App.vue": `<script setup lang="ts">

const cart = useCartStore();
</script>

<template>
    <nav>
    </nav>
</template>
`,
  }}
  jsFiles={{
    "components/DishCard.vue": `<script setup>
defineProps({ dish: { type: Object, required: true } });
const emit = defineEmits(["add"]);
</script>

<template>
    <li class="dish">
      <span>{{ dish.veg ? "🟢" : "🔴" }} {{ dish.name }} ₹{{ dish.price }}</span>
      <button @click="emit('add', dish)">Add</button>
    </li>
</template>
`,
  }}
/>

In a real project, start with `npm create vue@latest`, choose TypeScript, Router, Pinia and Vitest, then add `axios`. The files below go in `src/`. This lab runs the same files in the preview, with the pretend API at `api.meeraskitchen.dev`.

### What You Will Learn

1. How to plan the folders of a real app.
2. How to keep all API calls in one service file.
3. How to share a cart with a Pinia store.
4. How to show loading and error states.
5. How to validate a form and post an order.
6. How to test the store and a component.
7. What to check before you ship.

## Step 1: Plan the Folders

<div className="tscroll">

| File in `src/` | Its one job |
|---|---|
| `main.ts` | Creates the app, adds Pinia and the router |
| `router.ts` | The list of pages |
| `api/kitchen.ts` | Every HTTP call, with types |
| `stores/cart.ts` | The cart, shared by all pages |
| `components/DishCard.vue` | Small, reusable pieces |
| `views/MenuView.vue`, `views/CartView.vue` | One file per page |
| `tests/cart.spec.ts` | Tests, run with `npx vitest` |

</div>

- **views/** hold pages. **components/** hold pieces used inside pages.
- **api/** is the only place that knows the server's address. Pages call functions, not URLs.
- **stores/** hold data that more than one page needs.
- Each file has one job. When something breaks, you know where to look.
- This follows the layout from **Errors, Security and Deployment**, kept flat for a small app: one `router.ts` instead of a `router/` folder.

## Step 2: The Service and Loading States

- `api/kitchen.ts` wraps Axios. It returns plain typed data, not responses.
- A page tracks three states: **loading**, **error** and **data**. Show exactly one.
- Try it below: the menu loads slowly, then the second button asks a broken address.

const dishes = ref<Dish[]>([]);
const loading = ref<boolean>(false);
const error = ref<string>("");

async function load(path: string): Promise<void> {
    loading.value = true;
    error.value = "";
    dishes.value = [];
    try {
      dishes.value = await getDishes(path);
    } catch {
      error.value = "Could not load the menu";
    } finally {
      loading.value = false;
    }
}

load("/dishes?delay=800");
</script>

<template>
    <button @click="load('/dishes?delay=800')">Load menu</button>
    <button @click="load('/error')">Load from a broken server</button>
    <p v-if="loading">Loading…</p>
    <p v-else-if="error" style="color: crimson">{{ error }}</p>
    <ul v-else>
      <li v-for="d in dishes" :key="d.id">{{ d.name }} ₹{{ d.price }}</li>
    </ul>
</template>
`,
    "main.ts": `import { createApp } from "vue";

createApp(App).mount("#app");
`,
  }}
/>

## Step 3: The Cart Store

- The cart lives in Pinia, so the menu page and the cart page see the same data.
- Adding a dish twice raises its `qty` instead of adding a second line.
- `count` and `total` are computed, so they are always right.

Keep the store's rules (like "same dish raises qty") inside the store, not in the pages. Then every page gets them for free, and one test checks them.

const cart = useCartStore();
</script>

<template>
    <button @click="cart.add('Masala Dosa', 90)">Add Dosa</button>
    <button @click="cart.add('Mango Lassi', 70)">Add Lassi</button>
</template>
`,
    "CartSummary.vue": `<script setup lang="ts">

const cart = useCartStore();
</script>

<template>
    <p v-for="l in cart.lines" :key="l.name">{{ l.qty }} × {{ l.name }}</p>
    <p><b>{{ cart.count }} items, ₹{{ cart.total }}</b></p>
</template>
`,
    "main.ts": `import { createApp } from "vue";

createApp(App).use(createPinia()).mount("#app");
`,
    "App.vue": `<script setup lang="ts">
</script>

<template>
</template>
`,
  }}
/>

## Step 4: Checkout With Validation

- A `computed` called `problem` explains what is wrong, or is empty when all is well.
- The button is disabled while there is a problem or while sending, so the order cannot be sent twice.
- After a good reply, show the order number and clear the cart.

const items = ref<string[]>(["2 × Masala Dosa"]);
const table = ref<number | null>(null);
const sending = ref<boolean>(false);
const message = ref<string>("");

const problem = computed<string>(() => {
    if (items.value.length === 0) return "Your cart is empty";
    if (table.value === null || table.value < 1 || table.value > 12) return "Pick a table from 1 to 12";
    return "";
});

async function submit(): Promise<void> {
    if (problem.value) return;
    sending.value = true;
    try {
      const res = await axios.post("https://api.meeraskitchen.dev/orders", { table: table.value, items: items.value, total: 180 });
      message.value = \`Order #\${res.data.id} placed for table \${table.value}!\`;
      items.value = [];
    } finally {
      sending.value = false;
    }
}
</script>

<template>
    <p>Cart: {{ items.join(", ") || "empty" }}</p>
    <form @submit.prevent="submit">
      <input v-model.number="table" type="number" placeholder="Table (1–12)" />
      <button :disabled="!!problem || sending">{{ sending ? "Sending…" : "Place order" }}</button>
    </form>
    <p><small>{{ problem }}</small></p>
    <p>{{ message }}</p>
</template>
`,
    "main.ts": `import { createApp } from "vue";

createApp(App).mount("#app");
`,
  }}
/>

The browser check is for the user's comfort. The server must check the same rules again, because anyone can send a request without using your form.

## Step 5: Test It

- Test the store's rules and the components' behaviour, as in **Testing Vue Apps**.
- `setActivePinia(createPinia())` gives each test a fresh store.

defineProps<{ name: string; price: number }>();
const emit = defineEmits<{ add: [] }>();
</script>

<template>
    <p>{{ name }} ₹{{ price }} <button @click="emit('add')">Add</button></p>
</template>
`,
    "kitchen.spec.ts": `import { describe, it, expect, beforeEach } from "vitest";

describe("cart store", () => {
    beforeEach(() => setActivePinia(createPinia()));

    it("adds up the total", () => {
      const cart = useCartStore();
      cart.add("Masala Dosa", 90);
      cart.add("Mango Lassi", 70);
      expect(cart.total).toBe(160);
    });

    it("raises qty for the same dish", () => {
      const cart = useCartStore();
      cart.add("Masala Dosa", 90);
      cart.add("Masala Dosa", 90);
      expect(cart.lines.length).toBe(1);
      expect(cart.lines[0].qty).toBe(2);
    });
});

describe("DishCard", () => {
    it("emits add when clicked", async () => {
      const wrapper = mount(DishCard, { props: { name: "Dosa", price: 90 } });
      await wrapper.find("button").trigger("click");
      expect(wrapper.emitted("add")).toHaveLength(1);
    });
});
`,
    "main.ts": `import { run } from "vitest";

run();
`,
  }}
  jsFiles={{
    "DishCard.vue": `<script setup>
defineProps({ name: String, price: Number });
const emit = defineEmits(["add"]);
</script>

<template>
    <p>{{ name }} ₹{{ price }} <button @click="emit('add')">Add</button></p>
</template>
`,
  }}
/>

## Step 6: Before You Ship

- Run `npx vue-tsc --noEmit` and `npx vitest run`. Both must pass.
- Run `npm run build`, then `npm run preview` and click through every page.
- Check the browser Console for warnings, and Lighthouse for speed and accessibility, as in **Performance**.
- Set `VITE_API_URL` for production, and add the SPA fallback on your host, as in **Errors, Security and Deployment**.
- Add an error handler (`app.config.errorHandler`) so crashes are reported.

**Simple lesson:** plan, build in small steps, test, then ship with checks.

## TL;DR

- **views/** = one file per page.
- **components/** = small pieces used inside pages.
- **api/** = the only place with URLs. Returns typed data.
- **stores/** = data shared by several pages, with Pinia.
- **Three states** = loading, error, data. Show exactly one.
- **`problem` computed** = explains what is wrong with the form, or is empty.
- **Disable while sending** = stops double orders.
- **Tests** = store rules and component behaviour, with a fresh Pinia per test.
- **Before shipping** = vue-tsc, vitest, build, preview, Console check.

## Common mistakes

<div className="tscroll">

| Mistake | Wrong idea | Correct idea | What goes wrong |
|---|---|---|---|
| URLs scattered in every page | It is quicker | One service file | Changing the server means editing everything |
| Only a data state | The API is always fast | Show loading and error too | Blank or frozen pages |
| Cart kept in one page's refs | Pages can share refs | Use a Pinia store | The cart empties on navigation |
| A duplicate line for each click | Push is enough | Raise `qty` for the same dish | A messy cart and wrong counts |
| Enabled button while sending | Users click once | Disable while sending | Double orders |
| Checking rules only in the browser | The form is the only way in | The server checks too | Bad data from other clients |
| Skipping tests before shipping | It worked on my machine | Run vue-tsc and vitest | Bugs reach customers |

</div>

## Check Yourself

## Exercises

## What to Remember

- Give each folder and file one job: views, components, api, stores, tests.
- Keep every URL in one service file that returns typed data.
- Handle loading, error and data on every page that fetches.
- Share the cart with Pinia, validate with a computed, and disable while sending.
- Run vue-tsc, vitest and a preview build before you ship.

Next: **Vue Cheat Sheet**
