オーダーメイドリングのシミュレーターをJavaScriptでオブジェクト指向型に作ってみた

![Animation.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2713225/166dcb72-7c0d-8bc6-a267-86dbb31f85d6.gif)

実際のオーダーメイドリングのページ
https://www.rakuten.ne.jp/gold/million-bell/ordermade_ring.html

# こんな方におすすめ
JavaScriptのclassを用いたメソッドの再利用方法の学習・復習をしたい方

楽天Gold等のHTML・CSS・JavaScriptしか使えず、FTPでデータをアップロードする環境の中で動的なページを作りたい方

# 初めに
以前のオーダーメイドリングシミュレーターは静的なページを数十個用意して運用していたのですが、以下の理由で作り直そうと思いました。


・価格改定や商品仕様の変更があった際の編集作業が大変

・オーダーメイドリングの項目を選択度にページ遷移があるためスマホでのユーザビリティがあまり良くない

・UIが古臭くブランディングの観点でよろしくない


# 今回のページを作る際の指針

・ページ遷移がない動的なページにする
→JavaScriptを用いてオブジェクト指向型に作る。今回はreact風にコンポーネントを作り動的に再利用する仕様にしてみました。

・PC・スマホ両方でのユーザビリティが良いUIにする
→CSSとJavaScriptを併用して画面サイズに合わせて表示内容を変えます。


・価格改定や商品の仕様変更に柔軟に対応出来るようにする
→1000種類以上のオーダーメイドリングの情報をjson形式でまとめその情報を元に動的に表示するようにする。

# オーダーメイドリングの構成

・地金
・リングの幅
・リングの厚み
・彫り
・エッジ(縁)
・リングの形状
の6項目を順番に選び、最終的に商品ページに遷移する。

# 対応不可な指輪のパターンがある
・地金がシルバー925の場合、厚み1.2mmは対応できない。

・幅4mmの場合に対応できないエッジ(縁)がある。

これらの場合に、「○○なので、選べません」等の表示を行うメソッドも作ります。

# オーダーメイドリングのjsonデータの用意

jsonには全パータンの指輪の情報が入っています。
```ruby:list.json
[
	{
		"url":"yf4a-a",
		"price":"68000",
		"material":"14Kイエローゴールド",
		"type":"フラット",
		"width":"幅4mm",
		"thick":"厚み1.2mm",
		"cave":"スクロール",
		"edge":"カットアウト"
	},
	{
		"url":"pf4a-a",
		"price":"68000",
		"material":"14Kピンクゴールド",
		"type":"フラット",
		"width":"幅4mm",
		"thick":"厚み1.2mm",
		"cave":"スクロール",
		"edge":"カットアウト"
	},
 // ... （中略） ...
]
```
今回は、Excelで予めデータをまとめてVBAでjson化を行います。
価格改定等があった場合はまず、Excelで入力を行いjsonを作り直しそれを、FTPアップロードする方針です。


![excel.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2713225/c7d52800-b803-277d-db5d-b46855608ca1.png)

こうすることで、Excelが使える人なら誰でも金額変更ができます。

Excelからjsonに変換する方法はGoogle検索で情報が簡単に入手できるため今回は省させて頂きます。

楽天RMSのAPI等を使えば値段の修正も行わなくても良くなりますが、サーバサイドでライセンスキーの認証を行いAPIを叩くシステムを作る必要がございます。
時間的なリソースを多く使うため今回はAPIは使わない事にしました。

# 処理について

前置きが長くなってしまいましたが、今回の処理は以下のようになっております。

### json読込
初回読み込みまたは、オーダーメイドのボタンが押されると、リングの品番が1つだけリクエストされる。初回読み込みの場合は品番`yb4a-a`が読み込まれる

```ruby:ordermade.js
// json読み込み
const jsonUrl = "ordermade/js/list.json";

const getJson = (itemUrl) => {
    fetch(jsonUrl)
        .then(response => response.json())
        .then(data => formatJSON(data, itemUrl))
}

// 起動時の処理
window.addEventListener("load", getJson("yb4a-a"));

//各ボタンを押された時の処理
const change = (itemUrl) => { getJson(itemUrl); }
```

```ruby:ボタン部分のhtml
<li onclick="change('pb4a-a')" class="active">
	<img src="https://www.by-the-sea.info/images/ordermade/pink.webp">
	<p>14Kピンクゴールド</p>
	<p class="ex"></p>
	<span>+0円</span>
</li>
```

### リクエストされた品番のオブジェクトの取得
取得したjsonの中からリクエストされた品番のオブジェクトを取得し`state`と定義する。
```ruby:ordermade.js
// jsonを整形して表示する
function formatJSON(json, itemUrl) {

    //リクエストされた品番のオブジェクトをjsonから取り出す
    const getJson = json.filter(function (data) {
        return data.url == itemUrl;
    });

    //選択中の商品
    const state = getJson[0];
```

```ruby:state
{
	"url":"yf4a-a",
	"price":"68000",
	"material":"14Kイエローゴールド",
	"type":"フラット",
	"width":"幅4mm",
	"thick":"厚み1.2mm",
	"cave":"スクロール",
    "edge":"カットアウト"
}
```



### 必要な情報のみをまとめた配列を作る

例えば、地金を選ぶボタンを作る際に必要な情報を取得する場合は、選択中の商品`state`のプロパティの値でjsonのデータから地金以外が一致するオブジェクトを`array_filter`で絞り込むと取得できます。

```ruby:ordermade.js
    //素材に関する処理
    //jsonから必要なデータ取り出し
    const materials = json.filter(function (data) {
        return data.type == state.type && data.width == state.width && data.thick == state.thick && data.cave == state.cave && data.edge == state.edge;
    });
```
取得した地金の配列は`materials`と定義する。

```ruby:materials
[
    {
        "url": "yb4a-a",
        "price": "65000",
        "material": "14Kイエローゴールド",
        "type": "バレル",
        "width": "幅4mm",
        "thick": "厚み1.2mm",
        "cave": "スクロール",
        "edge": "カットアウト"
    },
    {
        "url": "pb4a-a",
        "price": "65000",
        "material": "14Kピンクゴールド",
        "type": "バレル",
        "width": "幅4mm",
        "thick": "厚み1.2mm",
        "cave": "スクロール",
        "edge": "カットアウト"
    },
    {
        "url": "wb4a-a",
        "price": "65000",
        "material": "14Kホワイトゴールド",
        "type": "バレル",
        "width": "幅4mm",
        "thick": "厚み1.2mm",
        "cave": "スクロール",
        "edge": "カットアウト"
    },
    {
        "url": "gb4a-a",
        "price": "65000",
        "material": "14Kグリーンゴールド",
        "type": "バレル",
        "width": "幅4mm",
        "thick": "厚み1.2mm",
        "cave": "スクロール",
        "edge": "カットアウト"
    },
    {
        "url": "sb4a-a",
        "price": "0",
        "material": "シルバー925",
        "type": "バレル",
        "width": "幅4mm",
        "thick": "厚み1.2mm",
        "cave": "スクロール",
        "edge": "カットアウト"
    }
]
```

### バリデーション

先ほどと同様、地金の例で説明すると厚み1.2mmの場合、シルバー925は選択ができません。
`materials`の中の複数のオブジェクトを`map関数`で繰り返しチェックを行います。
if文で
・「シルバー925」かつ「厚みが1.2mm」の場合は**選択不可コンポーネント**にデータを渡し、
・それ以外は**選択可能コンポーネント**へオブジェクトの情報を渡します。

```ruby:ordermade.js
    //バリデーション＆コンポーネント呼び出し
    const materialComponent = (meterials) => {
        const materialList = meterials.map((material) => {
            if (material.material == 'シルバー925' && material.thick == '厚み1.2mm') {
                //選択できないボタンのhtmlコンポーネント生成
                return new Component(state).disabledComponent(
                    material.url,
                    material.material,
                    '厚み1.2mmを選択中のため不可'
                );
            }
            else {
                //選択可能なボタンのhtmlコンポーネント生成
                return new Component(state).buttonComponent(
                    material.url,
                    material.material,
                    differencePrice(material.price)
                );
            }
        });
        //ボタンをまとめる
        return new Component(state).joinComponent(materialList, 'material');
    }
```
こちらのような処理を幅・厚み・彫り・エッジ・形状も同様に行います。


### ボタンコンポーネントの生成

![555.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2713225/e61fd63b-6bb1-831c-35f9-c2eefaee1ea9.png)

画像の右側部分に表示するボタンコンポーネントの生成を行います。
取り出した各項目事に、オーダーメイドリングの項目を選ぶボタンを生成する。
ここで、Componetクラスを繰り返しインスタンス化して各項目のボタンを動的に生成しています。

![buttonComponent （選択可能）.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2713225/73a2daf4-e309-1ef8-82fd-c20fb659b77b.jpeg)

```ruby:component.js
const Component = class {

    constructor(state) {
        this.state = state;
    }

    //リンク先のサイト
    site() {
        return 'https://item.rakuten.co.jp/million-bell/' + this.state.url + '/';
    }

    //現在のリングの画像と値段
    itemComponent() {
        return (`
                <h2 class="kanseiImage">完成イメージ</h2>
                <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
                    stroke="currentColor">
                    <path stroke-linecap="round" stroke-linejoin="round"
                        d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607zM10.5 7.5v6m3-3h-6" />
                </svg>
                <img src="ordermade/images/item_lording.gif" data-src="https://www.by-the-sea.info/images/item/ordermade/` + this.state.url + `.webp" height="250px" class="lazyload sp_only">
                <img src="ordermade/images/item_lording.gif" data-src="https://www.by-the-sea.info/images/item/ordermade/` + this.state.url + `.webp" width="100%" class="lazyload pc_only">
                <p class="sp_price"><span>` + Number(this.state.price).toLocaleString() + `</span>円(税込)</p>
                `);
    }

    //現在の選択中のリングの情報
    statusComponent() {
        return (`
            <ul class="status">
                <li>地金：` + this.state.material + `</li>
                <li>幅：` + this.state.width + `</li>
                <li>厚み：` + this.state.thick + `</li>
                <li>彫り：` + this.state.cave + `</li>
                <li>エッジ：` + this.state.edge + `</li>
                <li>形状：` + this.state.type + `</li>
            </ul>
            <p class="price"><span>` + Number(this.state.price).toLocaleString() + `</span>円（税込）</p>
            <p style="font-size:12px; margin-bottom:30px;">※完成イメージは合成画像のため、実際の商品とは色合い・彫り・デザインが多少異なる場合がございます。予めご了承下さい。</p>
            `);
    }

    //選択ボタンをまとめる
    joinComponent(array, string) {

        const nextStep = (next) => {
            return (`
                    <div align="center" onclick="tab('`+ next + `')" >
                            <div class="next_step">次のSTEPへ
                                <span class="u-icon--arrow-right"></span>
                            </div>
                    </div>
                    `);
        }

        const tubContent = (prop) => {
            switch (prop) {
                case 'material':
                    return {
                        step: 'STEP1',
                        title: '地金を選ぶ',
                        content: nextStep('.widthComponent')
                    }
                case 'width':
                    return {
                        step: 'STEP2',
                        title: '幅を選ぶ',
                        content: nextStep('.thickComponent')
                    }
                case 'thick':
                    return {
                        step: 'STEP3',
                        title: '厚みを選ぶ',
                        content: nextStep('.caveComponent')
                    }
                case 'cave':
                    return {
                        step: 'STEP4',
                        title: '彫りを選ぶ',
                        content: nextStep('.edgeComponent')
                    }
                case 'edge':
                    return {
                        step: 'STEP5',
                        title: 'エッジ（縁）を選ぶ',
                        content: nextStep('.typeComponent')
                    }
                case 'type':
                    return {
                        step: 'STEP6',
                        title: '形状を選ぶ',
                        content:
                            `
                            <h3>バレルとフラットのリング断面図</h3>
                            <div class="margin_bottom_10"></div>
                            <p class="ex">バレルはフラットから角をとっているため柔らかな印象ですが、同じ厚みでもフラットの方より厚い印象に仕上がります。</p>
                            <table border="0" cellspacing="10" style="text-align:center;">
                                <tbody>
                                    <tr>
                                        <td>バレルの断面図<img src="https://www.by-the-sea.info/images/ordermade/barrel_.webp"
                                                width="100%"></td>
                                        <td>フラットの断面図<img src="https://www.by-the-sea.info/images/ordermade/flat_.webp"
                                                width="100%">
                                        </td>
                                    </tr>
                                </tbody>
                            </table>
                            <span class="pc_only"><br><br>`+ this.buyComponent() + `</span>
                            `,
                    }
            }
        }
        return (`
        <div class="selects">
            <h2><span class="step">` + tubContent(string).step + `</span>` + tubContent(string).title + `</h2>
            <ul class="horizontal_scroll">` + array.join('') + `</ul>
            ` + tubContent(string).content + `
        </div>
        `);
    }


    //選択するボタン
    buttonComponent(url, content, difference) {
        return (`
                <li onclick="change('`+ url + `')" ` + this.buttonSelected(url) + ` class="active">
                    <img src="https://www.by-the-sea.info/images/ordermade/`+ this.buttonImage(content) + `.webp">
                    <p>`+ content + `</p>
                    <p class="ex">`+ this.subContent(content) + `</p>
                    <span>`+ difference + `</span>
                </li>
                `);
    }

    //選択できない時用のボタン
    disabledComponent(url, content, error) {
        return (`
            <li  ` + this.buttonSelected(url) + `>
                <img src="https://www.by-the-sea.info/images/ordermade/`+ this.buttonImage(content) + `.webp" style="opacity: 0.2;">
                <p>`+ content + `</p>
                <p class="ex">`+ this.subContent(content) + `</p>
                <span style="color: #FF0000;">`+ error + `</span>
            </li>
        `);
    }

    //選択中のボタン
    buttonSelected(url) {
        if (url == this.state.url) {
            return 'class="selected"'
        }
        else {
            return '';
        }
    }

    //ボタン画像の切替
    buttonImage(content) {
        switch (content) {
            case '14Kイエローゴールド':
                return 'yellow';
            case '14Kピンクゴールド':
                return 'pink';
            case '14Kホワイトゴールド':
                return 'white';
            case '14Kグリーンゴールド':
                return 'green';
            case 'シルバー925':
                return 'silver';
            case 'フラット':
                return 'flat';
            case 'バレル':
                return 'barrel';
            case '幅4mm':
                return '4mm';
            case '幅6mm':
                return '6mm';
            case '幅8mm':
                return '8mm';
            case '厚み1.2mm':
                return '1.2mm';
            case '厚み1.5mm':
                return '1.5mm';
            case '厚み2mm':
                return '2mm';
            case 'スクロール':
                return 'scroll';
            case 'マイレリーフ':
                return 'maile';
            case 'マイレ＆カレイキニ':
                return 'maile_karei';
            case 'プルメリア':
                return 'plumeria';
            case 'カレイキニ':
                return 'kareikini';
            case 'カットアウト':
                return 'cutout';
            case 'プレーン':
                return 'plane';
            case 'ダイヤモンドカット':
                return 'diacut';
            case 'コインエッジ':
                return 'coinedge';
            case 'ノーエッジ':
                return 'noedge';
            default:
                return '';
        }
    }

    //ボタンのサブコンテンツ
    subContent(content) {
        switch (content) {
            case 'フラット':
                return '表面が平らなタイプ。厚みが強調されより重厚で立体的な印象に仕上がります。';
            case 'バレル':
                return '付け心地のよい丸みを帯びた樽型。フラットより地金量が少ないためよりリーズナブルです。';
            case 'スクロール':
                return '永遠に途切れることのない愛を意味します。';
            case 'マイレリーフ':
                return '大切な人との絆を意味する神聖な葉のモチーフです。';
            case 'マイレ＆カレイキニ':
                return 'スクロールとマイレリーフの両方が楽しめます。';
            case 'プルメリア':
                return '大切な人の幸せを願う意味が込められております。';
            case 'カレイキニ':
                return '力強い波を表現することから「パワー」を意味します。';
            case 'カットアウト':
                return '柄に沿って縁をカットしており彫りが強調されます。';
            case 'プレーン':
                return '両サイドに柄が入っていないラインが入ります。';
            case 'ダイヤモンドカット':
                return '両サイドに細かくカットを施します。';
            case 'コインエッジ':
                return '両サイドに硬貨の縁のような溝状の模様を施します。';
            case 'ノーエッジ':
                return 'エッジに加工を施さない、柄のみのシンプルなデザイン';
            default:
                return '';
        }
    }

    //購入ページのボタン
    buyComponent() {
        return (`
                <div align="center">
                    <a href="`+ this.site() + `" class="buy-btn">
                        購入ページへ
                    </a>
                </div>
            `);
    }
}
```


### 生成した各項目の情報をhtmlに反映する。

生成したコンポーネントを`querySelector`を使いhtmlに反映を行います。
```ruby:ordermade.js
    //現在の商品のステータスをページ上に反映
    document.querySelector(".itemComponent").innerHTML = new Component(state).itemComponent();
    document.querySelector(".stateComponent").innerHTML = new Component(state).statusComponent();
    document.querySelector(".sp_stateComponent").innerHTML = new Component(state).statusComponent();
    document.querySelector(".materialComponent").innerHTML = materialComponent(materials);
    document.querySelector(".widthComponent").innerHTML = widthComponent(widths);
    document.querySelector(".caveComponent").innerHTML = caveComponent(caves);
    document.querySelector(".edgeComponent").innerHTML = edgeComponent(edges);
    document.querySelector(".thickComponent").innerHTML = thickComponent(thicks);
    document.querySelector(".typeComponent").innerHTML = typeComponent(types);
    document.querySelector(".buyComponent").innerHTML = new Component(state).buyComponent();
```
以上が大まかな流れです。
全体の詳細は知りたい方は、私のgithubをご確認下さい。

https://github.com/kairuec/ordermade


# jsonを取得する際の注意

ローカル環境のChromeでページを表示するとcrosエラーでjsonが読み込めません。以下をご参考ください。

https://monomonotech.jp/kurage/memo/m230329_webpage_imageview_without_server.html

jsonと、表示ページのドメインが違う場合もcrosエラーで上手く行きません。(楽天goldにアップしたjsonをYahooのトリプルのページで使う等)

# まとめ

domの内容一部変更する処理と比較して、javascriptで生成したhtmlデータを反映する方法の良い点は、生成するhtmlに直接、変数の値を入れたりmap関数等でループ処理が出来るところだと改めて思いました。

最後になりますが、当店のオーダーメイドリングもご検討頂けましたら幸いです🙇‍♀