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

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

コンテンツ・CMS

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

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

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

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

LWRって何?どんなメリットがあるの?
そんな疑問を解決するにはまずは以下の記事をご覧ください。
Salesforce LWRとは? Experience Cloudの次世代ランタイムを徹底解説

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

クリックして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をコピーしました
DXforce 案内エージェント