Closed9

microCMS + Next.js でお問い合わせフォームを作成する

kodukakoduka

microCMSのコンテンツを取得すると以下のようになる。

オブジェクト client.getObject

{
  "createdAt": "2024-07-29T05:44:35.482Z",
  "updatedAt": "2024-07-30T02:33:15.635Z",
  "publishedAt": "2024-07-29T05:52:39.625Z",
  "revisedAt": "2024-07-30T02:33:15.635Z",
  "text_field": "テキストフィールド",
  "text_area": "テキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリア\nテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリア\nテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリア",
  "rich_text": "<h1 id=\"h6aad7d63ec\">見出し1</h1><h2 id=\"had7ce3f1cb\">見出し2</h2><h3 id=\"h594976e74f\">見出し3</h3><h4 id=\"h0f52d4725a\">見出し4</h4><h5 id=\"h169558c650\">見出し5</h5><p><code>console.log(&apos;hello, world&apos;)</code></p><p>左揃え</p><p style=\"text-align: center\">中央揃え</p><p style=\"text-align: right\">右揃え</p><hr><blockquote><p>引用引用引用引用引用引用引用引用引用引用引用</p></blockquote><div data-filename=\"test.js\"><pre><code class=\"language-javascript\">console.debug(&apos;debug&apos;)\nconsole.log(&apos;log&apos;)\nconsole.info(&apos;info&apos;)\nconsole.warn(&apos;warn&apos;)\nconsole.error(&apos;error&apos;)</code></pre></div><table><tbody><tr><th colspan=\"1\" rowspan=\"1\"><p>ヘッダー1</p></th><th colspan=\"1\" rowspan=\"1\"><p>ヘッダー2</p></th><th colspan=\"1\" rowspan=\"1\"><p>ヘッダー3</p></th><th colspan=\"1\" rowspan=\"1\"><p>ヘッダー4</p></th></tr><tr><td colspan=\"1\" rowspan=\"1\"><p>内容A</p></td><td colspan=\"1\" rowspan=\"1\"><p>内容B</p></td><td colspan=\"1\" rowspan=\"1\"><p>内容C</p></td><td colspan=\"1\" rowspan=\"1\"><p>内容D</p></td></tr></tbody></table><ul><li>リスト1</li><li>リスト2</li><li>リスト3</li></ul><ol><li>リスト1</li><li>リスト2</li><li>リスト3</li></ol><p><a href=\"http://localhost:3000\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">リンク</a></p><figure><img src=\"https://images.microcms-assets.io/assets/afa7d9e292404898b6f86c4ac70b9694/95db46c6b9c84e4db436cd92cb54385d/Default_refreshing_men_smiling_face_freelance_japanese_people_1.jpg\" alt=\"\" width=\"1104\" height=\"1104\"></figure><div class=\"iframely-embed\"><div class=\"iframely-responsive\" style=\"height: 140px; padding-bottom: 0;\"><a href=\"https://github.com\" data-iframely-url=\"//cdn.iframe.ly/api/iframe?card=small&amp;url=https%3A%2F%2Fgithub.com%2F&amp;key=c271a3ec77ff4aa44d5948170dd74161\"></a></div></div><script async src=\"//cdn.iframe.ly/embed.js\" charset=\"utf-8\"></script>",
  "image": {
    "url": "https://images.microcms-assets.io/assets/afa7d9e292404898b6f86c4ac70b9694/a48355152b2746a086ec7c7adbf3a2d1/Default_refreshing_men_smiling_face_client_japanese_people_0.jpg",
    "height": 1104,
    "width": 1104
  },
  "images": [
    {
      "url": "https://images.microcms-assets.io/assets/afa7d9e292404898b6f86c4ac70b9694/a48355152b2746a086ec7c7adbf3a2d1/Default_refreshing_men_smiling_face_client_japanese_people_0.jpg",
      "height": 1104,
      "width": 1104
    },
    {
      "url": "https://images.microcms-assets.io/assets/afa7d9e292404898b6f86c4ac70b9694/95db46c6b9c84e4db436cd92cb54385d/Default_refreshing_men_smiling_face_freelance_japanese_people_1.jpg",
      "height": 1104,
      "width": 1104
    }
  ],
  "date": "2024-07-30T03:00:00.000Z",
  "boolean": true,
  "select_single": [
    "セレクトA"
  ],
  "select_multi": [
    "セレクトD",
    "セレクトE"
  ],
  "number": 1200
}

リストclient.getList

{
  "contents": [
    {
      "id": "yvbocg_e-p",
      "createdAt": "2024-07-30T02:35:26.517Z",
      "updatedAt": "2024-07-30T02:35:26.517Z",
      "publishedAt": "2024-07-30T02:35:26.517Z",
      "revisedAt": "2024-07-30T02:35:26.517Z",
      "test_object": {
        "createdAt": "2024-07-29T05:44:35.482Z",
        "updatedAt": "2024-07-30T02:33:15.635Z",
        "publishedAt": "2024-07-29T05:52:39.625Z",
        "revisedAt": "2024-07-30T02:33:15.635Z",
        "text_field": "テキストフィールド",
        "text_area": "テキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリア\nテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリア\nテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリアテキストエリア",
        "rich_text": "<h1 id=\"h6aad7d63ec\">見出し1</h1><h2 id=\"had7ce3f1cb\">見出し2</h2><h3 id=\"h594976e74f\">見出し3</h3><h4 id=\"h0f52d4725a\">見出し4</h4><h5 id=\"h169558c650\">見出し5</h5><p><code>console.log(&apos;hello, world&apos;)</code></p><p>左揃え</p><p style=\"text-align: center\">中央揃え</p><p style=\"text-align: right\">右揃え</p><hr><blockquote><p>引用引用引用引用引用引用引用引用引用引用引用</p></blockquote><div data-filename=\"test.js\"><pre><code class=\"language-javascript\">console.debug(&apos;debug&apos;)\nconsole.log(&apos;log&apos;)\nconsole.info(&apos;info&apos;)\nconsole.warn(&apos;warn&apos;)\nconsole.error(&apos;error&apos;)</code></pre></div><table><tbody><tr><th colspan=\"1\" rowspan=\"1\"><p>ヘッダー1</p></th><th colspan=\"1\" rowspan=\"1\"><p>ヘッダー2</p></th><th colspan=\"1\" rowspan=\"1\"><p>ヘッダー3</p></th><th colspan=\"1\" rowspan=\"1\"><p>ヘッダー4</p></th></tr><tr><td colspan=\"1\" rowspan=\"1\"><p>内容A</p></td><td colspan=\"1\" rowspan=\"1\"><p>内容B</p></td><td colspan=\"1\" rowspan=\"1\"><p>内容C</p></td><td colspan=\"1\" rowspan=\"1\"><p>内容D</p></td></tr></tbody></table><ul><li>リスト1</li><li>リスト2</li><li>リスト3</li></ul><ol><li>リスト1</li><li>リスト2</li><li>リスト3</li></ol><p><a href=\"http://localhost:3000\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">リンク</a></p><figure><img src=\"https://images.microcms-assets.io/assets/afa7d9e292404898b6f86c4ac70b9694/95db46c6b9c84e4db436cd92cb54385d/Default_refreshing_men_smiling_face_freelance_japanese_people_1.jpg\" alt=\"\" width=\"1104\" height=\"1104\"></figure><div class=\"iframely-embed\"><div class=\"iframely-responsive\" style=\"height: 140px; padding-bottom: 0;\"><a href=\"https://github.com\" data-iframely-url=\"//cdn.iframe.ly/api/iframe?card=small&amp;url=https%3A%2F%2Fgithub.com%2F&amp;key=c271a3ec77ff4aa44d5948170dd74161\"></a></div></div><script async src=\"//cdn.iframe.ly/embed.js\" charset=\"utf-8\"></script>",
        "image": {
          "url": "https://images.microcms-assets.io/assets/afa7d9e292404898b6f86c4ac70b9694/a48355152b2746a086ec7c7adbf3a2d1/Default_refreshing_men_smiling_face_client_japanese_people_0.jpg",
          "height": 1104,
          "width": 1104
        },
        "images": [
          {
            "url": "https://images.microcms-assets.io/assets/afa7d9e292404898b6f86c4ac70b9694/a48355152b2746a086ec7c7adbf3a2d1/Default_refreshing_men_smiling_face_client_japanese_people_0.jpg",
            "height": 1104,
            "width": 1104
          },
          {
            "url": "https://images.microcms-assets.io/assets/afa7d9e292404898b6f86c4ac70b9694/95db46c6b9c84e4db436cd92cb54385d/Default_refreshing_men_smiling_face_freelance_japanese_people_1.jpg",
            "height": 1104,
            "width": 1104
          }
        ],
        "date": "2024-07-30T03:00:00.000Z",
        "boolean": true,
        "select_single": [
          "セレクトA"
        ],
        "select_multi": [
          "セレクトD",
          "セレクトE"
        ],
        "number": 1200
      },
      "text": "テスト"
    }
  ],
  "totalCount": 1,
  "offset": 0,
  "limit": 10
}
kodukakoduka

もしAPIキー一つで運用する場合は、サーバー経由でアクセスするべきということね。

https://document.microcms.io/content-api/x-microcms-api-key

お問い合わせなどの更新がされるコンテンツAPIのみPOSTなどの書き込み系の個別権限を設定する。このAPIキーはサーバーサイドで処理するなど取り扱いには注意をする。

APIキーを増やす理由は端末毎に利用するなどの特殊な理由なのだろう。
基本的には、1つのAPIキーで個別に権限を設定する形になる気がする。
そうなると、POSTが1つでもある場合、サーバーサイドで利用することになることは間違いない。

https://document.microcms.io/content-api/x-microcms-api-key#h6db4883222

マルチデバイスに展開をしている場合、「ウェブサイト用」「iOS用」「Android用」のように同じ権限でAPIキーを複数作成することができます。

kodukakoduka

https://document.microcms.io/content-api/post-content#h929d25d495

リスト形式のAPIのみ利用可能です。オブジェクト形式のAPIでは利用できませんので、ご注意ください。
クエリパラメータを指定しない場合は、ステータスは公開中でコンテンツが作成されます。

WRITE APIのクエリはTeamプラン、Businessプラン、Advancedプラン、Enterpriseプランでご利用いただける機能です。
プランごとに利用できる機能については、料金プランページをご覧ください。

kodukakoduka

shadcn ui のFormコンポーネントでサーバーアクション使用したかったけど、react-hook-formがサーバーアクションに対応していないっぽい。で、issueでもshadcn uiを使わずに、標準のformで実装するように書いてあるので、サーバーアクションを利用したい場合は、shadcn uiのFormは利用できない。

https://github.com/shadcn-ui/ui/issues/1312#issuecomment-1945933360

shadcnによるフォーム・コンポーネントは使用せず、ネイティブのものを使用してください

UIは自前で用意するとして、バロデーションライブラリを探したところ、conformというものがあるらしいので、今度すこし触ってみようと思った。Zodと一緒に利用できるので良さげかもしれない。と個人的には思っている。(まだ使ったことないけど)

https://github.com/edmundhung/conform

kodukakoduka

参考サイトを基にGoogle ReCAPTCHAのトークンを検証する処理を実装した。
けど、ヘッダーの情報がドキュメントに記述されていないので、Content-Typeをapplication/jsonで送っていたらinvalid-input-responseで怒られていた。そのため、Content-Typeはapplication/x-www-form-urlencodedにする必要があった。

@/libs/recaptcha.ts
type SiteverifyResponse = {
  success: boolean
  challengeTs?: Date // timestamp of the challenge load (ISO format yyyy-MM-dd'T'HH:mm:ssZZ)
  hostname?: string // the hostname of the site where the reCAPTCHA was solved
  score: number
  action: string
  errorCodes?: unknown[] // optional
}

const RECAPTCHA_SECRET_KEY = process.env.RECAPTCHA_SECRET_KEY!
const GOODLE_BASE_URL = process.env.GOODLE_BASE_URL || 'https://www.google.com'

export async function siteverify(token: string): Promise<SiteverifyResponse> {
  const response = await fetch(`${GOODLE_BASE_URL}/recaptcha/api/siteverify`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: new URLSearchParams({
      secret: RECAPTCHA_SECRET_KEY,
      response: token,
    }),
  })
  const json = await response.json()

  return {
    success: json['success'],
    challengeTs: new Date(json['challenge_ts']),
    hostname: json['hostname'],
    score: json['score'],
    action: json['action'],
    errorCodes: json['error-codes'],
  }
}

https://zenn.dev/angelecho/articles/daeb265bb3bf4b

https://developer.mozilla.org/ja/docs/Web/HTTP/Methods/POST

application/x-www-form-urlencoded: キーと値は、 '&' で区切られ、キーと値の組が '=' で結ばれた形でエンコードされます。キーや値が英数字以外の文字であった場合は、パーセントエンコーディングされます。このため、このタイプはバイナリデータを扱うのには向きません(代わりに multipart/form-data を使用してください)

kodukakoduka
/api/webhook/contact/on-created/sendmail/route.ts
import type { NextRequest } from 'next/server'

import type { Contact } from '@/libs/data/contact'

import { toNewContents } from '@/libs/microcms/webhook'
import { sendMail } from '@/libs/sendgrid/mail'
import { createHmac, timingSafeEqual } from 'crypto'
import { NextResponse } from 'next/server'

const secret = process.env.MICROCMS_WEBHOOK_SECRET || 'secret'

export async function POST(request: NextRequest): Promise<NextResponse> {
  const signature = request.headers.get('X-MICROCMS-Signature') || request.headers.get('x-microcms-signature') || ''
  const body = await request.json()
  const expectedSignature = createHmac('sha256', secret)
    .update(typeof body === 'object' ? JSON.stringify(body) : body)
    .digest('hex')

  if (!timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) {
    return NextResponse.json({ error: 'Invalid token' }, { status: 401 })
  }

  const contact = toNewContents<Contact>(body as never).publishValue

  await sendMail({
    to: contact.email,
    from: 'test@local.co.jp',
    subject: 'お問い合わせいただきありがとうございます!',
    text: `${contact.name}様 お問い合わせいただきありがとうございます!`,
  })

  return NextResponse.json({ status: 200 })
}

https://document.microcms.io/manual/webhook-setting#hb2d39bd6cc

シークレット値が設定されている場合のみ、リクエストのヘッダにX-MICROCMS-Signature: <COMPUTED_HASH>が付与されます。
※ペイロードはmicroCMSがシークレット値とリクエストボディからSHA-256を使用したハッシュベースのHMACで生成します。

リクエストヘッダ「X-MICROCMS-Signature」は、Webhookの種類によっては、「x-microcms-signature(全て小文字)」の表記で送信される場合がございます。
本ヘッダの処理にあたっては、大文字・小文字を区別しない形での実装をお願い申し上げます。

kodukakoduka

"source.fixAll.eslint": "explicit"を追加することで、保存時にclassNameがソートされる。

.vscode/settings.json
"settings": {
    "editor.codeActionsOnSave": {
        "source.fixAll.eslint": "explicit",
    },
},

callees: ["cn"]を追加することで、cn関数内のclassNameもソート対象にすることができる。

.eslintrc.json
settings: {
    tailwindcss: {
        callees: ["cn"],
    },
},

https://www.gaji.jp/blog/2024/03/29/18899/

このスクラップは4ヶ月前にクローズされました