【プラグイン不要】複数商品に対応の簡易注文フォーム(金額合算機能付き)ContactForm7+カスタム投稿タイプ

「ECサイトを構築するまでの予算・規模はないけど、フォームだけで簡易注文を受けられるようにしたい!」
といったクライアントの要望って、実務で結構ありませんか?

私が実際経験した内容としては、

  • 注文フォームをつけたいけど、ECサイトを構築するほどではない商品規模
  • サイト側に決済機能をつけるほどではない規模
  • 送料は一律設定で問題ない
  • 注文商品や価格は変動するのでお客様自身で更新したい
  • プラグインのサブスク契約(例えばACF PRO)などをクライアントに負担させる規模でもない

という要望だったので、極力シンプルな設計でそれを実現する方法を考えました。

今回はContactForm7WordPress標準のカスタム投稿タイプだけで、簡易注文フォームを実装する方法をご紹介します。

この記事でできること👉️
  • カスタム投稿タイプで商品を管理(商品名・金額をシンプルに登録)
  • ContactForm7 のフォーム上に商品プルダウンを動的生成
  • 複数商品・数量指定に対応、金額をリアルタイム合算表示
  • 送信メールに注文内容を整形して届ける

完成イメージ

  • 商品プルダウン + 数量セレクト が1セット表示される
  • 「他の商品を追加する」ボタンで行を追加できる
  • 合計金額がリアルタイムで表示される
  • 送信ボタンを押すと管理者メールに注文内容が整形されて届く

決済システムはついていないので、注文フォーム送信後の返信メールで、銀行振込連絡先やURL型決済リンクを送らせるアナログ方式で運用することになります。
販売規模が小さい、手動で管理する、というお客様向きです。

前提・準備

  • WordPress が動いていること
  • ContactForm 7(CF7) がインストール済みであること
  • 子テーマを使用していること(functions.php の編集が必要なため)
  • 商品を設置する固定ページが用意されていること

手順

STEP 1:カスタム投稿タイプを登録する

まずは、子テーマの functions.php に以下を追記します。

php

// カスタム投稿タイプ「商品」を登録
function pdo_register_products_cpt() {
    $args = [
        'label'         => '商品',
        'labels'        => [
            'name'          => '商品',
            'singular_name' => '商品',
            'add_new_item'  => '商品を追加',
            'edit_item'     => '商品を編集',
            'new_item'      => '新しい商品',
            'all_items'     => 'すべての商品',
        ],
        'public'        => false,   // フロントに単独ページは不要
        'show_ui'       => true,    // 管理画面には表示する
        'show_in_menu'  => true,
        'supports'      => ['title', 'editor'], // タイトル(商品名)と本文(金額)だけ使う
        'menu_icon'     => 'dashicons-cart',
    ];
    register_post_type('pdo_product', $args);
}
add_action('init', 'pdo_register_products_cpt');

// 商品投稿タイプのみクラシックエディタ(非ブロックエディタ)にする
add_filter('use_block_editor_for_post_type', function($use_block_editor, $post_type) {
    if ($post_type === 'pdo_product') {
        return false;
    }
    return $use_block_editor;
}, 10, 2);
ポイント
  • public => false にすることで、商品の個別ページがフロントに表示されません。
    管理画面の登録専用として使います。
  • supportstitleeditor だけ指定しています。
    タイトルに商品名、本文に金額(数値のみ)を入力してもらう想定です。

STEP 2:商品データを登録する

管理画面のサイドメニューに「商品」が追加されているので、ここから商品を登録します。

  • タイトル → 商品名を入力(例:「Aセット」)
  • 本文 → 金額を数値のみ入力(例:7700
注意:本文欄への入力はテキストモードを推奨します

ビジュアルエディタやコピペで余計なHTMLタグが混入しても、PHPで自動除去しますが、念のため数値のみ入力してください。余計なタグが混入した時、不具合が発生する可能性があります。

STEP 3:固定ページテンプレートにPHP+JSを実装する

フォームを設置する固定ページ用のテンプレートファイルを子テーマに作成し、 以下のコードを wp_footer アクションの直前(フッター付近) に貼り付けます。

<?php
// 商品データをカスタム投稿タイプから取得する
$products = [];
$query = new WP_Query([
    'post_type'      => 'pdo_product',
    'posts_per_page' => -1,           // 全件取得
    'post_status'    => 'publish',
    'orderby'        => 'menu_order title',
    'order'          => 'ASC',
]);

if ($query->have_posts()) {
    while ($query->have_posts()) {
        $query->the_post();
        $products[] = [
            'name'  => get_the_title(),
            // 本文から余計なタグ・空白を除去して数値だけ取り出す
            'price' => intval(trim(strip_tags(get_the_content()))),
        ];
    }
    wp_reset_postdata();
}

$products_json = json_encode($products, JSON_UNESCAPED_UNICODE);
?>

<script type="text/javascript">
const productData = <?php echo $products_json; ?>;

document.addEventListener("DOMContentLoaded", function () {
    const productContainer = document.getElementById("product-container");
    const totalPriceSpan = document.getElementById("total-price");
    const addProductButton = document.getElementById("add-product");
    const hiddenField = document.getElementById("product-details");
    const formattedField = document.getElementById("formatted-product-details");

    if (!productContainer || !totalPriceSpan || !addProductButton || !hiddenField || !formattedField) {
        return;
    }

    const form = productContainer.closest("form");
    if (!form) {
        console.error("CF7 form not found.");
        return;
    }

    const firstItem = productContainer.querySelector(".product-item");
    let lastSelectedState = [];

    function createDefaultOption() {
        const option = document.createElement("option");
        option.value = "";
        option.textContent = "▼商品をリストから選択してください";
        option.dataset.price = "0";
        return option;
    }

    function populateProductOptions(selectElement) {
        selectElement.innerHTML = "";

        selectElement.appendChild(createDefaultOption());

        productData.forEach((product, index) => {
            const option = document.createElement("option");
            option.value = String(index);
            option.dataset.price = String(product.price);
            option.textContent = product.name + "(" + Number(product.price).toLocaleString() + "円)";
            selectElement.appendChild(option);
        });

        selectElement.selectedIndex = 0;
        selectElement.value = "";
    }

    function calculateTotal() {
        let total = 0;

        productContainer.querySelectorAll(".product-item").forEach((item) => {
            const select = item.querySelector(".product-select");
            const quantitySelect = item.querySelector(".product-quantity");

            if (!select || !quantitySelect) return;
            if (select.value === "") return;

            const selectedOption = select.options[select.selectedIndex];
            const price = parseInt(selectedOption.dataset.price || "0", 10);
            const quantity = parseInt(quantitySelect.value || "1", 10);

            total += price * quantity;
        });

        totalPriceSpan.textContent = total.toLocaleString();
    }

    function clearProductError() {
        const oldError = document.getElementById("product-select-error");
        if (oldError) oldError.remove();
    }

    function showProductError(message) {
        clearProductError();

        const error = document.createElement("div");
        error.id = "product-select-error";
        error.style.color = "#dc3232";
        error.style.fontSize = "14px";
        error.style.marginTop = "8px";
        error.style.marginBottom = "8px";
        error.textContent = message;

        const totalBox = form.querySelector(".total-price");
        if (totalBox) {
            totalBox.parentNode.insertBefore(error, totalBox);
        } else {
            productContainer.insertAdjacentElement("afterend", error);
        }
    }

    function bindProductItemEvents(item, isFirstItem = false) {
        const select = item.querySelector(".product-select");
        const quantitySelect = item.querySelector(".product-quantity");
        const removeButton = item.querySelector(".remove-product");

        if (!select || !quantitySelect) return;

        populateProductOptions(select);
        quantitySelect.value = "1";

        select.addEventListener("change", function () {
            clearProductError();
            calculateTotal();
        });

        quantitySelect.addEventListener("change", function () {
            calculateTotal();
        });

        if (removeButton) {
            if (isFirstItem) {
                removeButton.style.display = "none";
            } else {
                removeButton.style.display = "inline-block";
                removeButton.addEventListener("click", function () {
                    item.remove();
                    clearProductError();
                    calculateTotal();
                });
            }
        }
    }

    function addProductRow() {
        const newItem = firstItem.cloneNode(true);
        bindProductItemEvents(newItem, false);
        productContainer.appendChild(newItem);
    }

    function getCurrentState() {
        const rows = [];

        productContainer.querySelectorAll(".product-item").forEach((item) => {
            const select = item.querySelector(".product-select");
            const quantitySelect = item.querySelector(".product-quantity");

            rows.push({
                value: select ? select.value : "",
                quantity: quantitySelect ? quantitySelect.value : "1"
            });
        });

        return rows;
    }

    function restoreState(state) {
        if (!Array.isArray(state) || state.length === 0) return;

        productContainer.innerHTML = "";
        state.forEach((row, index) => {
            const item = firstItem.cloneNode(true);
            bindProductItemEvents(item, index === 0);

            const select = item.querySelector(".product-select");
            const quantitySelect = item.querySelector(".product-quantity");

            if (select) select.value = row.value ?? "";
            if (quantitySelect) quantitySelect.value = row.quantity ?? "1";

            productContainer.appendChild(item);
        });

        calculateTotal();
    }

    function resetProductUI() {
        productContainer.innerHTML = "";
        const newFirstItem = firstItem.cloneNode(true);
        productContainer.appendChild(newFirstItem);
        bindProductItemEvents(newFirstItem, true);
        clearProductError();
        hiddenField.value = "";
        formattedField.value = "";
        calculateTotal();
    }

    function collectSelectedProducts() {
        const mergedProducts = {};
        let totalAmount = 0;

        productContainer.querySelectorAll(".product-item").forEach((item) => {
            const select = item.querySelector(".product-select");
            const quantitySelect = item.querySelector(".product-quantity");

            if (!select || !quantitySelect) return;
            if (select.value === "") return;

            const index = parseInt(select.value, 10);
            const quantity = parseInt(quantitySelect.value || "1", 10);

            if (isNaN(index) || !productData[index]) return;

            const product = productData[index];
            const name = product.name;
            const price = parseInt(product.price, 10) || 0;

            if (!mergedProducts[index]) {
                mergedProducts[index] = {
                    name: name,
                    price: price,
                    quantity: 0,
                    totalPrice: 0
                };
            }

            mergedProducts[index].quantity += quantity;
            mergedProducts[index].totalPrice += price * quantity;
            totalAmount += price * quantity;
        });

        const productDetails = Object.values(mergedProducts);

        let formattedText = "";
        productDetails.forEach((product) => {
            formattedText += "・" + product.name +
                "(数量:" + product.quantity +
                "/単価:" + product.price.toLocaleString() +
                "円)= " + product.totalPrice.toLocaleString() + "円\n";
        });

        return {
            productDetails,
            formattedText,
            totalAmount
        };
    }

    function validateProducts() {
        clearProductError();

        const result = collectSelectedProducts();

        if (result.productDetails.length === 0) {
            showProductError("商品を1つ以上選択してください。");
            alert("商品を1つ以上選択してください。");
            return false;
        }

        hiddenField.value = JSON.stringify(result.productDetails);
        formattedField.value = result.formattedText + "\n合計見積もり金額:" + result.totalAmount.toLocaleString() + "円";

        return true;
    }

    bindProductItemEvents(firstItem, true);
    calculateTotal();

    addProductButton.addEventListener("click", function () {
        addProductRow();
    });

    form.addEventListener("click", function (event) {
        const submitButton = event.target.closest(".wpcf7-submit");
        if (!submitButton) return;

        lastSelectedState = getCurrentState();

        if (!validateProducts()) {
            event.preventDefault();
            event.stopPropagation();
            event.stopImmediatePropagation();
            return false;
        }
    }, true);

    form.addEventListener("submit", function (event) {
        lastSelectedState = getCurrentState();

        if (!validateProducts()) {
            event.preventDefault();
            event.stopPropagation();
            event.stopImmediatePropagation();
            return false;
        }
    }, true);

    document.addEventListener("wpcf7invalid", function (event) {
        if (event.target !== form) return;
        setTimeout(function () {
            restoreState(lastSelectedState);
        }, 50);
    });

    document.addEventListener("wpcf7mailfailed", function (event) {
        if (event.target !== form) return;
        setTimeout(function () {
            restoreState(lastSelectedState);
        }, 50);
    });

    document.addEventListener("wpcf7spam", function (event) {
        if (event.target !== form) return;
        setTimeout(function () {
            restoreState(lastSelectedState);
        }, 50);
    });

    document.addEventListener("wpcf7mailsent", function (event) {
        if (event.target !== form) return;
        resetProductUI();
    });
});
</script>
固定ページテンプレート(独自テンプレートファイル)の作り方

子テーマのディレクトリに page-order.php(任意のファイル名)を作成し、 固定ページの編集画面でそのテンプレートを選択してください。
テンプレートの作り方については下記記事を参照してください。

WordPressで固定ページテンプレート(独自テンプレートファイル)を作る方法【子テーマ対応・コピペOK】

STEP 4:CF7 のフォームを設定する

CF7の管理画面でフォームを編集し、冒頭に以下の hidden タグを追加します。

[hidden product-details id:product-details]
[hidden formatted-product-details id:formatted-product-details]

次に、フォームに商品選択UIを挿入します。フォームの任意の位置に貼り付けてください。

<div id="product-container"><div class="product-item"><select class="product-select"><option value="" data-price="0" selected disabled>▼商品をリストから選択してください</option></select>数量:<select class="product-quantity"><option value="1">1</option><option value="2">2</option><option value="3">3</option><option value="4">4</option><option value="5">5</option><option value="6">6</option><option value="7">7</option><option value="8">8</option><option value="9">9</option><option value="10">10</option></select><button type="button" class="remove-product">削除</button></div></div>
<button type="button" id="add-product">他の商品を追加する</button>
<div class="total-price">合計金額:<span id="total-price">0</span></div>
注意

 CF7のフォームエディタでは、HTMLの改行が自動的に <br> タグに変換されます。上のコードは改行なしで貼り付けてください。改行が入ると <select> 間に <br> が挿入され、プルダウンと数量が縦並びになってしまいます。

STEP 5:CSSを追加する

管理画面 → 外観 → カスタマイズ → 追加CSS に以下を貼り付けてください。
あくまで下記はサンプルなので、デザインはサイトに合わせて変更してくださいね!

/* =====================
   簡易注文フォーム
===================== */

#product-container {
    margin-bottom: 1em;
}

.product-item {
    margin-top: 1em;
    padding-top: 1em;
    border-top: 1px dashed #ddd;
}

/* CF7が<p>と<br>を自動挿入するため、<p>をflexコンテナにする */
.product-item p {
    display: flex;
    align-items: center;
    flex-wrap: nowrap;
    gap: 10px;
    margin: 0;
    font-size: 1rem;
}

/* CF7が挿入する<br>を非表示にする */
.product-item p br {
    display: none;
}

.product-select {
    flex: 0 1 auto;
    min-width: 0;
    max-width: 320px;
    width: auto !important;
    padding: 0.5em;
    box-sizing: border-box;
}

.product-quantity {
    flex: 0 0 auto;
    width: auto !important;
    padding: 0.5em;
    margin-left: 0.5em;
}

.remove-product {
    padding: 0.5em 1em;
    background-color: #f44336;
    color: #fff;
    border: none;
    border-radius: 4px;
    cursor: pointer;
    transition: background 0.2s;
}
.remove-product:hover {
    background-color: #d32f2f;
}

#add-product {
    display: inline-block;
    margin-top: 1em;
    padding: 0.5em 1.2em;
    background-color: #4caf50;
    color: #fff;
    border: none;
    border-radius: 4px;
    cursor: pointer;
    transition: background 0.2s;
}
#add-product:hover {
    background-color: #45a049;
}

.total-price {
    margin-top: 1.5em;
    font-size: 1.6em;
    font-weight: bold;
}

/* エラー時 */
.product-select.error {
    border: 2px solid red;
    background-color: #ffe6e6;
}

/* スマホ対応 */
@media (max-width: 600px) {
    .product-item p {
        flex-direction: column;
        align-items: stretch;
    }
    .product-select,
    .product-quantity,
    .remove-product {
        width: 100% !important;
    }
    .remove-product {
        text-align: center;
        margin-top: 5px;
    }
}

STEP 6:メール設定をする

CF7 の「メール」タブを開き、メール本文の適切な場所に以下を追記します。

■ご希望商品:
[formatted-product-details]

送信されるメールのイメージ:

■ご希望商品:
・商品Aセット(数量:1/単価:7,700円)= 7,700円
・商品Bセット(数量:2/単価:4,950円)= 9,900円

合計見積もり金額:17,600円
送料・手数料について

送料が別途かかる場合は、メール本文に「全国一律送料500円が別途加算されます」などの文言を入れるか、注文ページの冒頭や返信メールで個別にご案内するフローにするのがスムーズです。

よくあるトラブルと対処

商品がプルダウンに表示されない

PHPの WP_Query でカスタム投稿タイプ名(pdo_product)を変更している場合、 functions.php と テンプレートの両方で名前が一致しているか確認してください。

また、商品が「下書き」状態になっていないか確認してください。
post_status => 'publish' で取得しているため、公開済みのものだけが対象です。

金額が0円になる

商品登録画面(カスタムタイプ投稿)の本文欄に数値以外の文字(スペース・改行・HTMLタグ)が含まれている可能性があります。コピー&ペーストをしてくると、不要なタグが混入することがあります。
管理画面の商品編集画面で、本文をテキストモードに切り替えて数値のみになっているか確認してください。

確認画面と組み合わせたい

以前の記事(ContactForm7に確認画面をつける方法)で紹介した確認モーダルと組み合わせることができます。
ただし、確認モーダルの FIELD_LABELS_MAPformatted-product-details の表示設定を追加するなど、 若干の調整が必要ですので、ご自身でカスタマイズができない方に向けては、また追ってブログでご紹介できたらなと思います。
お急ぎの方は、パリスタデザイン事務所までご相談ください(見積もりは無料です)

まとめ

  • ACF Pro を使わず、WordPress標準のカスタム投稿タイプだけで商品管理できる
  • 商品の追加・削除・金額変更は管理画面から行えるので、クライアントが直接操作可能
  • JS と PHP のコードをコピペするだけで動く軽量な実装

商品点数が増えても管理画面から操作するだけなので、納品後のクライアント運用にも向いています。
ぜひ試してみてください!

はなわ
はなわ

WordPressまたはContactForm7のアップデートで動作しなくなる可能性があります。
お困りのことがあればコメントでお知らせください。

この記事を書いた人

はなわ

パリスタデザイン事務所のWebデザイナー・フロントエンドエンジニア。
WordPress構築・カスタマイズを中心に、CMS設計・構築や情報設計を得意としています。
AIやWeb技術、PC/ガジェットを活用しながら、「無理なく続く仕組みづくり」や実務に役立つ知見を発信しています。

お仕事のご依頼はこちら

パリスタデザイン事務所では、WordPress構築・カスタマイズを中心に、Web制作に関するご相談を承っています。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA