Product pages with a routerルーターで商品ページを作る

The shop gets more than one page: the catalog at / and a page for every product at /products/2. <wcs-router> picks the page from the URL, the URL's parts become state, links stay plain links, and Back and Forward work.

ショップに複数のページができます。商品一覧は/に、商品ごとのページは/products/2に。<wcs-router>がURLからページを選び、URLの部分がstateになり、リンクは素のリンクのまま、「戻る」と「進む」も働きます。

  1. 13
  2. 14
  3. 15
  4. 16

From here on: a local serverここから先: ローカルサーバー

Part 3 needs Node.js. Until now you could open index.html by double-clicking it; an app with pages needs real paths, and a file has none (its path is /C:/…/index.html), so the router takes it for a page it doesn't have — silently. In the folder of your index.html, run:

Part 3にはNode.jsが必要です。これまではindex.htmlをダブルクリックで開けましたが、ページのあるアプリには本物のパスが要ります。ファイルにはそれが無く(パスは/C:/…/index.html)、ルーターはそれを知らないページとして扱います。しかも何も言いません。index.htmlのあるフォルダで、次を実行します。

terminal
$ npx serve -s .

and open the address it prints (http://localhost:3000). -s makes it a server for a single-page app: a path it has no file for — /products/2 — gets index.html, so reloading a product page brings the page back and the router shows the product again.

表示されたアドレス(http://localhost:3000)を開きます。-sは単一ページアプリ向けのサーバーにする指定で、ファイルの無いパス(/products/2)にもindex.htmlを返します。だから商品のページを再読み込みしても、ページが戻り、ルーターがまたその商品を表示します。

The resultできあがり

Click a product's name: its page opens, and the URL shows …/products/3. Add it to the cart there, press the browser's Back, and you are on the catalog again — the cart never left. This demo lives in a folder of this site, which has no single-page fallback: a product page opened in a new tab and reloaded shows the site's 404. Step 16 publishes the shop with one.

商品の名前をクリックすると、その商品のページが開き、URLは…/products/3になります。そこでカートに入れてブラウザの「戻る」を押すと、商品一覧に戻ります。カートはずっとそのままです。このデモはこのサイトのフォルダの中にあり、このサイトには単一ページアプリ向けのfallbackがありません。新しいタブで開いた商品ページを再読み込みすると、サイトの404になります。fallback付きで公開する方法はステップ16で扱います。

Live — this step's pageライブ — このステップのページOpen in a new tab ↗新しいタブで開く ↗

Change the codeコードを変える

Start from your page of step 11. In <head>, say where the app starts, and load the router:

ステップ11のページから始めます。<head>で、アプリがどこから始まるかを示し、ルーターを読み込みます。

index.html — <head>new lines追加
<title>Little Roastery</title>
<base href="/">
<!-- … -->
<script type="module" src="https://esm.run/@wcstack/storage@4/auto"></script>
<script type="module" src="https://esm.run/@wcstack/router@4/auto"></script>

In the state, add routeParams after cart, and two getters before "products.*.matches":

stateでは、cartの後にrouteParamsを、"products.*.matches"の前にgetterを2つ加えます。

index.html — <wcs-state>new追加
cart: [],
routeParams: {},
// …
get "products.*.href"() {
  return "products/" + this["products.*.id"];
},
get product() {
  const id = this["routeParams.id"];
  return this.products.find(product => product.id === id) ?? null;
},

A product page needs its own “Add to cart”, so addToCart hands the work to a method both can call:

商品のページにも「Add to cart」が要るので、addToCartは両方から呼べるメソッドに仕事を渡します。

index.html — <wcs-state>changed変更
addToCart(event, index) {
  this.addProduct(this.products[index].id);
},
addCurrent() {
  this.addProduct(this.product.id);
},
addProduct(id) {
  const line = this.cart.findIndex(item => item.id === id);
  if (line >= 0) {
    this[`cart.${line}.quantity`] += 1;
  } else {
    this.cart = this.cart.concat({ id, quantity: 1 });
  }
},

In <main>, where the catalog section is, put the router and, right after it, the outlet it fills. Move your whole <section class="catalog"> into the first route, and turn each card's name into a link:

<main>の中、商品一覧のセクションがある場所に、ルーターと、そのすぐ後に、ルーターが中身を入れるoutletを置きます。<section class="catalog">を丸ごと最初のルートの中へ移し、カードの名前をリンクにします。

index.html — <main>changed変更
<main class="shop" data-wcs="hidden: catalogReady|not">
  <wcs-router data-wcs="typedParams: routeParams">
    <template>
      <wcs-route path="/">
        <section class="catalog">
          <!-- … -->
                <h3><a data-wcs="attr.href: .href">{{ .name }}</a></h3>
          <!-- … -->
        </section>
      </wcs-route>
      <wcs-route path="/products/:id(int)">
        <article class="product-page">
          <p class="back"><a href="./">← All products</a></p>
          <template data-wcs="if: product">
            <div class="card-icon">{{ product.icon }}</div>
            <h2>{{ product.name }}</h2>
            <p>{{ product.description }}</p>
            <p class="price">${{ product.price|toFixed(2) }}</p>
            <p class="badge" data-wcs="hidden: product.stock|ne(0)">Sold out</p>
            <button type="button" data-wcs="onclick: addCurrent; disabled: product.stock|eq(0)">Add to cart</button>
          </template>
          <template data-wcs="else:">
            <p class="no-results">No such product.</p>
          </template>
        </article>
      </wcs-route>
      <wcs-route fallback>
        <p class="no-results">Page not found. <a href="./">Back to the shop</a></p>
      </wcs-route>
    </template>
  </wcs-router>
  <wcs-outlet></wcs-outlet>

  <aside class="cart">

The router fills the element right after it, so keep <wcs-outlet> there. shop.css has wcs-router { display: contents; }: the router holds only its routes and takes no place in the grid.

ルーターは自分のすぐ後の要素に中身を入れるので、<wcs-outlet>はそこに置きます。shop.cssにはwcs-router { display: contents; }があり、ルーターはルートを持つだけで、グリッドの場所を取りません。

How it worksしくみ

Routes are templatesルートはテンプレート

<wcs-router> holds a <template> of <wcs-route> elements. When the URL changes, it finds the matching route and puts that route's content into <wcs-outlet>; the state binds it like everything else, so the catalog's for: and the product page's if: work as before. Leaving a route takes its content out; coming back puts the same nodes back. The cart is outside the router, so it stays on every page.

<wcs-router>は、<wcs-route>要素を並べた<template>を持ちます。URLが変わると、合うルートを見つけ、その中身を<wcs-outlet>に入れます。stateはそれをほかの部分と同じようにバインドするので、商品一覧のfor:も商品ページのif:もこれまでどおり働きます。ルートを離れると中身は取り出され、戻ると同じノードが戻ってきます。カートはルーターの外にあるので、どのページにも残ります。

The URL is inputURLは入力

typedParams is an output of the router, like value of <wcs-fetch>: typedParams: routeParams carries the URL's parameters into state. :id(int) converts the id to a number, so product.id === id compares numbers; a URL whose id is not an integer (/products/abc) matches no route and lands on the fallback. The product getter turns the id into a product, and the page reads product.name — or shows “No such product.” for an id the catalog doesn't have.

typedParamsは、<wcs-fetch>のvalueと同じく、ルーターの出力です。typedParams: routeParamsが、URLのパラメータをstateへ運びます。:id(int)はidを数値に変えるので、product.id === idは数値どうしの比較になります。idが整数でないURL(/products/abc)はどのルートにも合わず、fallbackに落ちます。productのgetterがidを商品に変え、ページはproduct.nameを読みます。商品一覧に無いidなら「No such product.」を出します。

Links are linksリンクはリンク

A card's name is an ordinary <a href="products/4">, relative to <base href="/">. When it is clicked, the browser asks the page before leaving (the Navigation API), and the router answers by switching the route in place — no reload, and an entry in the history, so Back and Forward step through the pages. <base href> also tells the router where the app starts: its paths (/products/:id) are counted from there.

カードの名前は、<base href="/">からの相対の、普通の<a href="products/4">です。クリックされると、ブラウザはページを離れる前にページに尋ね(Navigation API)、ルーターはその場でルートを切り替えて応えます。再読み込みはせず、履歴には記録が残るので、「戻る」と「進む」でページをたどれます。<base href>は、アプリがどこから始まるかもルーターに伝えます。ルートのパス(/products/:id)はそこから数えます。

Try itやってみよう

Link the cart to the product pages too. Add get "cart.*.href"() { return "products/" + this["cart.*.id"]; } to the state, and in the cart row wrap the name: <span class="line-name"><a data-wcs="attr.href: .href">{{ .name }}</a></span>.

カートからも商品ページへリンクしてみましょう。stateにget "cart.*.href"() { return "products/" + this["cart.*.id"]; }を加え、カートの行の名前を包みます: <span class="line-name"><a data-wcs="attr.href: .href">{{ .name }}</a></span>。

Common mistakesよくある間違い

Opening the fileファイルを開く

Double-click index.html now and the header and the cart appear, but where the catalog should be it says “Page not found.”: the router takes /C:/…/index.html for a page it doesn't have, and nothing in the console says why. Serve the folder.

ここでindex.htmlをダブルクリックすると、ヘッダーとカートは出ますが、商品一覧の場所には「Page not found.」と出ます。ルーターは/C:/…/index.htmlを知らないページとして扱い、コンソールにも理由は出ません。フォルダをサーバーで開きます。

Forgetting <base href><base href>を忘れる

Without it the router takes the address the page was loaded at as the start of the app. Loaded at /, all is well; loaded at /products/2 — a reload — it counts its paths from there, and the product page comes back as the catalog. The checker warns (@wcstack/lint 4.0.3 or later):

無いと、ルーターはページを読み込んだアドレスをアプリの始まりとみなします。/で読み込めば問題ありませんが、/products/2で読み込む(再読み込みする)と、そこからパスを数えるので、商品のページが商品一覧として戻ってきます。検査器は警告します(@wcstack/lint 4.0.3以降)。

npx @wcstack/lint index.html
index.html:11:30 warning wcs/base-href-missing An SPA using @wcstack/router needs <base href="/"> in <head> (without it, deep links misderive the basename)

0 error(s), 1 warning(s), 0 info

The outlet away from the routerルーターから離れたoutlet

The router looks for its outlet in one place: the element right after it. Put the router at the top of <body> and the outlet inside <main>, and the router makes an outlet of its own after itself: the pages appear above the header, and the outlet in <main> stays empty. Since 4.0.4 the router warns in the console ([wcs/router-outlet-misplaced]), and so does the checker.

ルーターがoutletを探すのは1か所、自分のすぐ後の要素だけです。ルーターを<body>の先頭に、outletを<main>の中に置くと、ルーターは自分の後ろに自前のoutletを作ります。ページはヘッダーの上に現れ、<main>の中のoutletは空のままです。4.0.4からは、ルーターがコンソールで警告し([wcs/router-outlet-misplaced])、検査器も報告します。

A server without the fallbackfallbackの無いサーバー

With npx serve . (no -s), everything works until you reload a product page: the server has no file products/2 and answers 404. Clicking through the shop never asks the server, which is why the mistake hides until a reload.

npx serve .(-sなし)でも、商品ページを再読み込みするまではすべて動きます。サーバーにはproducts/2というファイルが無いので、404を返します。ショップの中をクリックしてたどる間はサーバーに尋ねないので、この間違いは再読み込みまで隠れています。

The whole fileファイル全体

The complete index.html of this step, as you serve it from your own folder. npx @wcstack/lint index.html reports 0 error(s), 0 warning(s), 0 info for it. (The demo above differs in one line: its <base href> points at its folder on this site.)

このステップのindex.html全体で、自分のフォルダから配信する形です。npx @wcstack/lint index.htmlの結果は0 error(s), 0 warning(s), 0 infoです。(上のデモは1行だけ違い、<base href>がこのサイトの中のフォルダを指しています。)

Show index.htmlindex.htmlを表示
index.htmlstep 13ステップ13
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Little Roastery</title>
  <base href="/">
  <link rel="stylesheet" href="https://wcstack.github.io/tutorial/shop.css">
  <script type="module" src="https://esm.run/@wcstack/state@4/auto"></script>
  <script type="module" src="https://esm.run/@wcstack/fetch@4/auto"></script>
  <script type="module" src="https://esm.run/@wcstack/storage@4/auto"></script>
  <script type="module" src="https://esm.run/@wcstack/router@4/auto"></script>
</head>
<body>

<wcs-state>
  <script type="module">
    /** @typedef {{ id: number, name: string, category: string, description: string, price: number, stock: number, icon: string }} Product */

    export default {
      /** @type {Product[] | null} */
      catalog: null,
      loading: false,
      loadError: null,
      reload: false,
      cart: [],
      routeParams: {},
      query: "",
      category: "all",

      get products() {
        return this.catalog ?? [];
      },
      get catalogReady() {
        return this.catalog !== null;
      },
      get "products.*.href"() {
        return "products/" + this["products.*.id"];
      },
      get product() {
        const id = this["routeParams.id"];
        return this.products.find(product => product.id === id) ?? null;
      },
      get "products.*.matches"() {
        const query = this.query.trim().toLowerCase();
        return (this.category === "all" || this["products.*.category"] === this.category) &&
          this["products.*.name"].toLowerCase().includes(query);
      },
      get matchCount() {
        return this.$getAll("products.*.matches", []).filter(Boolean).length;
      },

      get "cart.*.product"() {
        const id = this["cart.*.id"];
        return this.products.find(product => product.id === id);
      },
      get "cart.*.name"() {
        return this["cart.*.product"]?.name ?? "…";
      },
      get "cart.*.price"() {
        return this["cart.*.product"]?.price ?? 0;
      },
      get "cart.*.lineTotal"() {
        return this["cart.*.price"] * this["cart.*.quantity"];
      },
      get itemCount() {
        return this.$getAll("cart.*.quantity", []).reduce((sum, n) => sum + n, 0);
      },
      get subtotal() {
        return this.$getAll("cart.*.lineTotal", []).reduce((sum, n) => sum + n, 0);
      },
      get shipping() {
        return this.subtotal >= 50 ? 0 : 5;
      },
      get total() {
        return this.subtotal + this.shipping;
      },

      get "cart.*.saved"() {
        return { id: this["cart.*.id"], quantity: this["cart.*.quantity"] };
      },
      get savedCart() {
        return this.$getAll("cart.*.saved", []);
      },
      set savedCart(lines) {
        if (Array.isArray(lines)) this.cart = lines;
      },

      retry() {
        this.reload = true;
      },
      addToCart(event, index) {
        this.addProduct(this.products[index].id);
      },
      addCurrent() {
        this.addProduct(this.product.id);
      },
      addProduct(id) {
        const line = this.cart.findIndex(item => item.id === id);
        if (line >= 0) {
          this[`cart.${line}.quantity`] += 1;
        } else {
          this.cart = this.cart.concat({ id, quantity: 1 });
        }
      },
      increase(event, index) {
        this[`cart.${index}.quantity`] += 1;
      },
      decrease(event, index) {
        this[`cart.${index}.quantity`] -= 1;
      },
      remove(event, index) {
        this.cart = this.cart.toSpliced(index, 1);
      }
    };
  </script>
</wcs-state>

<wcs-fetch url="https://wcstack.github.io/tutorial/data/products.json"
  data-wcs="value: catalog; loading: loading; error: loadError; trigger: reload"></wcs-fetch>
<wcs-storage key="little-roastery-cart"
  data-wcs="value#init=element: savedCart"></wcs-storage>

<header class="shop-header">
  <h1>Little Roastery</h1>
  <p class="cart-badge">Cart: <span data-wcs="textContent: itemCount"></span></p>
</header>

<p class="status" data-wcs="hidden: loading|not">Loading products…</p>
<p class="status error" data-wcs="hidden: loadError|not">
  Couldn't load the products.
  <button type="button" data-wcs="onclick: retry">Try again</button>
</p>

<main class="shop" data-wcs="hidden: catalogReady|not">
  <wcs-router data-wcs="typedParams: routeParams">
    <template>
      <wcs-route path="/">
        <section class="catalog">
          <h2>Products</h2>
          <div class="toolbar">
            <input type="search" placeholder="Search products" aria-label="Search products" data-wcs="value: query">
            <fieldset class="categories">
              <legend class="visually-hidden">Category</legend>
              <label><input type="radio" name="category" value="all" data-wcs="radio: category"> All</label>
              <label><input type="radio" name="category" value="coffee" data-wcs="radio: category"> Coffee</label>
              <label><input type="radio" name="category" value="tea" data-wcs="radio: category"> Tea</label>
              <label><input type="radio" name="category" value="tools" data-wcs="radio: category"> Tools</label>
            </fieldset>
          </div>
          <div class="grid">
            <template data-wcs="for: products">
              <article class="card" data-wcs="hidden: .matches|not; class.sold-out: .stock|eq(0)">
                <div class="card-icon">{{ .icon }}</div>
                <h3><a data-wcs="attr.href: .href">{{ .name }}</a></h3>
                <p>{{ .description }}</p>
                <p class="price">${{ .price|toFixed(2) }}</p>
                <p class="badge" data-wcs="hidden: .stock|ne(0)">Sold out</p>
                <button type="button" data-wcs="onclick: addToCart; disabled: .stock|eq(0)">Add to cart</button>
              </article>
            </template>
          </div>
          <p class="no-results" data-wcs="hidden: matchCount|ne(0)">No products match.</p>
        </section>
      </wcs-route>
      <wcs-route path="/products/:id(int)">
        <article class="product-page">
          <p class="back"><a href="./">← All products</a></p>
          <template data-wcs="if: product">
            <div class="card-icon">{{ product.icon }}</div>
            <h2>{{ product.name }}</h2>
            <p>{{ product.description }}</p>
            <p class="price">${{ product.price|toFixed(2) }}</p>
            <p class="badge" data-wcs="hidden: product.stock|ne(0)">Sold out</p>
            <button type="button" data-wcs="onclick: addCurrent; disabled: product.stock|eq(0)">Add to cart</button>
          </template>
          <template data-wcs="else:">
            <p class="no-results">No such product.</p>
          </template>
        </article>
      </wcs-route>
      <wcs-route fallback>
        <p class="no-results">Page not found. <a href="./">Back to the shop</a></p>
      </wcs-route>
    </template>
  </wcs-router>
  <wcs-outlet></wcs-outlet>

  <aside class="cart">
    <h2>Cart</h2>
    <template data-wcs="if: cart.length|eq(0)">
      <p class="empty">Your cart is empty.</p>
    </template>
    <template data-wcs="else:">
      <ul class="cart-lines">
        <template data-wcs="for: cart">
          <li>
            <span class="line-name">{{ .name }}</span>
            <span class="line-total">${{ .lineTotal|toFixed(2) }}</span>
            <span class="stepper">
              <button type="button" aria-label="Decrease" data-wcs="onclick: decrease; disabled: .quantity|le(1)">−</button>
              <span>{{ .quantity }}</span>
              <button type="button" aria-label="Increase" data-wcs="onclick: increase">+</button>
            </span>
            <button type="button" class="remove" data-wcs="onclick: remove">Remove</button>
          </li>
        </template>
      </ul>
      <dl class="totals">
        <dt>Subtotal</dt><dd>${{ subtotal|toFixed(2) }}</dd>
        <dt>Shipping</dt><dd>${{ shipping|toFixed(2) }}</dd>
        <dt class="grand">Total</dt><dd class="grand">${{ total|toFixed(2) }}</dd>
      </dl>
      <p class="hint" data-wcs="hidden: shipping|eq(0)">Free shipping on orders of $50 or more.</p>
    </template>
  </aside>
</main>

</body>
</html>