屋外イベントで使えるスタンプカードシステムを静的HTMLで作った

# はじめに
とあるイベントの運営に関わらせてもらって、**スタンプカードがほしいなー** となりました。チェックポイントでQRコードを読んだら、スタンプが押されていく仕組み。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/722610/3699d699-ad81-40d4-857c-946260790973.png)
こんなのが、QRを読んだ後に出てくればよし。（↑は最終的にできたシステム）

動作の確認はこちら。

https://yo16.github.io/stamp_card/


ウェブサービスを調べると、有料だったり、過剰だったり、ログインとかユーザー登録とかさせたくないし。そんなヘビーなものを求めてないんですよね。

考えてみたら、**静的HTMLでも簡単にできる** んじゃね？ということで、やってみたらできましたの話。**欲する人は多いけど、エンジニア的には簡単**、というおいしい話です。

ソースはこちらにあります。

https://github.com/yo16/stamp_card


# 要件の整理
- スタンプは、特定のURLへアクセスする（QRコードを読み取る）ことで押すことになる
- スタンプの設置台ごとのURLは、`card.html?stampId=stamp1`など
- 設置台ごとにIDが異なり、押されるスタンプの絵も異なる
- ユーザーは、自分でスタンプの状態を確認できる
- イベントは１日だけで、小規模なものなので、ズルをしてたくさんスタンプを押す対策などは、あんまり考えなくてよい

# 方法概要
- クッキーを使って、ユーザー（のスマホ）を識別する
- 同様にクッキーを使って、スタンプを押した状態を保持する
- URLパラメータからスタンプ台を認識して、どのスタンプを押したかを識別する

いや、大したことなさすぎて震えます。


# 詳細
私はReactでやったのでReactで説明しますが、素のJavaScriptでもできちゃう、むしろその方がいいまであるレベルです。

本当に細かいところは、githubをご覧ください。

## state
ユーザーIDとスタンプID配列を`useState`で定義します。初期値は、クッキーから取得。初回アクセス時は、ユーザーIDを生成したいので、getOrCreateです。
```TypeScript:StampCard.tsxの一部
const [userId, setUserId] = useState<string>(
    getOrCreateUniqueUserId()
);
const [stampIds, setStampIds] = useState<string[]>(
    getCookieValues("stamp_ids")
);
```

一応`getOrCreateUniqueUserId()`と`getCookieValues()`も載せておきます。
```TypeScript:cookieUtils.tsの一部
// クッキーを設定する
export const setCookie = (key:string, value:string) => {
    document.cookie = `${key}=${value}; ` +
      `path=/; max-age=${COOKIE_EXPIRATION_TIME}`;
}

// クッキーからユーザーIDを取得する or 作成する
export const getOrCreateUniqueUserId = ():string => {
    const key = 'user_id';
    const match = document.cookie.match(new RegExp(`${key}=([^;]+)`));
    if (match) return match[1];
    const id = crypto.randomUUID();

    // １日だけ有効なクッキーを作成する
    setCookie(key, id);

    return id;
}

// クッキーから文字列の配列を取得する
export const getCookieValues = (key: string):string[] => {
    const match = document.cookie.match(new RegExp(`${key}=([^;]+)`));
    if (match) return match[1].split(',');
    return [];
}
```


## 新しいスタンプが押された処理
URLパラメータとして、`/?stampId=stamp1`などと`stampId`が渡ってくるので、これを`useEffect()`で受け取って、stateの`stampIds`を更新します。
```TypeScript:StampCard.txtの一部
// 新しいスタンプを、stampIdsに追加し、クッキーを更新する
const addNewStamp = (newStampId:string) => {
    // 今回のスタンプIDが、存在していなければ追加する
    if (!stampIds.includes(newStampId)) {
        const newStampIds = [...stampIds, newStampId];

        // state値を更新
        setStampIds(newStampIds);

        // クッキーを更新
        setCookie('stamp_ids', newStampIds.join(','));
    }
}

useEffect(() => {
    // URLパラメータからstampIdを取得
    const params: URLSearchParams = new URLSearchParams(
        window.location.search
    );
    const newStampId: string | null = params.get("stampId");
    
    if (newStampId) {
        // スタンプIDが指定されている場合、stampIdsに追加し、クッキーを更新する
        addNewStamp(newStampId);
    }
}, []);
```
Cookieには、`user_id`として`"6477f618-be29-4e59-a7d8-d29afdda271e"`のようなUUID文字列が、`stamp_ids`として`"stamp1,stamp2"`のようなカンマ区切りのスタンプID文字列が入ります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/722610/6714da5d-a631-4439-b4dd-4ed9847cd318.png)
(dev tool＞アプリケーション＞Cookieの画面)

## 描画
あとはStampID配列を描画するだけです。

スタンプカードに合わせて、スタンプIDごとに位置を定義しておいて、それをスタンプカードの画像の上に重ねて置くだけ。
```html: 完成形のhtmlイメージ
<div style="position: relative;">
  <img src="stamp_card.png" />
  <img src="stamp1.png"
    style="
      position: absolute;
      left: 291;
      top: 173;"
  />
</div>
```
これをReactで書くだけなので、TypeScriptのコードは割愛。

基本的にはここまでです。


# Next Step
機能を追加していきます。

## 1: 初期化
野外のイベントをやってたら何があるかわからないので、まっさらにしてやり直ししたいことがあるでしょうということで、URLパラメータとして`initialize`が渡ってきたら、全部消すという処理を追加しました。

```TypeScript:StampCard.tsxの一部
const [userId, setUserId] = useState<string>(getOrCreateUniqueUserId());
const [stampIds, setStampIds] = useState<string[]>(
    getCookieValues("stamp_ids")
);

useEffect(() => {
    // ・・・省略・・・

    // クッキーの削除パラメータがあった場合は、削除する
    const initializeParam: string | null = params.get("initialize");
    if (initializeParam) {
        deleteCookie('user_id');
        deleteCookie('stamp_ids');
        setUserId(getOrCreateUniqueUserId());
        setStampIds([]);
    }
}
```
```TypeScript:cookieUtils.tsの一部
// クッキーを削除する
export const deleteCookie = (key:string) => {
    document.cookie = `${key}=; path=/; max-age=0`;
}
```

## 2: Cookieの利用拒否
ブラウザの設定で、Cookieの利用を拒否できるので、それを検知したいと思いました。変なセキュリティ意識の高い人が拒否ってそう。イベント中にQRコードを読み込んで「何も起きないんですけどー！」とか発生しうる。

~~意識高い系の人のためになんて~~対応はめんどくさいですが、現場でトラブルが起きるよりは、事前にやれることとしてやっておきます。

クッキーを書いて、すぐ読むことができるかどうか、というチェックです。
```TypeScript:cookieUtils.tsの一部
// Cookieが有効かどうかを確認する
export const checkCookieEnabled = (): boolean => {
    const testKey = 'cookie_test';
    document.cookie = `${testKey}=1; max-age=10; path=/`;
  
    const match = document.cookie.match(new RegExp(`${testKey}=([^;]+)`));
    const result = !!match;
  
    // テスト用Cookieを削除（副作用防止）
    document.cookie = `${testKey}=; max-age=0; path=/`;
  
    return result;
};
```
それをstateに設定して、
```TypeScript:StampCard.tsxの一部
const [cookieEnabled] = useState<boolean>(checkCookieEnabled());
```
描画する。
```JSX:StampCard.tsxの一部
<div>
    <p>ブラウザ設定で、Cookieが有効かどうか</p>
    <p>{cookieEnabled ? '有効' : '無効'}</p>
</div>
```
「クッキーが無効なので使えませーん！」というメッセージを出す感じです。


## 3: 利用ログ収集
ちょっと別の視点で、スタンプが押されたら、Google APIを使ってBigQueryへデータを蓄積したいと思いました。このデータはクライアントでは使用せず、後日、分析に使う。行動の時系列データなので分析しがいがありそうです。売り上げと重ねたら面白い。

方法は、静的HTMLでも、どこかへPOSTするだけならできます。GASへPOSTして、GASがBQに入れればいいです。レコード件数が少なければ、Spread Sheetでもいい。とにかくここでは、POSTすることの技術的な確認をします。

`useEffect()`から、次の関数を呼びます。

```TypeScript:postStamp.ts
// スタンプ情報をPOSTするURL
const POST_URL = "http://hoge.com/api/stamp";

// スタンプが追加された旨をPOSTする（非同期で実行し、クライアント側へは特に通知しない）
export const postStamp = (userId:string, stampId:string) => {
    fetch(POST_URL, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ userId, stampId }),
    })
    .then((res) => {
        if (!res.ok) throw new Error('通信エラー');
        return res.json();
    })
    .then(() => {
        console.log(`スタンプ「${stampId}」を獲得しました！`);
    })
    .catch(() => {
        console.log('通信に失敗しました。もう一度お試しください。');
    });
}
```
`console.log()`は、出しても出さなくてもいいけど。


# ところで、Cookieの使用許諾？
最近よく「Cookieを使っていいか？」っていうメッセージ（同意バナーと呼ぶ）が出ますので、あれの説明。

まず、**ブラウザの設定でCookieの使用可否**。これが唯一の技術的な壁です。ここで拒否されていたら使えないし、許可されていたら使える。

**では「Cookieを使っていいか？」とは何か？** その質問に **「ダメ」と答えられたら、自主的に見ない** というだけです。そこでどう答えようと、技術的には見られる。

ではなぜそれを聞くかというと、個人情報の扱いの問題。Cookieは個人情報であると解釈されることもあるので（特にヨーロッパ圏、GDPR対応というキーワードで調べて）、**法的リスクの観点で危ない橋を渡らない** ように、事前に確認して、OKなら使うしダメなら使わないということです。

日本では、**IPアドレスは個人情報** です。これは確定。でも **それ以外は微妙** で、特に単独では、どちらかというと個人情報ではないものが多い。（←判断は自己責任でお願いします。）なので、今回のスタンプカードのクッキーを保存して読むのはセーフと、私は考えました。

ということで、私が使おうとしているのは小さなイベントでもあるし、同意バナーは出さず、勝手にUUIDとStampIDを保存して読むことにします。

# まとめ
技術的に簡単という話だったので、一瞬で終わりそうでしたが、改造の話を書いていたら長くなって疲れました。

スタンプカードの利用は、イベントではよくありそうだし、簡単に使えるのであれば使いたいですよね。私は、イベントのウェブサイトの１ページを使おうと思っています。

今ふと思いついたんですが、ウェブサイトの一部でもなく、本当にスタンプカードだけのシステムが欲しいなら、GASでhttpサーバー立てればできますね。コピペも簡単。忘れないうちに作っておこうかな🤔

ではよきスタンプライフを！