Nuxt3(beta)でmicroCMSとStripeを利用して決済機能を導入

してみたくなった時の個人的メモです。なんやかんややったデモサイトは次の通りです。

https://nuxt3-microcms-stripe.netlify.app

Nuxt.jsのバージョン3がいいぞ！って聞いたのでNuxt.js初体験です。あとTailwind CSSがいいぞ！ってよく聞くので、Tailwind CSSとそのライブラリであるdaisyUIも初体験です。あとあとmicroCMSのWebhookも試してみたかったのでNetlifyも初体験です。

# 開発環境

* node.js : 16.14.2
* yarn : 1.22.10

# microCMSとStripeに商品情報を入力

します。次の記事が大変わかりやすく見やすいので、こちらを参考に商品情報を入力していきます。こちらの記事をまだ読まれていなければ、先に読まれることをお勧めします！

https://qiita.com/hideokamoto/items/6bb5a17f4c948e55a906

ある程度入力し終えたらNuxt.jsのプロジェクトを触っていきます。

# Nuxt.jsのインストール

下記コマンドでインストールします。オプションに``-o``を指定して起動すると自動でブラウザが開きます。便利！

```
npx nuxi init nuxt3-app
cd nuxt3-app
yarn install
yarn dev -o
```

# Tailwind CSSとdaisyUIのインストール

まずはTailwind CSSのインストールです。

```
yarn add -D @nuxtjs/tailwindcss
```

次いで``nuxt.config.ts``に以下を追記。

```typescript:nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxtjs/tailwindcss'],
  tailwindcss: {
    exposeConfig: true,
  },
});
```

それから``tailwind.config.js``を作成。

```
npx tailwindcss init
```

続いてdaisyUIのインストールをば。

```
yarn add -D daisyui
```

最後に``tailwind.config.js``に以下を追記で完了！

```javascript:tailwind.config.js
module.exports = {
  plugins: [require('daisyui')],
};
```


# Nuxt.jsのプロジェクトを編集

<details><summary>ディレクトリ構成</summary>

```
|
├── pages/
|   |── checkout/
|   |    ├── [status].vue
|   |    └── index.vue
|   |── product/
|   |    └── [id].vue
|   └── index.vue
├── server/api
├── stores
├── .env
├── .gitignore
├── app.vue
├── nuxt.config.ts
├── package.json
├── README.md
├── tailwind.config.js
├── tsconfig.json
└── yarn.lock
```
</details>


## PiniaとStripeのインストールと環境変数の設定

カート内の商品情報の状態管理にPiniaを使うのでインストール。Stripeも忘れずにインストール。

```
yarn add pinia @pinia/nuxt stripe
```

ルートディレクトリ直下に``.env``ファイルを作成。

```.env
BASE_URL=http://localhost:3000
MICROCMS_BASE_URL=https://xxxxx.microcms.io/api/v1
MICROCMS_API_KEY=xxxxxxxxxx
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxx
```

``nuxt.config.ts``に追記。

```diff_typescript:nuxt.config.ts
const { BASE_URL, MICROCMS_BASE_URL, MICROCMS_API_KEY, STRIPE_SECRET_KEY } = process.env;

export default defineNuxtConfig({
- buildModules: ['@nuxtjs/tailwindcss'],
+ buildModules: ['@pinia/nuxt', '@nuxtjs/tailwindcss'],
  publicRuntimeConfig: {
    baseUrl: BASE_URL,
    microcmsBaseUrl: MICROCMS_BASE_URL,
    microcmsApiKey: MICROCMS_API_KEY,
    stripeSecretKey: STRIPE_SECRET_KEY,
  },
  privateRuntimeConfig: {
    baseUrl:
      process.env.NODE_ENV !== 'production' ? BASE_URL : undefined,
    microcmsApiKey:
      process.env.NODE_ENV !== 'production' ? MICROCMS_API_KEY : undefined,
    stripeSecretKey:
      process.env.NODE_ENV !== 'production' ? STRIPE_SECRET_KEY : undefined,
  },
});
```

## microCMSとStripeからデータ取得

サーバサイドで取得します。``~/server/api``配下のファイルは自動的にAPIのエンドポイントとして動いてくれます。すごい。
下記でStripeから取得した``unit_amount``を100で割ってますが、これは取得するデータの都合上です（例：$8.99→899、日本円なら必要がないので注意）。

```typescript:sever/api/products.ts
import type { IncomingMessage, ServerResponse } from 'http';
import { useQuery } from 'h3';
import Stripe from 'stripe';
import config from '#config';

export interface API {
  contents: PRODUCT[];
  totalCount: number;
  offset: number;
  limit: number;
}
export interface PRODUCT {
  id: string;
  createdAt: string;
  updatedAt: string;
  publishedAt: string;
  revisedAt: string;
  name: string;
  description: string;
  stripe_price_id: string;
  price: PRICE;
  quantity?: number;
}
export interface PRICE {
  id: string;
  unit_amount: number;
  currency: string;
}

export default async (req: IncomingMessage, res: ServerResponse) => {
  const stripe = new Stripe(config.stripeSecretKey, {
    apiVersion: '2020-08-27',
  });

  const query = useQuery(req); // クエリの取得
  const id = query.id; // microCMSのコンテンツID
  const limit = query.limit || id ? 1 : 30; // 取得件数
  // IDの有無で全件取得か個別取得か切り替え
  const endpoint = id
    ? `/products/${id}?limit=${limit}`
    : `/products?limit=${limit}`;

  let products: PRODUCT | PRODUCT[];

  if (id) {
    // microCMSから商品情報を取得
    const contents = await $fetch<PRODUCT>(endpoint, {
      baseURL: config.microcmsBaseUrl,
      headers: {
        'X-MICROCMS-API-KEY': config.microcmsApiKey,
      },
    });

    const price = await stripe.prices.retrieve(contents.stripe_price_id); // microCMSのstripe料金IDからStripeの商品情報取得

    // microCMSから得た商品情報にStripeの商品情報を追加
    products = {
      ...contents,
      price: {
        id: price.id,
        unit_amount: price.unit_amount / 100,
        currency: price.currency,
      },
    };
  } else {
    const { contents } = await $fetch<API>(endpoint, {
      baseURL: config.microcmsBaseUrl,
      headers: {
        'X-MICROCMS-API-KEY': config.microcmsApiKey,
      },
    });

    products = await Promise.all(
      contents.map(async (elm: PRODUCT) => {
        const price = await stripe.prices.retrieve(elm.stripe_price_id);
        return {
          ...elm,
          price: {
            id: price.id,
            unit_amount: price.unit_amount / 100,
            currency: price.currency,
          },
        };
      })
    );
  }

  return products;
};
```

## クライアントサイドでデータを取得

一旦クライアントサイドでデータを取得できるか確認します。``app.vue``を編集してから全件取得してみます。

```vue:app.vue
<template>
  <NuxtPage />
</template>
```

```vue:pages/index.vue
<script setup lang="ts">
import { type PRODUCT } from '~~/server/api/products';

const { data: products } = await useFetch<PRODUCT[]>('/api/products');
</script>

<template>
  <div v-for="(i, key) in products" :key="key">
    <p>{{ i.name }}</p>
    <p>{{ i.price.unit_amount }}</p>
    <NuxtLink :to="`/product/${i.id}`">{{ i.description }}</NuxtLink>
  </div>
</template>
```

続いて個別取得。無事取得できれば次へ！

```vue:pages/product/[id].vue
<script setup lang="ts">
import { type PRODUCT } from '~~/server/api/products';

const route = useRoute();
const paramsId = route.params.id;

const { data: product } = await useFetch<PRODUCT>('/api/products', {
  params: { id: paramsId },
});
</script>

<template>
  <p>{{ product.name }}</p>
  <p>{{ product.price.unit_amount }}</p>
  <p>{{ product.description }}</p>
</template>
```

## ストアの構築

Piniaのストアを構築していきます。ひとまずはカートに商品を追加する機能と、Stripe決済に必要なデータを取得できるようにします。

```typescript:stores/cart.ts
import { defineStore } from 'pinia';
import { type PRODUCT } from '~~/server/api/products';

export const useCartStore = defineStore({
  id: 'cart',
  state: () => ({
    rawProducts: [] as PRODUCT[],
  }),
  getters: {
    // ストライプ決済用のカート内の商品データを取得
    stripeLineItems(state) {
      return state.rawProducts.map((elm) => {
        return { price: elm.price.id, quantity: elm.quantity };
      });
    },
  },
  actions: {
    // カートに商品を追加
    addProduct(product) {
      const exist = this.rawProducts.find((elm) => elm.id === product.id); // カート内に商品があるかどうか
      if (exist) {
        exist.quantity++; // 商品がすでにあれば個数を加算
      } else {
        product.quantity = 1; // 個数のプロパティを追加
        this.rawProducts.push(product); // 商品を追加
      }
    },
  },
});
```

カートに追加するボタンを追加（やり方は同じなので片方だけ）。

```diff_vue:pages/index.vue
<script setup lang="ts">
import { type PRODUCT } from '~~/server/api/products';
+ import { useCartStore } from '@/stores/cart';

const { data: products } = await useFetch<PRODUCT[]>('/api/products');

+ const store = useCartStore();
+ const addProduct = (product) => {
+   store.addProduct(product);
+ };
</script>

<template>
  <div v-for="(i, key) in products" :key="key">
    <p>{{ i.name }}</p>
    <p>{{ i.price.unit_amount }}</p>
    <NuxtLink :to="`/product/${i.id}`">{{ i.description }}</NuxtLink>
+   <button @click="addProduct(i)">Add cart</button>
  </div>
</template>
```

お次はカート内の商品情報を取得します。適当にカートに追加してみてStripeの料金ID等が取得できていればおｋです。

```vue:pages/checkout/index.vue
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { useCartStore } from '@/stores/cart';

const store = useCartStore();
const { stripeLineItems } = storeToRefs(store);
</script>

<template>
  <div v-for="(i, key) in stripeLineItems" :key="key">
    <p>{{ i.price }}</p>
    <p>{{ i.quantity }}</p>
  </div>
</template>
```

## StripeのCheckoutセッションの作成

POSTリクエストでカート内の商品情報を受け取って、StripeのCheckoutセッションを作成します。まずはサーバサイドから。

```typescript:server/checkout_session.ts
import type { IncomingMessage, ServerResponse } from 'http';
import { useBody } from 'h3';
import Stripe from 'stripe';
import config from '#config';

export default async (req: IncomingMessage, res: ServerResponse) => {
  const stripe = new Stripe(config.stripeSecretKey, {
    apiVersion: '2020-08-27',
  });

  const body = await useBody(req);
  const { lineItems } = body; // カート内の商品情報を取得

  // StripeのCheckoutセッションの作成
  const session = await stripe.checkout.sessions.create({
    customer_creation: 'if_required',
    line_items: lineItems,
    mode: 'payment',
    success_url: `${config.baseUrl}/checkout/success?session_id={CHECKOUT_SESSION_ID}`, // 顧客情報等を取得する場合はセッションIDを使用する
    cancel_url: `${config.baseUrl}/checkout/cancel`,
  });

  return session;
};
```

続いてクライアントサイド。

```diff_vue:pages/checkout/index.vue
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { useCartStore } from '@/stores/cart';

const store = useCartStore();
const { stripeLineItems } = storeToRefs(store);

+ const checkout = async () => {
+   const session = await $fetch('/api/checkout_session', {
+     method: 'POST', // リクエスト先でuseBodyを使うので'POST'を指定
+     body: { lineItems: stripeLineItems.value },
+   });
+   
+   window.open(session.url); // Stripe決済用のページを開く
+ };
</script>

<template>
  <div v-for="(i, key) in stripeLineItems" :key="key">
    <p>{{ i.price }}</p>
    <p>{{ i.quantity }}</p>
  </div>
+ <button @click="checkout">Check out</button>
</template>
```

最後に決済完了orキャンセル後のリダイレクトページを用意して完了です！

```vue:pages/checkout/[status].vue
<script setup lang="ts">
const route = useRoute();
const paramsStatus = route.params.status; // 決済の可否
const sessionId = route.query.session_id; // 決済のセッションID
</script>

<template>
  <div v-if="paramsStatus === 'success'">
    <p>Checkout complete!</p>
    <p>{{ sessionId }}</p>
  </div>

  <div v-else>
    <p>Checkout canceled...</p>
  </div>
</template>
```

# NetlifyへのデプロイとmicroCMSとの連携

Netlifyにログインし、「Import from Git」からGitHubのボタンを押しGitHubのリポジトリを選択しましょう。ビルドの設定は特にいじらずに「Show advanced」から環境変数を追加しデプロイします。
今回は何も考えずにデプロイまで来てしまったので、デプロイ後に``BASE_URL``を追加しています。 ~~今回はお試しｄ~~ 次はちゃんとしましょうね！

続いてmicroCMSへの商品の追加とNetlifyのデプロイを連携するためにNetlifyのWebhookを準備します（Site settings > Build & deploy > Continuous Deployment > Build hooks）。Webhookを作成するとエンドポイントが割り当てられるので、それを用いてmicroCMSの管理画面からNetlifyを選択し連携します（API設定 > Webhook）。

GitHubへプッシュしたり、microCMSに商品を追加したりしたら自動でビルドが走っているのを見て感動したら完了です。

# daisyUIのテーマを適用してみる

daisyUIにはデフォルトで29個のテーマが用意されており、簡単にテーマを適用することができます。公式ドキュメントにテーマジェネレータがあり、テーマのカスタマイズも容易です。
<details><summary>使用例</summary>

```typescript:composables/useTheme.ts
const themes: string[] = ["light", "dark", "cupcake", "bumblebee", "emerald", "corporate", "synthwave", "retro", "cyberpunk", "valentine", "halloween", "garden", "forest", "aqua", "lofi", "pastel", "fantasy", "wireframe", "black", "luxury", "dracula", "cmyk", "autumn", "business", "acid", "lemonade", "night", "coffee", "winter"];
 
const useTheme = () => {
  const theme = useState('theme', () => 'light');
  const changeTheme = (state: string) => {
    theme.value = state;
  };

  return {
    themes: readonly(themes),
    theme: readonly(theme),
    changeTheme,
  };
};

export default useTheme;
```

```vue:components/ChangeTheme.vue
<script setup lang="ts">
const { themes, changeTheme } = useTheme();
</script>

<template>
<div class="dropdown-content bg-base-200 text-base-content rounded-t-box rounded-b-box top-px h-96 w-40 overflow-y-auto shadow-2xl mt-12 border border-base-content border-opacity-5">
  <ul tabindex="0" class="menu menu-compat p-2">
    <li v-for="(i, key) in themes" :key="key">
      <a @click="changeTheme(i)">{{ i }}</a>
    </li>
  </ul>
</div>
</template>
```

```vue:app.vue
<script setup lang="ts">
const { theme } = useTheme();
</script>

<template>
  <Html :data-theme="theme" />
  <ChangeTheme />
  <NuxtPage />
</template>
```
</details>

# 色々触ってみた感想

<details><summary>感想</summary>
<dl>
  <dt>Nuxt3</dt>
  <dd>めっちゃよかった！まずNuxt3というよりVue3ですが、Composition APIいいですね。ロジックを簡単に切り出せるのでコンポーネントがすっきりします。そして何よりsetup記法がよすぎる！これを一度体験したらもうOptions APIには戻れそうにありません。</dd>
  <dd>そしてNuxt3ですが、こちらもいいですね（語彙力）。Vue3のrefなどを含め、諸々の関数がオートインポートされているので記述が楽です。更にVS Code側でもよしなにやってくれるので助かります。あとAPI Routesでお手軽にサーバサイドを試せるのには感動しました。正式リリースが待ち遠しいです。</dd>
  <dt>Typescripte</dt>
  <dd>今回はちょっとしか触れていないので、これからしっかり勉強していきましょう。</dd>
  <dt>Tailwind CSS(nuxt/tailwind)</dt>
  <dd>クラス名がわかりやすく使いやすかったです。nuxt/tailwindにはTailwind Viewerなるものがあり、使いたいCSSのコピペが捗ります（まだプレリリースなので全部ある訳ではないですが、それでも目で見て確認できるのは助かるし、/_tailwindで手軽に見に行けるのも個人的には嬉しみです）。ただ実際に使う場合にはクラスの順番がぐちゃぐちゃになると思うので、何かしらのプラグインを導入するのは必須でしょう（今回はここで力尽きたので後で試します）。</dd>
  <dt>daisyUI</dt>
  <dd>Tailwindがいくら便利とはいえ、Vuetifyに慣れた身としては一からスタイリングするのは辛い…そこでdaisyUI！ほぼほぼ必要なコンポーネントが用意されているので、Tailwindを使う場合は是非ともセットで使いたいですね！公式ドキュメントが見やすくポチポチしているだけでも面白いので、Tailwindをお使いでdaisyUI未体験の方は是非一度覗いてみてはいかがでしょうか？</dd>
  <dt>microCMS</dt>
  <dd>兎にも角にも管理画面が触りやすいです。ヘッドレスCMSためしてみようかな？使ってみようかな？と思っている方がいれば、是非ともお勧めしたい所存です！</dd>
  <dt>Stripe</dt>
  <dd>今回はちょっとしか触れていないので、これからしっかり勉強していきましょう。</dd>
  <dt>Netlify</dt>
  <dd>デプロイ連携初めて試してみたのですが、大変便利ですね。大変便利です。自動でビルド走ってるの見た時は単純にすごいなあ、と思いましたまる</dd>
</dl>
</details>

# 最後に

以下の記事を参考にさせていただきました。感謝！

[microCMSとStripe + Next.jsを利用したJamstackなHeadless ECサイトを作る方法（入門編）](https://qiita.com/hideokamoto/items/6bb5a17f4c948e55a906)
[Nuxt3でmicroCMSブログの雛形を作成しよう](https://blog.microcms.io/nuxt3-create-blog/)
[Nuxt 3 - The Hybrid Vue Framework](https://v3.nuxtjs.org/)
[Introduction - Nuxt Tailwind CSS](https://tailwindcss.nuxtjs.org/)
[Tailwind CSS v3.0 – Tailwind CSS](https://tailwindcss.com/blog/tailwindcss-v3)
[daisyUI — Tailwind CSS Components](https://daisyui.com/)
[支払いを受け付ける | Stripe のドキュメント](https://stripe.com/docs/payments/accept-a-payment?platform=web&ui=checkout)
[Nuxt3の新しい機能](https://zenn.dev/azukiazusa/articles/nuxt3-new-features)
[2022年の最新標準！Vue 3の新しい開発体験に触れよう](https://ics.media/entry/220120/)