Shopifyで簡単なレビューアプリを作成して、テーマアプリ拡張機能について学習する
こんにちは。
前回、Shopifyアプリの公式チュートリアルをやってみて、「テーマ側にアプリのデータを表示したり入力させるのってどうやるんだ?」って思ったので、超簡単にレビューアプリを作成しながら学習することにしてみました。
はじめに
環境構築
今回の記事では、Shopifyアプリのphpテンプレートを利用してます。
アプリの構築からローカルまでの立ち上げは、以下の記事の「ローカルサーバーを起動する」までをお読みいただければと思います。
theme app extensionsについて
テーマ アプリ拡張機能を使用すると、販売者は、Liquid テンプレートやコードを操作することなく、動的要素をテーマに簡単に追加できます。たとえば、動的要素には、製品のレビュー、価格、評価、または製品のインタラクティブな 3D モデルが含まれます。テーマ アプリ拡張機能は、Shopify のOnline Store 2.0 参照テーマであるデフォルトのDawnテーマなど、Online Store 2.0 テーマと統合できます。
これでアプリ埋め込めるんじゃない!?ってことでやっていきたいと思います。
他の無料アプリで、テーマ側に動的要素を表示しているのは確認してたので、できることは知ってましたが、初心者の私は辿り着くまでに数十分かかりました。
ざっくり要件を考える
ドキュメントを読みながら、以下なら実現可能だということで、これを元に作成していきます。
- テーマの商品画面にレビュー入力フォームを表示
- テーマ側で入力されたレビュー情報をアプリにPOSTし、データベースに登録
- レビュー情報登録時に、該当商品のレビュー平均を計算し、該当商品のカスタムデータにレビュー平均とレビューの総数を登録
- テーマの商品画面にレビュー平均と、総レビュー数を表示
- アプリの管理画面にレビューの一覧を表示
POSTするところが一番難しそう。。
このApp Proxyってやつが肝になりそうです。
theme app extensionsを導入する
さっそくtheme app extensionsなるものを使用開始して、テーマにアプリを埋め込んでいきます。
こちらの手順に従います。
前提条件
前提条件は以下になっていました。
- パートナー アカウント作成済。
- 生成されたテスト データを使用する開発ストアが作成済み。
- Shopify CLI 3.0 以降を使用するアプリを作成済み
- JSON テンプレートを使用し、アプリ ブロックをサポートするDawnなどの Online Store 2.0のテーマをインストール済み。
- テーマ アプリ拡張フレームワークを理解していること。
新しい拡張機能を作成する
- 作成したアプリのディレクトリで以下のコマンドを実行します。
$ npm run shopify app generate extension
- 拡張するタイプを聞かれるので、下の方にあるtheme app extensionを選択します。
- 拡張機能の名前を聞かれるので、任意の名前をつけます。
ここまでの手順を踏むと、extensionsというディレクトリが構築されます。
サンプルで、商品のレビューの埋め込みが入ってました。
やろうと思ってたことと被っててラッキーです。
└── extensions
└── my-theme-app-extension
├── assets
├── blocks
├── snippets
├── locales
├── package.json
└── shopify.extension.toml
ファイル構成についての詳細はこちら
- プレビューで確認
$ npm run dev
devコマンドで、拡張機能のdeployまでやってくれるので非常に便利です。
テーマの商品詳細ページで以下のようにアプリブロックが追加されているので、追加します。
以上で導入できました!
入力フォームの作成
テーマ側にレビュー内容の入力フォームを作成します。
ちなみにShopifyのテーマは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する処理を実装します。
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だけは書きましたが。
.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
<?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を作成します。
<?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のどのストアからのリクエストかを保証しつつ、アプリへ転送してくれます。
では早速設定していきます。
- パートナー ダッシュボードで、アプリ管理をクリックします。
- アプリの名前をクリックします。
- アプリの設定をクリックします。
- 「アプリプロキシ」セクションに移動します。
- サブパスのプリフィックスは「apps」、サブパスは「app-php-reviews」に設定します。
- プロキシURLは「URL」セクションの「アプリURL」と同じURLにします。
- Saveボタンを押して保存
これで「ストアURL/apps/app-php-reviews」以降のパスは指定されたプロキシ URL にプロキシされます。
入力フォームのsubmitを先ほど実装しましたが、fetchで指定してあるURLがこれです。
digital signatureの計算
これで、アプリ側へリクエストが可能になりましたが、リクエストが Shopify からのものであることを確認する必要があります。
ドキュメントに計算方法が書いてありましたが、なぜかRubyのコードしか書いておりませんでした。
まず、署名の検証にはshopifyのapiキーが必要なので、configに設定します。
web/config/shopify.phpにの配列に以下を追加します。
"apikey" => env('SHOPIFY_API_SECRET') // 追加
続いて、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に設定します。
/**
* 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として、レビュー入力時のパスを設定します。
protected $except = [
'api/graphql',
'api/webhooks',
'api/reviews/create' // 追加
];
routeの設定
リクエストを受ける準備ができたので、routeの設定をします。
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に以下に書き換えます。
<?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として作成します。
<?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のように呼び出すことができるので、レビューの表示は問題なさそう。
詳しくはこちら
あとは大元のReviewControllerを作成して、諸々登録を実装します。
<?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);
}
}
テーマに商品の評価を表示する
上記までで、レビューの登録が完了し、商品のカスタムデータにもレビューの平均と総数が保存されてるはずなので、テーマに表示してみます。
-{% 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">
表示できました!
アプリ管理画面にレビュー一覧を表示
ここまでで本来やりたかったことはできたので、一覧の実装については割愛したいと思います。
万が一ご希望あれば追記しますが、以下の記事でやってることとさして変わりがないので、、
さいごに
shopifyアプリがどういう仕組みなのか少しだけわかってきましたが、業務としてやるにはまだまだ学習が必要そうです。
日本語の情報が少ないので、学習コストちょっと僕には高いかも。。
しっかり理解してないのでまとまってない記事かもしれませんが、お読みいただきありがとうございました!
Discussion