🛍

Shopifyで簡単なレビューアプリを作成して、テーマアプリ拡張機能について学習する

2023/09/08に公開

こんにちは。
前回、Shopifyアプリの公式チュートリアルをやってみて、「テーマ側にアプリのデータを表示したり入力させるのってどうやるんだ?」って思ったので、超簡単にレビューアプリを作成しながら学習することにしてみました。

はじめに

環境構築

今回の記事では、Shopifyアプリのphpテンプレートを利用してます。
アプリの構築からローカルまでの立ち上げは、以下の記事の「ローカルサーバーを起動する」までをお読みいただければと思います。
https://zenn.dev/ryuwa/articles/shopify-php-tutorial#アプリを作成する

theme app extensionsについて

テーマ アプリ拡張機能を使用すると、販売者は、Liquid テンプレートやコードを操作することなく、動的要素をテーマに簡単に追加できます。たとえば、動的要素には、製品のレビュー、価格、評価、または製品のインタラクティブな 3D モデルが含まれます。テーマ アプリ拡張機能は、Shopify のOnline Store 2.0 参照テーマであるデフォルトのDawnテーマなど、Online Store 2.0 テーマと統合できます。

https://shopify.dev/docs/apps/online-store/theme-app-extensions

これでアプリ埋め込めるんじゃない!?ってことでやっていきたいと思います。
他の無料アプリで、テーマ側に動的要素を表示しているのは確認してたので、できることは知ってましたが、初心者の私は辿り着くまでに数十分かかりました。

ざっくり要件を考える

ドキュメントを読みながら、以下なら実現可能だということで、これを元に作成していきます。

  • テーマの商品画面にレビュー入力フォームを表示
  • テーマ側で入力されたレビュー情報をアプリにPOSTし、データベースに登録
  • レビュー情報登録時に、該当商品のレビュー平均を計算し、該当商品のカスタムデータにレビュー平均とレビューの総数を登録
  • テーマの商品画面にレビュー平均と、総レビュー数を表示
  • アプリの管理画面にレビューの一覧を表示

POSTするところが一番難しそう。。
このApp Proxyってやつが肝になりそうです。
https://shopify.dev/docs/apps/online-store/app-proxies

theme app extensionsを導入する

さっそくtheme app extensionsなるものを使用開始して、テーマにアプリを埋め込んでいきます。
こちらの手順に従います。
https://shopify.dev/docs/apps/online-store/theme-app-extensions/getting-started

前提条件

前提条件は以下になっていました。

  • パートナー アカウント作成済。
  • 生成されたテスト データを使用する開発ストアが作成済み。
  • Shopify CLI 3.0 以降を使用するアプリを作成済み
  • JSON テンプレートを使用し、アプリ ブロックをサポートするDawnなどの Online Store 2.0のテーマをインストール済み。
  • テーマ アプリ拡張フレームワークを理解していること。

新しい拡張機能を作成する

  1. 作成したアプリのディレクトリで以下のコマンドを実行します。
$ npm run shopify app generate extension
  1. 拡張するタイプを聞かれるので、下の方にあるtheme app extensionを選択します。
  2. 拡張機能の名前を聞かれるので、任意の名前をつけます。

ここまでの手順を踏むと、extensionsというディレクトリが構築されます。
サンプルで、商品のレビューの埋め込みが入ってました。
やろうと思ってたことと被っててラッキーです。

└── extensions
  └── my-theme-app-extension
      ├── assets
      ├── blocks
      ├── snippets
      ├── locales
      ├── package.json
      └── shopify.extension.toml

ファイル構成についての詳細はこちら
https://shopify.dev/docs/apps/online-store/theme-app-extensions/extensions-framework#file-structure

  1. プレビューで確認
$ npm run dev

devコマンドで、拡張機能のdeployまでやってくれるので非常に便利です。
テーマの商品詳細ページで以下のようにアプリブロックが追加されているので、追加します。

以上で導入できました!

入力フォームの作成

テーマ側にレビュー内容の入力フォームを作成します。
ちなみにShopifyのテーマはliquidという独自の言語で書かれてます。
まだ一回も勉強してないので、なんとなくで書いていきます。
extensions/拡張機能名/blocks配下にwrite_reviews.liquidファイルを作成し、入力フォームをテーマに埋め込めるようにします。
入力内容は

  • 名前
  • 評価
  • コメント

だけで簡素にしました。

extensions/拡張機能名/blocks/write_reviews.liquid
{{ 'style.css' | asset_url | stylesheet_tag }}
<div>
    <label for="review-name">Name: </label>
    <input id="review-name" type="text" placeholder="Name">
</div>
<div class="review">
    <label>Rating:</label>
    <div class="stars">
        <span>
            <input id="star5" type="radio" name="review-rating" value="5" checked><label for="star5"></label>
            <input id="star4" type="radio" name="review-rating" value="4"><label for="star4"></label>
            <input id="star3" type="radio" name="review-rating" value="3"><label for="star3"></label>
            <input id="star2" type="radio" name="review-rating" value="2" ><label for="star2"></label>
            <input id="star1" type="radio" name="review-rating" value="1" ><label for="star1"></label>
        </span>
    </div>
</div>
<div>
    <label for="review-comment">Comment: </label>
    <textarea id="review-comment" width="auto"></textarea>
</div>
<button onclick="submit({{product.id}})">Submit</button>

<script src="{{ 'product-reviews-form.js' | asset_url }}" defer></script>
{% schema %}
{
    "name": "Write Reviews",
    "target": "section"
}
{% endschema %}

scriptタグでproduct-reviews-form.jsを読み込んでますが、ここでsubmitする処理を実装します。

extensions/拡張機能名/assets/product-reviews-form.js
function submit(productId) {
  let inputData = {
    name: document.getElementById('review-name').value,
    comment: document.getElementById('review-comment').value,
    product_id: productId
  }

  const ratings = document.getElementsByName('review-rating');

  for (rating of ratings) {
    if (rating.checked) {
      inputData.rating = rating.value;
      break;
    }
  }


  const fetchOptions = {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(inputData)
  };
}

fetch("/apps/app-php-reviews/api/reviews/create", fetchOptions);

fetchAPIのURLについては後ほど説明します。
結構適当でエラー処理や成功時のアクションも何もしてませんが、とりあえず勉強したいのはテーマとアプリの連携なので省きます。
一応星のCSSだけは書きましたが。

extensions/拡張機能名/assets/style.css
.stars span{
  display: flex;
  flex-direction: row-reverse;
  justify-content: flex-end;
}

.stars input[type='radio']{
  display: none;
}

.stars label{
  color: #bab7b7;
  font-size: 20px;
  padding: 0 3px;
  cursor: pointer;
}

.stars input[type='radio']:checked ~ label{
  color: #ffc106;
}

これで、テーマ編集から商品詳細画面のアプリブロックに「Write Reviews」が追加されていると思うので、追加しておきます。
これで入力フォームの方は完成です。

DBテーブルとモデルの作成

先にレビュー内容を保存するテーブルとモデルを作成しておきます。

$ cd web
$ php artisan make:migration create_reviews_table --create=reviews
web/database/migrations/create_reviews_table.php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateReviewsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('reviews', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('shop_id');
            $table->string('product_id');
            $table->integer('rating');
            $table->text('comment');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('reviews');
    }
}

マイグレーションを実行します。

$ php artisan migrate

web/app/Models配下にReview.phpを作成します。

web/app/Models/Review.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Review extends Model
{
    protected $table = 'reviews';
    protected $fillable = [
        'name',
        'shop_id',
        'product_id',
        'rating',
        'comment'
    ];

    protected $dates = [
        'updated_at',
        'created_at'
    ];
}

App proxyを設定する

テーマ側へリクエストを送信したいわけですが、Shopifyからのリクエストだということを保証できなければなりません。
そこでShopify のApp proxyという機能を利用します。
Shopifyのどのストアからのリクエストかを保証しつつ、アプリへ転送してくれます。
https://shopify.dev/docs/apps/online-store/app-proxies

では早速設定していきます。

  1. パートナー ダッシュボードで、アプリ管理をクリックします。
  2. アプリの名前をクリックします。
  3. アプリの設定をクリックします。
  4. 「アプリプロキシ」セクションに移動します。
  5. サブパスのプリフィックスは「apps」、サブパスは「app-php-reviews」に設定します。
  6. プロキシURLは「URL」セクションの「アプリURL」と同じURLにします。
  1. Saveボタンを押して保存

これで「ストアURL/apps/app-php-reviews」以降のパスは指定されたプロキシ URL にプロキシされます。
入力フォームのsubmitを先ほど実装しましたが、fetchで指定してあるURLがこれです。

digital signatureの計算

これで、アプリ側へリクエストが可能になりましたが、リクエストが Shopify からのものであることを確認する必要があります。
ドキュメントに計算方法が書いてありましたが、なぜかRubyのコードしか書いておりませんでした。
まず、署名の検証にはshopifyのapiキーが必要なので、configに設定します。
web/config/shopify.phpにの配列に以下を追加します。

web/config/shopify.php
"apikey" => env('SHOPIFY_API_SECRET') // 追加

続いて、web/app/Http/MiddlewareにCalculateDigitalSignature.phpを作成して、リクエスト時に署名検証するように実装します。
もっといいやり方あったらご教示ください。

web/app/Http/Middleware/CalculateDigitalSignature.php
<?php

namespace App\Http\Middleware;

use App\Lib\AuthRedirection;
use App\Models\Session;
use Closure;
use Illuminate\Http\Request;
use Shopify\Utils;

class CalculateDigitalSignature
{
    /**
     * Checks if the shop in the query arguments is currently installed.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle(Request $request, Closure $next)
    {
        $sharedSecret = config('shopify.apikey');

        $queryParams = $request->query();
        $signature = $queryParams['signature'];
        unset($queryParams['signature']);

        $sortedParams = collect($queryParams)->map(function ($value, $key) {
            if (is_array($value)) {
                return $key . '=' . implode(',', $value);
            }
            return $key . '=' . $value;
        })->sort()->implode('');

        $calculatedSignature = hash_hmac('sha256', $sortedParams, $sharedSecret, false);


        if (!hash_equals($signature, $calculatedSignature)) {
            abort(401);
        }

        return $next($request);
    }
}

上記をKernel.phpに設定します。

web/app/Http/Kernel.php
/**
 * The application's route middleware.
 *
 * These middleware may be assigned to groups or used individually.
 *
 * @var array
 */
protected $routeMiddleware = [
  'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
  'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
  'can' => \Illuminate\Auth\Middleware\Authorize::class,
  'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
  'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
  'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
  'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
  'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,

  'shopify.auth' => \App\Http\Middleware\EnsureShopifySession::class,
  'shopify.installed' => \App\Http\Middleware\EnsureShopifyInstalled::class,
+ 'shopify.proxy' => \App\Http\Middleware\CalculateDigitalSignature::class,
];

これでdegital signature計算の準備ができました。
ついでにCSRF検証から除外する必要があるURIとして、レビュー入力時のパスを設定します。

web/app/Http/Middleware/VerifyCsrfToken.php
protected $except = [
        'api/graphql',
        'api/webhooks',
        'api/reviews/create' // 追加
    ];

routeの設定

リクエストを受ける準備ができたので、routeの設定をします。
web/routes/web.phpに以下のコードを追加します。

web/routes/web.php
use App\Http\Controllers\ReviewController;
...

Route::post('/api/reviews/create', [ReviewController::class, 'create'])->middleware('shopify.proxy');

ReviewControllerはこれから作成します。

レビュー情報の登録

レビューでPOSTされた情報を登録します。
まず、shopifyにカスタムデータを登録するためにはsessionが必要です。
web/app/Models/Session.phpに以下に書き換えます。

web/app/Models/Session.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Shopify\Auth\Session as AuthSession;

class Session extends Model
{
    use HasFactory;

    public function instantiateAuthSession()
    {
        $authSession = new AuthSession(
            $this->id,
            $this->shop,
            $this->is_online,
            $this->state
        );
        $authSession->setAccessToken($this->access_token);

        return $authSession;
    }
}

続いて、評価の平均計算や商品のカスタムデータへの登録処理をReviewHelperとして作成します。

ReviewHelper.php
<?php
namespace App\Lib;
use App\Models\Review;
use Shopify\Auth\Session;
use Shopify\Rest\Admin2023_07\Metafield;

class ReviewHelper
{
    public function calculateAverageRating ($shopId, $productId)
    {
        $reviews = Review::where([
            'shop_id' => $shopId,
            'product_id' => $productId
        ])->get();

        $reviewCount = count($reviews);
        $sumRaiting = 0;

        foreach($reviews as $review) {
            $sumRaiting += $review->rating;
        }
        $avarageRating = $sumRaiting / $reviewCount;
        $roundAvarageRating = round($avarageRating);

        return ([$reviewCount, $roundAvarageRating]);
    }

    public function createMetafieldAvgRating (Session $authSession, $productId, $roundAvarageRating)
    {
        $Metafield = new Metafield($authSession);
        $Metafield->product_id = $productId;
        $Metafield->description = '商品評価平均';
        $Metafield->namespace = "review";
        $Metafield->key = "avg_rating";
        $Metafield->value = json_encode([
            "value" =>  $roundAvarageRating,
            "scale_min" => 1,
            "scale_max" => 5
        ]);
        $Metafield->type = "rating";
        $Metafield->save(true);
    }

    public function createMetafieldCount (Session $authSession, $productId, $reviewCount)
    {
        $Metafield = new Metafield($authSession);
        $Metafield->product_id = $productId;
        $Metafield->description = '商品評価数';
        $Metafield->namespace = "review";
        $Metafield->key = "count";
        $Metafield->value = $reviewCount;
        $Metafield->type = "number_integer";
        $Metafield->save(true);
    }
}

商品の平均評価と評価総数を、商品のメタフィールドとして登録してます。
shopifyの商品データにカラムを追加して持たせるようなイメージです。
liquidファイルでblock.settings.product.metafields.review.avg_ratingのように呼び出すことができるので、レビューの表示は問題なさそう。
詳しくはこちら
https://shopify.dev/docs/apps/custom-data
あとは大元のReviewControllerを作成して、諸々登録を実装します。

web/app/Http/Controllers/ReviewController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Models\Review;
use Illuminate\Support\Facades\Log;
use Shopify\Clients\Rest;
use App\Models\Session;
use App\Lib\ReviewHelper;

class ReviewController extends Controller
{
    private $reviewHelper;

    public function __construct(ReviewHelper $reviewHelper)
    {
        $this->reviewHelper = $reviewHelper;
    }

    public function create(Request $request)
    {
        $requestData = $request->all();
        $session = Session::where('shop', $requestData['shop'])->first();

        if(!$session) {
            abort(401);
        }

        $authSession = $session->instantiateAuthSession();

        $productId = $requestData['product_id'];

        try {
            Review::create([
                'name' => $requestData['name'],
                'shop_id' => $session->id,
                'product_id' => $requestData['product_id'],
                'rating' => $requestData['rating'],
                'comment' => $requestData['comment']
            ]);

            [
                $reviewCount,
                $roundAvarageRating
            ] = $this->reviewHelper->calculateAverageRating($session->id, $requestData['product_id']);

            $this->reviewHelper->createMetafieldAvgRating($authSession, $productId, $roundAvarageRating);
            $this->reviewHelper->createMetafieldCount($authSession, $productId, $reviewCount);
        } catch (\Exception $e) {
            Log::error($e->getMessage());
            return response($e->getMessage(), 500);
        }

        return response('', 201);
    }
}

テーマに商品の評価を表示する

上記までで、レビューの登録が完了し、商品のカスタムデータにもレビューの平均と総数が保存されてるはずなので、テーマに表示してみます。

extensions/app-php-reviews-extension/blocks/star_rating.liquid
-{% assign avg_rating = block.settings.product.metafields.demo.avg_rating.value | round %}
+{% assign avg_rating = block.settings.product.metafields.review.avg_rating.value | replace: ',', '' | times: 1 | round %}
+
 <span style="color:{{ block.settings.colour }}">
   {% render 'stars', rating: avg_rating %}
 </span>
+({{block.settings.product.metafields.review.count}})
 {% if avg_rating >= 4 %}
   <br>
   <img src="{{ "thumbs-up.png" | asset_img_url: '15x' }}" height="15" width="15" loading="lazy">

表示できました!

アプリ管理画面にレビュー一覧を表示

ここまでで本来やりたかったことはできたので、一覧の実装については割愛したいと思います。
万が一ご希望あれば追記しますが、以下の記事でやってることとさして変わりがないので、、
https://zenn.dev/ryuwa/articles/shopify-php-tutorial

さいごに

shopifyアプリがどういう仕組みなのか少しだけわかってきましたが、業務としてやるにはまだまだ学習が必要そうです。
日本語の情報が少ないので、学習コストちょっと僕には高いかも。。

しっかり理解してないのでまとまってない記事かもしれませんが、お読みいただきありがとうございました!

GitHubで編集を提案

Discussion