【必読】なぜ今、Aura ではなく LWR を選ぶべきなのか?

【紅茶ECサイト 制作日誌 Vol.2】LWCで「美しい商品詳細ページ」を作る!CMS画像連携とStyling Hooksによるテーマ設計

SalesforceのLWR (Lightning Web Runtime) で構築する、架空の英国紅茶ブランドサイト 「The Royal Brew」

前回の記事では、標準機能とCSSハックでトップページを作成しました。

今回は、ECサイトの要である 「商品詳細ページ」 の開発です。

ブランドの世界観を完璧に表現するため、標準の「レコードの詳細」コンポーネントではなく LWC を開発してゼロから実装することにしました。

参考:今回のサンプルコード

クリックしてLWCのコードを展開する

1. royalProductDetail.js-meta.xml

LWRサイトで recordId を受け取るための重要な設定ファイルです。

<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>60.0</apiVersion>
    <isExposed>true</isExposed>
    <masterLabel>Royal Product Detail</masterLabel>
    <description>商品詳細を表示するLWR用コンポーネント</description>
    
    <targets>
        <target>lightningCommunity__Page</target>
        <target>lightningCommunity__Default</target>
    </targets>

    <targetConfigs>
        <targetConfig targets="lightningCommunity__Default">
            <property 
                name="recordId" 
                type="String" 
                label="Record ID" 
                description="The record ID from the URL." 
                default="{!recordId}" 
            />
        </targetConfig>
    </targetConfigs>
</LightningComponentBundle>

2. royalProductDetail.js

CMS画像のパス解決ロジックと、データの取得ロジックです。

import { LightningElement, api, wire } from 'lwc';
import { getRecord, getFieldValue } from 'lightning/uiRecordApi';
import basePath from '@salesforce/community/basePath'; // サイトのベースパス

// 商品オブジェクトの項目(環境に合わせてAPI名を変更してください)
import NAME_FIELD from '@salesforce/schema/Product2.Name';
import CODE_FIELD from '@salesforce/schema/Product2.ProductCode';
import DESC_FIELD from '@salesforce/schema/Product2.Description';
import FAMILY_FIELD from '@salesforce/schema/Product2.Family';
import IMAGE_FIELD from '@salesforce/schema/Product2.DisplayUrl'; 
import PRICE_FIELD from '@salesforce/schema/Product2.Price__c'; 

const FIELDS = [NAME_FIELD, CODE_FIELD, DESC_FIELD, FAMILY_FIELD, IMAGE_FIELD, PRICE_FIELD];

export default class RoyalProductDetail extends LightningElement {
    @api recordId; // meta.xmlの設定により、ここにURLのIDが入ります

    @wire(getRecord, { recordId: '$recordId', fields: FIELDS })
    product;

    // --- Getters ---

    get name() {
        return getFieldValue(this.product.data, NAME_FIELD);
    }
    
    get productCode() {
        return getFieldValue(this.product.data, CODE_FIELD);
    }

    get description() {
        return getFieldValue(this.product.data, DESC_FIELD);
    }

    get family() {
        return getFieldValue(this.product.data, FAMILY_FIELD);
    }

    // CMS画像のパス問題を解決するゲッター
    get imageUrl() {
        const rawUrl = getFieldValue(this.product.data, IMAGE_FIELD);
        
        if (!rawUrl) {
            return 'https://via.placeholder.com/600x600?text=No+Image';
        }

        // サイトのプレフィックス(basePath)と /sfsites/c を結合
        const sitePrefix = basePath === '/' ? '' : basePath;
        return `${sitePrefix}/sfsites/c${rawUrl}`;
    }

    // 通貨フォーマット
    get price() {
        const rawPrice = getFieldValue(this.product.data, PRICE_FIELD);
        if (!rawPrice) return '---';

        return new Intl.NumberFormat('ja-JP', {
            style: 'currency',
            currency: 'JPY'
        }).format(rawPrice);
    }

    get isLoading() {
        return !this.product.data;
    }
}

3. royalProductDetail.css

Styling Hooks(CSS変数)を使用した、テーマ変更に強いCSS設計です。 ※事前にエクスペリエンスビルダーで :root 定義を行っている前提とします。

/* 全体のコンテナ: 変数がない場合のフォールバック色も指定 */
.royal-pdp-container {
    max-width: 1200px;
    margin: 0 auto;
    padding: 2rem 1rem;
    font-family: 'EB Garamond', serif;
    color: var(--royal-navy, #0F1F3D);
}

/* 2カラムレイアウト */
.product-layout {
    display: flex;
    gap: 4rem;
    align-items: flex-start;
    flex-wrap: wrap;
}

/* 左カラム: 画像 */
.image-column {
    flex: 1;
    min-width: 300px;
}

.image-wrapper {
    background: #fff;
    padding: 2rem;
    border: 1px solid var(--royal-border, #E0E0E0);
    box-shadow: 0 10px 30px rgba(0,0,0,0.05);
    display: flex;
    justify-content: center;
    align-items: center;
}

.image-wrapper img {
    max-width: 100%;
    height: auto;
    max-height: 500px;
    object-fit: contain;
}

/* 右カラム: 情報 */
.info-column {
    flex: 1;
    min-width: 300px;
}

.product-category {
    font-family: 'Cinzel Decorative', cursive;
    color: var(--royal-gold, #C5A059);
    font-size: 1.1rem;
    letter-spacing: 0.1em;
    margin-bottom: 0.5rem;
    text-transform: uppercase;
    font-weight: 700;
}

.product-title {
    font-family: 'Cinzel Decorative', cursive;
    font-size: 3rem;
    line-height: 1.2;
    margin-bottom: 0.5rem;
    color: var(--royal-navy, #0F1F3D);
}

.product-description {
    font-size: 1.1rem;
    line-height: 1.8;
    margin-bottom: 2rem;
    color: #444;
}

/* 価格とアクション */
.action-area {
    margin-top: 2rem;
    border-top: 1px solid var(--royal-border, #eee);
    padding-top: 2rem;
}

.price-tag {
    font-family: 'Cinzel Decorative', cursive;
    font-size: 2.2rem;
    color: var(--royal-navy, #0F1F3D);
    margin-bottom: 1.5rem;
}

.price-tag .tax {
    font-size: 1rem;
    color: var(--royal-text-sub, #888);
    font-family: 'Lato', sans-serif;
}

/* カートボタン: 変数によるテーマ適用 */
.add-to-cart-btn {
    background-color: var(--royal-navy, #0F1F3D);
    color: #fff;
    border: none;
    padding: 1rem 3rem;
    font-family: 'Cinzel Decorative', cursive;
    font-size: 1.2rem;
    letter-spacing: 0.1em;
    cursor: pointer;
    transition: all 0.3s ease;
    width: 100%;
    font-weight: 700;
}

.add-to-cart-btn:hover {
    background-color: var(--royal-gold, #C5A059);
    color: var(--royal-navy, #0F1F3D);
}

4. royalProductDetail.html

構造の骨組みです。

<template>
    <div class="royal-pdp-container">
        
        <template lwc:if={isLoading}>
            <lightning-spinner alternative-text="Loading" size="medium"></lightning-spinner>
        </template>

        <template lwc:else>
            <div class="product-layout">
                <div class="image-column">
                    <div class="image-wrapper">
                        <img src={imageUrl} alt={name} />
                    </div>
                </div>

                <div class="info-column">
                    <div class="product-header">
                        <p class="product-category">{family}</p>
                        <h1 class="product-title">{name}</h1>
                        <p class="product-code">Item Code: {productCode}</p>
                    </div>

                    <div class="product-description">
                        <p>{description}</p>
                    </div>

                    <div class="action-area">
                        <p class="price-tag">{price} <span class="tax">(Tax incl.)</span></p>
                        <button class="add-to-cart-btn">Add to Cart</button>
                    </div>
                </div>
            </div>
        </template>
    </div>
</template>

今回の実装ポイント:LWRの流儀に従う

LWRサイトは、従来のAuraサイトとは異なり、Web標準技術(Shadow DOMなど)に厳格に準拠しています。そのため、開発のアプローチも変える必要があります。

  1. recordIdの取得: LWRサイト特有の「プロパティバインド設定」。
  2. CMS画像連携: 外部ユーザーにも画像を表示させるパス解決ロジック。
  3. Styling Hooks: 「Shadow DOMの壁」を越えてデザインを管理する設計。

Step 1: LWRで recordId を受け取る「お作法」

まず最初の関門です。社内用(Lightning Experience)のLWCでは @api recordId だけで動きましたが、LWRサイトでは動作しません。 js-meta.xml での明示的な設定 が必要です。

<targetConfigs>
    <targetConfig targets="lightningCommunity__Default">
        <property 
            name="recordId" 
            type="String" 
            label="Record ID" 
            default="{!recordId}" 
        />
    </targetConfig>
</targetConfigs>

この default="{!recordId}" が重要です。これにより、ビルダーがURL(例: /product/01t...)からIDを抽出し、コンポーネントに渡してくれるようになります。

Step 2: CMS画像のパス問題を解決する

次に商品画像です。
DisplayUrl 項目などにCMSの画像パス(/cms/delivery/media/...)が入っている場合、そのままでは表示されません。 サイトのパスプレフィックスLWR特有のパス を結合する必要があります。

// royalProductDetail.js
import basePath from '@salesforce/community/basePath'; // サイトのベースパス
const FIELDS = [..., IMAGE_FIELD]; // フィールド定義

// ...
    @api recordId;

    @wire(getRecord, { recordId: '$recordId', fields: FIELDS })
    product;

    get imageUrl() {
        const rawUrl = getFieldValue(this.product.data, IMAGE_FIELD);
        if (!rawUrl) return '';

        // basePath (例: /theroyalbrew) と /sfsites/c を結合
        const sitePrefix = basePath === '/' ? '' : basePath;
        return `${sitePrefix}/sfsites/c${rawUrl}`;
}

この処理を挟むことで、最終的に https://...site.com/theroyalbrew/sfsites/c/cms/delivery/media/MC... というURLになりCMS画像が取得できます。

Step 3: Styling Hooks で「色」を管理する

ここが今回のハイライトです。 LWRでは、Shadow DOMによってコンポーネントの内部スタイルが外部から隔離されています。そのため、「外部CSSで無理やり色を変える」という古い手法は通用しません。

代わりに、Salesforceが提供する Styling Hooks(CSSカスタムプロパティ) の仕組みを活用します。

戦略:標準フック vs 独自フック

通常は、Salesforce標準の --dxp-g-brand(テーマパネルのブランド色)などを使うのがセオリーです。 しかし今回は、「The Royal Brew」専用の厳密なカラーパレット(Royal Navy & Gold)を定義したいため、あえて 「独自のStyling Hooks」 を設計します。

グローバル変数の定義

エクスペリエンスビルダーの左側のパネルから [設定] > [詳細] > [ヘッドマークアップを編集] で、サイト全体のパレットを定義します。これが「色の水源」になります。

<style>
    :root {
        /* The Royal Palette */
        --royal-gold: #C5A059;       /* アクセント、ボタン、枠線 */
        --royal-navy: #0F1F3D;       /* メインテキスト、見出し、ボタン背景 */
        --royal-ivory: #FDFBF7;      /* 背景色 */
        --royal-text-sub: #888888;   /* 補足テキスト、パンくず */
        --royal-border: #E0E0E0;     /* 薄い境界線 */
	}
</style>
DXforce Point for Developers

もし、ビルダーの「テーマパネル」の色設定と連動させたい場合は、--royal-gold: var(--dxp-g-brand); のようにマッピングすることも可能です。

LWCでの使用(安全策付き)

LWC側では、直接カラーコードを書かずに変数を使います。

/* royalProductDetail.css */

.product-title {
    /* var(変数名, フォールバック色) */
    /* 変数が読み込めない時のために第2引数を指定するのがベストプラクティス */
    color: var(--royal-navy, #0F1F3D); 
    font-family: 'Cinzel Decorative', cursive;
}

.add-to-cart-btn {
    background-color: var(--royal-navy, #0F1F3D);
    color: #fff;
}

.add-to-cart-btn:hover {
    /* ホバー時は変数を切り替えるだけ */
    background-color: var(--royal-gold, #C5A059);
}

このように実装することで、将来「ゴールドの色味を少し変えたい」となった場合でも、LWCのコードを一切触ることなく、ビルダー上のCSSを1行修正するだけでサイト全体に反映されます。

完成画面

標準機能の制約から解放され、かつ保守性も高い「商品詳細ページ」が完成しました。

次回の制作日誌

デザインは完成しました。次は「機能」です。 「カートに入れる」ボタンを押した時、ヘッダーのカートアイコンの数字をどうやって増やすのか? コンポーネント間の通信技術 「Lightning Message Service (LMS)」 の実装に挑みます。

DXforceの管理人

福島 瑛二

2013年にJavaエンジニアとしてのキャリアをスタート。2019年にSalesforceと出会い、Salesforceエンジニアの道へ。

デザインや UI/UX の観点からもシステムを捉え、ユーザーにとって心地よい体験を実装することにやりがいを感じています。

CRM(顧客データ)や Data Cloud と連携した高度なサイトを目に見える形で表現できる Experience Cloud に大きな可能性を見出しており、バックエンドのデータ構造とフロントエンドの表現力を極めることがこれからの Salesforce エンジニアに求められるスキルだと確信しています。

Trailblazer: efukushima

福島 瑛二をフォローする

読者の声

タイトルとURLをコピーしました