app/template/default/Product/detail.twig line 1

Open in your IDE?
  1. {#
  2. This file is part of EC-CUBE
  3. Copyright(c) EC-CUBE CO.,LTD. All Rights Reserved.
  4. http://www.ec-cube.co.jp/
  5. For the full copyright and license information, please view the LICENSE
  6. file that was distributed with this source code.
  7. #}
  8. {% extends 'default_frame.twig' %}
  9. {% set body_class = 'product_page' %}
  10. {% block stylesheet %}
  11.     <style>
  12.         .slick-slider {
  13.             margin-bottom: 10px;
  14.             margin-top: -20px;
  15.         }
  16.         .slick-dots {
  17.             position: absolute;
  18.             bottom: -45px;
  19.             display: block;
  20.             width: 100%;
  21.             padding: 0;
  22.             list-style: none;
  23.             text-align: center;
  24.         }
  25.         .slick-dots li {
  26.             position: relative;
  27.             display: inline-block;
  28.             width: 20px;
  29.             height: 20px;
  30.             margin: 0 5px;
  31.             padding: 0;
  32.             cursor: pointer;
  33.         }
  34.         .slick-dots li button {
  35.             font-size: 0;
  36.             line-height: 0;
  37.             display: block;
  38.             width: 20px;
  39.             height: 20px;
  40.             padding: 5px;
  41.             cursor: pointer;
  42.             color: transparent;
  43.             border: 0;
  44.             outline: none;
  45.             background: transparent;
  46.         }
  47.         .slick-dots li button:hover,
  48.         .slick-dots li button:focus {
  49.             outline: none;
  50.         }
  51.         .slick-dots li button:hover:before,
  52.         .slick-dots li button:focus:before {
  53.             opacity: 1;
  54.         }
  55.         .slick-dots li button:before {
  56.             content: " ";
  57.             line-height: 20px;
  58.             position: absolute;
  59.             top: 0;
  60.             left: 0;
  61.             width: 12px;
  62.             height: 12px;
  63.             text-align: center;
  64.             opacity: .25;
  65.             background-color: black;
  66.             border-radius: 50%;
  67.         }
  68.         .slick-dots li.slick-active button:before {
  69.             opacity: .75;
  70.             background-color: black;
  71.         }
  72.         .slick-dots li button.thumbnail img {
  73.             width: 0;
  74.             height: 0;
  75.         }
  76.     </style>
  77.     <link rel="stylesheet" href="/html/user_data/js/style.css">
  78.     <link rel="stylesheet" href="/html/plugins/icheck-bootstrap/icheck-bootstrap.min.css">
  79.     <style>
  80.         /* Show zoom cursor on product images to hint click-to-expand */
  81.         .item_visual .slide-item a { display: block; cursor: zoom-in; }
  82.     </style>
  83.     <style>
  84.         /* ============================================
  85.            商品詳細グリッド: 画像50% / 内容50%(均等)
  86.         ============================================ */
  87.         @media (min-width: 768px) {
  88.             body.product_page .ec-grid2 {
  89.                 display: flex;
  90.                 align-items: flex-start;
  91.             }
  92.             body.product_page .ec-grid2 .ec-grid2__cell {
  93.                 width: 50%;
  94.                 flex: 0 0 50%;
  95.                 min-width: 0;
  96.             }
  97.             body.product_page .ec-grid2 .ec-grid2__cell2 {
  98.                 width: 50%;
  99.                 flex: 0 0 50%;
  100.                 min-width: 0;
  101.             }
  102.         }
  103.         /* ============================================
  104.            商品説明欄内のHTMLが画面幅を突き抜けないように
  105.         ============================================ */
  106.         body.product_page .ec-productRole__description {
  107.             min-width: 0;
  108.             overflow-wrap: break-word;
  109.             word-break: break-word;
  110.         }
  111.         body.product_page .ec-productRole__description img,
  112.         body.product_page .ec-productRole__description table,
  113.         body.product_page .ec-productRole__description iframe,
  114.         body.product_page .ec-productRole__description video {
  115.             max-width: 100% !important;
  116.             height: auto;
  117.         }
  118.         body.product_page .ec-productRole__description table {
  119.             display: block;
  120.             overflow-x: auto;
  121.             -webkit-overflow-scrolling: touch;
  122.         }
  123.         /* ============================================
  124.            スマホ用 見積金額 下部固定バー
  125.         ============================================ */
  126.         #sp-mitsumori-bar {
  127.             display: none;
  128.         }
  129.         /* ============================================
  130.            スマホ用 お見積りの詳細 ボトムシート
  131.         ============================================ */
  132.         .sp-detail-sheet { display: none; }
  133.         .sp-detail-sheet.is-open {
  134.             display: block;
  135.             position: fixed;
  136.             top: 0; left: 0; right: 0; bottom: 0;
  137.             z-index: 2000;
  138.         }
  139.         .sp-detail-sheet__overlay {
  140.             position: absolute;
  141.             top: 0; left: 0; right: 0; bottom: 0;
  142.             background: rgba(0,0,0,0.45);
  143.         }
  144.         .sp-detail-sheet__panel {
  145.             position: absolute;
  146.             left: 0; right: 0; bottom: 0;
  147.             background: #fff;
  148.             border-radius: 14px 14px 0 0;
  149.             max-height: 82vh;
  150.             display: flex;
  151.             flex-direction: column;
  152.             box-shadow: 0 -4px 20px rgba(0,0,0,0.25);
  153.             padding-bottom: env(safe-area-inset-bottom);
  154.             animation: spSheetUp 0.22s ease-out;
  155.         }
  156.         @keyframes spSheetUp {
  157.             from { transform: translateY(100%); }
  158.             to   { transform: translateY(0); }
  159.         }
  160.         .sp-detail-sheet__header {
  161.             display: flex;
  162.             align-items: center;
  163.             justify-content: space-between;
  164.             padding: 14px 16px;
  165.             border-bottom: 1px solid #eee;
  166.             flex: 0 0 auto;
  167.         }
  168.         .sp-detail-sheet__title { font-size: 16px; font-weight: bold; color: #333; }
  169.         .sp-detail-sheet__close {
  170.             background: none;
  171.             border: none;
  172.             font-size: 26px;
  173.             line-height: 1;
  174.             color: #888;
  175.             cursor: pointer;
  176.             padding: 0 4px;
  177.         }
  178.         .sp-detail-sheet__body {
  179.             overflow-y: auto;
  180.             -webkit-overflow-scrolling: touch;
  181.             padding: 4px 0;
  182.             flex: 1 1 auto;
  183.         }
  184.         .sp-detail-sheet__body ul { list-style: none; margin: 0; padding: 0; }
  185.         .sp-detail-sheet__body li { border-bottom: 1px solid #f2f2f2; }
  186.         .sp-detail-sheet__body .nav-link {
  187.             display: flex;
  188.             align-items: center;
  189.             justify-content: space-between;
  190.             padding: 12px 16px;
  191.             color: #333;
  192.             font-size: 14px;
  193.         }
  194.         .sp-detail-sheet__body .nav-link .float-right {
  195.             float: none !important;
  196.             font-weight: bold;
  197.             white-space: nowrap;
  198.             margin-left: auto;
  199.         }
  200.         .sp-detail-sheet__footer {
  201.             display: flex;
  202.             gap: 8px;
  203.             padding: 12px 16px 16px;
  204.             border-top: 1px solid #eee;
  205.             flex: 0 0 auto;
  206.         }
  207.         .sp-detail-sheet__footer button {
  208.             flex: 1 1 auto;
  209.             padding: 12px;
  210.             border-radius: 8px;
  211.             font-size: 14px;
  212.             font-weight: bold;
  213.             cursor: pointer;
  214.         }
  215.         .sp-detail-sheet__invoice {
  216.             background: #fff;
  217.             border: 1px solid #17a2b8;
  218.             color: #17a2b8;
  219.         }
  220.         .sp-detail-sheet__cart {
  221.             background: #c00;
  222.             border: 1px solid #c00;
  223.             color: #fff;
  224.         }
  225.         /* ============================================
  226.            商品タイプ選択 共通スタイル
  227.         ============================================ */
  228.         /* セクションラベル */
  229.         .rp-section-label {
  230.             font-size: 14px;
  231.             font-weight: bold;
  232.             color: #333;
  233.             margin-bottom: 10px;
  234.         }
  235.         .rp-section-label span {
  236.             font-weight: normal;
  237.             color: #c00;
  238.         }
  239.         /* ---- 1. 画像付きカード ---- */
  240.         .rp-card-group {
  241.             display: flex;
  242.             flex-wrap: wrap;
  243.             gap: 8px;
  244.             margin-bottom: 4px;
  245.         }
  246.         .rp-card {
  247.             position: relative;
  248.             cursor: pointer;
  249.             touch-action: manipulation; /* drop iOS 300ms tap delay (#15) */
  250.             -webkit-tap-highlight-color: rgba(0,0,0,0);
  251.             /* iOS Safari: prevent the first tap from being consumed as a text
  252.                selection ("字だけ青くなる" = selection highlight) which forced a
  253.                2nd tap to actually switch the option (#15). */
  254.             -webkit-user-select: none; -moz-user-select: none; user-select: none;
  255.             -webkit-touch-callout: none;
  256.             width: 110px;
  257.             border: 2px solid #ddd;
  258.             border-radius: 10px;
  259.             overflow: hidden;
  260.             transition: border-color 0.2s, box-shadow 0.2s;
  261.             background: #fff;
  262.             text-decoration: none;
  263.             color: #333;
  264.             display: block;
  265.         }
  266.         /* Gate :hover behind real hover-capable pointers. On iOS Safari a
  267.            :hover rule that changes appearance makes the first tap apply the
  268.            hover state and suppresses the click, so the option chip needed a
  269.            second tap to fire its onclick (is-selected + price update). */
  270.         @media (hover: hover) and (pointer: fine) {
  271.             .rp-card:hover {
  272.                 border-color: #999;
  273.                 box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  274.             }
  275.         }
  276.         .rp-card.is-selected {
  277.             border-color: #1a6fcf;
  278.             box-shadow: 0 2px 10px rgba(26,111,207,0.25);
  279.         }
  280.         /* visually-hidden instead of display:none. A label wrapping a
  281.            display:none radio fires its tap unreliably on iOS Safari (first tap
  282.            often ignored -> "2回タッチ"). Keeping the input in the layout but
  283.            invisible makes the first tap register. */
  284.         .rp-card input[type="radio"] {
  285.             position: absolute; opacity: 0; width: 1px; height: 1px;
  286.             margin: 0; pointer-events: none;
  287.         }
  288.         .rp-card__image {
  289.             width: 100%;
  290.             aspect-ratio: 1 / 1;
  291.             object-fit: cover;
  292.             display: block;
  293.             background: #f5f5f5;
  294.         }
  295.         .rp-card__placeholder {
  296.             width: 100%;
  297.             aspect-ratio: 1 / 1;
  298.             background: #f0f0f0;
  299.             display: flex;
  300.             align-items: center;
  301.             justify-content: center;
  302.             color: #bbb;
  303.             font-size: 24px;
  304.         }
  305.         .rp-card__name {
  306.             display: block;
  307.             padding: 5px 5px;
  308.             font-size: 11px;
  309.             color: #333;
  310.             text-align: center;
  311.             line-height: 1.4;
  312.             word-break: break-all;
  313.         }
  314.         .rp-card.is-selected .rp-card__name { color: #1a6fcf; font-weight: bold; }
  315.         .rp-card.is-selected::after {
  316.             content: "✓";
  317.             position: absolute;
  318.             top: 3px; right: 6px;
  319.             color: #1a6fcf;
  320.             font-size: 13px;
  321.             font-weight: bold;
  322.         }
  323.         /* ---- 2. ボタン式ラジオ(画像なし・8個以内)---- */
  324.         .rp-btn-group {
  325.             display: flex;
  326.             flex-wrap: wrap;
  327.             gap: 8px;
  328.             margin-bottom: 4px;
  329.             max-width: 100%;
  330.             min-width: 0;
  331.         }
  332.         .rp-btn {
  333.             cursor: pointer;
  334.             touch-action: manipulation; /* drop iOS 300ms tap delay (#15) */
  335.             -webkit-tap-highlight-color: rgba(0,0,0,0);
  336.             /* iOS Safari: block first-tap text selection so 1 tap switches (#15) */
  337.             -webkit-user-select: none; -moz-user-select: none; user-select: none;
  338.             -webkit-touch-callout: none;
  339.             padding: 8px 14px;
  340.             border: 2px solid #ddd;
  341.             border-radius: 10px;
  342.             font-size: 13px;
  343.             color: #333;
  344.             background: #fff;
  345.             transition: border-color 0.2s, background 0.2s;
  346.             white-space: normal;
  347.             max-width: 100%;
  348.             overflow-wrap: anywhere;
  349.             text-decoration: none;
  350.         }
  351.         @media (hover: hover) and (pointer: fine) {
  352.             .rp-btn:hover { border-color: #999; }
  353.         }
  354.         .rp-btn.is-selected {
  355.             border-color: #1a6fcf;
  356.             color: #1a6fcf;
  357.             font-weight: bold;
  358.             background: #f0f6ff;
  359.         }
  360.         .rp-btn input[type="radio"] {
  361.             position: absolute; opacity: 0; width: 1px; height: 1px;
  362.             margin: 0; pointer-events: none;
  363.         }
  364.         /* iOS Safari "2回タッチ" fix for the type-selection link chips.
  365.            The global theme stylesheet ships an ungated `a:hover{color:#296292}`
  366.            (paired with `a{color:#337ab7;text-decoration:underline}`). Because
  367.            .rp-btn / .rp-card are real <a> links, iOS Safari applies that hover
  368.            colour on the FIRST tap — the chip text turns blue (#296292, the
  369.            reported "字だけ青くなる") — and treats the tap as a hover, swallowing
  370.            the navigation so a 2nd tap was needed. The local .rp-*:hover rules
  371.            are already gated behind (hover:hover), but this inherited global
  372.            rule was not. Pin the hover/active colour to the base/selected colour
  373.            so tapping produces no visual change and the first tap navigates
  374.            immediately. Higher specificity (0,2,0 / 0,3,0) overrides a:hover. */
  375.         .rp-btn:hover,
  376.         .rp-btn:active { color: #333; text-decoration: none; }
  377.         .rp-btn.is-selected:hover,
  378.         .rp-btn.is-selected:active { color: #1a6fcf; text-decoration: none; }
  379.         .rp-card:hover,
  380.         .rp-card:active { color: #333; text-decoration: none; }
  381.         .rp-card:hover .rp-card__name,
  382.         .rp-card:active .rp-card__name { color: #333; }
  383.         .rp-card.is-selected:hover .rp-card__name,
  384.         .rp-card.is-selected:active .rp-card__name { color: #1a6fcf; }
  385.         /* ---- 選択肢が多い場合: 縦3段に収めて横スクロール(顧客要望「3列くらいで横スクロール」)---- */
  386.         /* grid-auto-flow:column + 3行固定 → 選択肢が縦に最大3段、超過分は右へ伸び横スクロールで閲覧 */
  387.         .rp-btn-group.is-scroll,
  388.         .opt-btn-group.is-scroll {
  389.             display: grid;
  390.             grid-auto-flow: column;
  391.             grid-template-rows: repeat(3, auto);
  392.             grid-auto-columns: max-content;
  393.             justify-content: start;
  394.             align-items: start;
  395.             gap: 8px;
  396.             overflow-x: auto;
  397.             overflow-y: hidden;
  398.             -webkit-overflow-scrolling: touch;
  399.             scrollbar-width: thin;
  400.             /* 上下に余白を確保して、行の枠線が overflow クリップ/スクロールバーに
  401.                切り取られないようにする(「文字列が長いと下枠が消える」対策)。 */
  402.             padding: 2px 0 12px;
  403.         }
  404.         /* is-scroll(横スクロール)内では選択肢を折り返さず単一行に固定する。
  405.            折り返すと行高が不揃いになり overflow-y:hidden で下枠が切れるため、
  406.            長い選択肢は折り返さず横スクロールで見せる(本来の設計意図)。 */
  407.         .rp-btn-group.is-scroll .rp-btn,
  408.         .rp-btn-group.is-scroll .opt-btn,
  409.         .opt-btn-group.is-scroll .rp-btn,
  410.         .opt-btn-group.is-scroll .opt-btn {
  411.             white-space: nowrap;
  412.             word-break: normal;
  413.             overflow-wrap: normal;
  414.         }
  415.         .rp-btn-group.is-scroll::-webkit-scrollbar,
  416.         .opt-btn-group.is-scroll::-webkit-scrollbar {
  417.             height: 6px;
  418.         }
  419.         .rp-btn-group.is-scroll::-webkit-scrollbar-thumb,
  420.         .opt-btn-group.is-scroll::-webkit-scrollbar-thumb {
  421.             background: #c0c0c0;
  422.             border-radius: 3px;
  423.         }
  424.         /* ---- 3. プルダウン(画像なし・9個以上)---- */
  425.         .rp-select-wrap {
  426.             margin-bottom: 4px;
  427.         }
  428.         .rp-select {
  429.             width: 100%;
  430.             max-width: 420px;
  431.             padding: 8px 12px;
  432.             border: 2px solid #ddd;
  433.             border-radius: 10px;
  434.             font-size: 13px;
  435.             color: #333;
  436.             background: #fff;
  437.             appearance: none;
  438.             background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23666' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
  439.             background-repeat: no-repeat;
  440.             background-position: right 12px center;
  441.             cursor: pointer;
  442.         }
  443.         .rp-select:focus {
  444.             outline: none;
  445.             border-color: #1a6fcf;
  446.         }
  447.         /* ---- オプション選択ボタン(枠線ラジオ)---- */
  448.         .opt-btn-group {
  449.             display: flex;
  450.             flex-wrap: wrap;
  451.             gap: 8px;
  452.             padding: 6px 0;
  453.             max-width: 100%;
  454.             min-width: 0;
  455.         }
  456.         .opt-btn {
  457.             cursor: pointer;
  458.             touch-action: manipulation; /* drop iOS 300ms tap delay (#15) */
  459.             -webkit-tap-highlight-color: rgba(0,0,0,0);
  460.             /* iOS Safari: block first-tap text selection so 1 tap switches (#15) */
  461.             -webkit-user-select: none; -moz-user-select: none; user-select: none;
  462.             -webkit-touch-callout: none;
  463.             padding: 7px 14px;
  464.             border: 2px solid #ddd;
  465.             border-radius: 10px;
  466.             font-size: 13px;
  467.             color: #333;
  468.             background: #fff;
  469.             transition: border-color 0.2s, background 0.2s;
  470.             white-space: normal;
  471.             max-width: 100%;
  472.             overflow-wrap: anywhere;
  473.             text-decoration: none;
  474.         }
  475.         @media (hover: hover) and (pointer: fine) {
  476.             .opt-btn:hover { border-color: #999; }
  477.         }
  478.         .opt-btn.is-selected {
  479.             border-color: #1a6fcf;
  480.             color: #1a6fcf;
  481.             font-weight: bold;
  482.             background: #f0f6ff;
  483.         }
  484.         .opt-btn input[type="radio"] {
  485.             position: absolute; opacity: 0; width: 1px; height: 1px;
  486.             margin: 0; pointer-events: none;
  487.         }
  488.         /* opt-btn with color swatch image. The label still works as a
  489.            radio target — clicking the image checks the input. The text
  490.            label stays under the image so users can still read the name. */
  491.         .opt-btn--with-image {
  492.             padding: 4px 6px 6px;
  493.             white-space: normal;
  494.             text-align: center;
  495.             display: inline-flex;
  496.             flex-direction: column;
  497.             align-items: center;
  498.             min-width: 96px;
  499.             max-width: 140px;
  500.         }
  501.         .opt-btn--with-image .opt-btn__img {
  502.             display: block;
  503.             width: 88px;
  504.             height: 66px;
  505.             object-fit: cover;
  506.             border-radius: 6px;
  507.             margin-bottom: 4px;
  508.             background: #f5f5f5;
  509.         }
  510.         .opt-btn--with-image .opt-btn__name {
  511.             display: block;
  512.             font-size: 11px;
  513.             line-height: 1.3;
  514.             color: inherit;
  515.         }
  516.         .opt-btn--with-image.is-selected .opt-btn__img {
  517.             outline: 2px solid #1a6fcf;
  518.             outline-offset: 1px;
  519.         }
  520.         /* sale_type=2 (内窓) 補助金対象ガラスの強調表示.
  521.            補助金が出る性能区分 (真空断熱 / Low-E複層 等) のガラス選択肢に
  522.            緑枠 + 「補助金対象」バッジを付け、選択前から判別できるようにする。 */
  523.         .opt-btn--subsidy {
  524.             border-color: #2e7d32;
  525.         }
  526.         .opt-btn--subsidy.is-selected {
  527.             border-color: #1a6fcf;
  528.         }
  529.         .opt-btn__subsidy-badge {
  530.             display: inline-block;
  531.             margin-left: 6px;
  532.             padding: 1px 6px;
  533.             border-radius: 6px;
  534.             background: #2e7d32;
  535.             color: #fff;
  536.             font-size: 11px;
  537.             font-weight: bold;
  538.             white-space: nowrap;
  539.         }
  540.         /* 補助金額・適用可否の強調 (顧客要望: もっと目立つ色とフォントに).
  541.            各窓の補助金額は緑・太字・拡大、適用条件ノートは緑の枠付きボックスにする。 */
  542.         .wt-subsidy-label {
  543.             color: #2e7d32;
  544.             font-weight: bold;
  545.             font-size: 12px;
  546.         }
  547.         .wt-subsidy-amount {
  548.             color: #2e7d32;
  549.             font-weight: bold;
  550.             font-size: 18px;
  551.             line-height: 1.2;
  552.         }
  553.         .subsidy-eligibility-note {
  554.             color: #1b5e20;
  555.             font-weight: bold;
  556.             font-size: 12.5px;
  557.             line-height: 1.5;
  558.             background: #eafaf0;
  559.             border: 1px solid #b7e4c7;
  560.             border-radius: 6px;
  561.             padding: 8px 10px;
  562.             margin-bottom: 10px;
  563.         }
  564.         .subsidy-eligibility-note strong {
  565.             color: #c0392b;  /* 「合計が5万円以上」を赤で強調 */
  566.         }
  567.         /* ============================================
  568.            施工エリア案内
  569.         ============================================ */
  570.         .ec-areaNotice {
  571.             margin: 16px 0;
  572.             padding: 16px 20px;
  573.             background: #f8f9fa;
  574.             border-left: 4px solid #1a6fcf;
  575.             border-radius: 0 8px 8px 0;
  576.         }
  577.         .ec-areaNotice__inner {
  578.             display: flex;
  579.             align-items: flex-start;
  580.             gap: 12px;
  581.         }
  582.         .ec-areaNotice__icon {
  583.             font-size: 22px;
  584.             line-height: 1;
  585.             flex-shrink: 0;
  586.             margin-top: 2px;
  587.         }
  588.         .ec-areaNotice__body {
  589.             flex: 1;
  590.         }
  591.         .ec-areaNotice__title {
  592.             font-size: 13px;
  593.             font-weight: bold;
  594.             color: #1a6fcf;
  595.             margin: 0 0 6px;
  596.         }
  597.         .ec-areaNotice__text {
  598.             font-size: 13px;
  599.             color: #444;
  600.             line-height: 1.7;
  601.             margin: 0;
  602.         }
  603.         .ec-areaNotice__text strong {
  604.             color: #222;
  605.         }
  606.         .ec-areaNotice__link {
  607.             display: inline-block;
  608.             margin-top: 8px;
  609.             font-size: 12px;
  610.             color: #1a6fcf;
  611.             text-decoration: underline;
  612.         }
  613.         .ec-areaNotice__link:hover {
  614.             color: #0d4fa0;
  615.         }
  616.         /* ============================================
  617.            SNSシェア + ブランド名 タイトル行
  618.         ============================================ */
  619.         .ec-productRole__titleRow {
  620.             display: flex;
  621.             align-items: center;
  622.             justify-content: space-between;
  623.             gap: 8px;
  624.             margin-bottom: 4px;
  625.         }
  626.         .ec-productRole__titleRow .ec-headingTitle {
  627.             margin: 0;
  628.             flex: 1;
  629.             font-size: 18px;
  630.         }
  631.         .ec-share-inline {
  632.             display: flex;
  633.             align-items: center;
  634.             gap: 8px;
  635.             flex-shrink: 0;
  636.         }
  637.         @media (max-width: 767px) {
  638.             /* maker_area の float を解除して独立表示 */
  639.             #maker_area {
  640.                 float: none !important;
  641.                 width: auto !important;
  642.                 height: auto !important;
  643.                 display: block;
  644.                 margin-bottom: 4px;
  645.             }
  646.             /* タイトル行: 商品名が全幅を占有しSNSは右寄せ下段 */
  647.             .ec-productRole__titleRow {
  648.                 flex-wrap: wrap;
  649.                 clear: both;
  650.             }
  651.             .ec-productRole__titleRow .ec-headingTitle {
  652.                 flex: 1 1 100%;
  653.                 font-size: 16px;
  654.             }
  655.             .ec-share-inline {
  656.                 flex: 0 0 auto;
  657.                 margin-left: auto;
  658.             }
  659.         }
  660.         .ec-share-inline a {
  661.             display: flex;
  662.             align-items: center;
  663.             justify-content: center;
  664.             width: 32px;
  665.             height: 32px;
  666.             border-radius: 50%;
  667.             font-size: 15px;
  668.             color: #fff;
  669.             text-decoration: none;
  670.             transition: opacity 0.2s;
  671.         }
  672.         .ec-share-inline a:hover { opacity: 0.8; }
  673.         .ec-share-inline .share-twitter  { background: #000; }
  674.         .ec-share-inline .share-facebook { background: linear-gradient(45deg, #f09433, #e6683c, #dc2743, #cc2366, #bc1888); }
  675.         .ec-share-inline .share-line     { background: #06c755; }
  676.         /* description_detail下のシェアブロックはPC/スマホ共通で非表示(タイトル行に移動) */
  677.         .ec-productRole__share { display: none; }
  678.         /* ============================================
  679.            PC用: 現在のお見積り額カード強調
  680.         ============================================ */
  681.         @media (min-width: 768px) {
  682.             .mitsumori-card-pc {
  683.                 border: 2px solid #1a6fcf !important;
  684.                 border-radius: 10px !important;
  685.                 box-shadow: 0 4px 16px rgba(26,111,207,0.18) !important;
  686.                 margin-top: 20px;
  687.                 overflow: hidden;
  688.                 background: linear-gradient(135deg, #1a6fcf, #0d4fa0) !important;
  689.             }
  690.             .mitsumori-card-pc .card-header {
  691.                 background: linear-gradient(135deg, #1a6fcf, #0d4fa0) !important;
  692.                 color: #fff !important;
  693.                 padding: 14px 16px !important;
  694.                 border-bottom: none !important;
  695.             }
  696.             .mitsumori-card-pc .card-title {
  697.                 color: #fff !important;
  698.                 font-size: 15px !important;
  699.                 font-weight: bold !important;
  700.                 margin: 0 !important;
  701.             }
  702.             .mitsumori-card-pc #mitsumori_message {
  703.                 font-size: 22px !important;
  704.                 font-weight: bold !important;
  705.                 color: #fff !important;
  706.             }
  707.             .mitsumori-card-pc .btn-tool {
  708.                 color: #fff !important;
  709.             }
  710.             /* 合計行を大きく強調 */
  711.             .mitsumori-card-pc .nav-item:first-child .nav-link {
  712.                 font-size: 16px !important;
  713.                 font-weight: bold !important;
  714.                 color: #c00 !important;
  715.                 background: #fff8f8 !important;
  716.                 padding: 12px 16px !important;
  717.             }
  718.             .mitsumori-card-pc .nav-item:first-child #mitsumori_goukei {
  719.                 font-size: 20px !important;
  720.                 font-weight: bold !important;
  721.                 color: #c00 !important;
  722.             }
  723.             /* card-body(明細リスト)を白背景に */
  724.             .mitsumori-card-pc .card-body {
  725.                 background: #fff !important;
  726.             }
  727.             .mitsumori-card-pc .card-footer {
  728.                 background: #f8faff !important;
  729.                 border-top: 1px solid #dce8fb !important;
  730.                 padding: 12px 16px !important;
  731.             }
  732.         }
  733.         @media (max-width: 767px) {
  734.             /* スマホでは現在のお見積り額カードの強調スタイルをすべてリセット */
  735.             .mitsumori-card-pc {
  736.                 border: none !important;
  737.                 border-radius: 0 !important;
  738.                 box-shadow: none !important;
  739.                 margin-top: 0 !important;
  740.                 overflow: visible !important;
  741.                 background: none !important;
  742.                 /* sticky-top(position:sticky / z-index:1020) を解除。
  743.                    そのままだと最下部で #sp-mitsumori-bar(z-index:1000) の上に
  744.                    重なり、合計バーと文字が被るため通常フローへ戻す。 */
  745.                 position: static !important;
  746.                 z-index: auto !important;
  747.             }
  748.             .mitsumori-card-pc .card-header {
  749.                 background: none !important;
  750.                 color: inherit !important;
  751.                 padding: inherit !important;
  752.                 border-bottom: inherit !important;
  753.             }
  754.             .mitsumori-card-pc .card-title { color: inherit !important; }
  755.             .mitsumori-card-pc #mitsumori_message { font-size: inherit !important; color: inherit !important; }
  756.             .mitsumori-card-pc .btn-tool { color: inherit !important; }
  757.             /* 折りたたみトグルボタン */
  758.             .btn-mitsumori-toggle {
  759.                 background: none !important;
  760.                 border: 1px solid #aaa !important;
  761.                 border-radius: 4px !important;
  762.                 padding: 2px 8px !important;
  763.                 font-size: 12px !important;
  764.                 color: #333 !important;
  765.                 line-height: 1.4 !important;
  766.             }
  767.             .btn-mitsumori-toggle .toggle-icon {
  768.                 display: inline-block;
  769.                 transition: transform 0.2s;
  770.             }
  771.             /* 展開状態: ▼→▲ */
  772.             .card:not(.collapsed-card) .btn-mitsumori-toggle .toggle-icon::before {
  773.                 content: "▲ 閉じる";
  774.             }
  775.             .card:not(.collapsed-card) .btn-mitsumori-toggle .toggle-icon {
  776.                 display: none;
  777.             }
  778.             .card:not(.collapsed-card) .btn-mitsumori-toggle::after {
  779.                 content: "▲ 閉じる";
  780.                 font-size: 12px;
  781.             }
  782.             .mitsumori-card-pc .nav-item:first-child .nav-link {
  783.                 font-size: inherit !important;
  784.                 color: inherit !important;
  785.                 background: none !important;
  786.                 padding: inherit !important;
  787.             }
  788.             .mitsumori-card-pc .nav-item:first-child #mitsumori_goukei {
  789.                 font-size: inherit !important;
  790.                 color: inherit !important;
  791.             }
  792.             .mitsumori-card-pc .card-footer {
  793.                 background: none !important;
  794.                 border-top: inherit !important;
  795.                 padding: inherit !important;
  796.             }
  797.             /* 横スクロール禁止 */
  798.             body, html {
  799.                 overflow-x: hidden;
  800.                 max-width: 100vw;
  801.             }
  802.             /* ボタンの折り返しを許可(nowrapが原因で横スクロール発生するため) */
  803.             .opt-btn,
  804.             .rp-btn {
  805.                 white-space: normal;
  806.                 word-break: break-all;
  807.             }
  808.             /* エリア案内: スマホでコンパクト */
  809.             .ec-areaNotice {
  810.                 margin: 8px 0;
  811.                 padding: 8px 10px;
  812.                 border-left-width: 3px;
  813.                 cursor: pointer;
  814.             }
  815.             .ec-areaNotice__inner {
  816.                 flex-direction: row;
  817.                 gap: 8px;
  818.                 align-items: flex-start;
  819.             }
  820.             .ec-areaNotice__icon { font-size: 15px; }
  821.             /* タイトル行: 展開トグル */
  822.             .ec-areaNotice__title {
  823.                 font-size: 11px;
  824.                 margin-bottom: 3px;
  825.                 display: flex;
  826.                 justify-content: space-between;
  827.                 align-items: center;
  828.             }
  829.             .ec-areaNotice__title::after {
  830.                 content: "▼";
  831.                 font-size: 9px;
  832.                 color: #888;
  833.                 margin-left: 6px;
  834.                 transition: transform 0.2s;
  835.             }
  836.             .ec-areaNotice.is-open .ec-areaNotice__title::after {
  837.                 transform: rotate(180deg);
  838.             }
  839.             /* 折りたたみ対象: 閉じた状態では非表示 */
  840.             .ec-areaNotice__detail {
  841.                 display: none;
  842.             }
  843.             .ec-areaNotice.is-open .ec-areaNotice__detail {
  844.                 display: block;
  845.             }
  846.             .ec-areaNotice__text { font-size: 11px; line-height: 1.5; }
  847.             .ec-areaNotice__text br { display: none; }
  848.             .ec-areaNotice__link { font-size: 11px; margin-top: 3px; }
  849.             .rp-card { width: calc(33.333% - 6px); min-width: 80px; }
  850.             
  851.             #sp-mitsumori-bar {
  852.                 display: flex;
  853.                 position: fixed;
  854.                 bottom: 0;
  855.                 left: 0;
  856.                 right: 0;
  857.                 z-index: 1000;
  858.                 background: #fff;
  859.                 border-top: 2px solid #e0e0e0;
  860.                 box-shadow: 0 -2px 8px rgba(0,0,0,0.12);
  861.                 align-items: center;
  862.                 padding: 8px 10px calc(8px + env(safe-area-inset-bottom)) 10px;
  863.                 gap: 8px;
  864.             }
  865.             #sp-mitsumori-bar .sp-bar__label {
  866.                 font-size: 11px;
  867.                 color: #666;
  868.                 white-space: nowrap;
  869.             }
  870.             /* 価格ラッパ: 合計金額は最重要のため常に全桁表示する(縮小・省略しない)。
  871.                幅が足りない場合は下のボタン側を縮める(flex 配分でボタンが先に縮む)。 */
  872.             #sp-mitsumori-bar .sp-bar__pricewrap {
  873.                 flex: 0 0 auto;
  874.                 min-width: 0;
  875.             }
  876.             #sp-mitsumori-bar .sp-bar__price {
  877.                 font-size: 18px;
  878.                 font-weight: bold;
  879.                 color: #c00;
  880.                 white-space: nowrap;
  881.             }
  882.             #sp-mitsumori-bar .sp-bar__btn {
  883.                 font-size: 12px;
  884.                 padding: 8px 12px;
  885.                 background: #333;
  886.                 color: #fff;
  887.                 border: none;
  888.                 border-radius: 6px;
  889.                 white-space: nowrap;
  890.                 cursor: pointer;
  891.                 position: relative;
  892.                 display: inline-flex;
  893.                 align-items: center;
  894.                 gap: 6px;
  895.                 flex: 1 1 auto;
  896.                 min-width: 0;
  897.                 justify-content: center;
  898.             }
  899.             #sp-mitsumori-bar .sp-bar__btn i {
  900.                 font-size: 14px;
  901.                 flex: 0 0 auto;
  902.             }
  903.             /* お見積りの詳細(内訳)を開くボタン。価格・カートボタンを圧迫しないよう
  904.                最小幅のアイコン+短ラベルにし、幅不足時も縮まない固定幅で維持する。 */
  905.             #sp-mitsumori-bar .sp-bar__detail-btn {
  906.                 display: inline-flex;
  907.                 flex-direction: column;
  908.                 align-items: center;
  909.                 justify-content: center;
  910.                 gap: 2px;
  911.                 flex: 0 0 auto;
  912.                 padding: 4px 8px;
  913.                 background: #fff;
  914.                 color: #c00;
  915.                 border: 1px solid #c00;
  916.                 border-radius: 6px;
  917.                 font-size: 10px;
  918.                 font-weight: bold;
  919.                 line-height: 1.1;
  920.                 white-space: nowrap;
  921.                 cursor: pointer;
  922.             }
  923.             #sp-mitsumori-bar .sp-bar__detail-btn i { font-size: 15px; }
  924.             /* 幅が足りないときはボタンの文字を省略し、価格と数字を守る。 */
  925.             #sp-mitsumori-bar .sp-bar__btn-label {
  926.                 overflow: hidden;
  927.                 text-overflow: ellipsis;
  928.                 white-space: nowrap;
  929.             }
  930.             #sp-mitsumori-bar .sp-bar__cart-badge {
  931.                 position: absolute;
  932.                 top: -6px;
  933.                 right: -6px;
  934.                 background: #c00;
  935.                 color: #fff;
  936.                 border-radius: 50%;
  937.                 min-width: 16px;
  938.                 height: 16px;
  939.                 font-size: 10px;
  940.                 line-height: 16px;
  941.                 padding: 0 4px;
  942.                 font-weight: bold;
  943.             }
  944.             #sp-mitsumori-bar .sp-bar__home-btn {
  945.                 display: inline-flex;
  946.                 align-items: center;
  947.                 justify-content: center;
  948.                 width: 36px;
  949.                 height: 36px;
  950.                 flex: 0 0 auto;
  951.                 border: 1px solid #ccc;
  952.                 border-radius: 6px;
  953.                 background: #fff;
  954.                 color: #333;
  955.                 text-decoration: none;
  956.                 cursor: pointer;
  957.             }
  958.             #sp-mitsumori-bar .sp-bar__home-btn i {
  959.                 font-size: 16px;
  960.             }
  961.             #sp-mitsumori-bar .sp-bar__home-btn:hover {
  962.                 background: #f0f0f0;
  963.             }
  964.             /* 下部バーの高さ分ページ下部にpaddingを追加 (iOS safe-area 込み) */
  965.             body {
  966.                 padding-bottom: calc(80px + env(safe-area-inset-bottom));
  967.             }
  968.         }
  969.         /* ============================================
  970.            カート追加モーダル: 縦並び中央配置
  971.         ============================================ */
  972.         .add-cart-modal__wrap {
  973.             text-align: center;
  974.         }
  975.         .add-cart-modal__header {
  976.             font-size: 16px;
  977.             margin: 20px 0 24px;
  978.             text-align: center;
  979.         }
  980.         .add-cart-modal__actions {
  981.             display: flex;
  982.             flex-direction: column;
  983.             align-items: center;
  984.             gap: 16px;
  985.             padding: 0 20px 20px;
  986.         }
  987.         .add-cart-modal__primary {
  988.             display: inline-block;
  989.             width: 80%;
  990.             max-width: 320px;
  991.             padding: 14px 20px;
  992.             font-size: 15px;
  993.             font-weight: bold;
  994.             text-align: center;
  995.             background: #d9534f;
  996.             color: #fff !important;
  997.             border-radius: 6px;
  998.             text-decoration: none;
  999.             cursor: pointer;
  1000.         }
  1001.         .add-cart-modal__primary:hover {
  1002.             background: #c9302c;
  1003.             color: #fff !important;
  1004.             text-decoration: none;
  1005.         }
  1006.         .add-cart-modal__secondary {
  1007.             display: inline-block;
  1008.             padding: 6px 14px;
  1009.             font-size: 12px;
  1010.             color: #666;
  1011.             border: 1px solid #ccc;
  1012.             border-radius: 4px;
  1013.             background: #fff;
  1014.             cursor: pointer;
  1015.         }
  1016.         .add-cart-modal__secondary:hover {
  1017.             background: #f0f0f0;
  1018.             color: #333;
  1019.         }
  1020.         @media (max-width: 767px) {
  1021.             .add-cart-modal__actions {
  1022.                 gap: 20px;
  1023.             }
  1024.         }
  1025.     </style>
  1026. {% endblock %}
  1027. {% block javascript %}
  1028.     <script>
  1029.         eccube.classCategories = {{ class_categories_as_json(Product)|raw }};
  1030.         // 規格2に選択肢を割り当てる。
  1031.         function fnSetClassCategories(form, classcat_id2_selected) {
  1032.             var $form = $(form);
  1033.             var product_id = $form.find('input[name=product_id]').val();
  1034.             var $sele1 = $form.find('select[name=classcategory_id1]');
  1035.             var $sele2 = $form.find('select[name=classcategory_id2]');
  1036.             eccube.setClassCategories($form, product_id, $sele1, $sele2, classcat_id2_selected);
  1037.         }
  1038.         {% if form.classcategory_id2 is defined %}
  1039.         fnSetClassCategories(
  1040.             $('#form1'), {{ form.classcategory_id2.vars.value|json_encode|raw }}
  1041.         );
  1042.         {% elseif form.classcategory_id1 is defined %}
  1043.         eccube.checkStock($('#form1'), {{ Product.id }}, {{ form.classcategory_id1.vars.value|json_encode|raw }}, null);
  1044.         {% endif %}
  1045.     </script>
  1046.     <script>
  1047.         $(function() {
  1048.             // bfcache無効化
  1049.             $(window).bind('pageshow', function(event) {
  1050.                 if (event.originalEvent.persisted) {
  1051.                     location.reload(true);
  1052.                 }
  1053.             });
  1054.             // Core Web Vital の Cumulative Layout Shift(CLS)対策のため
  1055.             // img タグに width, height が付与されている.
  1056.             // 630px 未満の画面サイズでは縦横比が壊れるための対策
  1057.             // see https://github.com/EC-CUBE/ec-cube/pull/5023
  1058.             $('.ec-grid2__cell').hide();
  1059.             var removeSize = function () {
  1060.                 $('.slide-item').height('');
  1061.                 $('.slide-item img')
  1062.                     .removeAttr('width')
  1063.                     .removeAttr('height')
  1064.                     .removeAttr('style');
  1065.             };
  1066.             var slickInitial = function(slick) {
  1067.                 $('.ec-grid2__cell').fadeIn(1500);
  1068.                 var baseHeight = $(slick.target).height();
  1069.                 var baseWidth = $(slick.target).width();
  1070.                 var rate = baseWidth / baseHeight;
  1071.                 if(baseHeight * rate < 400){
  1072.                     $('.slide-item').height(baseHeight * rate); // 余白を削除する
  1073.                 }else{
  1074.                     $('.slide-item').height(400); // 余白を削除する
  1075.                 }
  1076.                 // transform を使用することでCLSの影響を受けないようにする
  1077.                 $('.slide-item img')
  1078.                     .css(
  1079.                         {
  1080.                             'transform-origin': 'top left',
  1081.                             'transform': 'scaleY(' + rate + ')',
  1082.                             'transition': 'transform .1s'
  1083.                         }
  1084.                     );
  1085.                 // 正しいサイズに近くなったら属性を解除する
  1086.                 setTimeout(removeSize, 500);
  1087.             };
  1088.             $('.item_visual').on('init', slickInitial);
  1089.             // リサイズ時は CLS の影響を受けないため属性を解除する
  1090.             $(window).resize(removeSize);
  1091.             $('.item_visual').slick({
  1092.                 dots: false,
  1093.                 arrows: true,
  1094.                 responsive: [{
  1095.                     breakpoint: 768,
  1096.                     settings: {
  1097.                         dots: true,
  1098.                         arrows: false
  1099.                     }
  1100.                 }]
  1101.             });
  1102.             $('.slideThumb').on('click', function() {
  1103.                 var index = $(this).attr('data-index');
  1104.                 $('.item_visual').slick('slickGoTo', index, false);
  1105.             })
  1106.         });
  1107.     </script>
  1108.     <script>
  1109.         $(function() {
  1110.             $('.add-cart').on('click', function(event) {
  1111.                 {% if form.classcategory_id1 is defined %}
  1112.                 // 規格1フォームの必須チェック
  1113.                 if ($('#classcategory_id1').val() == '__unselected' || $('#classcategory_id1').val() == '') {
  1114.                     $('#classcategory_id1')[0].setCustomValidity('{{ '項目が選択されていません'|trans }}');
  1115.                     return true;
  1116.                 } else {
  1117.                     $('#classcategory_id1')[0].setCustomValidity('');
  1118.                 }
  1119.                 {% endif %}
  1120.                 {% if form.classcategory_id2 is defined %}
  1121.                 // 規格2フォームの必須チェック
  1122.                 if ($('#classcategory_id2').val() == '__unselected' || $('#classcategory_id2').val() == '') {
  1123.                     $('#classcategory_id2')[0].setCustomValidity('{{ '項目が選択されていません'|trans }}');
  1124.                     return true;
  1125.                 } else {
  1126.                     $('#classcategory_id2')[0].setCustomValidity('');
  1127.                 }
  1128.                 {% endif %}
  1129.                 // タイプ2: カート送信直前に set_count を quantity に同期
  1130.                 if($('#set_count').length){
  1131.                     var _sc = parseInt($('#set_count').val()) || 1;
  1132.                     $('#quantity').val(_sc);
  1133.                     $('input[name="quantity"]').val(_sc);
  1134.                 }
  1135.                 // 個数フォームのチェック
  1136.                 if ($('#quantity').val() < 1) {
  1137.                     $('#quantity')[0].setCustomValidity('{{ '1以上で入力してください。'|trans }}');
  1138.                     return true;
  1139.                 } else {
  1140.                     $('#quantity')[0].setCustomValidity('');
  1141.                 }
  1142.                 event.preventDefault();
  1143.                 $form = $('#form1');
  1144.                 $.ajax({
  1145.                     url: $form.attr('action'),
  1146.                     type: $form.attr('method'),
  1147.                     data: $form.serialize(),
  1148.                     dataType: 'json',
  1149.                     beforeSend: function(xhr, settings) {
  1150.                         // Buttonを無効にする
  1151.                         $('.add-cart').prop('disabled', true);
  1152.                     }
  1153.                 }).done(function(data) {
  1154.                     // レスポンス内のメッセージをalertで表示
  1155.                     $.each(data.messages, function() {
  1156.                         $('#ec-modal-header').text(this);
  1157.                     });
  1158.                     $('.ec-modal').show()
  1159.                     // カートブロックを更新する
  1160.                     $.ajax({
  1161.                         url: "{{ url('block_cart') }}",
  1162.                         type: 'GET',
  1163.                         dataType: 'html'
  1164.                     }).done(function(html) {
  1165.                         $('.ec-headerRole__cart').html(html);
  1166.                     });
  1167.                 }).fail(function(data) {
  1168.                     alert('{{ 'カートへの追加に失敗しました。'|trans }}');
  1169.                 }).always(function(data) {
  1170.                     // Buttonを有効にする
  1171.                     $('.add-cart').prop('disabled', false);
  1172.                 });
  1173.             });
  1174.         });
  1175.         $('.ec-modal-wrap').on('click', function(e) {
  1176.             // モーダル内の処理は外側にバブリングさせない
  1177.             e.stopPropagation();
  1178.         });
  1179.         $('.ec-modal-overlay, .ec-modal, .ec-modal-close, .ec-inlineBtn--cancel').on('click', function() {
  1180.             $('.ec-modal').hide()
  1181.         });
  1182.         var pw = "{% if mitsumori_json %}{{ mitsumori_json.pw }}{% endif %}";
  1183.         var pd = "{% if mitsumori_json %}{{ mitsumori_json.pd }}{% endif %}";
  1184.         var ph = "{% if mitsumori_json %}{{ mitsumori_json.ph }}{% endif %}";
  1185.         var pm = "{% if mitsumori_json %}{{ mitsumori_json.pm }}{% endif %}";
  1186.         var pc = "{% if mitsumori_json %}{{ mitsumori_json.pc }}{% endif %}";
  1187.         // option1 / option2: カテゴリ別 option 軸 (um: タイプ=subtype, fe: ct_up 等)
  1188.         var option1 = "{% if mitsumori_json and mitsumori_json.option1 is defined %}{{ mitsumori_json.option1 }}{% endif %}";
  1189.         var option2 = "{% if mitsumori_json and mitsumori_json.option2 is defined %}{{ mitsumori_json.option2 }}{% endif %}";
  1190.         var op0 = "{% if mitsumori_json %}{{ mitsumori_json.op[0] }}{% endif %}";
  1191.         var op1 = "{% if mitsumori_json %}{{ mitsumori_json.op[1] }}{% endif %}";
  1192.         var op2 = "{% if mitsumori_json %}{{ mitsumori_json.op[2] }}{% endif %}";
  1193.         var op3 = "{% if mitsumori_json %}{{ mitsumori_json.op[3] }}{% endif %}";
  1194.         var op4 = "{% if mitsumori_json %}{{ mitsumori_json.op[4] }}{% endif %}";
  1195.         var op5 = "{% if mitsumori_json %}{{ mitsumori_json.op[5] }}{% endif %}";
  1196.         var op6 = "{% if mitsumori_json %}{{ mitsumori_json.op[6] }}{% endif %}";
  1197.         var op7 = "{% if mitsumori_json %}{{ mitsumori_json.op[7] }}{% endif %}";
  1198.         var op8 = "{% if mitsumori_json %}{{ mitsumori_json.op[8] }}{% endif %}";
  1199.         var op9 = "{% if mitsumori_json %}{{ mitsumori_json.op[9] }}{% endif %}";
  1200.         var op10 = "{% if mitsumori_json %}{{ mitsumori_json.op[10] }}{% endif %}";
  1201.         const formatter = new Intl.NumberFormat('ja-JP');
  1202.         // ============================================================
  1203.         //  free_area 1 エントリのスキーマ(11 キー固定)
  1204.         // ============================================================
  1205.         //   w           : 幅 (string, 例 "239.9cm" / tf は "1m")
  1206.         //   d           : 奥行
  1207.         //   h           : 高さ (tf はロール長 "10m")
  1208.         //   m           : 素材
  1209.         //   c           : カラー
  1210.         //   price       : 販売価格 (税抜、amount=1 ベース)
  1211.         //   unit_price  : 商品 per-unit 増分 (税抜、AMOUNT_CATEGORIES のみ)
  1212.         //   maker_price : メーカー価格 (税抜)
  1213.         //   ct          : 基本工事費 (税抜、amount=1 ベース)
  1214.         //   option1     : fe → ct_up / その他カテゴリ → category-specific
  1215.         //   option2     : um → ct_up / その他カテゴリ → category-specific
  1216.         //
  1217.         //  ct4 (設置場所) / ct5 (撤去) / ct6 (残土) は option_area 側で扱う.
  1218.         // ============================================================
  1219.         // 施工オプション定義(option_area)— 各エントリ {name, comment, on, off, price}
  1220.         var op_data = {{ op_json|default('[]')|raw }};
  1221.         // option_item_area:
  1222.         //   tg 用 — block 別オプション差額表
  1223.         //     { axis_labels: {F:'床材',front:'前面型',side1:'右側面型',side2:'左側面型'},
  1224.         //       blocks: { 'W_label|D_label': { F:[{idx,label,diff},...], front:[...], side1:[...], side2:[...] }, ... } }
  1225.         //   fe (sale_type=4) 用 — ブロックの種類×段数の差額表 (fe_block キー)
  1226.         //     { fe_block: { depends_on_name: '...', depends_on_value: '...',
  1227.         //                   title: '...', default_key: '...',
  1228.         //                   choices: [{key,label,price}, ...] } }
  1229.         // 該当しないカテゴリでは fe_block は null. axis_labels/blocks は空.
  1230.         var oi_data = {{ oi_json|default('{"axis_labels":{},"blocks":{},"fe_block":null}')|raw }};
  1231.         // 選択中の tg オプション差額 (axis -> selected choice_idx). ラジオ click で更新される.
  1232.         var oi_selected = { F: 1, front: 1, side1: 1, side2: 1 };
  1233.         // 選択中の fe ブロック種類×段数 (key 文字列). 既定は oi_data.fe_block.default_key.
  1234.         var fe_block_selected = (oi_data && oi_data.fe_block && oi_data.fe_block.default_key) || '';
  1235.         // sale_type ID(mtb_sale_type の id。本サイトでの意味は次の通り):
  1236.         //   1 通常 / 2 補助金窓 / 3 物置 / 4 フェンス / 5 デッキ / 6 芝生 / 9 商品のみ
  1237.         var SALE_TYPE_ID = {{ ProductClass.SaleType.id }};
  1238.         {# option1 / option2 ラベルをカテゴリ別に決定 (sh=31, rd=43, mo=15) — 以降の UI / JS 両方で使用 #}
  1239.         {% set option1_label = 'オプション1' %}
  1240.         {% set option2_label = 'オプション2' %}
  1241.         {% if Product.ProductCategories is not empty %}
  1242.             {% for pcat in Product.ProductCategories %}
  1243.                 {% if pcat.category_id == 31 %}{% set option1_label = 'サイズプリセット' %}{% set option2_label = '駆動方式' %}{% endif %}
  1244.                 {% if pcat.category_id == 43 %}{% set option1_label = 'ランマ' %}{% set option2_label = 'タイプ' %}{% endif %}
  1245.                 {% if pcat.category_id == 15 %}{% set option1_label = '棚タイプ' %}{% endif %}
  1246.                 {% if pcat.category_id == 17 %}{% set option1_label = '窓タイプ' %}{% endif %}
  1247.             {% endfor %}
  1248.         {% endif %}
  1249.         // 消費税率(pp.price/pp.ct/op_data.price は scraper が税抜で保存しているため、
  1250.         // calcGoukei 出口で 1+TAX_RATE を掛けて税込 mitsumori_goukei にする)
  1251.         var TAX_RATE = 0.10;
  1252.         function toIncTax(v) { return Math.round(v * (1 + TAX_RATE)); }
  1253.         /**
  1254.          * tg 用 block 別オプション差額の集計.
  1255.          *   現選択 (W, D) から block key を作り、oi_data.blocks[blockKey] を取得.
  1256.          *   各 axis (F/front/side1/side2) で oi_selected[axis] の choice の diff を合計.
  1257.          * 戻り値: { total: 差額合計(税抜), items: [{name, label, diff}, ...] }
  1258.          *   block が無い (tg 以外 / 該当 (W,D) が未取得) なら total=0.
  1259.          */
  1260.         function collectOptionItems(curW, curD) {
  1261.             var result = { total: 0, items: [] };
  1262.             if (!oi_data || !oi_data.blocks) return result;
  1263.             var blockKey = (curW || '') + '|' + (curD || '');
  1264.             var block = oi_data.blocks[blockKey];
  1265.             if (!block) return result;
  1266.             var axes = ['F', 'front', 'side1', 'side2'];
  1267.             for (var i = 0; i < axes.length; i++) {
  1268.                 var axis = axes[i];
  1269.                 var choices = block[axis];
  1270.                 if (!choices || !choices.length) continue;
  1271.                 var idx = oi_selected[axis] || 1;
  1272.                 var pick = null;
  1273.                 for (var j = 0; j < choices.length; j++) {
  1274.                     if (parseInt(choices[j].idx) === parseInt(idx)) { pick = choices[j]; break; }
  1275.                 }
  1276.                 if (!pick) pick = choices[0];
  1277.                 var diff = parseInt(pick.diff) || 0;
  1278.                 result.total += diff;
  1279.                 result.items.push({
  1280.                     name:  oi_data.axis_labels[axis] || axis,
  1281.                     label: pick.label,
  1282.                     diff:  diff,
  1283.                 });
  1284.             }
  1285.             return result;
  1286.         }
  1287.         /**
  1288.          * tg ラジオ群を block 別に DOM へ描画する.
  1289.          *   W/D が確定したら現 block の 4 軸 (F/front/side1/side2) ラジオを #tg-options 要素に出す.
  1290.          *   block が空なら #tg-options を空にする.
  1291.          */
  1292.         function renderOptionItemRadios(curW, curD) {
  1293.             var $box = $('#tg-options');
  1294.             if (!$box.length) return;
  1295.             $box.empty();
  1296.             if (!oi_data || !oi_data.blocks) return;
  1297.             var blockKey = (curW || '') + '|' + (curD || '');
  1298.             var block = oi_data.blocks[blockKey];
  1299.             if (!block) return;
  1300.             var axes = ['F', 'front', 'side1', 'side2'];
  1301.             axes.forEach(function(axis) {
  1302.                 var choices = block[axis];
  1303.                 if (!choices || !choices.length) return;
  1304.                 var axisLabel = (oi_data.axis_labels && oi_data.axis_labels[axis]) || axis;
  1305.                 var $section = $('<div class="form-group tg-axis"></div>');
  1306.                 $section.append('<p class="rp-section-label"><strong>' + axisLabel + '</strong>: <span class="tg-axis-selected"></span></p>');
  1307.                 // 他オプション(opt-btn)と統一したボタン型 UI で描画(縦積み・長ラベル折返し).
  1308.                 var $list = $('<div class="opt-btn-group no-hscroll" style="flex-direction:column;align-items:stretch;"></div>');
  1309.                 choices.forEach(function(ch, i) {
  1310.                     var inputId = 'tg_' + axis + '_' + ch.idx;
  1311.                     var name = 'tg_' + axis;
  1312.                     var isSel = (parseInt(ch.idx) === parseInt(oi_selected[axis] || 1));
  1313.                     var diffTxt = ch.diff > 0 ? '+' + formatter.format(ch.diff) + '円' : (ch.diff < 0 ? formatter.format(ch.diff) + '円' : '±0');
  1314.                     $list.append(
  1315.                         '<label class="opt-btn' + (isSel ? ' is-selected' : '') + '" style="text-align:left;display:flex;align-items:center;flex-wrap:wrap;">' +
  1316.                         '<input type="radio" name="' + name + '" id="' + inputId + '" value="' + ch.idx + '" data-axis="' + axis + '"' + (isSel ? ' checked' : '') + '>' +
  1317.                         ch.label + '<span class="tg-diff">(' + diffTxt + ')</span></label>'
  1318.                     );
  1319.                 });
  1320.                 $section.append($list);
  1321.                 $box.append($section);
  1322.             });
  1323.             // ラジオ選択で oi_selected 更新 + is-selected 付替え → 再計算
  1324.             $box.off('change.tg').on('change.tg', 'input[type="radio"][data-axis]', function() {
  1325.                 var axis = $(this).data('axis');
  1326.                 oi_selected[axis] = parseInt($(this).val()) || 1;
  1327.                 $(this).closest('.opt-btn-group').find('label.opt-btn').removeClass('is-selected');
  1328.                 $(this).closest('label.opt-btn').addClass('is-selected');
  1329.                 mitsumori_simulation('refresh', '');
  1330.             });
  1331.             updateOptionItemSelectedLabels();
  1332.         }
  1333.         function updateOptionItemSelectedLabels() {
  1334.             $('#tg-options .tg-axis').each(function() {
  1335.                 var $sel = $(this).find('input[type="radio"]:checked');
  1336.                 if (!$sel.length) return;
  1337.                 var label = $sel.parent().contents().filter(function() { return this.nodeType === 3; }).text().trim();
  1338.                 $(this).find('.tg-axis-selected').text(label);
  1339.             });
  1340.         }
  1341.         // =========================================================
  1342.         // fe (sale_type=4) ブロック種類×段数 サブ UI
  1343.         // =========================================================
  1344.         /**
  1345.          * fe_block の親オプション (= op_data 内の「ご希望のフェンス設置方法」 entry)
  1346.          * を name でルックアップし、その index を返す. 見つからなければ -1.
  1347.          *   fe_block.depends_on_name が設定されていればそれで照合.
  1348.          *   未設定なら 'ご希望のフェンス設置方法' を含む name にフォールバック.
  1349.          */
  1350.         function findFeBlockParentOpIndex() {
  1351.             if (!oi_data || !oi_data.fe_block) return -1;
  1352.             var needle = oi_data.fe_block.depends_on_name || 'ご希望のフェンス設置方法';
  1353.             for (var i = 0; i < op_data.length; i++) {
  1354.                 if (!op_data[i] || !op_data[i].name) continue;
  1355.                 if (op_data[i].name.indexOf(needle) >= 0) return i;
  1356.             }
  1357.             return -1;
  1358.         }
  1359.         /**
  1360.          * fe_block の現在の選択値の price (税抜) と choice 内容を集計.
  1361.          *   親オプション (= 'ご希望のフェンス設置方法') が depends_on_value を選んでいる
  1362.          *   ときだけ加算する. それ以外 (既存ブロック側 / 親未選択) は price=0.
  1363.          * 戻り値: { price: 0|N, choice: {key,label,price}|null, active: bool }
  1364.          */
  1365.         function collectFeBlock() {
  1366.             var result = { price: 0, choice: null, active: false };
  1367.             if (SALE_TYPE_ID !== 4) return result;
  1368.             if (!oi_data || !oi_data.fe_block) return result;
  1369.             if (!oi_data.fe_block.choices || !oi_data.fe_block.choices.length) return result;
  1370.             var parentIdx = findFeBlockParentOpIndex();
  1371.             if (parentIdx < 0) return result;
  1372.             var ops = [op0,op1,op2,op3,op4,op5,op6,op7,op8,op9,op10];
  1373.             var parentSel = ops[parentIdx];
  1374.             var triggerValue = oi_data.fe_block.depends_on_value || '新規にブロック積みを行う';
  1375.             if (parentSel !== triggerValue) return result;  // 親が新規ブロック積み以外なら無加算
  1376.             result.active = true;
  1377.             var choices = oi_data.fe_block.choices;
  1378.             var pick = null;
  1379.             for (var j = 0; j < choices.length; j++) {
  1380.                 if (choices[j].key === fe_block_selected) { pick = choices[j]; break; }
  1381.             }
  1382.             if (!pick) pick = choices[0];
  1383.             result.choice = pick;
  1384.             result.price  = parseInt(pick.price) || 0;
  1385.             return result;
  1386.         }
  1387.         /**
  1388.          * fe_block ラジオ群を #fe-block-options 要素に描画する.
  1389.          *   active=false (親が「新規にブロック積み」以外) のときは非表示.
  1390.          */
  1391.         function renderFeBlockRadios() {
  1392.             var $box = $('#fe-block-options');
  1393.             if (!$box.length) return;
  1394.             if (!oi_data || !oi_data.fe_block || !oi_data.fe_block.choices) {
  1395.                 $box.empty().hide();
  1396.                 return;
  1397.             }
  1398.             var parentIdx = findFeBlockParentOpIndex();
  1399.             if (parentIdx < 0) {
  1400.                 $box.empty().hide();
  1401.                 return;
  1402.             }
  1403.             var ops = [op0,op1,op2,op3,op4,op5,op6,op7,op8,op9,op10];
  1404.             var triggerValue = oi_data.fe_block.depends_on_value || '新規にブロック積みを行う';
  1405.             var visible = (ops[parentIdx] === triggerValue);
  1406.             if (!visible) {
  1407.                 $box.hide();
  1408.                 return;
  1409.             }
  1410.             var fb = oi_data.fe_block;
  1411.             $box.empty();
  1412.             var $title = $('<p class="rp-section-label"><strong>' + (fb.title || 'ブロックの種類と段数') + '</strong></p>');
  1413.             $box.append($title);
  1414.             // 他オプション群と同じ .opt-btn-group / .opt-btn 形式で描画.
  1415.             //   生の <label><input type=radio> ... 形式だと CSS 定義が無く
  1416.             //   ラベル折り返し・縦ズレ・隣接 opt-btn との間隔不揃いが発生する.
  1417.             var $list = $('<div class="opt-btn-group no-hscroll" style="flex-direction:column;align-items:stretch;"></div>');
  1418.             fb.choices.forEach(function(ch) {
  1419.                 var inputId = 'fe_block_' + ch.key;
  1420.                 var isSel   = (ch.key === fe_block_selected);
  1421.                 var checked = isSel ? ' checked' : '';
  1422.                 var selCls  = isSel ? ' is-selected' : '';
  1423.                 // 選択肢ラベルには価格を表示しない(差額は合計金額側でのみ反映)。
  1424.                 $list.append(
  1425.                     '<label class="opt-btn' + selCls + '" style="text-align:left;display:flex;align-items:center;flex-wrap:wrap;">' +
  1426.                     '<input type="radio" name="fe_block" id="' + inputId + '" value="' + ch.key + '"' + checked + '>' +
  1427.                     '<span class="fe-block-label">' + ch.label + '</span>' +
  1428.                     '</label>'
  1429.                 );
  1430.             });
  1431.             $box.append($list);
  1432.             $box.show();
  1433.             $box.off('change.fe_block').on('change.fe_block', 'input[type="radio"][name="fe_block"]', function() {
  1434.                 fe_block_selected = $(this).val();
  1435.                 // is-selected 切替 (opt-btn 形式共通)
  1436.                 $box.find('label.opt-btn').removeClass('is-selected');
  1437.                 $(this).closest('label.opt-btn').addClass('is-selected');
  1438.                 mitsumori_simulation('refresh', '');
  1439.             });
  1440.         }
  1441.         /**
  1442.          * fe (sale_type=4) で op_data エントリの連動表示制御.
  1443.          *   親「ご希望のフェンス設置方法」の選択値に応じて、
  1444.          *     - 「既存ブロックに穴空け工事が必要ですか?」 entry を表示/非表示
  1445.          *   切替の trigger は親選択値の文字列で判定 (entry index にハードコードしない).
  1446.          */
  1447.         function updateFeOpConditionalVisibility() {
  1448.             if (SALE_TYPE_ID !== 4) return;
  1449.             var parentIdx = findFeBlockParentOpIndex();
  1450.             if (parentIdx < 0) return;
  1451.             var ops = [op0,op1,op2,op3,op4,op5,op6,op7,op8,op9,op10];
  1452.             var parentSel = ops[parentIdx];
  1453.             // 親が「既存ブロックに設置する」 (= off 側) のとき穴空け entry を表示
  1454.             var triggerOffValue = (op_data[parentIdx] && op_data[parentIdx].off) || '既存ブロックに設置する';
  1455.             $('div[data-op-name]').each(function() {
  1456.                 var n = $(this).attr('data-op-name') || '';
  1457.                 if (n.indexOf('穴空け') >= 0) {
  1458.                     if (parentSel === triggerOffValue) {
  1459.                         $(this).show();
  1460.                     } else {
  1461.                         $(this).hide();
  1462.                     }
  1463.                 }
  1464.             });
  1465.         }
  1466.         /**
  1467.          * 選択中の施工オプションを集計し、ON選択分の price を合計する.
  1468.          * 戻り値: { total: 合計金額, items: [{name, value, price}, ...] }
  1469.          */
  1470.         function collectOptions() {
  1471.             var ops = [op0,op1,op2,op3,op4,op5,op6,op7,op8,op9,op10];
  1472.             var total = 0;
  1473.             var items = [];
  1474.             for (var i = 0; i < ops.length; i++) {
  1475.                 if (!op_data[i] || !op_data[i].name) continue;
  1476.                 var sel = ops[i];
  1477.                 if (!sel) continue;
  1478.                 var price = parseInt(op_data[i].price) || 0;
  1479.                 // 「ON」選択時のみ加算(OFF や「不要」「商品購入のみ」は加算しない)
  1480.                 var isOn = (sel === op_data[i].on);
  1481.                 items.push({
  1482.                     name:  op_data[i].name,
  1483.                     value: sel,
  1484.                     price: isOn ? price : 0,
  1485.                     on:    op_data[i].on,
  1486.                     off:   op_data[i].off,
  1487.                 });
  1488.                 if (isOn) total += price;
  1489.             }
  1490.             // fe (sale_type=4) のブロック種類×段数 分を items / total に統合.
  1491.             // 親オプション「ご希望のフェンス設置方法」 が 「新規にブロック積みを行う」 のとき
  1492.             // だけ加算される (collectFeBlock 内で判定済み).
  1493.             var fb = collectFeBlock();
  1494.             if (fb.active && fb.choice) {
  1495.                 items.push({
  1496.                     name:  (oi_data.fe_block && oi_data.fe_block.title) || 'ブロックの種類と段数',
  1497.                     value: fb.choice.label,
  1498.                     price: fb.price,
  1499.                     on:    fb.choice.label,
  1500.                     off:   '',
  1501.                 });
  1502.                 total += fb.price;
  1503.             }
  1504.             return { total: total, items: items };
  1505.         }
  1506.         /**
  1507.          * free_area 1 エントリ ({w,d,h,m,c,price,unit_price,maker_price,ct,option1,option2})
  1508.          * から sale_type 別に最終金額を算出する.
  1509.          *
  1510.          * 計算式(amount=1 ベース、出口で × 1.10 税込化):
  1511.          *   1 通常        : price + ct + option_sum
  1512.          *   2 補助金窓    : price × set_count + (ct + (set_count-1) × option2) + option_sum
  1513.          *   3 物置        : (price + ct) × daisu + option_sum
  1514.          *   4 フェンス    : price + (maisu-1) × unit_price
  1515.          *                  + ct + max(0, maisu-3) × option1
  1516.          *                  + option_sum   (maisu >= 3)
  1517.          *   5 デッキ      : price + ct + option_sum   (area は表示用、計算非関与)
  1518.          *   6 芝生        : price × ceil(area / (w_m × h_m)) + ct + option_sum
  1519.          *   9 商品のみ    : price のみ(カート quantity 倍で総額)
  1520.          *
  1521.          * fe では option1 を ct_up、um では option2 を ct_up として転用.
  1522.          * tf では w / h にロール幅・長さが格納されており parseLen で数値抽出.
  1523.          *
  1524.          * @param {Object} pp  free_area の 1 行(matrix エントリ)
  1525.          * @param {number} optionTotal  collectOptions().total
  1526.          * @param {Object} ctx  UI 入力 {set_count, daisu, maisu, area, quantityOnly, ...}
  1527.          * @returns {Object} {goukei, price, ct, opt, qty}  すべて税込整数
  1528.          */
  1529.         function calcGoukei(pp, optionTotal, ctx) {
  1530.             var basePrice  = parseInt(pp.price)       || 0;
  1531.             var baseCt     = parseInt(pp.ct)          || 0;
  1532.             var unitPrice  = parseInt(pp.unit_price)  || 0;
  1533.             var optSum     = optionTotal;
  1534.             var price      = basePrice;
  1535.             var ct         = baseCt;
  1536.             var qty        = 1;
  1537.             var goukei     = 0;
  1538.             // 「商品購入のみ」選択時は工事費・オプションをゼロにする(任意 sale_type で適用)
  1539.             var ops = [op0,op1,op2,op3,op4,op5,op6,op7,op8,op9,op10];
  1540.             var purchaseOnly = ops.some(function(v){ return v === '商品購入のみ'; });
  1541.             if (purchaseOnly) {
  1542.                 baseCt = 0;
  1543.                 ct     = 0;
  1544.                 optSum = 0;
  1545.             }
  1546.             switch (SALE_TYPE_ID) {
  1547.                 case 2: {  // 補助金・窓 (シンプル表引き方式 2026-05-)
  1548.                     // 2026-05 仕様簡素化: 枚数軸を廃止し、subtype に枚数を内包.
  1549.                     // pp.price / pp.ct は (W, H, ガラス, subtype) の単一価格.
  1550.                     // 複数枚をまとめて買う場合はカート quantity で表現.
  1551.                     qty    = 1;
  1552.                     price  = basePrice;
  1553.                     ct     = baseCt;
  1554.                     goukei = price + ct + optSum;
  1555.                     break;
  1556.                 }
  1557.                 case 3: {  // 物置・ゴミステーション
  1558.                     qty   = Math.max(1, parseInt(ctx.daisu) || 1);
  1559.                     price = basePrice * qty;
  1560.                     ct    = baseCt * qty;
  1561.                     goukei = price + ct + optSum;
  1562.                     break;
  1563.                 }
  1564.                 case 4: {  // フェンス・組み立て式(maisu ≥ 3、base_amount = 3)
  1565.                     // scraper は amount=3 を base にしているので pp.price = price(3),
  1566.                     // pp.unit_price = price(4) - price(3).
  1567.                     qty   = Math.max(3, parseInt(ctx.maisu) || 3);
  1568.                     price = basePrice + (qty - 3) * unitPrice;
  1569.                     // option1 が ct_up (4枚目以降の増分). 未設定なら ct 固定動作
  1570.                     var ctUp1 = parseInt(pp.option1) || 0;
  1571.                     ct = baseCt + Math.max(0, qty - 3) * ctUp1;
  1572.                     // fe_block(ブロック積み段数): collectOptions が base(3枚分)を optSum に
  1573.                     // 算入済み. ブロック費も本体同様に枚数連動するため、4枚目以降は 1枚毎に
  1574.                     // choice.unit_price(= block(4)-block(3)) を加算する.
  1575.                     // (顧客指摘「何枚施工でも増える金額が同じ」の是正。unit_price 未設定の
  1576.                     //  旧データでは +0 で従来動作。)
  1577.                     if (!purchaseOnly) {
  1578.                         var fbC = collectFeBlock();
  1579.                         if (fbC.active && fbC.choice) {
  1580.                             optSum += Math.max(0, qty - 3) * (parseInt(fbC.choice.unit_price) || 0);
  1581.                         }
  1582.                     }
  1583.                     goukei = price + ct + optSum;
  1584.                     break;
  1585.                 }
  1586.                 case 5: {  // ウッドデッキ・タイルデッキ
  1587.                     // area は担当者向けの参考値として保持(計算には使わない)
  1588.                     qty    = 1;
  1589.                     goukei = basePrice + baseCt + optSum;
  1590.                     price  = basePrice;
  1591.                     ct     = baseCt;
  1592.                     break;
  1593.                 }
  1594.                 case 6: {  // 芝生・人工芝(ロール単位)
  1595.                     // pp.w "1m", pp.h "10m" から数値抽出
  1596.                     var w_m = parseLen(pp.w);
  1597.                     var h_m = parseLen(pp.h);
  1598.                     var rollSize = w_m * h_m;
  1599.                     if (!rollSize || rollSize <= 0) rollSize = 1;
  1600.                     // 施工サイズ(面積)があれば必要枚数を自動算出。無ければ直接指定の枚数を使う。
  1601.                     var area  = parseFloat(ctx.area) || 0;
  1602.                     if (area > 0) {
  1603.                         qty = Math.max(1, Math.ceil(area / rollSize));
  1604.                     } else {
  1605.                         qty = Math.max(1, parseInt(ctx.tf_qty) || 1);
  1606.                     }
  1607.                     price  = basePrice * qty;
  1608.                     ct     = baseCt;
  1609.                     goukei = price + ct + optSum;
  1610.                     break;
  1611.                 }
  1612.                 case 7: {  // 数量買い・基本工事費固定(gf ガーデンファニチャー / st 石材)
  1613.                     qty    = Math.max(1, parseInt(ctx.suuryou) || 1);
  1614.                     price  = basePrice * qty;
  1615.                     ct     = baseCt;                 // ct は固定(数量に依らない)
  1616.                     goukei = price + ct + optSum;
  1617.                     break;
  1618.                 }
  1619.                 case 9: {  // 商品のみ購入
  1620.                     // カート側で quantity × unit price するため goukei は単品価格
  1621.                     qty    = parseInt(ctx.quantityOnly) || 1;
  1622.                     price  = basePrice;
  1623.                     ct     = 0;
  1624.                     optSum = 0;
  1625.                     goukei = price;
  1626.                     break;
  1627.                 }
  1628.                 default: {  // 1: 通常(カーポート等、matrix 選択のみ)
  1629.                     qty    = 1;
  1630.                     goukei = basePrice + baseCt + optSum;
  1631.                     break;
  1632.                 }
  1633.             }
  1634.             // 出口で税込化(pp.price/pp.ct/op_data.price はすべて税抜で保存されている前提)
  1635.             return {
  1636.                 goukei: toIncTax(goukei),
  1637.                 price:  toIncTax(price),
  1638.                 ct:     toIncTax(ct),
  1639.                 opt:    toIncTax(optSum),
  1640.                 qty:    qty,
  1641.             };
  1642.         }
  1643.         /**
  1644.          * 寸法文字列から「メートル単位の数値」を抽出する.
  1645.          *
  1646.          * 単位は m / cm / mm を認識し、いずれも m 単位に正規化して返す.
  1647.          * 単位が無い場合は m 単位とみなす(旧 tf データ "1m" "10m" との互換).
  1648.          * 数値が見つからない場合は 0 を返す(呼び出し側で fallback).
  1649.          *
  1650.          * 例:
  1651.          *   "1m"     → 1
  1652.          *   "100cm"  → 1   (2026-06 以降の m → cm 正規化に対応)
  1653.          *   "200cm"  → 2
  1654.          *   "5.5m"   → 5.5
  1655.          *   "1000mm" → 1
  1656.          *   ""       → 0
  1657.          */
  1658.         function parseLen(s) {
  1659.             if (!s) return 0;
  1660.             var str = String(s);
  1661.             var m = str.match(/(\d+(?:\.\d+)?)\s*(mm|cm|m)?/i);
  1662.             if (!m) return 0;
  1663.             var n = parseFloat(m[1]);
  1664.             if (isNaN(n)) return 0;
  1665.             var unit = (m[2] || 'm').toLowerCase();
  1666.             if (unit === 'cm') return n / 100;
  1667.             if (unit === 'mm') return n / 1000;
  1668.             return n;  // 'm' or no unit
  1669.         }
  1670.         // pp は autoSelectFirstColor 等からも参照するので global にする
  1671.         var pp = {{ pp|raw }};
  1672.         // sale_type=2 の幅 / 高さ ladder (Twig p_w / p_h を JS で参照するため).
  1673.         // 入力 mm 値を pp の幅・高さバケット文字列にマッピングする際の元データ.
  1674.         // p_w / p_h は連想配列で来ることがあるため Object.values で配列化する.
  1675.         var PP_W_LADDER = Object.values({{ p_w|default({})|json_encode|raw }} || {});
  1676.         var PP_H_LADDER = Object.values({{ p_h|default({})|json_encode|raw }} || {});
  1677.         /* ============================================================
  1678.            sale_type=2 (補助金窓) 専用: 複数タイプ集計 & 補助金計算
  1679.            ============================================================
  1680.            - 入力 UI: #window-types-container 配下の .window-type-block
  1681.            - 補助金根拠: 先進的窓リノベ2026事業
  1682.              https://window-renovation2026.env.go.jp/construction/inner-window.html
  1683.            - 価格軸: (subtype, w, h, glass, color) で pp を lookup
  1684.            - 1 商品 = 1 明細 にまとめ、内訳 JSON を別途保存予定 (cart 連携は後続タスク)
  1685.            ============================================================ */
  1686.         // 補助金マトリクス: [住宅区分][性能][サイズ区分] → 1 箇所あたり金額 (税抜・端数なし)
  1687.         var WINDOW_SUBSIDY_MATRIX = {
  1688.             detached:  { // 戸建住宅・延床240㎡以下の非住宅
  1689.                 P: { G: 140000, L: 89000, M: 58000, S: 36000 },
  1690.                 S: { G: 76000,  L: 52000, M: 34000, S: 22000 }
  1691.             },
  1692.             apartment: { // 低層・中高層集合住宅・240㎡超の非住宅
  1693.                 P: { G: 152000, L: 98000, M: 64000, S: 40000 },
  1694.                 S: { G: 83000,  L: 57000, M: 37000, S: 24000 }
  1695.             }
  1696.         };
  1697.         // 住宅区分: 画面 UI (input[name="window_housing_type"]) から取得する.
  1698.         // 既定値は PFC meta_key="住宅区分" を fallback (未設定なら '戸建').
  1699.         var HOUSING_TYPE_DEFAULT = {{ getProduct_field(Product.id, '住宅区分')|default('戸建')|json_encode|raw }};
  1700.         function getCurrentHousingKey() {
  1701.             var el = document.querySelector('input[name="window_housing_type"]:checked');
  1702.             var v  = el ? el.value : HOUSING_TYPE_DEFAULT;
  1703.             return (v === '集合住宅') ? 'apartment' : 'detached';
  1704.         }
  1705.         // 商品シリーズ自動判定 (Product.name から)
  1706.         // 根拠カタログ: YKK AP「ウチリモ」XAAAA-H26-067-1 (2026.04), 同「LiteU」XAAAA-H16-088-2,
  1707.         //              三協アルミ「プラメイクEII」0142_STJ1931L, LIXIL「インプラス」(既知)
  1708.         var PRODUCT_NAME_RAW = {{ Product.name|default('')|json_encode|raw }};
  1709.         function detectProductSeries(name) {
  1710.             if (!name) return null;
  1711.             if (/ウチリモ/.test(name)) return 'uchirimo';
  1712.             if (/プラマード\s*U/i.test(name)) return 'pramado_u';
  1713.             if (/インプラス/.test(name)) return 'inplus';
  1714.             if (/プラメイク\s*EII|プラメイクEII/i.test(name)) return 'plamake_eii';
  1715.             if (/LiteU/i.test(name)) return 'lite_u';
  1716.             return null;
  1717.         }
  1718.         // シリーズ上限グレード:
  1719.         //   'P'  = SS/P まで到達可能 (真空断熱ガラス採用シリーズ)
  1720.         //   'S'  = Low-E 複層止まり (真空断熱ガラスのラインなし)
  1721.         //   null = 単板のみ → 補助金対象外
  1722.         var SERIES_GRADE_CAP = {
  1723.             uchirimo:    'P',
  1724.             pramado_u:   'P',
  1725.             inplus:      'S',
  1726.             plamake_eii: 'S',
  1727.             lite_u:      null
  1728.         };
  1729.         var DETECTED_SERIES = detectProductSeries(PRODUCT_NAME_RAW);
  1730.         var SERIES_GRADE_CAP_VAL = (DETECTED_SERIES !== null && Object.prototype.hasOwnProperty.call(SERIES_GRADE_CAP, DETECTED_SERIES))
  1731.             ? SERIES_GRADE_CAP[DETECTED_SERIES]
  1732.             : undefined;
  1733.         // 商品単位グレード上限 (PFC `meta_key=補助金グレード上限` で個別上書き)
  1734.         // 値: 'P' / 'SS' / 'S' / '対象外' / '無' / 'NONE' / 空文字 (未指定)
  1735.         var PRODUCT_GRADE_CAP_RAW = {{ getProduct_field(Product.id, '補助金グレード上限')|default('')|json_encode|raw }};
  1736.         function normalizeGradeCap(v) {
  1737.             if (v === null || v === undefined || v === '') return undefined;
  1738.             var s = String(v).trim().toUpperCase();
  1739.             if (s === 'NONE' || s === '対象外' || s === '無') return null;
  1740.             if (s === 'P' || s === 'SS') return 'P';
  1741.             if (s === 'S') return 'S';
  1742.             return undefined;
  1743.         }
  1744.         var PRODUCT_GRADE_CAP = normalizeGradeCap(PRODUCT_GRADE_CAP_RAW);
  1745.         // 窓種 (subtype = option1) ごとのグレード上限.
  1746.         // 根拠: YKK AP「ウチリモ」業務用 (XAAAA-H26-067-1) の窓種別ラインナップで
  1747.         //      真空ガラス (Glavenir) の設定有無を確認:
  1748.         //        引違い窓 (2/3/4枚建) / 浴室仕様 引違い窓 / FIX窓        → P 到達可能
  1749.         //        内開き窓 (居室/浴室) / 開き窓テラス / テラスドア類       → 真空ガラスなし → S 止まり
  1750.         function detectSubtypeGradeCap(subtype) {
  1751.             if (!subtype) return undefined;
  1752.             var s = String(subtype);
  1753.             if (/内開|開き窓テラス|テラスドア|開き戸/.test(s)) return 'S';
  1754.             return undefined;
  1755.         }
  1756.         // クリップ: 強さは 'P' > 'S' > null (対象外). cap=null は強制対象外, cap=undefined は無効化なし.
  1757.         function clipGradeBy(grade, cap) {
  1758.             if (cap === undefined) return grade;
  1759.             if (cap === null)      return null;
  1760.             if (grade === null)    return null;
  1761.             if (cap === 'S' && grade === 'P') return 'S';
  1762.             return grade;
  1763.         }
  1764.         // D-2/D-3: 一部の窓種は Low-E に「アルゴンガス入り」が必須で、ガス無は補助対象外.
  1765.         //   - 浴室仕様 (ウチリモ): Low-E は Ar入のみ S、Ar無は対象外
  1766.         //   - インプラス 開き窓 / テラスドア: Low-E複層 ガス有のみ S、ガス無は対象外
  1767.         //   ※ 引違い窓・FIX窓・(非浴室)内開き窓は従来どおり Ar 有無を問わない.
  1768.         //   server 側 WindowSubsidyCalculator の subtypeRequiresArgon / glassHasArgon /
  1769.         //   applyArgonRequirement と完全一致させること.
  1770.         function subtypeRequiresArgon(series, subtype) {
  1771.             var s = String(subtype || '');
  1772.             if (/浴室/.test(s)) return true;
  1773.             if (series === 'inplus' && (/テラスドア/.test(s) || (/開き窓/.test(s) && !/内開き/.test(s)))) return true;
  1774.             return false;
  1775.         }
  1776.         function glassHasArgon(glassName) {
  1777.             return /アルゴンガス入り/.test(String(glassName || ''));
  1778.         }
  1779.         function applyArgonRequirement(grade, glassName, subtype) {
  1780.             if (grade === null) return null;
  1781.             // 真空(P)等 Low-E 以外はこの規則の対象外 (真空はガス不要).
  1782.             if (!/Low-?E/i.test(String(glassName || ''))) return grade;
  1783.             if (subtypeRequiresArgon(DETECTED_SERIES, subtype) && !glassHasArgon(glassName)) return null;
  1784.             return grade;
  1785.         }
  1786.         function finalizeGlassGrade(raw, subtype, glassName) {
  1787.             var g = clipGradeBy(raw, SERIES_GRADE_CAP_VAL);
  1788.             g     = clipGradeBy(g,   detectSubtypeGradeCap(subtype));
  1789.             g     = clipGradeBy(g,   PRODUCT_GRADE_CAP);
  1790.             g     = applyArgonRequirement(g, glassName, subtype);
  1791.             return g;
  1792.         }
  1793.         /**
  1794.          * ガラスタイプ名から性能区分 'P' (SS, Uw≤1.1) / 'S' (Uw≤1.5) / null を判別.
  1795.          * null = 補助対象外.
  1796.          *
  1797.          * 根拠: YKK AP「ウチリモ 内窓」業務用カタログ (XAAAA-H26-069-1) で
  1798.          *       P (SS) は真空断熱ガラスのみ、Low-E はアルゴン入りでも S 止まり、
  1799.          *       Low-E ガス無 + 型ガラス / フロストは 2026年4月で対象外、
  1800.          *       が確認できた。アルゴン入り Low-E を P 判定する旧ルールは誤りだった。
  1801.          *
  1802.          * シリーズによっては P がそもそも存在しない (LIXIL インプラスは S 一律)
  1803.          * ため、最終的には PFC で商品単位の上限を上書きできる仕組みが必要。
  1804.          */
  1805.         function detectGlassPerformance(glassName) {
  1806.             if (!glassName) return null;
  1807.             var n = String(glassName);
  1808.             // 補助対象外: 単板 / 和紙調 / 防音 / 防犯 / 型ガラス・フロスト (Low-E ガス無の型は 2026/4 で対象外)
  1809.             if (/単ガラス|単板|和紙調|防音|防犯|複層\/型|型ガラス|フロスト/.test(n)) return null;
  1810.             // P (SS): 真空断熱ガラスのみ
  1811.             if (/真空断熱/.test(n)) return 'P';
  1812.             // S: Low-E 系のみ (アルゴン入りでも S 止まり)
  1813.             if (/Low-?E/i.test(n)) return 'S';
  1814.             // 一般複層 / ブラインドイン複層 / 複層強化 など Low-E 無しの複層は補助対象外
  1815.             return null;
  1816.         }
  1817.         /**
  1818.          * 幅 mm × 高さ mm からサイズ区分 'G' (≥4.0㎡) / 'L' (2.8-3.9) / 'M' (1.6-2.7) / 'S' (0.2-1.5) / null.
  1819.          * null = 補助対象外 (0.2㎡ 未満).
  1820.          */
  1821.         function detectSizeClass(w_mm, h_mm) {
  1822.             var w = parseFloat(w_mm) || 0;
  1823.             var h = parseFloat(h_mm) || 0;
  1824.             if (w <= 0 || h <= 0) return null;
  1825.             var area = (w * h) / 1000000.0;
  1826.             if (area < 0.2) return null;
  1827.             if (area < 1.6) return 'S';
  1828.             if (area < 2.8) return 'M';
  1829.             if (area < 4.0) return 'L';
  1830.             return 'G';
  1831.         }
  1832.         function lookupSubsidyAmount(performance, sizeClass) {
  1833.             if (!performance || !sizeClass) return 0;
  1834.             var t = WINDOW_SUBSIDY_MATRIX[getCurrentHousingKey()];
  1835.             if (!t || !t[performance]) return 0;
  1836.             return t[performance][sizeClass] || 0;
  1837.         }
  1838.         /**
  1839.          * mm 入力値を ladder バケット文字列に変換する.
  1840.          *   bucketizeMm(1750, ["100cm まで","150cm まで","200cm まで","300cm まで"]) → "200cm まで"
  1841.          * - 入力が空 or 0 のときはそのまま返す (未入力扱い)
  1842.          * - 入力がラダー最大を超えるときは最大バケットを返す (= 価格上限の保護)
  1843.          * - 入力文字列が既にラダーラベルそのものなら無変換 (互換のため)
  1844.          */
  1845.         function bucketizeMm(input, ladder) {
  1846.             if (!input && input !== 0) return '';
  1847.             if (!ladder || ladder.length === 0) return input;
  1848.             var s = String(input).trim();
  1849.             if (s === '') return '';
  1850.             if (ladder.indexOf(s) >= 0) return s;
  1851.             var mm = parseMm(s);
  1852.             if (mm <= 0) return s;
  1853.             var withMax = ladder.map(function(b) { return { label: b, max: parseMm(b) }; });
  1854.             withMax.sort(function(a, b) { return a.max - b.max; });
  1855.             for (var i = 0; i < withMax.length; i++) {
  1856.                 if (mm <= withMax[i].max) return withMax[i].label;
  1857.             }
  1858.             return withMax[withMax.length - 1].label;
  1859.         }
  1860.         /**
  1861.          * pp 配列から該当 1 行を返す. 完全一致優先、見つからなければ最初の候補.
  1862.          * 軸: subtype (option1) / w / h / m / c. axisMatch は既存ロジックと同じ.
  1863.          * sale_type=2 では pw_val / ph_val に mm 数値が来るため、内部で bucketizeMm
  1864.          * を通してから既存マッチングに渡す.
  1865.          */
  1866.         function lookupPpRow(subtype, pw_val, ph_val, pm_val, pc_val) {
  1867.             if (!pp || pp.length === 0) return null;
  1868.             var pw_bucket = bucketizeMm(pw_val, PP_W_LADDER);
  1869.             var ph_bucket = bucketizeMm(ph_val, PP_H_LADDER);
  1870.             var axMatch = function(input, value) {
  1871.                 return input == value || input === '指定なし' || value === '' || value == null;
  1872.             };
  1873.             var colMatch = function(input, value) {
  1874.                 if (axMatch(input, value)) return true;
  1875.                 if (typeof value === 'string' && value.indexOf('/') !== -1 && input) {
  1876.                     return value.split('/').some(function(s) { return s.trim() === input; });
  1877.                 }
  1878.                 return false;
  1879.             };
  1880.             var candidates = pp.filter(function(el) {
  1881.                 return axMatch(pw_bucket, el.w)
  1882.                     && axMatch(ph_bucket, el.h)
  1883.                     && axMatch(pm_val, el.m)
  1884.                     && axMatch(subtype, el.option1);
  1885.             });
  1886.             for (var i = 0; i < candidates.length; i++) {
  1887.                 if (colMatch(pc_val, candidates[i].c)) return candidates[i];
  1888.             }
  1889.             return candidates[0] || null;
  1890.         }
  1891.         /**
  1892.          * "1000mm" / "200cm" / "1.0m" / 単位なし → mm に正規化して返す.
  1893.          * UI 上の「200cm まで」等の文字列をそのまま渡せるようにする.
  1894.          */
  1895.         function parseMm(s) {
  1896.             if (!s) return 0;
  1897.             var str = String(s);
  1898.             var m = str.match(/(\d+(?:\.\d+)?)/);
  1899.             if (!m) return 0;
  1900.             var num = parseFloat(m[1]);
  1901.             if (/mm/i.test(str)) return num;
  1902.             if (/cm/i.test(str)) return num * 10;
  1903.             if (/(^|\d)\s*m($|\W)/i.test(str)) return num * 1000;
  1904.             return num;
  1905.         }
  1906.         /**
  1907.          * 1 タイプブロックから入力を読み、pp lookup → 単価・補助金額を計算して返す.
  1908.          * incomplete=true: 軸のどれかが未選択 → 計算しない (UI で「未選択」を示すため).
  1909.          */
  1910.         function collectWindowTypeRow(block) {
  1911.             var get = function(axis) {
  1912.                 // radio / checkbox は :checked、それ以外 (number/text) は値そのまま.
  1913.                 var radio = block.querySelector('input[data-axis="' + axis + '"]:checked');
  1914.                 if (radio) return radio.value;
  1915.                 var any = block.querySelector('input[data-axis="' + axis + '"]');
  1916.                 if (!any) return '';
  1917.                 if (any.type === 'radio' || any.type === 'checkbox') return '';
  1918.                 return (any.value || '').trim();
  1919.             };
  1920.             var setqtyEl = block.querySelector('input[data-axis="setqty"]');
  1921.             var subtype = get('subtype');
  1922.             var pw_val  = get('pw');
  1923.             var ph_val  = get('ph');
  1924.             var pm_val  = get('pm');
  1925.             var pc_val  = get('pc');
  1926.             var setqty  = setqtyEl ? Math.max(1, parseInt(setqtyEl.value) || 1) : 1;
  1927.             var row = {
  1928.                 subtype: subtype, pw: pw_val, ph: ph_val, pm: pm_val, pc: pc_val, setqty: setqty,
  1929.                 unit_price_exc: 0, unit_ct_exc: 0, subsidy: 0,
  1930.                 size_class: null, glass_perf: null,
  1931.                 incomplete: false
  1932.             };
  1933.             // p_option1 / p_w / p_h / p_m / color のいずれかが商品データに無いときは未選択でも完了扱いにする.
  1934.             // (例: 商品によっては subtype が無い場合がある)
  1935.             var hasSubtype = !!block.querySelector('input[data-axis="subtype"]');
  1936.             var hasPw      = !!block.querySelector('input[data-axis="pw"]');
  1937.             var hasPh      = !!block.querySelector('input[data-axis="ph"]');
  1938.             var hasPm      = !!block.querySelector('input[data-axis="pm"]');
  1939.             var hasPc      = !!block.querySelector('input[data-axis="pc"]');
  1940.             if ((hasSubtype && !subtype) || (hasPw && !pw_val) || (hasPh && !ph_val)
  1941.                 || (hasPm && !pm_val)  || (hasPc && !pc_val)) {
  1942.                 row.incomplete = true;
  1943.                 return row;
  1944.             }
  1945.             var ppRow = lookupPpRow(subtype, pw_val, ph_val, pm_val, pc_val);
  1946.             if (ppRow) {
  1947.                 row.unit_price_exc = parseInt(ppRow.price) || 0;
  1948.                 row.unit_ct_exc    = parseInt(ppRow.ct)    || 0;
  1949.             }
  1950.             // ガラス名検出 → シリーズ上限 → subtype 上限 → 商品上限 の順で min クリップ.
  1951.             // 例: LiteU (SERIES_GRADE_CAP_VAL=null) は Low-E でも null に落ちて補助対象外.
  1952.             // 例: ウチリモの「内開き窓」subtype は真空ガラスでも S に落ちる.
  1953.             row.glass_perf = finalizeGlassGrade(detectGlassPerformance(pm_val), subtype, pm_val);
  1954.             row.size_class = detectSizeClass(parseMm(pw_val), parseMm(ph_val));
  1955.             row.subsidy    = lookupSubsidyAmount(row.glass_perf, row.size_class);
  1956.             return row;
  1957.         }
  1958.         /**
  1959.          * sale_type=2 用集計: 全タイプから合計を出し DOM 更新.
  1960.          * 計算式 (税抜→税込で出口で 1.10 倍):
  1961.          *   商品金額 = Σ(各タイプ単価 × set_qty)
  1962.          *   基本工事費 = 1 回分 (各タイプ pp.ct のうち最初に得た非ゼロ値)
  1963.          *   補助金合計 = Σ(各タイプ補助金 × set_qty)
  1964.          *   実質支払額 = (商品+ct+option) 税込 - 補助金合計
  1965.          */
  1966.         function recalcAllForSaleType2() {
  1967.             if (typeof SALE_TYPE_ID === 'undefined' || SALE_TYPE_ID != 2) return;
  1968.             if (!pp || pp.length === 0) {
  1969.                 $('#mitsumori_message').text('価格データ準備中');
  1970.                 $('#mitsumori_btn').hide();
  1971.                 ['#mitsumori_goukei','#mitsumori_off','#mitsumori_price','#maker_price',
  1972.                  '#mitsumori_ct','#mitsumori_option','#mitsumori_kei','#mitsumori_shoukei','#mitsumori_tax']
  1973.                  .forEach(function(s){ $(s).text('---円'); });
  1974.                 return;
  1975.             }
  1976.             var blocks = document.querySelectorAll('#window-types-container .window-type-block');
  1977.             if (blocks.length === 0) return;
  1978.             var rows = [];
  1979.             var hasIncomplete = false;
  1980.             var baseCt = 0;
  1981.             var hasAnyPriced = false;
  1982.             blocks.forEach(function(b) {
  1983.                 var r = collectWindowTypeRow(b);
  1984.                 rows.push({ block: b, row: r });
  1985.                 if (r.incomplete) hasIncomplete = true;
  1986.                 if (r.unit_ct_exc > 0 && baseCt === 0) baseCt = r.unit_ct_exc;
  1987.                 if (r.unit_price_exc > 0) hasAnyPriced = true;
  1988.                 var subSpan = b.querySelector('.wt-subsidy-amount');
  1989.                 if (subSpan) subSpan.textContent = r.incomplete ? '―' : Math.round(r.subsidy * r.setqty).toLocaleString();
  1990.             });
  1991.             if (hasIncomplete || !hasAnyPriced) {
  1992.                 $('#mitsumori_message').text('窓タイプを選択してください');
  1993.                 $('#mitsumori_btn').hide();
  1994.                 return;
  1995.             }
  1996.             var ops = [op0,op1,op2,op3,op4,op5,op6,op7,op8,op9,op10];
  1997.             var purchaseOnly = ops.some(function(v){ return v === '商品購入のみ'; });
  1998.             var sumPriceExc = 0, sumSubsidy = 0, totalPanes = 0;
  1999.             rows.forEach(function(item) {
  2000.                 var r = item.row;
  2001.                 sumPriceExc += r.unit_price_exc * r.setqty;
  2002.                 sumSubsidy  += r.subsidy * r.setqty;
  2003.                 if (r.unit_price_exc > 0) totalPanes += r.setqty;
  2004.             });
  2005.             // 内窓 工事費(顧客最終回答): 1枚目 22,000円 + 2枚目以降 1枚あたり +5,000円(税込表示額)。
  2006.             // 既存パイプライン整合のため税抜(ctExc)も用意(税行 taxAmt 用)。商品購入のみは 0。
  2007.             var WINDOW_CT_BASE = 22000, WINDOW_CT_ADD = 5000;
  2008.             var ctIncTarget = purchaseOnly ? 0 : (WINDOW_CT_BASE + Math.max(0, totalPanes - 1) * WINDOW_CT_ADD);
  2009.             var ctExc  = purchaseOnly ? 0 : Math.round(ctIncTarget / (1 + TAX_RATE));
  2010.             var optExc = purchaseOnly ? 0 : (collectOptions().total || 0);
  2011.             var sumPriceInc = Math.round(sumPriceExc * (1 + TAX_RATE));
  2012.             var ctInc       = ctIncTarget;
  2013.             var optInc      = Math.round(optExc * (1 + TAX_RATE));
  2014.             var goukeiInc   = sumPriceInc + ctInc + optInc;
  2015.             // 窓リノベ2026 申請条件:
  2016.             //   - 補助金合計 ≥ 50,000 円 (5 万円未満は申請不可 → 還元 0 円扱い)
  2017.             //   - 1 戸あたり上限 1,000,000 円 (住宅・非住宅240㎡以下)
  2018.             //     ※ 非住宅 240㎡超の 10,000,000 円上限は対象外 (商品設計時に別扱い想定)
  2019.             var SUBSIDY_MIN_AMOUNT = 50000;
  2020.             var SUBSIDY_MAX_PER_UNIT = 1000000;
  2021.             var subsidyRawSum = sumSubsidy;
  2022.             var subsidyEligible = subsidyRawSum >= SUBSIDY_MIN_AMOUNT;
  2023.             var subsidyAmt = 0;
  2024.             var subsidyNote = '';
  2025.             if (subsidyEligible) {
  2026.                 if (subsidyRawSum > SUBSIDY_MAX_PER_UNIT) {
  2027.                     subsidyAmt = SUBSIDY_MAX_PER_UNIT;
  2028.                     subsidyNote = '※補助上限 ' + SUBSIDY_MAX_PER_UNIT.toLocaleString()
  2029.                         + '円 超過分は控除対象外(補助金合計 ' + subsidyRawSum.toLocaleString() + '円)';
  2030.                 } else {
  2031.                     subsidyAmt = subsidyRawSum;
  2032.                     subsidyNote = '※補助金合計 ' + subsidyRawSum.toLocaleString()
  2033.                         + '円(5万円以上)が適用されます';
  2034.                 }
  2035.             } else if (subsidyRawSum > 0) {
  2036.                 // (b) per-window 補助金額は常時表示。合計5万円以上で適用される旨と
  2037.                 //     あといくらで適用かを明示する(閾値 SUBSIDY_MIN_AMOUNT は据え置き)。
  2038.                 var subsidyShort = SUBSIDY_MIN_AMOUNT - subsidyRawSum;
  2039.                 subsidyNote = '※補助金は各窓の補助金額の合計が5万円以上で適用されます'
  2040.                     + '(現在の合計 ' + subsidyRawSum.toLocaleString() + '円 / あと '
  2041.                     + subsidyShort.toLocaleString() + '円で適用)';
  2042.             } else if (SERIES_GRADE_CAP_VAL === null) {
  2043.                 // LiteU 等の単板構成シリーズ: グレード判定で必ず null になる
  2044.                 subsidyNote = '※本商品は補助金(先進的窓リノベ2026事業)対象外です';
  2045.             } else if (PRODUCT_GRADE_CAP === null) {
  2046.                 subsidyNote = '※本商品は補助金対象外として登録されています';
  2047.             }
  2048.             var implementedInc = Math.max(0, goukeiInc - subsidyAmt);
  2049.             var taxAmt      = Math.round((sumPriceExc + ctExc + optExc) * TAX_RATE);
  2050.             $('#mitsumori_message').text(subsidyNote);
  2051.             $('#mitsumori_btn').show();
  2052.             $('#mitsumori_goukei').text(goukeiInc.toLocaleString() + '円');
  2053.             $('#mitsumori_price').text(sumPriceInc.toLocaleString() + '円');
  2054.             $('#mitsumori_ct').text(ctInc.toLocaleString() + '円');
  2055.             $('#mitsumori_option').text(optInc.toLocaleString() + '円');
  2056.             $('#mitsumori_off').text((subsidyAmt > 0 ? '-' : '') + subsidyAmt.toLocaleString() + '円');
  2057.             $('#maker_price').text('---円'); // 補助金窓では未使用
  2058.             $('#mitsumori_kei').text(goukeiInc.toLocaleString() + '円');
  2059.             $('#mitsumori_shoukei').text(implementedInc.toLocaleString() + '円');
  2060.             $('#mitsumori_tax').text(taxAmt.toLocaleString() + '円');
  2061.             // 内訳を mitsumori_json に詰めて hidden input にセット (PDF / カート連携用)
  2062.             var window_types = rows.map(function(item) {
  2063.                 var r = item.row;
  2064.                 // 幅 / 高さは数値入力 (mm) が来るので "1750mm" 形式で保存する.
  2065.                 // 古い radio 由来の "200cm まで" 形式が来た場合はそのまま保存して互換.
  2066.                 var w_mm = parseMm(r.pw);
  2067.                 var h_mm = parseMm(r.ph);
  2068.                 var w_save = (w_mm > 0 && /^\s*\d+(?:\.\d+)?\s*$/.test(String(r.pw))) ? (w_mm + 'mm') : r.pw;
  2069.                 var h_save = (h_mm > 0 && /^\s*\d+(?:\.\d+)?\s*$/.test(String(r.ph))) ? (h_mm + 'mm') : r.ph;
  2070.                 return {
  2071.                     subtype:        r.subtype,
  2072.                     w:              w_save,
  2073.                     h:              h_save,
  2074.                     glass:          r.pm,
  2075.                     color:          r.pc,
  2076.                     setqty:         r.setqty,
  2077.                     unit_price_inc: Math.round(r.unit_price_exc * (1 + TAX_RATE)),
  2078.                     line_total_inc: Math.round(r.unit_price_exc * r.setqty * (1 + TAX_RATE)),
  2079.                     size_class:     r.size_class,
  2080.                     glass_perf:     r.glass_perf,
  2081.                     subsidy_unit:   r.subsidy,
  2082.                     subsidy_total:  r.subsidy * r.setqty
  2083.                 };
  2084.             });
  2085.             var opCalc2 = collectOptions();
  2086.             var mitsumori_json = {
  2087.                 "mitsumori_goukei": goukeiInc.toLocaleString() + '円',
  2088.                 "mitsumori_price":  sumPriceInc.toLocaleString() + '円',
  2089.                 "mitsumori_ct":     ctInc.toLocaleString() + '円',
  2090.                 "mitsumori_option": optInc.toLocaleString() + '円',
  2091.                 "mitsumori_off":    (subsidyAmt > 0 ? '-' : '') + subsidyAmt.toLocaleString() + '円',
  2092.                 "maker_price":      '---円',
  2093.                 "product_id":       {{ Product.id }},
  2094.                 "sale_type":        SALE_TYPE_ID,
  2095.                 "housing_key":      getCurrentHousingKey(),
  2096.                 "series_key":       DETECTED_SERIES,
  2097.                 "series_grade_cap": (SERIES_GRADE_CAP_VAL === undefined) ? null : SERIES_GRADE_CAP_VAL,
  2098.                 "product_grade_cap":(PRODUCT_GRADE_CAP    === undefined) ? null : PRODUCT_GRADE_CAP,
  2099.                 "window_types":     window_types,
  2100.                 "subsidy_total":    subsidyAmt,
  2101.                 "subsidy_raw":      subsidyRawSum,
  2102.                 "subsidy_eligible": subsidyEligible,
  2103.                 "subsidy_note":     subsidyNote,
  2104.                 "op_items":         opCalc2.items
  2105.             };
  2106.             $('#mitsumori_json').val(JSON.stringify(mitsumori_json));
  2107.             renderWindowTypesModalRows(window_types, subsidyAmt);
  2108.         }
  2109.         /**
  2110.          * sale_type=2 用: 見積モーダル明細テーブルを window_types で動的展開.
  2111.          * - 既存の商品名行 (1 行目) は hide() し、各タイプ行を上に挿入
  2112.          * - 基本工事費行はそのまま (#mitsumori_ct_01 / _02 が更新されている)
  2113.          * - 補助金 (割引額) 行を末尾に追加
  2114.          * - 既存の renderOptionDetailRows と共存できるよう class で区別
  2115.          */
  2116.         function renderWindowTypesModalRows(window_types, subsidyAmt) {
  2117.             var $tbody = $('.modal-mitsumori table tbody');
  2118.             if (!$tbody.length) return;
  2119.             // 既存の動的行を全削除 (opt-row は既存実装、wt-row / wt-subsidy-row は今回追加)
  2120.             $tbody.find('tr.wt-row, tr.wt-subsidy-row').remove();
  2121.             var $itemRow = $tbody.find('tr').first();
  2122.             var fmt = new Intl.NumberFormat('ja-JP');
  2123.             if (!window_types || window_types.length === 0) {
  2124.                 // 集計対象なし → 商品名行を表示に戻す
  2125.                 $itemRow.show();
  2126.                 return;
  2127.             }
  2128.             window_types.forEach(function(wt, i) {
  2129.                 var label = '窓タイプ' + (i + 1) + ':'
  2130.                     + (wt.subtype || '') + ' / '
  2131.                     + (wt.w || '') + '×' + (wt.h || '') + ' / '
  2132.                     + (wt.glass || '') + ' / '
  2133.                     + (wt.color || '');
  2134.                 var $tr = $('<tr class="wt-row"></tr>')
  2135.                     .append('<td>' + label + '</td>')
  2136.                     .append('<td>' + wt.setqty + '</td>')
  2137.                     .append('<td>セット</td>')
  2138.                     .append('<td>' + fmt.format(wt.unit_price_inc) + '円</td>')
  2139.                     .append('<td>' + fmt.format(wt.line_total_inc) + '円</td>');
  2140.                 $itemRow.before($tr);
  2141.             });
  2142.             // 元の商品名行は複数タイプに置換されたので非表示
  2143.             $itemRow.hide();
  2144.             // 補助金(割引額)行を末尾に追加
  2145.             if (subsidyAmt > 0) {
  2146.                 var $subRow = $('<tr class="wt-subsidy-row"></tr>')
  2147.                     .append('<td>補助金(割引額)</td>')
  2148.                     .append('<td>1</td>')
  2149.                     .append('<td>式</td>')
  2150.                     .append('<td>-' + fmt.format(subsidyAmt) + '円</td>')
  2151.                     .append('<td>-' + fmt.format(subsidyAmt) + '円</td>');
  2152.                 $tbody.append($subRow);
  2153.             }
  2154.         }
  2155.         // 公開: onWindowTypeAxisChange (UI 骨格 task 1) からも呼ばれる
  2156.         window.recalcAll = recalcAllForSaleType2;
  2157.         // 公開: ガラス選択肢が補助金対象か (窓タイプブロック生成時のバッジ判定に使う).
  2158.         // subtype は P→S に落とすことはあっても null にはしないため、性能区分の
  2159.         // 対象/対象外は subtype に依存しない。ここでは series/product 上限のみ適用し、
  2160.         // finalizeGlassGrade が null でなければ「補助金対象ガラス」と判定する。
  2161.         window.glassYieldsSubsidy = function(glassName) {
  2162.             if (!glassName) return false;
  2163.             return finalizeGlassGrade(detectGlassPerformance(glassName), '', glassName) !== null;
  2164.         };
  2165.         function mitsumori_simulation(type , value_id){
  2166.             // sale_type=2 (補助金窓) は複数タイプ集計 UI に統合済み. 既存 mitsumori_simulation は使わない.
  2167.             if (typeof SALE_TYPE_ID !== 'undefined' && SALE_TYPE_ID == 2) {
  2168.                 // type/value_id が op\d+ (施工オプション) の場合はグローバル変数を先に更新
  2169.                 if (type && /^op\d+$/.test(type) && value_id) {
  2170.                     var v = $('#'+value_id).val();
  2171.                     if (type === 'op0')  op0  = v;
  2172.                     if (type === 'op1')  op1  = v;
  2173.                     if (type === 'op2')  op2  = v;
  2174.                     if (type === 'op3')  op3  = v;
  2175.                     if (type === 'op4')  op4  = v;
  2176.                     if (type === 'op5')  op5  = v;
  2177.                     if (type === 'op6')  op6  = v;
  2178.                     if (type === 'op7')  op7  = v;
  2179.                     if (type === 'op8')  op8  = v;
  2180.                     if (type === 'op9')  op9  = v;
  2181.                     if (type === 'op10') op10 = v;
  2182.                 }
  2183.                 recalcAllForSaleType2();
  2184.                 return;
  2185.             }
  2186.             // 価格データ未登録 (pp 行 0 件) の商品は axis 入力に依存せず
  2187.             // 「価格データ準備中」を表示して終了する.
  2188.             // (例: scrape 未対象の新規商品 — um のウチリモ 7487/7488 等)
  2189.             if (!pp || pp.length === 0) {
  2190.                 $('#mitsumori_message').text("価格データ準備中");
  2191.                 $('#mitsumori_btn').hide();
  2192.                 $('#mitsumori_goukei').text("---円");
  2193.                 $('#mitsumori_off').text("---円");
  2194.                 $('#mitsumori_price').text("---円");
  2195.                 $('#maker_price').text("---円");
  2196.                 $('#mitsumori_ct').text("---円");
  2197.                 $('#mitsumori_option').text("---円");
  2198.                 $('#mitsumori_kei').text("---円");
  2199.                 $('#mitsumori_shoukei').text("---円");
  2200.                 $('#mitsumori_tax').text("---円");
  2201.                 return;
  2202.             }
  2203.             if(type == "pw"){
  2204.                 pw = $('#'+value_id).val();
  2205.             }
  2206.             if(type == "pd"){
  2207.                 pd = $('#'+value_id).val();
  2208.             }
  2209.             if(type == "ph"){
  2210.                 ph = $('#'+value_id).val();
  2211.             }
  2212.             if(type == "pm"){
  2213.                 pm = $('#'+value_id).val();
  2214.             }
  2215.             if(type == "pc"){
  2216.                 pc = $('#'+value_id).val();
  2217.             }
  2218.             if(type == "option1"){
  2219.                 option1 = $('#'+value_id).val();
  2220.             }
  2221.             if(type == "option2"){
  2222.                 option2 = $('#'+value_id).val();
  2223.             }
  2224.             if(type == "op0"){ op0 = $('#'+value_id).val(); }
  2225.             if(type == "op1"){ op1 = $('#'+value_id).val(); }
  2226.             if(type == "op2"){ op2 = $('#'+value_id).val(); }
  2227.             if(type == "op3"){ op3 = $('#'+value_id).val(); }
  2228.             if(type == "op4"){ op4 = $('#'+value_id).val(); }
  2229.             if(type == "op5"){ op5 = $('#'+value_id).val(); }
  2230.             if(type == "op6"){ op6 = $('#'+value_id).val(); }
  2231.             if(type == "op7"){ op7 = $('#'+value_id).val(); }
  2232.             if(type == "op8"){ op8 = $('#'+value_id).val(); }
  2233.             if(type == "op9"){ op9 = $('#'+value_id).val(); }
  2234.             if(type == "op10"){ op10 = $('#'+value_id).val(); }
  2235.             // op0 が「商品購入のみ」のとき、他のオプション (op1..op10) を disable 化.
  2236.             // 価格計算側 (calcGoukei の purchaseOnly 分岐) は既にゼロ化しているが、
  2237.             // UI で選択可能に見えるとユーザーが混乱するため radio を完全に無効化する.
  2238.             (function applyPurchaseOnlyUI() {
  2239.                 var isPurchaseOnly = (op0 === '商品購入のみ');
  2240.                 for (var k = 1; k <= 10; k++) {
  2241.                     var $inputs = $('input[name="op' + k + '"]');
  2242.                     if ($inputs.length === 0) continue;
  2243.                     $inputs.prop('disabled', isPurchaseOnly);
  2244.                     var $group = $inputs.first().closest('.form-group');
  2245.                     $group.css({
  2246.                         'opacity':        isPurchaseOnly ? '0.4'  : '1',
  2247.                         'pointer-events': isPurchaseOnly ? 'none' : 'auto'
  2248.                     });
  2249.                 }
  2250.             })();
  2251.             // 全 type 共通: 該当 radio を checked にし、ボタンの is-selected を付け替え + ラベル更新.
  2252.             //   value_id が指定されていて、対応する DOM 要素が opt-btn 配下にあれば適用する.
  2253.             //   (icheck-danger 形式から opt-btn 形式に統一した際、option1/option2/pw/pd/ph/pm/pc 等の
  2254.             //    クリックで is-selected が更新されない不具合があったため、op\d+ 限定の処理を一般化)
  2255.             if (value_id) {
  2256.                 var $clicked = $('#' + value_id);
  2257.                 if ($clicked.length && $clicked.closest('.opt-btn').length) {
  2258.                     $clicked.prop('checked', true);
  2259.                     var groupName = $clicked.attr('name');
  2260.                     $('input[name="' + groupName + '"]').closest('.opt-btn').removeClass('is-selected');
  2261.                     $clicked.closest('.opt-btn').addClass('is-selected');
  2262.                     var $group = $clicked.closest('.form-group');
  2263.                     var $label = $group.find('.rp-section-label');
  2264.                     if ($label.length) {
  2265.                         var labelText = $label.text().split(':')[0].trim();
  2266.                         $label.html(labelText + ': <span>' + $clicked.val() + '</span>');
  2267.                     }
  2268.                     // op_data の price=0 & 解体/撤去/設置場所 オプションの場合、ON 選択時に「現場調査後に正式お見積もり」文言を表示.
  2269.                     //   (twig 側で is_removal_unknown ブロックを描画済み。display 切替のみ JS で行う)
  2270.                     if (/^op\d+$/.test(type)) {
  2271.                         var opIdx = type.replace('op', '');
  2272.                         var opEntry = op_data[parseInt(opIdx)];
  2273.                         if (opEntry) {
  2274.                             var $note = $group.find('.opt-survey-note[data-op-idx="' + opIdx + '"]');
  2275.                             if ($note.length) {
  2276.                                 var isOn = ($clicked.val() === opEntry.on);
  2277.                                 $note.toggle(isOn);
  2278.                             }
  2279.                         }
  2280.                         // wd (sale_type=5) のステップ / デッキフェンス用の注記もここで切替.
  2281.                         //   ステップ → deck_step (op0 だが op_data 範囲外), 必要 選択時に表示.
  2282.                         var radioName = $clicked.attr('name');
  2283.                         if (radioName === 'deck_step') {
  2284.                             $group.find('.opt-survey-note-wd-step').toggle($clicked.val() === '必要');
  2285.                         } else if (radioName === 'deck_fence') {
  2286.                             $group.find('.opt-survey-note-wd-fence').toggle($clicked.val() === '必要');
  2287.                         }
  2288.                     }
  2289.                 }
  2290.             }
  2291.             // 必須項目チェックは sale_type 1 系(カーポート/物置/フェンスなど価格マトリクス必要)に限定
  2292.             var requireMatrix = (SALE_TYPE_ID == 1 || SALE_TYPE_ID == 2 || SALE_TYPE_ID == 3 || SALE_TYPE_ID == 4 || SALE_TYPE_ID == 5 || SALE_TYPE_ID == 6 || SALE_TYPE_ID == 7 || SALE_TYPE_ID == 9);
  2293.             // sale_type=3 (物置・ゴミステーション・照明 等) では「素材」を選択肢として扱わない.
  2294.             //   - mo/gs/sy は m が "指定なし" など名目的にしか入っていない
  2295.             //   - sl は m が空のままで matrix に行が来る
  2296.             //   そのため pm の必須チェックを sale_type=3 でスキップする。
  2297.             var pmRequired = (SALE_TYPE_ID != 3);
  2298.             if (requireMatrix) {
  2299.                 if(pmRequired && pm == ""){
  2300.                     $('#mitsumori_message').text("素材を選択してください");
  2301.                 }
  2302.                 if(ph == ""){
  2303.                     $('#mitsumori_message').text("高さを選択してください");
  2304.                 }
  2305.                 if(pd == ""){
  2306.                     $('#mitsumori_message').text("奥行きを選択してください");
  2307.                 }
  2308.                 if(pw == ""){
  2309.                     $('#mitsumori_message').text("幅を選択してください");
  2310.                 }
  2311.                 if(pc == ""){
  2312.                     $('#mitsumori_message').text("カラーを選択してください");
  2313.                     $('#mitsumori_btn').hide();
  2314.                     $('#mitsumori_goukei').text("---円");
  2315.                     $('#mitsumori_off').text("---円");
  2316.                     $('#mitsumori_price').text("---円");
  2317.                     $('#maker_price').text("---円");
  2318.                     $('#mitsumori_ct').text("---円");
  2319.                     $('#mitsumori_option').text("---円");
  2320.                 }
  2321.             }
  2322.             if(pw != "" && pd != "" && ph != "" && (!pmRequired || pm != "") && pc != ""){
  2323.                 $('#mitsumori_btn').show();
  2324.                 // 入力コンテキスト(sale_type 別パラメータ)
  2325.                 var ctx = {
  2326.                     set_count:    parseInt($('#set_count').val())    || 1,
  2327.                     daisu:        parseInt($('#daisu').val())        || 1,
  2328.                     maisu:        parseInt($('#maisu').val())        || 3,
  2329.                     suuryou:      parseInt($('#suuryou').val())      || 1,
  2330.                     area:         (function(){
  2331.                         // tf(人工芝): 施工サイズ 縦(m)×横(m) から面積を算出し #area に反映.
  2332.                         var L = parseFloat($('#tf_len').val()) || 0;
  2333.                         var W = parseFloat($('#tf_wid').val()) || 0;
  2334.                         if (L > 0 && W > 0) { var a = L * W; $('#area').val(a); return a; }
  2335.                         return parseFloat($('#area').val()) || 0;
  2336.                     })(),
  2337.                     tf_len:       parseFloat($('#tf_len').val())     || 0,
  2338.                     tf_wid:       parseFloat($('#tf_wid').val())     || 0,
  2339.                     tf_qty:       parseInt($('#tf_qty').val())       || 1,
  2340.                     quantityOnly: parseInt($('#quantity_only').val())|| 1,
  2341.                     mado_w:       $('#mado_w').val() || '',
  2342.                     mado_h:       $('#mado_h').val() || '',
  2343.                     mado_type:    $('input[name=mado_type]:checked').val()  || '',
  2344.                     glass_type:   $('input[name=glass_type]:checked').val() || '',
  2345.                     deck_step:    $('input[name=deck_step]:checked').val()  || '',
  2346.                     deck_fence:   $('input[name=deck_fence]:checked').val() || '',
  2347.                 };
  2348.                 // 施工オプション集計(残土処理を含む)
  2349.                 var opCalc = collectOptions();
  2350.                 var pp_matched = false;
  2351.                 // 各軸の汎用 axisMatch:
  2352.                 //   user 選択 == matrix 値 / user "指定なし" / matrix 値が空
  2353.                 var axisMatch = function(input, value){
  2354.                     return input == value || input == "指定なし" || value === "" || value == null;
  2355.                 };
  2356.                 // 色専用マッチャー: scraper のデータ形式に合わせて拡張する
  2357.                 //   1. 完全一致 / "指定なし" / 値が空 → 真 (axisMatch と同じ)
  2358.                 //   2. matrix 値が "色1/色2/色3" の形 (同価格グループ) → split して user 選択が含まれれば真
  2359.                 // scraper のロジック (scraper_price.py L765-779):
  2360.                 //   全色同価格 → c=""、価格違いグループあり → 各 group を "/" 結合した色名で 1 行
  2361.                 var colorMatch = function(input, value){
  2362.                     if (axisMatch(input, value)) return true;
  2363.                     if (typeof value === 'string' && value.indexOf('/') !== -1 && input) {
  2364.                         var parts = value.split('/');
  2365.                         for (var idx = 0; idx < parts.length; idx++) {
  2366.                             if (parts[idx].trim() === input) return true;
  2367.                         }
  2368.                     }
  2369.                     return false;
  2370.                 };
  2371.                 // 他軸 (w/d/h/m + option1/option2) でマッチする pp 行を抽出.
  2372.                 // option1/option2 は um の subtype 等で使われる. axisMatch は free_area 側が
  2373.                 // 空のとき真を返すので、um 以外のカテゴリ (option 軸を使わない) でも影響なし.
  2374.                 var candidates = pp.filter(function(el){
  2375.                     return axisMatch(pw, el.w) && axisMatch(pd, el.d) && axisMatch(ph, el.h) && axisMatch(pm, el.m)
  2376.                         && axisMatch(option1, el.option1) && axisMatch(option2, el.option2);
  2377.                 });
  2378.                 // フォールバック: 全軸一致が 0 件のときに限り option1/option2 を無視して再マッチ。
  2379.                 // 玄関ドア等で option1(なし/ランマ付き)・option2(◯◯タイプ) が pm(開き方) から
  2380.                 // 導出可能な冗長軸で、未選択だと全軸 AND 一致を満たせず価格が出ない事象の救済。
  2381.                 // pm/幅/奥行/高さ は従来どおり一致を要求するため通常商品の価格選択に影響しない。
  2382.                 if (candidates.length === 0) {
  2383.                     candidates = pp.filter(function(el){
  2384.                         return axisMatch(pw, el.w) && axisMatch(pd, el.d) && axisMatch(ph, el.h) && axisMatch(pm, el.m);
  2385.                     });
  2386.                 }
  2387.                 var picked = null;
  2388.                 // 優先 1: 色も含めて完全一致 ("色1/色2" 形式の split match を含む)
  2389.                 for (var ii = 0; ii < candidates.length; ii++) {
  2390.                     if (colorMatch(pc, candidates[ii].c)) { picked = candidates[ii]; break; }
  2391.                 }
  2392.                 // 優先 2: 完全一致なし & user の pc が候補のどの c 値にも該当しない (= 命名違い等)
  2393.                 //   かつ候補全行で price が一致する場合は、色軸を実質無視して先頭行を採用.
  2394.                 //   例 id=3353: pp.c は "ステンカラー+..." 6種だが price は全て同じなので
  2395.                 //     SM(スミ) を選んでも安全に最初の行の価格を返せる.
  2396.                 if (!picked && candidates.length > 0) {
  2397.                     var ppCsInCand = candidates.map(function(r){ return r.c || ''; });
  2398.                     var pcAbsent = (pc !== '' && pc !== '指定なし' && ppCsInCand.indexOf(pc) === -1
  2399.                                     && ppCsInCand.every(function(v){ return v.indexOf('/') === -1 || v.split('/').map(function(s){return s.trim();}).indexOf(pc) === -1; }));
  2400.                     if (pcAbsent) {
  2401.                         var uniqPrices = new Set();
  2402.                         candidates.forEach(function(r){ uniqPrices.add(String(r.price)); });
  2403.                         if (uniqPrices.size === 1) {
  2404.                             // 全候補同価格 → 色軸を wildcard 扱いで OK
  2405.                             picked = candidates[0];
  2406.                         }
  2407.                         // 価格が異なる場合は意図的に picked=null のまま「対応しておりません」.
  2408.                     }
  2409.                 }
  2410.                 pp.forEach((el) => {
  2411.                   if (el === picked) {
  2412.                     pp_matched = true;
  2413.                     // tg block-keyed オプション差額を集計 (tg 以外は total=0)
  2414.                     var oiCalc = collectOptionItems(el.w || '', el.d || '');
  2415.                     // tg ブロックに合わせてラジオを再描画 (tg 以外は no-op)
  2416.                     renderOptionItemRadios(el.w || '', el.d || '');
  2417.                     // fe ブロックの種類×段数 サブ radio (fe 以外/未設定は no-op)
  2418.                     renderFeBlockRadios();
  2419.                     // fe ご希望のフェンス設置方法 — 「既存ブロックに穴空け」 entry の連動表示
  2420.                     updateFeOpConditionalVisibility();
  2421.                     var calc       = calcGoukei(el, opCalc.total, ctx);
  2422.                     var goukei     = calc.goukei;
  2423.                     var price      = calc.price;
  2424.                     var ct         = calc.ct;
  2425.                     var optionSum  = calc.opt;
  2426.                     // tf(人工芝): 必要枚数入力へ算出結果を反映(施工サイズ入力時は上書き、
  2427.                     // 直接指定時は同値なので実質変化なし。要素が無い sale_type では no-op)
  2428.                     var $tfQty = $('#tf_qty');
  2429.                     if ($tfQty.length) $tfQty.val(calc.qty || 1);
  2430.                     // オプション差額は税抜 → 税込に変換して合計に加算
  2431.                     var oiIncTax   = toIncTax(oiCalc.total);
  2432.                     goukei += oiIncTax;
  2433.                     price  += oiIncTax;  // 差額は本体価格扱いとして mitsumori_price にも加算
  2434.                     // maker_price も scraper が税抜で保存しているため税込に変換
  2435.                 var makerPrice = toIncTax(parseInt(el.maker_price) || 0);
  2436.                     $('#mitsumori_message').text(formatter.format(goukei) + "円");
  2437.                     $('#mitsumori_goukei').text(formatter.format(goukei) + "円");
  2438.                     $('#mitsumori_off').text(formatter.format(price - makerPrice) + "円");
  2439.                     $('#mitsumori_price').text(formatter.format(price) + "円");
  2440.                     $('#maker_price').text(formatter.format(makerPrice) + "円");
  2441.                     $('#mitsumori_ct').text(formatter.format(ct) + "円");
  2442.                     $('#mitsumori_option').text(formatter.format(optionSum) + "円");
  2443.                     $('#mitsumori_kei').text(formatter.format(goukei) + "円");
  2444.                     $('#mitsumori_price_01').text(formatter.format(price) + "円");
  2445.                     $('#mitsumori_price_02').text(formatter.format(price) + "円");
  2446.                     $('#mitsumori_ct_01').text(formatter.format(ct) + "円");
  2447.                     $('#mitsumori_ct_02').text(formatter.format(ct) + "円");
  2448.                     $('#mitsumori_goukei_02').text(formatter.format(goukei) + "円");
  2449.                     var shoukei = goukei / 1.1;
  2450.                     var tax     = parseInt(goukei - shoukei);
  2451.                     $('#mitsumori_shoukei').text(formatter.format(parseInt(shoukei)) + "円");
  2452.                     $('#mitsumori_tax').text(formatter.format(tax) + "円");
  2453.                     // PDF 明細用:オプション内訳テーブルを差し込む (施工オプション + tg block オプション)
  2454.                     renderOptionDetailRows(opCalc.items.concat(oiCalc.items.map(function(it){
  2455.                         return { name: it.name, value: it.label, price: it.diff };
  2456.                     })));
  2457.                   }
  2458.                 });
  2459.                 if (!pp_matched) {
  2460.                     $('#mitsumori_message').text("この組み合わせは対応しておりません");
  2461.                     $('#mitsumori_btn').hide();
  2462.                     $('#mitsumori_goukei').text("---円");
  2463.                     $('#mitsumori_off').text("---円");
  2464.                     $('#mitsumori_price').text("---円");
  2465.                     $('#maker_price').text("---円");
  2466.                     $('#mitsumori_ct').text("---円");
  2467.                     $('#mitsumori_option').text("---円");
  2468.                     $('#mitsumori_kei').text("---円");
  2469.                     $('#mitsumori_shoukei').text("---円");
  2470.                     $('#mitsumori_tax').text("---円");
  2471.                 }
  2472.                 // mitsumori_json: バック側に送信する見積データ
  2473.                 var mitsumori_json = { "mitsumori_goukei": $('#mitsumori_goukei').text(),
  2474.                                         "mitsumori_goukei_02": $('#mitsumori_goukei_02').text(),
  2475.                                         "mitsumori_price": $('#mitsumori_price').text(),
  2476.                                         "maker_price": $('#maker_price').text(),
  2477.                                         "mitsumori_off": $('#mitsumori_off').text(),
  2478.                                         "mitsumori_ct": $('#mitsumori_ct').text(),
  2479.                                         "mitsumori_option": $('#mitsumori_option').text(),
  2480.                                         "product_id": {{ Product.id }},
  2481.                                         "sale_type": SALE_TYPE_ID,
  2482.                                         "pw": pw,
  2483.                                         "pd": pd,
  2484.                                         "ph": ph,
  2485.                                         "pm": pm,
  2486.                                         "pc": pc,
  2487.                                         "op": [op0,op1,op2,op3,op4,op5,op6,op7,op8,op9,op10],
  2488.                                         "op_items": opCalc.items,
  2489.                                         "mado_w":     ctx.mado_w,
  2490.                                         "mado_h":     ctx.mado_h,
  2491.                                         "mado_type":  ctx.mado_type,
  2492.                                         "glass_type": ctx.glass_type,
  2493.                                         "set_count":  ctx.set_count,
  2494.                                         "daisu":      ctx.daisu,
  2495.                                         "maisu":      ctx.maisu,
  2496.                                         "suuryou":    ctx.suuryou,
  2497.                                         "area":       ctx.area,
  2498.                                         "tf_len":     ctx.tf_len,
  2499.                                         "tf_wid":     ctx.tf_wid,
  2500.                                         "tf_qty":     $('#tf_qty').val(),
  2501.                                         "deck_step":  ctx.deck_step,
  2502.                                         "deck_fence": ctx.deck_fence };
  2503.                 $('#mitsumori_json').val(JSON.stringify(mitsumori_json));
  2504.             }
  2505.         }
  2506.         // PDF/モーダル明細の動的差し替え(基本工事費の次にオプション行を追加)
  2507.         function renderOptionDetailRows(items) {
  2508.             var $tbody = $('.modal-mitsumori table tbody');
  2509.             if (!$tbody.length) return;
  2510.             $tbody.find('tr.opt-row').remove();
  2511.             var anchorRow = $tbody.find('tr').filter(function(){
  2512.                 return $(this).find('td:first').text().indexOf('残土') !== -1
  2513.                     || $(this).find('td:first').text().indexOf('基本工事費') !== -1;
  2514.             }).last();
  2515.             items.forEach(function(it){
  2516.                 if (!it.name || !it.value) return;
  2517.                 var priceTxt = it.price > 0
  2518.                     ? formatter.format(it.price) + '円'
  2519.                     : '0円';
  2520.                 var $tr = $('<tr class="opt-row"></tr>')
  2521.                     .append('<td>' + it.name + ':' + it.value + '</td>')
  2522.                     .append('<td>1</td>')
  2523.                     .append('<td>式</td>')
  2524.                     .append('<td>' + priceTxt + '</td>')
  2525.                     .append('<td>' + (it.price > 0 ? formatter.format(it.price) + '円' : '') + '</td>');
  2526.                 if (anchorRow.length) {
  2527.                     anchorRow.after($tr);
  2528.                     anchorRow = $tr;
  2529.                 } else {
  2530.                     $tbody.append($tr);
  2531.                 }
  2532.             });
  2533.         }
  2534.         // 見積書モーダルを開く直前に、右側「現在のお見積り額」パネルの表示値を
  2535.         // モーダル内の対応 ID にコピーして金額の食い違いを防ぐ。
  2536.         // pw/pd/ph/pm が未選択で mitsumori_simulation の full update に到達しない場合でも、
  2537.         // パネル側の値(HTML 静的初期値 or 直近の simulation 結果)が modal にも反映される。
  2538.         function syncMitsumoriModalFromPanel() {
  2539.             var goukeiText = $('#mitsumori_goukei').text();
  2540.             var priceText  = $('#mitsumori_price').text();
  2541.             var ctText     = $('#mitsumori_ct').text();
  2542.             $('#mitsumori_kei').text(goukeiText);
  2543.             $('#mitsumori_price_01').text(priceText);
  2544.             $('#mitsumori_price_02').text(priceText);
  2545.             $('#mitsumori_ct_01').text(ctText);
  2546.             $('#mitsumori_ct_02').text(ctText);
  2547.             $('#mitsumori_goukei_02').text(goukeiText);
  2548.             // 小計 / 消費税 は税込 goukei から逆算
  2549.             var num = parseInt(goukeiText.replace(/[^\d-]/g, ''), 10);
  2550.             if (!isNaN(num) && num > 0) {
  2551.                 var shoukei = Math.round(num / 1.1);
  2552.                 var tax     = num - shoukei;
  2553.                 $('#mitsumori_shoukei').text(formatter.format(shoukei) + '円');
  2554.                 $('#mitsumori_tax').text(formatter.format(tax) + '円');
  2555.             } else {
  2556.                 $('#mitsumori_shoukei').text('---円');
  2557.                 $('#mitsumori_tax').text('---円');
  2558.             }
  2559.         }
  2560.         $(document).on('show.bs.modal', '#modal-mitsumori', function () {
  2561.             // 発行日を表示時点の当日日付に設定(固定ダミー日付の残置防止)
  2562.             var _now = new Date();
  2563.             var _issue = _now.getFullYear() + '/' +
  2564.                 ('0' + (_now.getMonth() + 1)).slice(-2) + '/' +
  2565.                 ('0' + _now.getDate()).slice(-2);
  2566.             $('#mitsumori_issue_date').text(_issue);
  2567.             syncMitsumoriModalFromPanel();
  2568.         });
  2569.         {# pc の auto-init: pw/pd/ph/pm と同じく p_c(matrix 派生)の length==1 or 全空で「指定なし」化.
  2570.            search_word(color) が空の「複合色」型商品で pc が空のまま放置されて
  2571.            「カラーを選択してください」固定になるバグの修正.
  2572.            search_word に登録された name があれば優先採用. #}
  2573.         {% if p_c and (p_c|length == 1 or p_c|join == "") %}
  2574.             pc = "指定なし";
  2575.             {% for pc_val in p_c %}{% if pc_val %} pc = "{{ pc_val }}"; {% endif %}{% endfor %}
  2576.             {% if color and color|length == 1 %}
  2577.                 {% for cc in color %}{% if cc and cc['name'] %} pc = "{{ cc['name'] }}"; {% endif %}{% endfor %}
  2578.             {% endif %}
  2579.         {% elseif color and color|length == 1 %}
  2580.             pc = "指定なし";
  2581.             {% for cc in color %}{% if cc and cc['name'] %} pc = "{{ cc['name'] }}"; {% endif %}{% endfor %}
  2582.         {% endif %}
  2583.         {% if p_w and (p_w|length == 1 or p_w|join == "") %}
  2584.             pw = "指定なし";
  2585.             {% for pw in p_w %}{% if pw %} pw = "{{ pw }}"; {% endif %}{% endfor %}
  2586.         {% endif %}
  2587.         {% if p_d and (p_d|length == 1 or p_d|join == "") %}
  2588.             pd = "指定なし";
  2589.             {% for pd in p_d %}{% if pd %} pd = "{{ pd }}"; {% endif %}{% endfor %}
  2590.         {% endif %}
  2591.         {% if p_h and (p_h|length == 1 or p_h|join == "") %}
  2592.             ph = "指定なし";
  2593.             {% for ph in p_h %}{% if ph %} ph = "{{ ph }}"; {% endif %}{% endfor %}
  2594.         {% endif %}
  2595.         {% if p_m and (p_m|length == 1 or p_m|join == "") %}
  2596.             pm = "指定なし";
  2597.             {% for pm in p_m %}{% if pm %} pm = "{{ pm }}"; {% endif %}{% endfor %}
  2598.         {% endif %}
  2599.         {# p_option1 / p_option2 の auto-init: length==1 or 全空で JS 変数を埋める #}
  2600.         {% if p_option1 and (p_option1|length == 1 or p_option1|join == "") %}
  2601.             option1 = "";
  2602.             {% for opt1 in p_option1 %}{% if opt1 %} option1 = "{{ opt1|e('js') }}"; {% endif %}{% endfor %}
  2603.         {% endif %}
  2604.         {% if p_option2 and (p_option2|length == 1 or p_option2|join == "") %}
  2605.             option2 = "";
  2606.             {% for opt2 in p_option2 %}{% if opt2 %} option2 = "{{ opt2|e('js') }}"; {% endif %}{% endfor %}
  2607.         {% endif %}
  2608.         // カラー radio が複数あるとき初期チェックが無いと「カラーを選択してください」固定になるので、
  2609.         // 何もチェックされていなければ「最初の radio」を選択する。
  2610.         // sale_type≠3 は name="color", sale_type=3 は name="color3".
  2611.         //
  2612.         // 注意:
  2613.         //   ・search_word(= radio の値)と pp.c(= matrix の色軸)は別の命名体系のケースがある
  2614.         //     (例 id=3353: radio="SM(スミ)" / pp.c="ステンカラー+セピアブラウン")
  2615.         //   ・pp.c に合わせて radio を選ぼうとしても文字列一致しないので無意味。
  2616.         //   ・単純に「先頭の radio」を選ぶ実装に戻し、マッチング側で命名体系違いを吸収する
  2617.         //     (pc が pp.c のどの値にもマッチしないときは色軸を wildcard 扱いにする)。
  2618.         //   ・.rp-section-label のテキストも忘れずに同期する(古い session 値が残ると
  2619.         //     "ステンカラー+セピアブラウン" のような表示と実際の radio が食い違う)。
  2620.         (function autoSelectFirstColor(){
  2621.             var groups = ['color', 'color3'];
  2622.             for (var gi = 0; gi < groups.length; gi++) {
  2623.                 var name = groups[gi];
  2624.                 var nodes = document.querySelectorAll('input[type="radio"][name="' + name + '"]');
  2625.                 if (nodes.length === 0) continue;
  2626.                 var anyChecked = false;
  2627.                 for (var i = 0; i < nodes.length; i++) {
  2628.                     if (nodes[i].checked) { anyChecked = true; break; }
  2629.                 }
  2630.                 if (anyChecked) continue;
  2631.                 var target = nodes[0];
  2632.                 target.checked = true;
  2633.                 pc = target.value;
  2634.                 // opt-btn の is-selected を付け替え
  2635.                 var lbl = target.closest('.opt-btn');
  2636.                 if (lbl) {
  2637.                     var sib = lbl.parentNode ? lbl.parentNode.querySelectorAll('.opt-btn') : [];
  2638.                     for (var k = 0; k < sib.length; k++) sib[k].classList.remove('is-selected');
  2639.                     lbl.classList.add('is-selected');
  2640.                 }
  2641.                 // 「カラー: <name>」表示の同期(古い mitsumori_json.pc が残らないように)
  2642.                 var grp = (lbl && lbl.closest('.form-group')) || (target.closest('.form-group'));
  2643.                 if (grp) {
  2644.                     var section = grp.querySelector('.rp-section-label');
  2645.                     if (section) {
  2646.                         var labelHead = section.textContent.split(':')[0].trim();
  2647.                         section.innerHTML = labelHead + ': <span>' + target.value + '</span>';
  2648.                     }
  2649.                 }
  2650.             }
  2651.         })();
  2652.         // option1 / option2 / pw / pd / ph / pm (matrix 軸の radio 群) も同様に
  2653.         // 「どれもチェックされていなければ先頭を選ぶ」を適用する.
  2654.         //   sale_type=2 の窓タイプ等で「初期表示時に一番初めの選択肢が選択されていない」事象を解消.
  2655.         (function autoSelectFirstMatrixAxis(){
  2656.             var axes = [
  2657.                 { name: 'pw',      jsVar: 'pw' },
  2658.                 { name: 'pd',      jsVar: 'pd' },
  2659.                 { name: 'ph',      jsVar: 'ph' },
  2660.                 { name: 'pm',      jsVar: 'pm' },
  2661.                 { name: 'glass_type', jsVar: 'pm' },
  2662.                 { name: 'option1', jsVar: 'option1' },
  2663.                 { name: 'option2', jsVar: 'option2' }
  2664.             ];
  2665.             for (var ai = 0; ai < axes.length; ai++) {
  2666.                 var ax = axes[ai];
  2667.                 var nodes = document.querySelectorAll('input[type="radio"][name="' + ax.name + '"]');
  2668.                 if (nodes.length === 0) continue;
  2669.                 var anyChecked = false;
  2670.                 for (var i = 0; i < nodes.length; i++) {
  2671.                     if (nodes[i].checked) { anyChecked = true; break; }
  2672.                 }
  2673.                 if (anyChecked) continue;
  2674.                 var target = nodes[0];
  2675.                 target.checked = true;
  2676.                 // グローバル JS 変数も埋める
  2677.                 if (ax.jsVar === 'pw') pw = target.value;
  2678.                 else if (ax.jsVar === 'pd') pd = target.value;
  2679.                 else if (ax.jsVar === 'ph') ph = target.value;
  2680.                 else if (ax.jsVar === 'pm') pm = target.value;
  2681.                 else if (ax.jsVar === 'option1') option1 = target.value;
  2682.                 else if (ax.jsVar === 'option2') option2 = target.value;
  2683.                 var lbl = target.closest('.opt-btn');
  2684.                 if (lbl) {
  2685.                     var sib = lbl.parentNode ? lbl.parentNode.querySelectorAll('.opt-btn') : [];
  2686.                     for (var k = 0; k < sib.length; k++) sib[k].classList.remove('is-selected');
  2687.                     lbl.classList.add('is-selected');
  2688.                 }
  2689.                 var grp = (lbl && lbl.closest('.form-group')) || (target.closest('.form-group'));
  2690.                 if (grp) {
  2691.                     var section = grp.querySelector('.rp-section-label');
  2692.                     if (section) {
  2693.                         var labelHead = section.textContent.split(':')[0].trim();
  2694.                         section.innerHTML = labelHead + ': <span>' + target.value + '</span>';
  2695.                     }
  2696.                 }
  2697.             }
  2698.         })();
  2699.         // 取付/設置工事トグル(off 値が「商品購入のみ」のオプション)は、新規アクセス時に
  2700.         // どちらの radio も未チェックのため初期価格が「商品購入のみ」= 基本工事費抜きになる。
  2701.         // 顧客要望(#10)に合わせ、未選択時は「工事も希望する」(on 側) を初期選択し、
  2702.         // 初期表示を基本工事費込みの最安値に統一する(色・寸法軸の先頭自動選択と同型)。
  2703.         //   ・判定キーは calcGoukei / applyPurchaseOnlyUI と同じ定数「商品購入のみ」。
  2704.         //   ・別用途で op{n} を流用する商品(ウッドデッキ deck_step 等)は radio 値に
  2705.         //     「商品購入のみ」を含まないため対象外(既存挙動を壊さない)。
  2706.         //   ・mitsumori_json 復元やユーザー操作で既に選択済みなら尊重する。
  2707.         (function autoSelectFirstInstall(){
  2708.             for (var i = 0; i <= 10; i++) {
  2709.                 var nodes = document.querySelectorAll('input[type="radio"][name="op' + i + '"]');
  2710.                 if (nodes.length === 0) continue;
  2711.                 // このグループが「商品購入のみ」を off に持つ工事トグルか判定し、
  2712.                 // 「商品購入のみ」でない側 (= 工事希望 on) を取得する。
  2713.                 var hasPurchaseOnly = false;
  2714.                 var onNode = null;
  2715.                 for (var j = 0; j < nodes.length; j++) {
  2716.                     if (nodes[j].value === '商品購入のみ') hasPurchaseOnly = true;
  2717.                     else if (!onNode) onNode = nodes[j];
  2718.                 }
  2719.                 if (!hasPurchaseOnly || !onNode) continue;
  2720.                 var anyChecked = false;
  2721.                 for (var k = 0; k < nodes.length; k++) {
  2722.                     if (nodes[k].checked) { anyChecked = true; break; }
  2723.                 }
  2724.                 if (anyChecked) continue;
  2725.                 onNode.checked = true;
  2726.                 // グローバル op 変数を on 値で埋める(calcGoukei の purchaseOnly 判定が参照)
  2727.                 switch (i) {
  2728.                     case 0:  op0  = onNode.value; break;
  2729.                     case 1:  op1  = onNode.value; break;
  2730.                     case 2:  op2  = onNode.value; break;
  2731.                     case 3:  op3  = onNode.value; break;
  2732.                     case 4:  op4  = onNode.value; break;
  2733.                     case 5:  op5  = onNode.value; break;
  2734.                     case 6:  op6  = onNode.value; break;
  2735.                     case 7:  op7  = onNode.value; break;
  2736.                     case 8:  op8  = onNode.value; break;
  2737.                     case 9:  op9  = onNode.value; break;
  2738.                     case 10: op10 = onNode.value; break;
  2739.                 }
  2740.                 // opt-btn の is-selected を付け替え
  2741.                 var lbl = onNode.closest('.opt-btn');
  2742.                 if (lbl) {
  2743.                     var sib = lbl.parentNode ? lbl.parentNode.querySelectorAll('.opt-btn') : [];
  2744.                     for (var s = 0; s < sib.length; s++) sib[s].classList.remove('is-selected');
  2745.                     lbl.classList.add('is-selected');
  2746.                 }
  2747.                 // rp-section-label 表示同期 + 現地調査文言(is_removal_unknown / is_tf_kouji)を on 表示に
  2748.                 var grp = (lbl && lbl.closest('.form-group')) || onNode.closest('.form-group');
  2749.                 if (grp) {
  2750.                     var section = grp.querySelector('.rp-section-label');
  2751.                     if (section) {
  2752.                         var labelHead = section.textContent.split(':')[0].trim();
  2753.                         section.innerHTML = labelHead + ': <span>' + onNode.value + '</span>';
  2754.                     }
  2755.                     var note = grp.querySelector('.opt-survey-note[data-op-idx="' + i + '"]');
  2756.                     if (note) note.style.display = '';
  2757.                 }
  2758.             }
  2759.         })();
  2760.         // ページ読み込み時: 見積金額が「合計」になっているため、カート数量は基本1
  2761.         // sale_type=9(商品のみ購入)だけ通常 EC として数量を引き継ぐ
  2762.         (function initQuantityByType(){
  2763.             var saleType = {{ ProductClass.SaleType.id }};
  2764.             if (saleType == 9 && $('#quantity_only').length) {
  2765.                 $('#quantity').val(parseInt($('#quantity_only').val()) || 1);
  2766.             } else {
  2767.                 $('#quantity').val(1);
  2768.                 $('input[name="quantity"]').val(1);
  2769.             }
  2770.         })();
  2771.         // type → ラベルのプレフィックス文字列
  2772.         // option1/option2 のラベルは category 依存 (rd: ランマ/タイプ, sh: サイズプリセット/駆動方式 等).
  2773.         // PHP から渡された option1_label / option2_label を埋め込み、JS 側でラベル更新に使う.
  2774.         var optLabelMap = {pc:'カラー', pw:'幅', pd:'奥行き', ph:'高さ', pm:'素材',
  2775.                            option1: '{{ option1_label|default('オプション1')|e('js') }}',
  2776.                            option2: '{{ option2_label|default('オプション2')|e('js') }}'};
  2777.         // type → input[name]
  2778.         var optNameMap  = {pc:'color', pw:'pw', pd:'pd', ph:'ph', pm:'pm',
  2779.                            option1: 'option1', option2: 'option2'};
  2780.         // opt-btn用: 値を直接受け取るラッパー
  2781.         function mitsumori_simulation_val(type, value) {
  2782.             if (type === 'pc') pc = value;
  2783.             if (type === 'pw') pw = value;
  2784.             if (type === 'pd') pd = value;
  2785.             if (type === 'ph') ph = value;
  2786.             if (type === 'pm') pm = value;
  2787.             if (type === 'option1') option1 = value;
  2788.             if (type === 'option2') option2 = value;
  2789.             // is-selected クラスを同グループ内で付け替え
  2790.             var inputName = optNameMap[type];
  2791.             if (inputName) {
  2792.                 var $btns = $('input[name="' + inputName + '"]').closest('.opt-btn');
  2793.                 $btns.removeClass('is-selected');
  2794.                 $btns.filter(function(){
  2795.                     return $(this).find('input').val() === value;
  2796.                 }).addClass('is-selected');
  2797.             }
  2798.             // ラベル行の選択値テキストを更新(例: 「カラー: ブラック」)
  2799.             var labelPrefix = optLabelMap[type];
  2800.             if (labelPrefix) {
  2801.                 // 対象のrp-section-labelを特定(input[name]を含む親を遡る)
  2802.                 var $group = $('input[name="' + inputName + '"]').first().closest('.form-group');
  2803.                 var $label = $group.find('.rp-section-label');
  2804.                 $label.html(labelPrefix + ': <span>' + value + '</span>');
  2805.             }
  2806.             // simulation本体を呼ぶ(value_idは空でOK、グローバル変数を使う)
  2807.             mitsumori_simulation('', '');
  2808.         }
  2809.         mitsumori_simulation("","");
  2810.         // ボタン群: 親幅を超える、または3行以上になる場合は is-scroll を付与し
  2811.         // 縦3段グリッド+横スクロール表示にする(顧客要望「3列くらいで横スクロール」)
  2812.         function applyBtnGroupScroll() {
  2813.             $('.rp-btn-group, .opt-btn-group').each(function() {
  2814.                 var $group = $(this);
  2815.                 $group.removeClass('is-scroll');
  2816.                 // 意図的な縦リスト(no-hscroll / flex-direction:column)は横スクロール化しない。
  2817.                 // フェンス fe_block の縦6行(A-2)や内窓(sale_type=2)の長いガラス名(A-3)が
  2818.                 // 3行以上/幅超過で is-scroll 化され、列崩れ・左見切れを起こすのを防ぐ。
  2819.                 if ($group.hasClass('no-hscroll') || $group.css('flex-direction') === 'column') return;
  2820.                 var $items = $group.children();
  2821.                 if ($items.length === 0) return;
  2822.                 var parentWidth = $group.parent().width() || $group.width();
  2823.                 var totalWidth = 0;
  2824.                 var gap = parseInt($group.css('gap'), 10) || 8;
  2825.                 $items.each(function() {
  2826.                     totalWidth += this.offsetWidth + gap;
  2827.                 });
  2828.                 // 単一ボタンが親幅を超える、もしくは合計幅が親幅を超える → 横スクロール化
  2829.                 var anyTooWide = false;
  2830.                 $items.each(function() {
  2831.                     if (this.offsetWidth > parentWidth) { anyTooWide = true; return false; }
  2832.                 });
  2833.                 // 折り返し時の行数も計測
  2834.                 var tops = {};
  2835.                 $items.each(function() { tops[this.offsetTop] = true; });
  2836.                 var rowCount = Object.keys(tops).length;
  2837.                 if (anyTooWide || totalWidth > parentWidth * 2 || rowCount >= 3) {
  2838.                     $group.addClass('is-scroll');
  2839.                 }
  2840.             });
  2841.         }
  2842.         applyBtnGroupScroll();
  2843.         $(window).on('resize', function() {
  2844.             clearTimeout(window._btnScrollTimer);
  2845.             window._btnScrollTimer = setTimeout(applyBtnGroupScroll, 150);
  2846.         });
  2847.         // iOS Safari 1-tap fix (#15 再発): is-scroll の横スクロールコンテナ内タップや
  2848.         // 継承された a:hover により、初回タップが hover / スクロール判定に吸われ「2 回
  2849.         // タッチ」が必要になる。touch 端末では touchend で 1 回目に選択 / 遷移を確定し、
  2850.         // 後続のゴーストクリックを preventDefault で抑止する(二重発火なし)。指が動いた
  2851.         // 場合はスクロール操作とみなし何もしない。デスクトップは従来の click のまま。
  2852.         (function oneTapSelectFix(){
  2853.             if (!('ontouchstart' in window)) return;
  2854.             var moved = false, sx = 0, sy = 0;
  2855.             document.addEventListener('touchstart', function(e){
  2856.                 moved = false;
  2857.                 var t = e.touches && e.touches[0];
  2858.                 if (t) { sx = t.clientX; sy = t.clientY; }
  2859.             }, { passive: true });
  2860.             document.addEventListener('touchmove', function(e){
  2861.                 var t = e.touches && e.touches[0];
  2862.                 if (t && (Math.abs(t.clientX - sx) > 8 || Math.abs(t.clientY - sy) > 8)) moved = true;
  2863.             }, { passive: true });
  2864.             document.addEventListener('touchend', function(e){
  2865.                 if (moved) return;
  2866.                 var el = (e.target && e.target.closest) ? e.target.closest('.opt-btn, .rp-btn, .rp-card') : null;
  2867.                 if (!el) return;
  2868.                 // 窓タイプ / 住宅区分は専用の委譲 click ハンドラを持つため除外.
  2869.                 if (el.closest('#window-types-container') || el.closest('#window-housing-type-group')) return;
  2870.                 e.preventDefault(); // 遅延ゴーストクリックを抑止し二重処理を防ぐ
  2871.                 if (el.tagName === 'A' && el.href) { window.location.href = el.href; return; }
  2872.                 el.click(); // label→radio チェック + inline onclick / change を 1 回だけ発火
  2873.             }, { passive: false });
  2874.         })();
  2875.         // onload: 見積金額が確定している場合は「工事費込み価格」表示を一致させる
  2876.         (function syncPrice02Display() {
  2877.             var goukeiEl = document.getElementById('mitsumori_goukei');
  2878.             var displayEl = document.getElementById('price02-display');
  2879.             if (!goukeiEl || !displayEl) return;
  2880.             var goukeiText = goukeiEl.textContent.trim();
  2881.             // ---円(未確定)の場合は書き換えない
  2882.             if (goukeiText && goukeiText !== '---円') {
  2883.                 displayEl.textContent = goukeiText;
  2884.             }
  2885.             // 以降の変更にも追従: mitsumori_goukei を監視
  2886.             new MutationObserver(function() {
  2887.                 var text = goukeiEl.textContent.trim();
  2888.                 if (text && text !== '---円') {
  2889.                     displayEl.textContent = text;
  2890.                 }
  2891.             }).observe(goukeiEl, { childList: true, subtree: true, characterData: true });
  2892.         })();
  2893.         function contact_form(product_id){
  2894.             $('#form1').attr('action', '{{ url('contact', {product:Product.id}) }}');
  2895.             $('#form1').submit();
  2896.         }
  2897.         // maisu / madoset は下部の新しい関数定義を使用
  2898.         function heibei(bei){
  2899.             $('#heibei').val(parseInt($('#heibei').val()) + bei);
  2900.             if(parseInt($('#heibei').val()) < 1){ $('#heibei').val(1); }
  2901.             if(parseInt($('#heibei').val()) > 100){ $('#heibei').val(100); }
  2902.             // goukei に既に枚数を含むため、カート数量は1で固定
  2903.             $('#quantity').val(1);
  2904.             mitsumori_simulation('heibei','heibei');
  2905.         }
  2906.         function daisu(dai){
  2907.             $('#daisu').val(parseInt($('#daisu').val()) + dai);
  2908.             if(parseInt($('#daisu').val()) < 1){ $('#daisu').val(1); }
  2909.             if(parseInt($('#daisu').val()) > 10){ $('#daisu').val(10); }
  2910.             $('#quantity').val(1);
  2911.             mitsumori_simulation('daisu','daisu');
  2912.         }
  2913.         // sale_type=7(数量買い・基本工事費固定)用:個数入力
  2914.         function suuryou(n){
  2915.             $('#suuryou').val(parseInt($('#suuryou').val()) + n);
  2916.             if(parseInt($('#suuryou').val()) < 1){ $('#suuryou').val(1); }
  2917.             if(parseInt($('#suuryou').val()) > 100){ $('#suuryou').val(100); }
  2918.             $('#quantity').val(1);
  2919.             mitsumori_simulation('suuryou','suuryou');
  2920.         }
  2921.         function maisu(mai){
  2922.             $('#maisu').val(parseInt($('#maisu').val()) + mai);
  2923.             // sale_type=4 (フェンス) は3枚未満不可、6 (芝生) は1枚から
  2924.             var minMai = (SALE_TYPE_ID == 4) ? 3 : 1;
  2925.             if(parseInt($('#maisu').val()) < minMai){ $('#maisu').val(minMai); }
  2926.             if(parseInt($('#maisu').val()) > 100){ $('#maisu').val(100); }
  2927.             $('#quantity').val(1);
  2928.             mitsumori_simulation('maisu','maisu');
  2929.         }
  2930.         // タイプ2: セット数プルダウン連動
  2931.         function madosetSelect(val){
  2932.             var v = parseInt(val);
  2933.             if(v < 1) v = 1;
  2934.             if(v > 20) v = 20;
  2935.             $('#set_count').val(v);
  2936.             $('#quantity').val(1);
  2937.             $('input[name="quantity"]').val(1);
  2938.             mitsumori_simulation('set','set_count');
  2939.         }
  2940.         // タイプ2: ±ボタン版(後方互換)
  2941.         function madoset(mai){
  2942.             var current = parseInt($('#set_count').val()) || 1;
  2943.             var next = current + mai;
  2944.             if(next < 1) next = 1;
  2945.             if(next > 20) next = 20;
  2946.             $('#set_count').val(next);
  2947.             madosetSelect(next);
  2948.         }
  2949.         // タイプ5: ステップ・フェンス選択値を mitsumori_json に保存するためのグローバル変数
  2950.         var deck_step = "{% if mitsumori_json and mitsumori_json.deck_step is defined %}{{ mitsumori_json.deck_step|default('不要') }}{% else %}不要{% endif %}";
  2951.         var deck_fence = "{% if mitsumori_json and mitsumori_json.deck_fence is defined %}{{ mitsumori_json.deck_fence|default('不要') }}{% else %}不要{% endif %}";
  2952.         // PDF出力: モーダルの mitsumori_json をフォームに同期してから送信
  2953.         function syncPdfJson(){
  2954.             var jsonVal = $('#mitsumori_json').val();
  2955.             $('#pdf_mitsumori_json').val(jsonVal);
  2956.         }
  2957.         // タイプ9: 商品のみ購入
  2958.         function quantityOnly(val){
  2959.             var current = parseInt($('#quantity_only').val()) || 1;
  2960.             var next = current + val;
  2961.             if(next < 1) next = 1;
  2962.             $('#quantity_only').val(next);
  2963.             $('#quantity').val(next);
  2964.         }
  2965.         // ===== スマホ用下部固定バーへの金額同期 =====
  2966.         // mitsumori_message と mitsumori_goukei を監視して下部バーを更新
  2967.         (function() {
  2968.             var msgTarget   = document.getElementById('mitsumori_message');
  2969.             var goukeiTarget = document.getElementById('mitsumori_goukei');
  2970.             var spPrice     = document.getElementById('sp-mitsumori-price');
  2971.             var spBtn       = document.querySelector('#sp-mitsumori-bar .sp-bar__btn');
  2972.             if (!spPrice || !spBtn) return;
  2973.             function syncBar() {
  2974.                 var msg    = msgTarget ? msgTarget.textContent : '';
  2975.                 var goukei = goukeiTarget ? goukeiTarget.textContent : '---円';
  2976.                 // メッセージが「〇〇を選択してください」の場合はメッセージを表示しボタンを非表示
  2977.                 if (msg.indexOf('選択してください') !== -1) {
  2978.                     spPrice.textContent = msg;
  2979.                     spPrice.style.fontSize = '13px';
  2980.                     spPrice.style.color    = '#888';
  2981.                     spBtn.style.display    = 'none';
  2982.                 } else {
  2983.                     // 価格確定時はボタンを表示
  2984.                     spPrice.textContent = goukei;
  2985.                     spPrice.style.fontSize = '18px';
  2986.                     spPrice.style.color    = '#c00';
  2987.                     spBtn.style.display    = 'block';
  2988.                 }
  2989.             }
  2990.             // 初期値を同期
  2991.             syncBar();
  2992.             // mitsumori_message の変化を監視
  2993.             if (msgTarget) {
  2994.                 new MutationObserver(syncBar).observe(
  2995.                     msgTarget, { childList: true, subtree: true, characterData: true }
  2996.                 );
  2997.             }
  2998.             // mitsumori_goukei の変化も監視
  2999.             if (goukeiTarget) {
  3000.                 new MutationObserver(syncBar).observe(
  3001.                     goukeiTarget, { childList: true, subtree: true, characterData: true }
  3002.                 );
  3003.             }
  3004.         })();
  3005.         // ===== スマホ用 お見積りの詳細 ボトムシート 開閉 =====
  3006.         // 現在の内訳カード(.mitsumori-card-pc .card-body)を複製して表示する。
  3007.         // 価格計算ロジックには触れず、その時点で表示中の内訳をそのまま見せる。
  3008.         // window に直接ぶら下げてスコープに依存せず onclick から呼べるようにする。
  3009.         window.openSpMitsumoriDetail = function () {
  3010.             var sheet = document.getElementById('sp-mitsumori-detail');
  3011.             var body  = document.getElementById('sp-detail-body');
  3012.             var src   = document.querySelector('.mitsumori-card-pc .card-body');
  3013.             if (!sheet || !body) return;
  3014.             body.innerHTML = '';
  3015.             if (src) {
  3016.                 // 複製し、重複 id を避けるため clone 側の id を除去する
  3017.                 var clone = src.cloneNode(true);
  3018.                 var ided = clone.querySelectorAll('[id]');
  3019.                 for (var i = 0; i < ided.length; i++) { ided[i].removeAttribute('id'); }
  3020.                 body.appendChild(clone);
  3021.             }
  3022.             sheet.classList.add('is-open');
  3023.             sheet.setAttribute('aria-hidden', 'false');
  3024.             document.body.style.overflow = 'hidden';
  3025.         };
  3026.         window.closeSpMitsumoriDetail = function () {
  3027.             var sheet = document.getElementById('sp-mitsumori-detail');
  3028.             if (!sheet) return;
  3029.             sheet.classList.remove('is-open');
  3030.             sheet.setAttribute('aria-hidden', 'true');
  3031.             document.body.style.overflow = '';
  3032.         };
  3033.         // ===== 現在のお見積り額: 折りたたみトグルボタンのテキスト切り替え =====
  3034.         (function() {
  3035.             var card = document.querySelector('.mitsumori-card-pc');
  3036.             var btn  = document.querySelector('.btn-mitsumori-toggle .toggle-icon');
  3037.             if (!card || !btn) return;
  3038.             // MutationObserverでcollapsed-cardクラスの変化を監視
  3039.             new MutationObserver(function() {
  3040.                 if (card.classList.contains('collapsed-card')) {
  3041.                     btn.textContent = '▼ 詳細';
  3042.                 } else {
  3043.                     btn.textContent = '▲ 閉じる';
  3044.                 }
  3045.             }).observe(card, { attributes: true, attributeFilter: ['class'] });
  3046.         })();
  3047.         // ===== エリア案内: スマホのみタップで展開 =====
  3048.         (function() {
  3049.             var notice = document.querySelector('.ec-areaNotice');
  3050.             if (!notice) return;
  3051.             notice.addEventListener('click', function(e) {
  3052.                 if (window.innerWidth > 767) return;
  3053.                 // リンククリック時は展開/折りたたみせずリンク遷移
  3054.                 if (e.target.tagName === 'A') return;
  3055.                 notice.classList.toggle('is-open');
  3056.             });
  3057.         })();
  3058.     </script>
  3059.     <script type="application/ld+json">
  3060.     {
  3061.         "@context": "https://schema.org/",
  3062.         "@type": "Product",
  3063.         "name": "{{ Product.name }}",
  3064.         "image": [
  3065.             {% for img in Product.ProductImage %}
  3066.                 "{{ app.request.schemeAndHttpHost }}{{ asset(img, 'save_image') }}"{% if not loop.last %},{% endif %}
  3067.             {% else %}
  3068.                 "{{ app.request.schemeAndHttpHost }}{{ asset(''|no_image_product, 'save_image') }}"
  3069.             {% endfor %}
  3070.         ],
  3071.         "description": "{{ Product.description_list | default(Product.description_detail) | replace({'\n': '', '\r': ''}) | slice(0,300) }}",
  3072.         {% if Product.code_min %}
  3073.         "sku": "{{ Product.code_min }}",
  3074.         {% endif %}
  3075.         "offers": {
  3076.             "@type": "Offer",
  3077.             "url": "{{ url('product_detail', {'id': Product.id}) }}",
  3078.             "priceCurrency": "{{ eccube_config.currency }}",
  3079.             "price": {{ Product.getPrice02IncTaxMin ? Product.getPrice02IncTaxMin : 0}},
  3080.             "availability": "{{ Product.stock_find ? "InStock" : "OutOfStock" }}"
  3081.         }
  3082.     }
  3083.     </script>
  3084. {% endblock %}
  3085. {% block main %}
  3086.     {# option1 / option2 ラベルをカテゴリ別に決定 (sh=31, rd=43, mo=15) - block scope のため main 側でも再定義 #}
  3087.     {% set option1_label = 'オプション1' %}
  3088.     {% set option2_label = 'オプション2' %}
  3089.     {# rd(玄関ドア, cat43) は素材(m)が開き方+ランマを内包し option1(ランマ)/option2(タイプ) が
  3090.        完全な冗長軸のため、素材のみ「開き方」として表示し option1/option2 の選択 UI は抑制する。
  3091.        価格マッチングは m + カラーで一意に決まり、未選択 option は fallback が無視するため影響なし。 #}
  3092.     {% set is_cat43 = false %}
  3093.     {% if Product.ProductCategories is not empty %}
  3094.         {% for pcat in Product.ProductCategories %}
  3095.             {% if pcat.category_id == 31 %}{% set option1_label = 'サイズプリセット' %}{% set option2_label = '駆動方式' %}{% endif %}
  3096.             {% if pcat.category_id == 43 %}{% set option1_label = 'ランマ' %}{% set option2_label = 'タイプ' %}{% set is_cat43 = true %}{% endif %}
  3097.             {% if pcat.category_id == 15 %}{% set option1_label = '棚タイプ' %}{% endif %}
  3098.             {% if pcat.category_id == 17 %}{% set option1_label = '窓タイプ' %}{% endif %}
  3099.         {% endfor %}
  3100.     {% endif %}
  3101.     <div class="ec-productRole" style="margin-top:20px;">
  3102.         <div class="ec-grid2">
  3103.             <div class="ec-grid2__cell">
  3104.                 <div class="ec-sliderItemRole">
  3105.                     {# 商品名 + ブランド名 + SNSシェア #}
  3106.                     <div class="ec-productRole__title">
  3107.                         <div class="ec-productRole__titleRow">
  3108.                             <h2 class="ec-headingTitle">{{ Product.name }}</h2>
  3109.                             <div class="ec-share-inline">
  3110.                                 <a href="https://twitter.com/share?url={{ app.request.uri|url_encode }}&text={{ Product.name|url_encode }}"
  3111.                                    class="share-twitter" target="_blank" rel="noreferrer noopener" title="Xでシェア">
  3112.                                     <svg viewBox="0 0 24 24" style="width:15px;height:15px;fill:#fff;"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.74l7.73-8.835L1.254 2.25H8.08l4.254 5.622L18.244 2.25zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
  3113.                                 </a>
  3114.                                 <a href="https://www.instagram.com/" class="share-facebook" target="_blank" rel="noreferrer noopener" title="Instagram">
  3115.                                     <svg viewBox="0 0 24 24" style="width:15px;height:15px;fill:#fff;"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/></svg>
  3116.                                 </a>
  3117.                                 <a href="https://social-plugins.line.me/lineit/share?url={{ app.request.uri|url_encode }}"
  3118.                                    class="share-line" target="_blank" rel="noreferrer noopener" title="LINEでシェア">
  3119.                                     <i class="fab fa-line"></i>
  3120.                                 </a>
  3121.                             </div>
  3122.                         </div>
  3123.                     </div>
  3124.                     {% if BaseInfo.option_favorite_product %}
  3125.                      <div style="position:relative;top: 20px;left: 10px;z-index:100;">
  3126.                         {% if is_favorite == false %}
  3127.                         <form action="{{ url('product_add_favorite', {id:Product.id}) }}" method="post">
  3128.                            <button type="submit" id="favorite" class="favorite">&#9825;</button>
  3129.                         </form>
  3130.                         {% else %}
  3131.                         <form action="{{ url('product_add_favorite', {id:Product.id}) }}" method="post">
  3132.                            <button type="submit" id="favorite" class="favorite" style="color:red;">&#9829;</button>
  3133.                         </form>
  3134.                         {% endif %}
  3135.                      </div>
  3136.                     {% endif %}
  3137.                     <div class="item_visual">
  3138.                         {% for ProductImage in Product.ProductImage %}
  3139.                             <div class="slide-item">
  3140.                                 <a class="js-zoom" href="{{ asset(ProductImage, 'save_image') }}" aria-label="{{ loop.first ? Product.name : '' }}">
  3141.                                     <img src="{{ asset(ProductImage, 'save_image') }}" alt="{{ loop.first ? Product.name : '' }}" width="550" height="550" style="max-height: 400px;"{% if loop.index > 1 %} loading="lazy"{% endif %}>
  3142.                                 </a>
  3143.                             </div>
  3144.                         {% else %}
  3145.                             <div class="slide-item"><img src="{{ asset(''|no_image_product, 'save_image') }}" alt="{{ Product.name }}" width="550" height="550" style="max-height: 400px;"></div>
  3146.                         {% endfor %}
  3147.                     </div>
  3148.                     <div class="item_nav">
  3149.                         {% for ProductImage in Product.ProductImage %}
  3150.                             <div class="slideThumb" data-index="{{ loop.index0 }}"><img src="{{ asset(ProductImage, 'save_image') }}" alt="" width="80" height="80" loading="lazy"></div>
  3151.                         {% endfor %}
  3152.                     </div>
  3153.                 </div>
  3154.                     {# ===== 施工エリア案内(見積シミュレーションの下)===== #}
  3155.                     <div class="ec-areaNotice" style="margin-top:16px;margin-right:10px;">
  3156.                         <div class="ec-areaNotice__inner">
  3157.                             <div class="ec-areaNotice__icon">📍</div>
  3158.                             <div class="ec-areaNotice__body">
  3159.                                 <p class="ec-areaNotice__title">施工対応エリアについて</p>
  3160.                                 <div class="ec-areaNotice__detail">
  3161.                                     <p class="ec-areaNotice__text">
  3162.                                         当店の施工サービスは、<strong>山梨県全域</strong>および<br>
  3163.                                         <strong>諏訪エリア(諏訪市・岡谷市・茅野市・諏訪郡)</strong>を対象としております。<br>
  3164.                                         上記エリア外のお客様には、商品のみのご購入が可能でございます。<br>
  3165.                                         ご不明な点がございましたら、お気軽にお問い合わせください。
  3166.                                     </p>
  3167.                                     <a class="ec-areaNotice__link" href="{{ url('contact') }}">エリア外のお客様・ご相談はこちら →</a>
  3168.                                 </div>
  3169.                             </div>
  3170.                         </div>
  3171.                     </div>
  3172.             </div>
  3173.             <div class="ec-grid2__cell2">
  3174.                 <div class="ec-productRole__profile" >
  3175.                     {# 関連カテゴリ (DIY=21 / 石材=28 / 照明=44 は非表示要件のため除外) #}
  3176.                     {% set hidden_cat_ids = [21, 28, 44] %}
  3177.                     {% if Product.ProductCategories is not empty %}
  3178.                         <div class="ec-productRole__category" style="padding:10px 0;">
  3179.                             {% for ProductCategory in Product.ProductCategories %}
  3180.                                 {% if ProductCategory.Category.id not in hidden_cat_ids %}
  3181.                                 <ul>
  3182.                                     <li>
  3183.                                         {% for Category in ProductCategory.Category.path %}
  3184.                                             <a href="{{ url('product_list') }}?category_id={{ Category.id }}">{{ Category.name }}</a> {%- if loop.last == false %}
  3185.                                             <span>></span>{% endif -%}
  3186.                                         {% endfor %}
  3187.                                     </li>
  3188.                                 </ul>
  3189.                                 {% endif %}
  3190.                             {% endfor %}
  3191.                         </div>
  3192.                     {% endif %}
  3193.                     {# 販売価格 #}
  3194.                     <div class="ec-productRole__price">
  3195.                         <span style="color:black;font-size:15px;">工事費込み価格</span>
  3196.                         {% if Product.hasProductClass -%}
  3197.                             {% if Product.getPrice02IncTaxMin == Product.getPrice02IncTaxMax %}
  3198.                                 <div class="ec-price">
  3199.                                     <span id="price02-display" class="ec-price__price price02-default">{{ Product.getPrice02IncTaxMin|price }}</span>
  3200.                                     <span class="ec-price__tax">({{ '税込'|trans }})~</span>
  3201.                                 </div>
  3202.                             {% else %}
  3203.                                 <div class="ec-price">
  3204.                                     <span id="price02-display" class="ec-price__price price02-default">{{ Product.getPrice02IncTaxMin|price }} ~ {{ Product.getPrice02IncTaxMax|price }}</span>
  3205.                                     <span class="ec-price__tax">({{ '税込'|trans }})</span>
  3206.                                 </div>
  3207.                             {% endif %}
  3208.                         {% else %}
  3209.                             <div class="ec-price">
  3210.                                 <span id="price02-display" class="ec-price__price">{{ Product.getPrice02IncTaxMin|price }}</span>
  3211.                                     <span class="ec-price__tax">({{ '税込'|trans }})~</span>
  3212.                             </div>
  3213.                         {% endif %}
  3214.                     </div>
  3215.                     {# タグ #}
  3216.                     <ul class="ec-productRole__tags">
  3217.                         {% for Tag in Product.Tags %}
  3218.                             <li class="ec-productRole__tag tag_{{ Tag.id }}">{{ Tag }}</li>
  3219.                         {% endfor %}
  3220.                     </ul>
  3221.                     {# 商品コード #}
  3222.                     {% if Product.code_min is not empty %}
  3223.                         <div class="ec-productRole__code">
  3224.                             {{ '商品コード'|trans }}: <span class="product-code-default">{{ Product.code_min }}{% if Product.code_min != Product.code_max %} ~ {{ Product.code_max }}{% endif %}</span>
  3225.                         </div>
  3226.                     {% endif %}
  3227.                     <div class="ec-productRole__description">
  3228.                         <div class="ec-rectHeading">
  3229.                             <h4>製品情報</h4>
  3230.                         </div>
  3231.                         {{ Product.sales_infomation|raw|nl2br }}
  3232.                     </div>
  3233.                     <div class="card card-danger" style="clear:both;margin:1px;">
  3234.                       <div class="card-header">
  3235.                         <h3 class="card-title">見積シミュレーション</h3>
  3236.                       </div>
  3237.                       <div class="card-body p-2">
  3238.                         {% set related_image = getProduct_field(Product.id,"related_image") %}
  3239.                         {# related_image は配列: ["1段目画像あり","2段目画像あり","3段目画像あり"] のうち該当するものが格納 #}
  3240.                         {% set img1 = related_image and ("1段目画像あり" in related_image) %}
  3241.                         {% set img2 = related_image and ("2段目画像あり" in related_image) %}
  3242.                         {% set img3 = related_image and ("3段目画像あり" in related_image) %}
  3243.                         {% set img4 = related_image and ("4段目画像あり" in related_image) %}
  3244.                         {% set type1 = getProduct_field(Product.id,"related_name1") %}
  3245.                         {% set type2 = getProduct_field(Product.id,"related_name2") %}
  3246.                         {% set type3 = getProduct_field(Product.id,"related_name3") %}
  3247.                         {% set type4 = getProduct_field(Product.id,"related_name4") %}
  3248.                         {% set hasRelated = (type1 and related_product1 and related_product1|length)
  3249.                                          or (type2 and related_product2 and related_product2|length)
  3250.                                          or (type3 and related_product3 and related_product3|length)
  3251.                                          or (type4 and related_product4 and related_product4|length) %}
  3252.                         {% if hasRelated %}
  3253.                             <div class="row" style="border-bottom:1px solid rgba(0,0,0,.125)"><label class="col-12 col-form-label">この商品のタイプを選択してください。</label></div>
  3254.                         {% endif %}
  3255.                         {# ===== 1段目 ===== #}
  3256.                         {% if type1 and related_product1 and related_product1|length %}
  3257.                         <div class="form-group mt-3 pb-3" style="border-bottom:1px solid rgba(0,0,0,.125)">
  3258.                           <div class="rp-section-label">{{ type1 }}{% if img1 and base_select1 %}: <span>{{ base_select1 }}</span>{% endif %}</div>
  3259.                           {% if img1 %}
  3260.                             {# --- 画像付きカード --- #}
  3261.                             <div class="rp-card-group">
  3262.                               {% for rp_id, rp_name in related_product1 %}
  3263.                                 <a class="rp-card{% if base_select1 == rp_name %} is-selected{% endif %}" href="/products/detail/{{ rp_id }}">
  3264.                                   <img class="rp-card__image"
  3265.                                        src="{{ (related_image_obj[rp_id] is defined and related_image_obj[rp_id]) ? asset(related_image_obj[rp_id], 'save_image') : asset(''|no_image_product, 'save_image') }}"
  3266.                                        alt="{{ rp_name }}"
  3267.                                        width="120" height="120" loading="lazy"
  3268.                                        onerror="this.style.display='none';this.nextElementSibling.style.display='flex';">
  3269.                                   <div class="rp-card__placeholder" style="display:none;">📦</div>
  3270.                                   <span class="rp-card__name">{{ rp_name }}</span>
  3271.                                 </a>
  3272.                               {% endfor %}
  3273.                             </div>
  3274.                           {% else %}
  3275.                             {# --- ボタン式ラジオ --- #}
  3276.                             <div class="rp-btn-group">
  3277.                               {% for rp_id, rp_name in related_product1 %}
  3278.                                 <a class="rp-btn{% if base_select1 == rp_name %} is-selected{% endif %}" href="/products/detail/{{ rp_id }}">
  3279.                                   {{ rp_name }}
  3280.                                 </a>
  3281.                               {% endfor %}
  3282.                             </div>
  3283.                           {% endif %}
  3284.                         </div>
  3285.                         {% endif %}
  3286.                         {# ===== 2段目 ===== #}
  3287.                         {% if type2 and related_product2 and related_product2|length %}
  3288.                         <div class="form-group mt-3 pb-3" style="border-bottom:1px solid rgba(0,0,0,.125)">
  3289.                           <div class="rp-section-label">{{ type2 }}{% if img2 and base_select2 %}: <span>{{ base_select2 }}</span>{% endif %}</div>
  3290.                           {% if img2 %}
  3291.                             <div class="rp-card-group">
  3292.                               {% for rp_id, rp_name in related_product2 %}
  3293.                                 <a class="rp-card{% if base_select2 == rp_name %} is-selected{% endif %}" href="/products/detail/{{ rp_id }}">
  3294.                                   <img class="rp-card__image"
  3295.                                        src="{{ (related_image_obj[rp_id] is defined and related_image_obj[rp_id]) ? asset(related_image_obj[rp_id], 'save_image') : asset(''|no_image_product, 'save_image') }}"
  3296.                                        alt="{{ rp_name }}"
  3297.                                        width="120" height="120" loading="lazy"
  3298.                                        onerror="this.style.display='none';this.nextElementSibling.style.display='flex';">
  3299.                                   <div class="rp-card__placeholder" style="display:none;">📦</div>
  3300.                                   <span class="rp-card__name">{{ rp_name }}</span>
  3301.                                 </a>
  3302.                               {% endfor %}
  3303.                             </div>
  3304.                           {% else %}
  3305.                             <div class="rp-btn-group">
  3306.                               {% for rp_id, rp_name in related_product2 %}
  3307.                                 <a class="rp-btn{% if base_select2 == rp_name %} is-selected{% endif %}" href="/products/detail/{{ rp_id }}">
  3308.                                   {{ rp_name }}
  3309.                                 </a>
  3310.                               {% endfor %}
  3311.                             </div>
  3312.                           {% endif %}
  3313.                         </div>
  3314.                         {% endif %}
  3315.                         {# ===== 3段目 ===== #}
  3316.                         {% if type3 and related_product3 and related_product3|length %}
  3317.                         <div class="form-group mt-3 pb-3" style="border-bottom:1px solid rgba(0,0,0,.125)">
  3318.                           <div class="rp-section-label">{{ type3 }}{% if img3 and base_select3 %}: <span>{{ base_select3 }}</span>{% endif %}</div>
  3319.                           {% if img3 %}
  3320.                             <div class="rp-card-group">
  3321.                               {% for rp_id, rp_name in related_product3 %}
  3322.                                 <a class="rp-card{% if base_select3 == rp_name %} is-selected{% endif %}" href="/products/detail/{{ rp_id }}">
  3323.                                   <img class="rp-card__image"
  3324.                                        src="{{ (related_image_obj[rp_id] is defined and related_image_obj[rp_id]) ? asset(related_image_obj[rp_id], 'save_image') : asset(''|no_image_product, 'save_image') }}"
  3325.                                        alt="{{ rp_name }}"
  3326.                                        width="120" height="120" loading="lazy"
  3327.                                        onerror="this.style.display='none';this.nextElementSibling.style.display='flex';">
  3328.                                   <div class="rp-card__placeholder" style="display:none;">📦</div>
  3329.                                   <span class="rp-card__name">{{ rp_name }}</span>
  3330.                                 </a>
  3331.                               {% endfor %}
  3332.                             </div>
  3333.                           {% else %}
  3334.                             <div class="rp-btn-group">
  3335.                               {% for rp_id, rp_name in related_product3 %}
  3336.                                 <a class="rp-btn{% if base_select3 == rp_name %} is-selected{% endif %}" href="/products/detail/{{ rp_id }}">
  3337.                                   {{ rp_name }}
  3338.                                 </a>
  3339.                               {% endfor %}
  3340.                             </div>
  3341.                           {% endif %}
  3342.                         </div>
  3343.                         {% endif %}
  3344.                         {# ===== 4段目 ===== #}
  3345.                         {% if type4 and related_product4 and related_product4|length %}
  3346.                         <div class="form-group mt-3 pb-3" style="border-bottom:1px solid rgba(0,0,0,.125)">
  3347.                           <div class="rp-section-label">{{ type4 }}{% if img4 and base_select4 %}: <span>{{ base_select4 }}</span>{% endif %}</div>
  3348.                           {% if img4 %}
  3349.                             {# --- 画像付きカード --- #}
  3350.                             <div class="rp-card-group">
  3351.                               {% for rp_id, rp_name in related_product4 %}
  3352.                                 <a class="rp-card{% if base_select4 == rp_name %} is-selected{% endif %}" href="/products/detail/{{ rp_id }}">
  3353.                                   <img class="rp-card__image"
  3354.                                        src="{{ (related_image_obj[rp_id] is defined and related_image_obj[rp_id]) ? asset(related_image_obj[rp_id], 'save_image') : asset(''|no_image_product, 'save_image') }}"
  3355.                                        alt="{{ rp_name }}" width="120" height="120" loading="lazy">
  3356.                                   <span class="rp-card__label">{{ rp_name }}</span>
  3357.                                 </a>
  3358.                               {% endfor %}
  3359.                             </div>
  3360.                           {% else %}
  3361.                             {# --- ボタン式ラジオ --- #}
  3362.                             <div class="rp-btn-group">
  3363.                               {% for rp_id, rp_name in related_product4 %}
  3364.                                 <a class="rp-btn{% if base_select4 == rp_name %} is-selected{% endif %}" href="/products/detail/{{ rp_id }}">
  3365.                                   {{ rp_name }}
  3366.                                 </a>
  3367.                               {% endfor %}
  3368.                             </div>
  3369.                           {% endif %}
  3370.                         </div>
  3371.                         {% endif %}
  3372.                         {# ガレージ等で全オプション空のときに見出しのみ表示される事象を回避.
  3373.                            色 / 寸法 / 素材 / option1/2 / op_data いずれかに「描画される」要素がある場合のみ
  3374.                            「この商品のオプションを選択してください。」見出しを表示する.
  3375.                            op_data は cg=9 で「残土」 / gs=27 で「撤去」「残土」を後段ループで
  3376.                            hide するため、単純な op|length > 0 では「見出しだけ残る」事象が
  3377.                            wh / cg の一部商品で発生していた. 描画される entry を事前カウントする. #}
  3378.                         {% set is_gs_h = false %}
  3379.                         {% set is_cg_h = false %}
  3380.                         {% if Product.ProductCategories is not empty %}
  3381.                             {% for pc_h in Product.ProductCategories %}
  3382.                                 {% if pc_h.category_id == 27 %}{% set is_gs_h = true %}{% endif %}
  3383.                                 {% if pc_h.category_id == 9  %}{% set is_cg_h = true %}{% endif %}
  3384.                             {% endfor %}
  3385.                         {% endif %}
  3386.                         {% set visible_op_count = 0 %}
  3387.                         {% if op and op|length %}
  3388.                             {% for opi in op %}
  3389.                                 {% if opi['name'] %}
  3390.                                     {% set _hide_h = false %}
  3391.                                     {% if is_gs_h and (opi['name'] matches '/撤去/' or opi['name'] matches '/残土/') %}{% set _hide_h = true %}{% endif %}
  3392.                                     {% if is_cg_h and opi['name'] matches '/残土/' %}{% set _hide_h = true %}{% endif %}
  3393.                                     {% if not _hide_h %}{% set visible_op_count = visible_op_count + 1 %}{% endif %}
  3394.                                 {% endif %}
  3395.                             {% endfor %}
  3396.                         {% endif %}
  3397.                         {% set has_any_option =
  3398.                             (color and color|length and ProductClass.SaleType.id != 3)
  3399.                             or (p_w and p_w|length and p_w|join != "")
  3400.                             or (p_d and p_d|length and p_d|join != "")
  3401.                             or (p_h and p_h|length and p_h|join != "")
  3402.                             or (p_m and p_m|length and p_m|join != "")
  3403.                             or (p_option1 and p_option1|length and p_option1|join != "")
  3404.                             or (p_option2 and p_option2|length and p_option2|join != "")
  3405.                             or ProductClass.SaleType.id in [2, 5, 6, 7, 9]
  3406.                         %}
  3407.                         {% if has_any_option %}
  3408.                         <div class="row" style="border-bottom:1px solid rgba(0,0,0,.125)"><label class="col-12 col-form-label">この商品のオプションを選択してください。</label></div>
  3409.                         {% endif %}
  3410.                         {# sale_type=2 (補助金窓) はタイプごとに個別カラー選択するため共通カラーは非表示 #}
  3411.                         {% if color and color|length and ProductClass.SaleType.id != 3 and ProductClass.SaleType.id != 2 %}
  3412.                         <div class="form-group mt-3 pb-2" style="border-bottom:1px solid rgba(0,0,0,.125)">
  3413.                           <div class="rp-section-label">カラー{% if mitsumori_json and mitsumori_json.pc %}: <span>{{ mitsumori_json.pc }}</span>{% endif %}</div>
  3414.                           <div class="opt-btn-group">
  3415.                             {% set idx = 0 %}
  3416.                             {% for cc in color %}{% if cc and cc['name'] %}{% set idx = idx + 1 %}
  3417.                               {% set has_img = cc['img'] is defined and cc['img'] is not empty %}
  3418.                               <label class="opt-btn{% if has_img %} opt-btn--with-image{% endif %}{% if (mitsumori_json and mitsumori_json.pc == cc['name']) or color|length == 1 %} is-selected{% endif %}"
  3419.                                      onclick="mitsumori_simulation_val('pc', '{{ cc['name']|e('js') }}');">
  3420.                                 <input type="radio" name="color" id="cc_{{ idx }}" value="{{ cc['name'] }}"
  3421.                                        {% if (mitsumori_json and mitsumori_json.pc == cc['name']) or color|length == 1 %}checked{% endif %}>
  3422.                                 {% if has_img %}
  3423.                                   <img src="{{ cc['img'] }}" alt="{{ cc['name'] }}" class="opt-btn__img"
  3424.                                        onerror="this.parentNode.classList.remove('opt-btn--with-image'); this.remove();">
  3425.                                   <span class="opt-btn__name">{{ cc['name'] }}</span>
  3426.                                 {% else %}
  3427.                                   {{ cc['name'] }}
  3428.                                 {% endif %}
  3429.                               </label>
  3430.                             {% endif %}{% endfor %}
  3431.                           </div>
  3432.                         </div>
  3433.                         {% endif %}
  3434.                         {# sale_type=2 (補助金窓) はタイプごとに個別幅選択するため共通幅は非表示.
  3435.                            tf (人工芝, sale_type=6) は専用の「芝の幅」+「施工サイズ」UI を下部に持つため、
  3436.                            汎用の 幅/奥行/高さ は描画しない (両方出すと name="pw"/"ph" が重複し、
  3437.                            片方を選ぶともう片方の選択が外れる顧客指摘あり). #}
  3438.                         {% if p_w and p_w|length and p_w|join != "" and ProductClass.SaleType.id not in [2, 3, 6] %}
  3439.                         <div class="form-group mt-3 pb-2" style="border-bottom:1px solid rgba(0,0,0,.125)">
  3440.                           <div class="rp-section-label">幅{% if mitsumori_json and mitsumori_json.pw %}: <span>{{ mitsumori_json.pw }}</span>{% endif %}</div>
  3441.                           <div class="opt-btn-group">
  3442.                             {% set idx = 0 %}
  3443.                             {% for pw in p_w %}{% if pw %}{% set idx = idx + 1 %}
  3444.                               <label class="opt-btn{% if (mitsumori_json and mitsumori_json.pw == pw) or p_w|length == 1 %} is-selected{% endif %}"
  3445.                                      onclick="mitsumori_simulation_val('pw', '{{ pw|e('js') }}');">
  3446.                                 <input type="radio" name="pw" id="pw_{{ idx }}" value="{{ pw }}"
  3447.                                        {% if (mitsumori_json and mitsumori_json.pw == pw) or p_w|length == 1 %}checked{% endif %}>
  3448.                                 {{ pw }}
  3449.                               </label>
  3450.                             {% endif %}{% endfor %}
  3451.                           </div>
  3452.                         </div>
  3453.                         {% endif %}
  3454.                         {% if p_d and p_d|length and p_d|join != "" and ProductClass.SaleType.id not in [3, 6] %}
  3455.                         <div class="form-group mt-3 pb-2" style="border-bottom:1px solid rgba(0,0,0,.125)">
  3456.                           <div class="rp-section-label">奥行き{% if mitsumori_json and mitsumori_json.pd %}: <span>{{ mitsumori_json.pd }}</span>{% endif %}</div>
  3457.                           <div class="opt-btn-group">
  3458.                             {% set idx = 0 %}
  3459.                             {% for pd in p_d %}{% if pd %}{% set idx = idx + 1 %}
  3460.                               <label class="opt-btn{% if (mitsumori_json and mitsumori_json.pd == pd) or p_d|length == 1 %} is-selected{% endif %}"
  3461.                                      onclick="mitsumori_simulation_val('pd', '{{ pd|e('js') }}');">
  3462.                                 <input type="radio" name="pd" id="pd_{{ idx }}" value="{{ pd }}"
  3463.                                        {% if (mitsumori_json and mitsumori_json.pd == pd) or p_d|length == 1 %}checked{% endif %}>
  3464.                                 {{ pd }}
  3465.                               </label>
  3466.                             {% endif %}{% endfor %}
  3467.                           </div>
  3468.                         </div>
  3469.                         {% endif %}
  3470.                         {# sale_type=2 (補助金窓) はタイプごとに個別高さ選択するため共通高さは非表示.
  3471.                            sale_type=6 (人工芝) は専用 UI なので汎用高さは描画しない. #}
  3472.                         {% if p_h and p_h|length and p_h|join != "" and ProductClass.SaleType.id not in [2, 3, 6] %}
  3473.                         <div class="form-group mt-3 pb-2" style="border-bottom:1px solid rgba(0,0,0,.125)">
  3474.                           <div class="rp-section-label">高さ{% if mitsumori_json and mitsumori_json.ph %}: <span>{{ mitsumori_json.ph }}</span>{% endif %}</div>
  3475.                           <div class="opt-btn-group">
  3476.                             {% set idx = 0 %}
  3477.                             {% for ph in p_h %}{% if ph %}{% set idx = idx + 1 %}
  3478.                               <label class="opt-btn{% if (mitsumori_json and mitsumori_json.ph == ph) or p_h|length == 1 %} is-selected{% endif %}"
  3479.                                      onclick="mitsumori_simulation_val('ph', '{{ ph|e('js') }}');">
  3480.                                 <input type="radio" name="ph" id="ph_{{ idx }}" value="{{ ph }}"
  3481.                                        {% if (mitsumori_json and mitsumori_json.ph == ph) or p_h|length == 1 %}checked{% endif %}>
  3482.                                 {{ ph }}
  3483.                               </label>
  3484.                             {% endif %}{% endfor %}
  3485.                           </div>
  3486.                         </div>
  3487.                         {% endif %}
  3488.                         {# sale_type=2 (補助金窓) はタイプごとに個別ガラス選択するため共通素材は非表示 #}
  3489.                         {% if p_m and p_m|length and p_m|join != "" and ProductClass.SaleType.id != 3 and ProductClass.SaleType.id != 2 %}
  3490.                         <div class="form-group mt-3 pb-2" style="border-bottom:1px solid rgba(0,0,0,.125)">
  3491.                           <div class="rp-section-label">{% if is_cat43 %}開き方{% else %}素材{% endif %}{% if mitsumori_json and mitsumori_json.pm %}: <span>{{ mitsumori_json.pm }}</span>{% endif %}</div>
  3492.                           <div class="opt-btn-group">
  3493.                             {% set idx = 0 %}
  3494.                             {% for pm in p_m %}{% if pm %}{% set idx = idx + 1 %}
  3495.                               <label class="opt-btn{% if (mitsumori_json and mitsumori_json.pm == pm) or p_m|length == 1 %} is-selected{% endif %}"
  3496.                                      onclick="mitsumori_simulation_val('pm', '{{ pm|e('js') }}');">
  3497.                                 <input type="radio" name="pm" id="pm_{{ idx }}" value="{{ pm }}"
  3498.                                        {% if (mitsumori_json and mitsumori_json.pm == pm) or p_m|length == 1 %}checked{% endif %}>
  3499.                                 {{ pm }}
  3500.                               </label>
  3501.                             {% endif %}{% endfor %}
  3502.                           </div>
  3503.                         </div>
  3504.                         {% endif %}
  3505.                         {# option1 (sale_type 2 は専用 UI が下にあるため除外 / sale_type 3 は下部ブロックで描画 /
  3506.                            sale_type 4 = fe・ts は option1 が ct_unit_price (枚数増分の工事費単価) で
  3507.                            ユーザー選択肢ではなく計算用の内部値のため radio として表示しない. 値は
  3508.                            上の auto-init (p_option1|length == 1) で JS の option1 変数に埋め込み済み) #}
  3509.                         {% if p_option1 and p_option1|length and p_option1|join != "" and ProductClass.SaleType.id != 2 and ProductClass.SaleType.id != 3 and ProductClass.SaleType.id != 4 and not is_cat43 %}
  3510.                         <div class="form-group mt-3 pb-2" style="border-bottom:1px solid rgba(0,0,0,.125)">
  3511.                           <div class="rp-section-label">{{ option1_label }}{% if mitsumori_json and mitsumori_json.option1 is defined and mitsumori_json.option1 %}: <span>{{ mitsumori_json.option1 }}</span>{% endif %}</div>
  3512.                           <div class="opt-btn-group">
  3513.                             {% set idx = 0 %}
  3514.                             {% for opt1 in p_option1 %}{% if opt1 %}{% set idx = idx + 1 %}
  3515.                               <label class="opt-btn{% if (mitsumori_json and mitsumori_json.option1 is defined and mitsumori_json.option1 == opt1) or p_option1|length == 1 %} is-selected{% endif %}"
  3516.                                      onclick="mitsumori_simulation_val('option1', '{{ opt1|e('js') }}');">
  3517.                                 <input type="radio" name="option1" id="option1_{{ idx }}" value="{{ opt1 }}"
  3518.                                        {% if (mitsumori_json and mitsumori_json.option1 is defined and mitsumori_json.option1 == opt1) or p_option1|length == 1 %}checked{% endif %}>
  3519.                                 {{ opt1 }}
  3520.                               </label>
  3521.                             {% endif %}{% endfor %}
  3522.                           </div>
  3523.                         </div>
  3524.                         {% endif %}
  3525.                         {# option2 #}
  3526.                         {% if p_option2 and p_option2|length and p_option2|join != "" and ProductClass.SaleType.id != 2 and ProductClass.SaleType.id != 3 and not is_cat43 %}
  3527.                         <div class="form-group mt-3 pb-2" style="border-bottom:1px solid rgba(0,0,0,.125)">
  3528.                           <div class="rp-section-label">{{ option2_label }}{% if mitsumori_json and mitsumori_json.option2 is defined and mitsumori_json.option2 %}: <span>{{ mitsumori_json.option2 }}</span>{% endif %}</div>
  3529.                           <div class="opt-btn-group">
  3530.                             {% set idx = 0 %}
  3531.                             {% for opt2 in p_option2 %}{% if opt2 %}{% set idx = idx + 1 %}
  3532.                               <label class="opt-btn{% if (mitsumori_json and mitsumori_json.option2 is defined and mitsumori_json.option2 == opt2) or p_option2|length == 1 %} is-selected{% endif %}"
  3533.                                      onclick="mitsumori_simulation_val('option2', '{{ opt2|e('js') }}');">
  3534.                                 <input type="radio" name="option2" id="option2_{{ idx }}" value="{{ opt2 }}"
  3535.                                        {% if (mitsumori_json and mitsumori_json.option2 is defined and mitsumori_json.option2 == opt2) or p_option2|length == 1 %}checked{% endif %}>
  3536.                                 {{ opt2 }}
  3537.                               </label>
  3538.                             {% endif %}{% endfor %}
  3539.                           </div>
  3540.                         </div>
  3541.                         {% endif %}
  3542.                         <!-- 1: 施工見積(通常) → 幅/奥行/高さ/素材/カラーは上部の共通ブロックで表示済み -->
  3543.                         {% if ProductClass.SaleType.id == 1 %}
  3544.                         {% endif %}
  3545.                         <!-- 2: 施工見積(補助金・窓) 複数タイプ追加対応 (2026-05) -->
  3546.                         {% if ProductClass.SaleType.id == 2 %}
  3547.                             {# 2026-05 改修: ex-shop プラメイク方式に倣い、1 商品で
  3548.                                複数の「窓タイプ」(subtype × 幅 × 高さ × ガラス × カラー × セット数)
  3549.                                を追加できる UI に変更. 各タイプは内訳 JSON として 1 明細にまとめる. #}
  3550.                             {# ec-productRole__profile は標準テンプレで height:600px / overflow:auto
  3551.                                が当たっており、複数タイプ追加カードが内部スクロールに押し込まれて
  3552.                                見えない. sale_type=2 のときだけ縦方向に伸ばす. #}
  3553.                             <style>
  3554.                                 .ec-productRole__profile {
  3555.                                     overflow: visible !important;
  3556.                                     max-height: none !important;
  3557.                                     height: auto !important;
  3558.                                 }
  3559.                             </style>
  3560.                             {# 住宅区分 UI: 補助金単価が戸建 / 集合住宅で異なるためお客様に申告いただく. #}
  3561.                             {% set housing_default = getProduct_field(Product.id, '住宅区分')|default('戸建') %}
  3562.                             <div class="card mt-3 mb-3">
  3563.                               <div class="card-header" style="background:#fff3cd;color:#333;">
  3564.                                 <strong>お住まいの種別を選択</strong>
  3565.                                 <small class="ms-2 text-muted">補助金単価が変わります</small>
  3566.                               </div>
  3567.                               <div class="card-body p-2">
  3568.                                 <div class="opt-btn-group" id="window-housing-type-group">
  3569.                                   <label class="opt-btn">
  3570.                                     <input type="radio" name="window_housing_type" value="戸建"
  3571.                                            {% if housing_default != '集合住宅' %}checked{% endif %}>
  3572.                                     戸建住宅<small class="d-block text-muted">(延床240㎡以下の非住宅含む)</small>
  3573.                                   </label>
  3574.                                   <label class="opt-btn">
  3575.                                     <input type="radio" name="window_housing_type" value="集合住宅"
  3576.                                            {% if housing_default == '集合住宅' %}checked{% endif %}>
  3577.                                     集合住宅<small class="d-block text-muted">(低層・中高層/240㎡超の非住宅)</small>
  3578.                                   </label>
  3579.                                 </div>
  3580.                               </div>
  3581.                             </div>
  3582.                             <div class="card mt-3 mb-3">
  3583.                               <div class="card-header" style="background:#fff3cd;color:#333;">
  3584.                                 <strong>窓タイプを選択(複数追加可・最大 20)</strong>
  3585.                               </div>
  3586.                               <div class="card-body p-2">
  3587.                                 {# (b) 補助金5万円要件の常設明示。各窓の補助金額はブロック内に常時表示。 #}
  3588.                                 <p class="subsidy-eligibility-note">※先進的窓リノベ2026の補助金は、各窓の補助金額の<strong>合計が5万円以上</strong>で適用されます。各窓ごとの補助金額は下に常時表示しています。</p>
  3589.                                 <div id="window-types-container"><!-- 動的に窓タイプブロックが追加される --></div>
  3590.                                 <div class="text-center mt-2">
  3591.                                   <button type="button" id="btn-add-window-type" class="btn btn-outline-primary btn-sm" onclick="addWindowType();">+ 窓タイプを追加</button>
  3592.                                   <div class="small text-muted mt-1">最大 20 タイプまで追加できます</div>
  3593.                                 </div>
  3594.                               </div>
  3595.                             </div>
  3596.                             {# 窓タイプブロックのテンプレート. __IDX__ を JS で連番に置換して clone する. #}
  3597.                             <template id="window-type-template">
  3598.                               <div class="window-type-block" data-type-idx="__IDX__" style="border:1px solid #ddd;border-radius:6px;padding:10px;margin-bottom:10px;background:#fafafa;">
  3599.                                 <div class="d-flex justify-content-between align-items-center mb-2">
  3600.                                   <strong>窓タイプ <span class="wt-num">__IDX__</span></strong>
  3601.                                   <button type="button" class="btn btn-sm btn-outline-danger btn-remove-window-type" onclick="removeWindowType(this);">× 削除</button>
  3602.                                 </div>
  3603.                                 {# 窓タイプ (subtype) #}
  3604.                                 {% if p_option1 and p_option1|length and p_option1|join != "" %}
  3605.                                 <div class="form-group mt-2 pb-2" style="border-bottom:1px dashed rgba(0,0,0,.1)">
  3606.                                   <div class="rp-section-label">窓タイプ</div>
  3607.                                   <div class="opt-btn-group">
  3608.                                     {% for v in p_option1 %}{% if v %}
  3609.                                       <label class="opt-btn">
  3610.                                         <input type="radio" name="wt_subtype___IDX__" value="{{ v }}" data-axis="subtype" onchange="onWindowTypeAxisChange(this);">
  3611.                                         {{ v }}
  3612.                                       </label>
  3613.                                     {% endif %}{% endfor %}
  3614.                                   </div>
  3615.                                 </div>
  3616.                                 {% endif %}
  3617.                                 {# 幅 (mm 直接入力) #}
  3618.                                 {% if p_w and p_w|length and p_w|join != "" %}
  3619.                                 <div class="form-group mt-2 pb-2" style="border-bottom:1px dashed rgba(0,0,0,.1)">
  3620.                                   <div class="rp-section-label">幅</div>
  3621.                                   <div class="input-group" style="max-width:240px;">
  3622.                                     <input type="number" name="wt_pw___IDX__" class="form-control" min="100" step="10"
  3623.                                            placeholder="例 1750" data-axis="pw" data-unit="mm"
  3624.                                            oninput="onWindowTypeAxisChange(this);">
  3625.                                     <span class="input-group-text">mm</span>
  3626.                                   </div>
  3627.                                   <small class="text-muted">商品の対応幅: 〜{{ (p_w|last)|default('') }}({{ p_w|length }} 段階の価格帯から自動選択)</small>
  3628.                                 </div>
  3629.                                 {% endif %}
  3630.                                 {# 高さ (mm 直接入力) #}
  3631.                                 {% if p_h and p_h|length and p_h|join != "" %}
  3632.                                 <div class="form-group mt-2 pb-2" style="border-bottom:1px dashed rgba(0,0,0,.1)">
  3633.                                   <div class="rp-section-label">高さ</div>
  3634.                                   <div class="input-group" style="max-width:240px;">
  3635.                                     <input type="number" name="wt_ph___IDX__" class="form-control" min="100" step="10"
  3636.                                            placeholder="例 1200" data-axis="ph" data-unit="mm"
  3637.                                            oninput="onWindowTypeAxisChange(this);">
  3638.                                     <span class="input-group-text">mm</span>
  3639.                                   </div>
  3640.                                   <small class="text-muted">商品の対応高さ: 〜{{ (p_h|last)|default('') }}({{ p_h|length }} 段階の価格帯から自動選択)</small>
  3641.                                 </div>
  3642.                                 {% endif %}
  3643.                                 {# ガラスタイプ #}
  3644.                                 {% if p_m and p_m|length and p_m|join != "" %}
  3645.                                 <div class="form-group mt-2 pb-2" style="border-bottom:1px dashed rgba(0,0,0,.1)">
  3646.                                   <div class="rp-section-label">ガラスタイプ</div>
  3647.                                   {# A-3: 内窓ガラス名は長い(例 ~449px)。横スクロール(is-scroll)化すると
  3648.                                      左が見切れるため no-hscroll で縦積み+折り返し表示に固定する。 #}
  3649.                                   <div class="opt-btn-group no-hscroll">
  3650.                                     {% for v in p_m %}{% if v %}
  3651.                                       <label class="opt-btn">
  3652.                                         <input type="radio" name="wt_pm___IDX__" value="{{ v }}" data-axis="pm" onchange="onWindowTypeAxisChange(this);">
  3653.                                         {{ v }}
  3654.                                       </label>
  3655.                                     {% endif %}{% endfor %}
  3656.                                   </div>
  3657.                                   <small class="text-muted">「<span style="color:#2e7d32;font-weight:bold;">補助金対象</span>」が付いたガラスは、先進的窓リノベ補助金の対象です。</small>
  3658.                                 </div>
  3659.                                 {% endif %}
  3660.                                 {# 窓枠カラー #}
  3661.                                 {% if color and color|length %}
  3662.                                 <div class="form-group mt-2 pb-2" style="border-bottom:1px dashed rgba(0,0,0,.1)">
  3663.                                   <div class="rp-section-label">窓枠カラー</div>
  3664.                                   <div class="opt-btn-group">
  3665.                                     {% for cc in color %}{% if cc and cc['name'] %}
  3666.                                       {% set has_img = cc['img'] is defined and cc['img'] is not empty %}
  3667.                                       <label class="opt-btn{% if has_img %} opt-btn--with-image{% endif %}">
  3668.                                         <input type="radio" name="wt_pc___IDX__" value="{{ cc['name'] }}" data-axis="pc" onchange="onWindowTypeAxisChange(this);">
  3669.                                         {% if has_img %}
  3670.                                           <img src="{{ cc['img'] }}" alt="{{ cc['name'] }}" class="opt-btn__img"
  3671.                                                onerror="this.parentNode.classList.remove('opt-btn--with-image'); this.remove();">
  3672.                                           <span class="opt-btn__name">{{ cc['name'] }}</span>
  3673.                                         {% else %}
  3674.                                           {{ cc['name'] }}
  3675.                                         {% endif %}
  3676.                                       </label>
  3677.                                     {% endif %}{% endfor %}
  3678.                                   </div>
  3679.                                 </div>
  3680.                                 {% endif %}
  3681.                                 {# セット数 + タイプごとの補助金額表示 (補助金は task 2 で実計算) #}
  3682.                                 <div class="form-group row mt-2 align-items-center">
  3683.                                   <label class="col-4 col-form-label">セット数</label>
  3684.                                   <div class="col-4">
  3685.                                     <div class="input-group">
  3686.                                       <input type="number" name="wt_setqty___IDX__" class="form-control wt-input-setqty" value="1" min="1" max="99" data-axis="setqty" onchange="onWindowTypeAxisChange(this);">
  3687.                                       <span class="input-group-text">セット</span>
  3688.                                     </div>
  3689.                                   </div>
  3690.                                   <div class="col-4 text-end">
  3691.                                     <span class="wt-subsidy-label">補助金額</span><br><span class="wt-subsidy-amount">―</span> <span class="wt-subsidy-label">円</span>
  3692.                                   </div>
  3693.                                 </div>
  3694.                               </div>
  3695.                             </template>
  3696.                             {# 複数タイプ管理 JS. addWindowType / removeWindowType / 再番号付け. #}
  3697.                             <script>
  3698.                             (function() {
  3699.                                 var MAX_WINDOW_TYPES = 20;
  3700.                                 function addWindowType() {
  3701.                                     var container = document.getElementById('window-types-container');
  3702.                                     if (!container) return;
  3703.                                     var existing = container.querySelectorAll('.window-type-block');
  3704.                                     if (existing.length >= MAX_WINDOW_TYPES) return;
  3705.                                     var newIdx = existing.length + 1;
  3706.                                     var tpl = document.getElementById('window-type-template');
  3707.                                     if (!tpl) return;
  3708.                                     // <template> の innerHTML を取り出して __IDX__ を新規連番に置換し、要素化して append.
  3709.                                     var html = tpl.innerHTML.replace(/__IDX__/g, String(newIdx));
  3710.                                     var wrapper = document.createElement('div');
  3711.                                     wrapper.innerHTML = html.trim();
  3712.                                     var block = wrapper.firstChild;
  3713.                                     container.appendChild(block);
  3714.                                     decorateSubsidyGlass(block);
  3715.                                     updateRemoveButtonVisibility();
  3716.                                     if (typeof recalcAll === 'function') recalcAll();
  3717.                                 }
  3718.                                 // 補助金対象ガラスの選択肢に「補助金対象」バッジ + 緑枠を付与する.
  3719.                                 // 判定は calc script 側の window.glassYieldsSubsidy に委譲 (ロジック二重化を避ける)。
  3720.                                 function decorateSubsidyGlass(block) {
  3721.                                     if (!block || typeof window.glassYieldsSubsidy !== 'function') return;
  3722.                                     var radios = block.querySelectorAll('input[data-axis="pm"]');
  3723.                                     radios.forEach(function(inp) {
  3724.                                         var label = inp.closest('.opt-btn');
  3725.                                         if (!label || label.querySelector('.opt-btn__subsidy-badge')) return;
  3726.                                         if (window.glassYieldsSubsidy(inp.value)) {
  3727.                                             label.classList.add('opt-btn--subsidy');
  3728.                                             var badge = document.createElement('span');
  3729.                                             badge.className = 'opt-btn__subsidy-badge';
  3730.                                             badge.textContent = '補助金対象';
  3731.                                             label.appendChild(badge);
  3732.                                         }
  3733.                                     });
  3734.                                 }
  3735.                                 function removeWindowType(btn) {
  3736.                                     var block = btn.closest('.window-type-block');
  3737.                                     if (!block) return;
  3738.                                     block.remove();
  3739.                                     reindexBlocks();
  3740.                                     updateRemoveButtonVisibility();
  3741.                                     if (typeof recalcAll === 'function') recalcAll();
  3742.                                 }
  3743.                                 function updateRemoveButtonVisibility() {
  3744.                                     var blocks = document.querySelectorAll('#window-types-container .window-type-block');
  3745.                                     blocks.forEach(function(b) {
  3746.                                         var btn = b.querySelector('.btn-remove-window-type');
  3747.                                         if (btn) btn.style.display = blocks.length > 1 ? '' : 'none';
  3748.                                     });
  3749.                                 }
  3750.                                 function reindexBlocks() {
  3751.                                     // ブロック削除後に 1 から振り直し. input[name] の末尾 _N も書き換える.
  3752.                                     var blocks = document.querySelectorAll('#window-types-container .window-type-block');
  3753.                                     blocks.forEach(function(b, i) {
  3754.                                         var n = i + 1;
  3755.                                         b.setAttribute('data-type-idx', String(n));
  3756.                                         var numSpan = b.querySelector('.wt-num');
  3757.                                         if (numSpan) numSpan.textContent = String(n);
  3758.                                         b.querySelectorAll('input[name]').forEach(function(inp) {
  3759.                                             inp.name = inp.name.replace(/_\d+$/, '_' + n);
  3760.                                         });
  3761.                                     });
  3762.                                 }
  3763.                                 // 軸変更イベント. recalcAll が定義されていれば再計算 (task 2 で実装).
  3764.                                 window.onWindowTypeAxisChange = function() {
  3765.                                     if (typeof recalcAll === 'function') recalcAll();
  3766.                                 };
  3767.                                 window.addWindowType = addWindowType;
  3768.                                 window.removeWindowType = removeWindowType;
  3769.                                 // 隠し radio(opacity:0/pointer-events:none) を label で包む opt-btn は iOS で初回タップの
  3770.                                 // change が不発になりやすい。窓タイプ/住宅区分は label に onclick が無く change 依存だった
  3771.                                 // ため「2回タッチ/選択できない」が発生。click は確実に発火するので委譲 click で選択状態・
  3772.                                 // is-selected・再計算を行う(JSで複製される窓タイプにも委譲で対応)。
  3773.                                 document.addEventListener('click', function(ev) {
  3774.                                     if (!ev.target || !ev.target.closest) return;
  3775.                                     var label = ev.target.closest('.opt-btn');
  3776.                                     if (!label) return;
  3777.                                     if (!label.closest('#window-types-container') && !label.closest('#window-housing-type-group')) return;
  3778.                                     var input = label.querySelector('input[type="radio"]');
  3779.                                     if (!input || input.disabled) return;
  3780.                                     input.checked = true;
  3781.                                     var nm = input.getAttribute('name');
  3782.                                     if (nm) {
  3783.                                         document.querySelectorAll('input[name="' + nm + '"]').forEach(function(i) {
  3784.                                             var l = i.closest('.opt-btn');
  3785.                                             if (l) l.classList.toggle('is-selected', i.checked);
  3786.                                         });
  3787.                                     }
  3788.                                     if (typeof recalcAll === 'function') recalcAll();
  3789.                                 });
  3790.                                 // 初期表示で 1 ブロック追加 + 住宅区分ラジオの change で再計算.
  3791.                                 document.addEventListener('DOMContentLoaded', function() {
  3792.                                     if (document.querySelector('#window-types-container')) {
  3793.                                         addWindowType();
  3794.                                     }
  3795.                                     var housingInputs = document.querySelectorAll('input[name="window_housing_type"]');
  3796.                                     var syncHousingSelectedClass = function() {
  3797.                                         housingInputs.forEach(function(el) {
  3798.                                             var lbl = el.closest('.opt-btn');
  3799.                                             if (!lbl) return;
  3800.                                             if (el.checked) lbl.classList.add('is-selected');
  3801.                                             else            lbl.classList.remove('is-selected');
  3802.                                         });
  3803.                                     };
  3804.                                     syncHousingSelectedClass();
  3805.                                     housingInputs.forEach(function(el) {
  3806.                                         el.addEventListener('change', function() {
  3807.                                             syncHousingSelectedClass();
  3808.                                             if (typeof recalcAll === 'function') recalcAll();
  3809.                                         });
  3810.                                     });
  3811.                                 });
  3812.                             })();
  3813.                             </script>
  3814.                         {% endif %}
  3815.                         <!-- 3: 施工見積(物置・ゴミステーション) -->
  3816.                         {% if ProductClass.SaleType.id == 3 %}
  3817.                             {# 幅・奥行き・高さ(p_w/p_d/p_h からボタン型ラジオ生成) #}
  3818.                             {% if p_w and p_w|length and p_w|join != "" %}
  3819.                             <div class="form-group mt-3 pb-2" style="border-bottom:1px solid rgba(0,0,0,.125)">
  3820.                               <div class="rp-section-label">幅{% if mitsumori_json and mitsumori_json.pw %}: <span>{{ mitsumori_json.pw }}</span>{% endif %}</div>
  3821.                               <div class="opt-btn-group">
  3822.                                 {% set idx = 0 %}
  3823.                                 {% for pw_val in p_w %}{% if pw_val %}{% set idx = idx + 1 %}
  3824.                                   <label class="opt-btn{% if (mitsumori_json and mitsumori_json.pw == pw_val) or p_w|length == 1 %} is-selected{% endif %}"
  3825.                                          onclick="mitsumori_simulation('pw','pw3_{{ idx }}');">
  3826.                                     <input type="radio" name="pw" id="pw3_{{ idx }}" value="{{ pw_val }}"
  3827.                                            {% if (mitsumori_json and mitsumori_json.pw == pw_val) or p_w|length == 1 %}checked{% endif %}>
  3828.                                     {{ pw_val }}
  3829.                                   </label>
  3830.                                 {% endif %}{% endfor %}
  3831.                               </div>
  3832.                             </div>
  3833.                             {% endif %}
  3834.                             {% if p_d and p_d|length and p_d|join != "" %}
  3835.                             <div class="form-group mt-3 pb-2" style="border-bottom:1px solid rgba(0,0,0,.125)">
  3836.                               <div class="rp-section-label">奥行き{% if mitsumori_json and mitsumori_json.pd %}: <span>{{ mitsumori_json.pd }}</span>{% endif %}</div>
  3837.                               <div class="opt-btn-group">
  3838.                                 {% set idx = 0 %}
  3839.                                 {% for pd_val in p_d %}{% if pd_val %}{% set idx = idx + 1 %}
  3840.                                   <label class="opt-btn{% if (mitsumori_json and mitsumori_json.pd == pd_val) or p_d|length == 1 %} is-selected{% endif %}"
  3841.                                          onclick="mitsumori_simulation('pd','pd3_{{ idx }}');">
  3842.                                     <input type="radio" name="pd" id="pd3_{{ idx }}" value="{{ pd_val }}"
  3843.                                            {% if (mitsumori_json and mitsumori_json.pd == pd_val) or p_d|length == 1 %}checked{% endif %}>
  3844.                                     {{ pd_val }}
  3845.                                   </label>
  3846.                                 {% endif %}{% endfor %}
  3847.                               </div>
  3848.                             </div>
  3849.                             {% endif %}
  3850.                             {% if p_h and p_h|length and p_h|join != "" %}
  3851.                             <div class="form-group mt-3 pb-2" style="border-bottom:1px solid rgba(0,0,0,.125)">
  3852.                               <div class="rp-section-label">高さ{% if mitsumori_json and mitsumori_json.ph %}: <span>{{ mitsumori_json.ph }}</span>{% endif %}</div>
  3853.                               <div class="opt-btn-group">
  3854.                                 {% set idx = 0 %}
  3855.                                 {% for ph_val in p_h %}{% if ph_val %}{% set idx = idx + 1 %}
  3856.                                   <label class="opt-btn{% if (mitsumori_json and mitsumori_json.ph == ph_val) or p_h|length == 1 %} is-selected{% endif %}"
  3857.                                          onclick="mitsumori_simulation('ph','ph3_{{ idx }}');">
  3858.                                     <input type="radio" name="ph" id="ph3_{{ idx }}" value="{{ ph_val }}"
  3859.                                            {% if (mitsumori_json and mitsumori_json.ph == ph_val) or p_h|length == 1 %}checked{% endif %}>
  3860.                                     {{ ph_val }}
  3861.                                   </label>
  3862.                                 {% endif %}{% endfor %}
  3863.                               </div>
  3864.                             </div>
  3865.                             {% endif %}
  3866.                             {# 棚タイプ(p_m データから選択肢を生成) #}
  3867.                             {% if p_m and p_m|length and p_m|join != "" %}
  3868.                             <div class="form-group mt-3 pb-2" style="border-bottom:1px solid rgba(0,0,0,.125)">
  3869.                               <div class="rp-section-label">棚タイプ <small class="text-muted">(間仕切り仕様)</small>{% if mitsumori_json and mitsumori_json.pm %}: <span>{{ mitsumori_json.pm }}</span>{% endif %}</div>
  3870.                               <div class="opt-btn-group">
  3871.                                 {% set idx = 0 %}
  3872.                                 {% for pm_val in p_m %}{% if pm_val %}{% set idx = idx + 1 %}
  3873.                                   <label class="opt-btn{% if (mitsumori_json and mitsumori_json.pm == pm_val) or p_m|length == 1 %} is-selected{% endif %}"
  3874.                                          onclick="mitsumori_simulation('pm','pm3_{{ idx }}');">
  3875.                                     <input type="radio" name="pm" id="pm3_{{ idx }}" value="{{ pm_val }}"
  3876.                                            {% if (mitsumori_json and mitsumori_json.pm == pm_val) or p_m|length == 1 %}checked{% endif %}>
  3877.                                     {{ pm_val }}
  3878.                                   </label>
  3879.                                 {% endif %}{% endfor %}
  3880.                               </div>
  3881.                             </div>
  3882.                             {% endif %}
  3883.                             {# option1 (mo の 棚タイプ 等、p_option1 データから選択肢を生成) #}
  3884.                             {% if p_option1 and p_option1|length and p_option1|join != "" %}
  3885.                             <div class="form-group mt-3 pb-2" style="border-bottom:1px solid rgba(0,0,0,.125)">
  3886.                               <div class="rp-section-label">{{ option1_label }}{% if mitsumori_json and mitsumori_json.option1 is defined and mitsumori_json.option1 %}: <span>{{ mitsumori_json.option1 }}</span>{% endif %}</div>
  3887.                               <div class="opt-btn-group">
  3888.                                 {% set idx = 0 %}
  3889.                                 {% for opt1_val in p_option1 %}{% if opt1_val %}{% set idx = idx + 1 %}
  3890.                                   <label class="opt-btn{% if (mitsumori_json and mitsumori_json.option1 is defined and mitsumori_json.option1 == opt1_val) or p_option1|length == 1 %} is-selected{% endif %}"
  3891.                                          onclick="mitsumori_simulation('option1','option1_3_{{ idx }}');">
  3892.                                     <input type="radio" name="option1" id="option1_3_{{ idx }}" value="{{ opt1_val }}"
  3893.                                            {% if (mitsumori_json and mitsumori_json.option1 is defined and mitsumori_json.option1 == opt1_val) or p_option1|length == 1 %}checked{% endif %}>
  3894.                                     {{ opt1_val }}
  3895.                                   </label>
  3896.                                 {% endif %}{% endfor %}
  3897.                               </div>
  3898.                             </div>
  3899.                             {% endif %}
  3900.                             {# カラー(共通の color データ使用) #}
  3901.                             {% if color and color|length %}
  3902.                             <div class="form-group mt-3 pb-2" style="border-bottom:1px solid rgba(0,0,0,.125)">
  3903.                               <div class="rp-section-label">カラー{% if mitsumori_json and mitsumori_json.pc %}: <span>{{ mitsumori_json.pc }}</span>{% endif %}</div>
  3904.                               <div class="opt-btn-group">
  3905.                                 {% set idx = 0 %}
  3906.                                 {% for cc in color %}{% if cc and cc['name'] %}{% set idx = idx + 1 %}
  3907.                                   <label class="opt-btn{% if (mitsumori_json and mitsumori_json.pc == cc['name']) or color|length == 1 %} is-selected{% endif %}"
  3908.                                          onclick="mitsumori_simulation('pc','cc3_{{ idx }}');">
  3909.                                     <input type="radio" name="color3" id="cc3_{{ idx }}" value="{{ cc['name'] }}"
  3910.                                            {% if (mitsumori_json and mitsumori_json.pc == cc['name']) or color|length == 1 %}checked{% endif %}>
  3911.                                     {{ cc['name'] }}
  3912.                                   </label>
  3913.                                 {% endif %}{% endfor %}
  3914.                               </div>
  3915.                             </div>
  3916.                             {% endif %}
  3917.                             {# 台数 #}
  3918.                             <div class="form-group row mt-2" style="border-bottom:1px solid rgba(0,0,0,.125)">
  3919.                               <label class="col-4 col-form-label">台数</label>
  3920.                               <div class="col-4">
  3921.                                 <div class="input-group mb-3">
  3922.                                   <input type="number" name="daisu" id="daisu" class="form-control" value="{{ mitsumori_json.daisu|default('1') }}" min="1" max="10" onchange="mitsumori_simulation('daisu','daisu');">
  3923.                                   <span class="input-group-text">台</span>
  3924.                                 </div>
  3925.                               </div>
  3926.                               <div class="col-4">
  3927.                                 <button type="button" class="btn btn-info" onclick="daisu(+1);">+</button>
  3928.                                 <button type="button" class="btn btn-danger" onclick="daisu(-1);">ー</button>
  3929.                               </div>
  3930.                             </div>
  3931.                         {% endif %}
  3932.                         <!-- 4: 施工見積(フェンス・組み立て式) -->
  3933.                         {% if ProductClass.SaleType.id == 4 %}
  3934.                             {# 枚数 #}
  3935.                             <div class="form-group row mt-2" style="border-bottom:1px solid rgba(0,0,0,.125)">
  3936.                               <label class="col-4 col-form-label">枚数</label>
  3937.                               <div class="col-4 mb-3">
  3938.                                 <div class="input-group">
  3939.                                   <input type="number" name="maisu" id="maisu" class="form-control" value="{{ mitsumori_json.maisu|default('3') }}" min="3" max="20" onchange="if(parseInt(this.value)<3)this.value=3; mitsumori_simulation('maisu','maisu');">
  3940.                                   <span class="input-group-text">枚</span>
  3941.                                 </div>
  3942.                               </div>
  3943.                               <div class="col-4">
  3944.                                 <button type="button" class="btn btn-info" onclick="maisu(+1);">+</button>
  3945.                                 <button type="button" class="btn btn-danger" onclick="maisu(-1);">ー</button>
  3946.                               </div>
  3947.                             </div>
  3948.                         {% endif %}
  3949.                         <!-- 5: 施工見積(ウッドデッキ・タイルデッキ) -->
  3950.                         {% if ProductClass.SaleType.id == 5 %}
  3951.                             {# ステップの有無 #}
  3952.                             <div class="form-group mt-3 pb-2" style="border-bottom:1px solid rgba(0,0,0,.125)">
  3953.                               <div class="rp-section-label">ステップ</div>
  3954.                               <div class="opt-btn-group">
  3955.                                 {% set step_yes_checked = (mitsumori_json and mitsumori_json.deck_step is defined and mitsumori_json.deck_step == '必要') %}
  3956.                                 {% set step_no_checked = (not mitsumori_json or mitsumori_json.deck_step is not defined or mitsumori_json.deck_step == '不要' or not mitsumori_json.deck_step) %}
  3957.                                 <label class="opt-btn{% if step_yes_checked %} is-selected{% endif %}"
  3958.                                        onclick="mitsumori_simulation('op0','deck_step_yes');">
  3959.                                   <input type="radio" name="deck_step" id="deck_step_yes" value="必要"
  3960.                                          {% if step_yes_checked %}checked{% endif %}>
  3961.                                   必要
  3962.                                 </label>
  3963.                                 <label class="opt-btn{% if step_no_checked %} is-selected{% endif %}"
  3964.                                        onclick="mitsumori_simulation('op0','deck_step_no');">
  3965.                                   <input type="radio" name="deck_step" id="deck_step_no" value="不要"
  3966.                                          {% if step_no_checked %}checked{% endif %}>
  3967.                                   不要
  3968.                                 </label>
  3969.                               </div>
  3970.                               <div class="opt-survey-note-wd-step" style="font-size:12px;color:#c00;margin-top:6px;{% if not step_yes_checked %}display:none;{% endif %}">
  3971.                                 ※ 現場調査後に正式お見積もりさせていただきます。
  3972.                               </div>
  3973.                             </div>
  3974.                             {# ウッドデッキフェンスの有無 #}
  3975.                             <div class="form-group mt-3 pb-2" style="border-bottom:1px solid rgba(0,0,0,.125)">
  3976.                               <div class="rp-section-label">デッキフェンス</div>
  3977.                               <div class="opt-btn-group">
  3978.                                 {% set fence_yes_checked = (mitsumori_json and mitsumori_json.deck_fence is defined and mitsumori_json.deck_fence == '必要') %}
  3979.                                 {% set fence_no_checked = (not mitsumori_json or mitsumori_json.deck_fence is not defined or mitsumori_json.deck_fence == '不要' or not mitsumori_json.deck_fence) %}
  3980.                                 <label class="opt-btn{% if fence_yes_checked %} is-selected{% endif %}"
  3981.                                        onclick="mitsumori_simulation('op1','deck_fence_yes');">
  3982.                                   <input type="radio" name="deck_fence" id="deck_fence_yes" value="必要"
  3983.                                          {% if fence_yes_checked %}checked{% endif %}>
  3984.                                   必要
  3985.                                 </label>
  3986.                                 <label class="opt-btn{% if fence_no_checked %} is-selected{% endif %}"
  3987.                                        onclick="mitsumori_simulation('op1','deck_fence_no');">
  3988.                                   <input type="radio" name="deck_fence" id="deck_fence_no" value="不要"
  3989.                                          {% if fence_no_checked %}checked{% endif %}>
  3990.                                   不要
  3991.                                 </label>
  3992.                               </div>
  3993.                               <div class="opt-survey-note-wd-fence" style="font-size:12px;color:#c00;margin-top:6px;{% if not fence_yes_checked %}display:none;{% endif %}">
  3994.                                 ※ 現場調査後に正式お見積もりさせていただきます。
  3995.                               </div>
  3996.                             </div>
  3997.                         {% endif %}
  3998.                         <!-- 6: 施工見積(芝生・枚数・数量買い) -->
  3999.                         {% if ProductClass.SaleType.id == 6 %}
  4000.                             {# 規格(p_m データ → 素材から抽出) #}
  4001.                             {% if p_m and p_m|length and p_m|join != "" %}
  4002.                             <div class="form-group mt-3 pb-2" style="border-bottom:1px solid rgba(0,0,0,.125)">
  4003.                               <div class="rp-section-label">規格{% if mitsumori_json and mitsumori_json.pm %}: <span>{{ mitsumori_json.pm }}</span>{% endif %}</div>
  4004.                               <div class="opt-btn-group">
  4005.                                 {% set idx = 0 %}
  4006.                                 {% for pm_val in p_m %}{% if pm_val %}{% set idx = idx + 1 %}
  4007.                                   <label class="opt-btn{% if (mitsumori_json and mitsumori_json.pm == pm_val) or p_m|length == 1 %} is-selected{% endif %}"
  4008.                                          onclick="mitsumori_simulation('pm','pm6_{{ idx }}');">
  4009.                                     <input type="radio" name="pm" id="pm6_{{ idx }}" value="{{ pm_val }}"
  4010.                                            {% if (mitsumori_json and mitsumori_json.pm == pm_val) or p_m|length == 1 %}checked{% endif %}>
  4011.                                     {{ pm_val }}
  4012.                                   </label>
  4013.                                 {% endif %}{% endfor %}
  4014.                               </div>
  4015.                             </div>
  4016.                             {% endif %}
  4017.                             {# 芝の幅(p_w = ロール幅。free_area の w。例 100cm/200cm = 1m/2m 幅。
  4018.                                h はロール長(例 1000cm=10m)で固定のため UI には出さない)。 #}
  4019.                             {% if p_w and p_w|length and p_w|join != "" %}
  4020.                             <div class="form-group mt-3 pb-2" style="border-bottom:1px solid rgba(0,0,0,.125)">
  4021.                               <div class="rp-section-label">芝の幅{% if mitsumori_json and mitsumori_json.pw %}: <span>{{ mitsumori_json.pw }}</span>{% endif %}</div>
  4022.                               <div class="opt-btn-group">
  4023.                                 {% set idx = 0 %}
  4024.                                 {% for pw_val in p_w %}{% if pw_val %}{% set idx = idx + 1 %}
  4025.                                   <label class="opt-btn{% if (mitsumori_json and mitsumori_json.pw == pw_val) or p_w|length == 1 %} is-selected{% endif %}"
  4026.                                          onclick="mitsumori_simulation('pw','pw6_{{ idx }}');">
  4027.                                     <input type="radio" name="pw" id="pw6_{{ idx }}" value="{{ pw_val }}"
  4028.                                            {% if (mitsumori_json and mitsumori_json.pw == pw_val) or p_w|length == 1 %}checked{% endif %}>
  4029.                                     {{ pw_val }}
  4030.                                   </label>
  4031.                                 {% endif %}{% endfor %}
  4032.                               </div>
  4033.                             </div>
  4034.                             {% endif %}
  4035.                             {# 1ロールの長さ表記(顧客要望 #10)。h はロール長 (例 1000cm=10m) で固定のため選択ではなく表示のみ。 #}
  4036.                             {% set _roll_h = (p_h is defined and p_h|length) ? (p_h|first) : '' %}
  4037.                             {% if _roll_h %}
  4038.                             <div class="form-group mt-3 pb-2" style="border-bottom:1px solid rgba(0,0,0,.125)">
  4039.                               <div class="rp-section-label">1ロールの長さ:<span style="font-weight:bold;">{% if 'cm' in _roll_h %}{{ (_roll_h|replace({'cm':'', ',':''})|trim) / 100 }}m{% else %}{{ _roll_h }}{% endif %}</span></div>
  4040.                             </div>
  4041.                             {% endif %}
  4042.                             {# 施工サイズ(縦 × 横、m 入力)→ 必要枚数を自動算出して表示.
  4043.                                面積=縦×横 を #area に流し込み、case 6 の qty=ceil(area/ロール面積) を流用. #}
  4044.                             <div class="form-group row mt-2" style="border-bottom:1px solid rgba(0,0,0,.125)">
  4045.                               <label class="col-4 col-form-label">施工サイズ</label>
  4046.                               <div class="col-8">
  4047.                                 <div class="input-group">
  4048.                                   <input type="number" name="tf_len" id="tf_len"
  4049.                                     class="form-control"
  4050.                                     value="{{ mitsumori_json.tf_len|default('') }}"
  4051.                                     placeholder="縦" min="0.1" step="0.1"
  4052.                                     onchange="mitsumori_simulation('tf_len','tf_len');">
  4053.                                   <span class="input-group-text">m&nbsp;×</span>
  4054.                                   <input type="number" name="tf_wid" id="tf_wid"
  4055.                                     class="form-control"
  4056.                                     value="{{ mitsumori_json.tf_wid|default('') }}"
  4057.                                     placeholder="横" min="0.1" step="0.1"
  4058.                                     onchange="mitsumori_simulation('tf_wid','tf_wid');">
  4059.                                   <span class="input-group-text">m</span>
  4060.                                 </div>
  4061.                                 {# area は内部計算用に保持(縦×横を JS で書き込む) #}
  4062.                                 <input type="hidden" name="area" id="area" value="{{ mitsumori_json.area|default('') }}">
  4063.                                 {# 必要枚数は直接入力も可能。施工サイズ入力時は自動算出して上書きする。
  4064.                                    施工サイズ未入力でも、ここで枚数だけ指定してカートに入れられる。 #}
  4065.                                 <div style="margin-top:10px;display:inline-block;background:#fff3cd;border:1px solid #ffe69c;border-radius:6px;padding:8px 14px;font-size:16px;color:#c00;font-weight:bold;">
  4066.                                   必要枚数:<input type="number" name="tf_qty" id="tf_qty"
  4067.                                     value="{{ mitsumori_json.tf_qty|default('1') }}" min="1" step="1"
  4068.                                     style="width:80px;font-size:22px;font-weight:bold;color:#c00;text-align:right;border:1px solid #ffe69c;border-radius:4px;padding:2px 6px;"
  4069.                                     onchange="mitsumori_simulation('tf_qty','tf_qty');"> 枚
  4070.                                 </div>
  4071.                                 <div style="margin-top:6px;font-size:12px;color:#666;">※ 施工サイズを入力すると必要枚数を自動計算します。サイズが分からない場合は枚数を直接ご指定ください。</div>
  4072.                               </div>
  4073.                             </div>
  4074.                         {% endif %}
  4075.                         <!-- 7: 施工見積(数量買い・基本工事費固定) -->
  4076.                         {% if ProductClass.SaleType.id == 7 %}
  4077.                             {# 個数(物置の daisu と同形式、単位は「個」) #}
  4078.                             <div class="form-group row mt-2" style="border-bottom:1px solid rgba(0,0,0,.125)">
  4079.                               <label class="col-4 col-form-label">個数</label>
  4080.                               <div class="col-4">
  4081.                                 <div class="input-group mb-3">
  4082.                                   <input type="number" name="suuryou" id="suuryou" class="form-control"
  4083.                                     value="{{ mitsumori_json.suuryou|default('1') }}" min="1" max="100"
  4084.                                     onchange="mitsumori_simulation('suuryou','suuryou');">
  4085.                                   <span class="input-group-text">個</span>
  4086.                                 </div>
  4087.                               </div>
  4088.                               <div class="col-4">
  4089.                                 <button type="button" class="btn btn-info" onclick="suuryou(+1);">+</button>
  4090.                                 <button type="button" class="btn btn-danger" onclick="suuryou(-1);">ー</button>
  4091.                               </div>
  4092.                             </div>
  4093.                         {% endif %}
  4094.                         <!-- 9: 商品のみ購入 -->
  4095.                         {% if ProductClass.SaleType.id == 9 %}
  4096.                             <div class="form-group row mt-2" style="border-bottom:1px solid rgba(0,0,0,.125)">
  4097.                               <label class="col-4 col-form-label">数量</label>
  4098.                               <div class="col-4">
  4099.                                 <div class="input-group mb-3">
  4100.                                   <input type="number" name="quantity_only" id="quantity_only" class="form-control" value="1" min="1" onchange="$('#quantity').val(this.value);">
  4101.                                   <span class="input-group-text">個</span>
  4102.                                 </div>
  4103.                               </div>
  4104.                               <div class="col-4">
  4105.                                 <button type="button" class="btn btn-info" onclick="quantityOnly(+1);">+</button>
  4106.                                 <button type="button" class="btn btn-danger" onclick="quantityOnly(-1);">ー</button>
  4107.                               </div>
  4108.                             </div>
  4109.                         {% endif %}
  4110.                         <div class="row" style="border-bottom:1px solid rgba(0,0,0,.125)"><label class="col-12 col-form-label">取り付け工事のご希望に関してお答えください。</label></div>
  4111.                         {# gs (ゴミステーション, category id=27) では「撤去対応」「残土・ガラ処理」は ex-shop に存在しないため非表示
  4112.                            cg (カーゲート, category id=9) では「残土・ガラ処理」分は撤去金額側に内包済みのため非表示
  4113.                            (撤去オプション自体は残す) #}
  4114.                         {% set is_gs = false %}
  4115.                         {% set is_cg = false %}
  4116.                         {% if Product.ProductCategories is not empty %}
  4117.                             {% for ProductCategory in Product.ProductCategories %}
  4118.                                 {% if ProductCategory.category_id == 27 %}{% set is_gs = true %}{% endif %}
  4119.                                 {% if ProductCategory.category_id == 9 %}{% set is_cg = true %}{% endif %}
  4120.                             {% endfor %}
  4121.                         {% endif %}
  4122.                         {% for i in 0..10 %}
  4123.                             {% if op and op|length >= i+1 %}
  4124.                             {% if op[i]['name'] %}
  4125.                             {% set hide_opt = false %}
  4126.                             {% if is_gs and (op[i]['name'] matches '/撤去/' or op[i]['name'] matches '/残土/') %}
  4127.                                 {% set hide_opt = true %}
  4128.                             {% endif %}
  4129.                             {% if is_cg and op[i]['name'] matches '/残土/' %}
  4130.                                 {% set hide_opt = true %}
  4131.                             {% endif %}
  4132.                             {% if not hide_opt %}
  4133.                             {# price=0 で「解体」「撤去」「設置場所」(wd 等で API 差額未提供) を含むオプションは
  4134.                                選択時に「現場調査後に正式お見積もり」文言を出す. #}
  4135.                             {% set is_removal_unknown = (op[i]['price']|default(0) == 0 or op[i]['price']|default('0') == '0')
  4136.                                                           and (op[i]['name'] matches '/解体/' or op[i]['name'] matches '/撤去/' or op[i]['name'] matches '/設置場所/') %}
  4137.                             {# tf (人工芝, sale_type=6): ex-shop に施工価格がないため「取り付け工事も希望する」選択時に現地調査文言を表示 #}
  4138.                             {% set is_tf_kouji = (ProductClass.SaleType.id == 6 and op[i]['name'] matches '/取り付け工事/') %}
  4139.                             <div class="form-group mt-3 pb-3" data-op-idx="{{ i }}" data-op-name="{{ op[i]['name']|e('html_attr') }}" style="border-bottom:1px solid rgba(0,0,0,.125)">
  4140.                               <div class="rp-section-label">{{ op[i]['name'] }}{% if mitsumori_json and mitsumori_json.op[i] %}: <span>{{ mitsumori_json.op[i] }}</span>{% endif %}</div>
  4141.                               {% if op[i]['comment'] %}<div class="opt-comment" style="font-size:12px;color:#666;margin:4px 0 6px;">{{ op[i]['comment'] }}</div>{% endif %}
  4142.                               <div class="opt-btn-group">
  4143.                                 {# off が空 (= ex-shop に選択肢が 1 つしかない基礎施工等) のときは on 側を
  4144.                                    既定選択にして 2 個目の空ラジオを描画しない. #}
  4145.                                 <label class="opt-btn{% if (mitsumori_json and mitsumori_json.op[i] == op[i]['on']) or not op[i]['off'] %} is-selected{% endif %}"
  4146.                                        onclick="mitsumori_simulation('op{{ i }}','op{{ i }}_1');">
  4147.                                   <input type="radio" name="op{{ i }}" id="op{{ i }}_1" value="{{ op[i]['on'] }}" {% if (mitsumori_json and mitsumori_json.op[i] == op[i]['on']) or not op[i]['off'] %}checked{% endif %}>
  4148.                                   {{ op[i]['on'] }}
  4149.                                 </label>
  4150.                                 {% if op[i]['off'] %}
  4151.                                 <label class="opt-btn{% if mitsumori_json and mitsumori_json.op[i] == op[i]['off'] %} is-selected{% endif %}"
  4152.                                        onclick="mitsumori_simulation('op{{ i }}','op{{ i }}_2');">
  4153.                                   <input type="radio" name="op{{ i }}" id="op{{ i }}_2" value="{{ op[i]['off'] }}" {% if mitsumori_json and mitsumori_json.op[i] == op[i]['off'] %}checked{% endif %}>
  4154.                                   {{ op[i]['off'] }}
  4155.                                 </label>
  4156.                                 {% endif %}
  4157.                               </div>
  4158.                               {% if is_removal_unknown or is_tf_kouji %}
  4159.                               <div class="opt-survey-note" data-op-idx="{{ i }}" style="font-size:12px;color:#c00;margin-top:6px;{% if not (mitsumori_json and mitsumori_json.op[i] == op[i]['on']) %}display:none;{% endif %}">
  4160.                                 ※ 現場調査後に正式お見積もりさせていただきます。
  4161.                               </div>
  4162.                               {% endif %}
  4163.                             </div>
  4164.                             {% endif %}
  4165.                             {% endif %}
  4166.                             {% endif %}
  4167.                         {% endfor %}
  4168.                         {# fe (sale_type=4) ブロックの種類×段数 サブ UI コンテナ.
  4169.                            親オプション「ご希望のフェンス設置方法」 = 「新規にブロック積みを行う」
  4170.                            を選択したときだけ JS (renderFeBlockRadios) で表示される. #}
  4171.                         {% if ProductClass.SaleType.id == 4 and oi and oi.fe_block %}
  4172.                         <div id="fe-block-options" class="form-group mt-3 pb-3" style="display:none;border-bottom:1px solid rgba(0,0,0,.125)"></div>
  4173.                         {% endif %}
  4174.                         {# tg カテゴリ用: block 別オプション差額ラジオ (W/D 確定後に JS が動的描画).
  4175.                            他オプションと同じ列(card-body 内・カートボタンより前)に表示する. #}
  4176.                         <div id="tg-options" class="tg-options-container col-12"></div>
  4177.                       </div>
  4178.                       <!-- /.card-body -->
  4179.                     </div>
  4180.                     <form action="{{ url('product_add_cart', {id:Product.id}) }}" method="post" id="form1" name="form1">
  4181.                         {% if Product.stock_find %}
  4182.                             <div class="ec-productRole__actions">
  4183.                                 {% if form.classcategory_id1 is defined %}
  4184.                                     <div class="ec-select">
  4185.                                         {{ form_row(form.classcategory_id1) }}
  4186.                                         {{ form_errors(form.classcategory_id1) }}
  4187.                                     </div>
  4188.                                     {% if form.classcategory_id2 is defined %}
  4189.                                         <div class="ec-select">
  4190.                                             {{ form_row(form.classcategory_id2) }}
  4191.                                             {{ form_errors(form.classcategory_id2) }}
  4192.                                         </div>
  4193.                                     {% endif %}
  4194.                                 {% endif %}
  4195.                                 <div class="ec-numberInput">
  4196.                                     {{ form_widget(form.quantity, { type: 'hidden' }) }}
  4197.                                     {{ form_errors(form.quantity) }}
  4198.                                 </div>
  4199.                             </div>
  4200.                         {% else %}
  4201.                             <div class="ec-productRole__btn">
  4202.                                 <button type="button" class="ec-blockBtn--action" disabled="disabled">
  4203.                                     {{ 'ただいま品切れ中です。'|trans }}
  4204.                                 </button>
  4205.                             </div>
  4206.                         {% endif %}
  4207.                         <div class="ec-productRole__btn mt-3">
  4208.                            <button type="submit" id="cart_btn" class="ec-blockBtn--action add-cart">カートに入れる</button>
  4209.                         </div>
  4210.                         {{ form_rest(form) }}
  4211.                     </form>
  4212.                     <div class="ec-modal add-cart-modal">
  4213.                         <div class="ec-modal-overlay">
  4214.                             <div class="ec-modal-wrap add-cart-modal__wrap">
  4215.                                 <span class="ec-modal-close"><span class="ec-icon"><img src="{{ asset('assets/icon/cross-dark.svg') }}" alt=""/></span></span>
  4216.                                 <div id="ec-modal-header" class="text-center add-cart-modal__header">{{ 'カートに追加しました。'|trans }}</div>
  4217.                                 <div class="ec-modal-box add-cart-modal__box">
  4218.                                     <div class="ec-role add-cart-modal__actions">
  4219.                                         <a href="{{ url('cart') }}" class="ec-inlineBtn--action add-cart-modal__primary">{{ 'カートに進む'|trans }}</a>
  4220.                                         <span class="ec-inlineBtn--cancel add-cart-modal__secondary">{{ '商品検索を続ける'|trans }}</span>
  4221.                                     </div>
  4222.                                 </div>
  4223.                             </div>
  4224.                         </div>
  4225.                     </div>
  4226.                     <div class="ec-productRole__description">{{ Product.description_detail|raw|nl2br }}
  4227.                     </div>
  4228.                     {# SNSシェア #}
  4229.                     <div class="ec-productRole__share">
  4230.                         <ul class="ec-share">
  4231.                             <li class="ec-share__item">
  4232.                                 <a href="https://twitter.com/share?url={{ app.request.uri|url_encode }}&text={{ Product.name|url_encode }}" class="ec-share__link ec-share__link--twitter" target="_blank" rel="noreferrer noopener">
  4233.                                     <i class="fab fa-twitter"></i>
  4234.                                 </a>
  4235.                             </li>
  4236.                             <li class="ec-share__item">
  4237.                                 <a href="https://www.facebook.com/sharer/sharer.php?u={{ app.request.uri|url_encode }}" class="ec-share__link ec-share__link--facebook" target="_blank" rel="noreferrer noopener">
  4238.                                     <i class="fab fa-facebook-f"></i>
  4239.                                 </a>
  4240.                             </li>
  4241.                             <li class="ec-share__item">
  4242.                                 <a href="https://social-plugins.line.me/lineit/share?url={{ app.request.uri|url_encode }}" class="ec-share__link ec-share__link--line" target="_blank" rel="noreferrer noopener">
  4243.                                     <i class="fab fa-line"></i>
  4244.                                 </a>
  4245.                             </li>
  4246.                         </ul>
  4247.                     </div>
  4248.                 </div>
  4249.                 {# ===== スマホ用 見積金額 下部固定バー ===== #}
  4250.                 <div id="sp-mitsumori-bar">
  4251.                     <div class="sp-bar__pricewrap">
  4252.                         <div class="sp-bar__label">合計(工事費・税込)</div>
  4253.                         <div class="sp-bar__price" id="sp-mitsumori-price">---円</div>
  4254.                     </div>
  4255.                     <button type="button" class="sp-bar__detail-btn"
  4256.                             onclick="openSpMitsumoriDetail();" title="お見積りの詳細(内訳)を表示">
  4257.                         <i class="far fa-file-alt"></i>
  4258.                         <span>詳細</span>
  4259.                     </button>
  4260.                     <button type="button" class="sp-bar__btn"
  4261.                             onclick="document.getElementById('cart_btn2') && document.getElementById('cart_btn2').click();">
  4262.                         <i class="fas fa-shopping-cart"></i>
  4263.                         <span class="sp-bar__btn-label">カートへ</span>
  4264.                         {% set spTotalQty = get_carts_total_quantity() %}
  4265.                         {% if spTotalQty > 0 %}
  4266.                             <span class="sp-bar__cart-badge">{{ spTotalQty|number_format }}</span>
  4267.                         {% endif %}
  4268.                     </button>
  4269.                     <a href="{{ url('homepage') }}" class="sp-bar__home-btn" title="トップページへ">
  4270.                         <i class="fas fa-home"></i>
  4271.                     </a>
  4272.                 </div>
  4273.                 {# ===== スマホ用 お見積りの詳細 ボトムシート ===== #}
  4274.                 {# タップ時に現在の内訳カード(.mitsumori-card-pc)の中身を複製して表示する。
  4275.                    価格計算には触れず、ライブ同期済みの表示値をそのまま見せる方式。 #}
  4276.                 <div id="sp-mitsumori-detail" class="sp-detail-sheet" aria-hidden="true">
  4277.                     <div class="sp-detail-sheet__overlay" onclick="closeSpMitsumoriDetail();"></div>
  4278.                     <div class="sp-detail-sheet__panel" role="dialog" aria-label="お見積りの詳細">
  4279.                         <div class="sp-detail-sheet__header">
  4280.                             <span class="sp-detail-sheet__title">お見積りの詳細</span>
  4281.                             <button type="button" class="sp-detail-sheet__close" onclick="closeSpMitsumoriDetail();" aria-label="閉じる">&times;</button>
  4282.                         </div>
  4283.                         <div class="sp-detail-sheet__body" id="sp-detail-body"></div>
  4284.                         <div class="sp-detail-sheet__footer">
  4285.                             <button type="button" class="sp-detail-sheet__invoice"
  4286.                                     onclick="closeSpMitsumoriDetail(); document.getElementById('mitsumori_btn') && document.getElementById('mitsumori_btn').click();">見積書を表示</button>
  4287.                             <button type="button" class="sp-detail-sheet__cart"
  4288.                                     onclick="closeSpMitsumoriDetail(); document.getElementById('cart_btn2') && document.getElementById('cart_btn2').click();">カートに入れる</button>
  4289.                         </div>
  4290.                     </div>
  4291.                 </div>
  4292.                 <div class="card col-12 collapsed-card sticky-top float-right mitsumori-card-pc">
  4293.                     <div class="card-header">
  4294.                       <h3 class="card-title">現在のお見積り額</h3>
  4295.                       <div class="card-tools">
  4296.                         <span class="float-left" id="mitsumori_message">{{ mitsumori_json.mitsumori_goukei|default('---円') }}</span>
  4297.                         <button type="button" class="btn btn-tool btn-mitsumori-toggle" data-card-widget="collapse" title="詳細を表示">
  4298.                           <span class="toggle-icon">▼ 詳細</span>
  4299.                         </button>
  4300.                       </div>
  4301.                     </div>
  4302.                     <div class="card-body p-0">
  4303.                       <ul class="nav nav-pills flex-column">
  4304.                         <li class="nav-item active">
  4305.                           <a class="nav-link">
  4306.                             <i class="far fa-file-alt"></i> 合計(工事費・税込)
  4307.                             <span class="float-right" id="mitsumori_goukei">{{ mitsumori_json.mitsumori_goukei|default('---円') }}</span>
  4308.                           </a>
  4309.                         </li>
  4310.                         <li class="nav-item active">
  4311.                           <a class="nav-link">
  4312.                             <i class="far fa-file-alt"></i> 商品価格
  4313.                             <span class="float-right" id="mitsumori_price">{{ mitsumori_json.mitsumori_price|default('---円') }}</span>
  4314.                           </a>
  4315.                         </li>
  4316.                         {# sale_type=2 (補助金窓) ではカタログ価格軸を使わないので非表示. JS 側で #maker_price には "---円" を入れる. #}
  4317.                         {% if ProductClass.SaleType.id != 2 %}
  4318.                         <li class="nav-item">
  4319.                           <a class="nav-link">
  4320.                             &nbsp;&nbsp;<i class="far fa-file-alt"></i> カタログ価格
  4321.                             <span class="float-right" id="maker_price">{{ mitsumori_json.maker_price|default('---円') }}</span>
  4322.                           </a>
  4323.                         </li>
  4324.                         {% else %}
  4325.                         {# sale_type=2 でも JS が #maker_price を参照するので hidden で残す #}
  4326.                         <span id="maker_price" style="display:none;">---円</span>
  4327.                         {% endif %}
  4328.                         <li class="nav-item">
  4329.                           <a class="nav-link">
  4330.                             &nbsp;&nbsp;<i class="far fa-file-alt"></i>
  4331.                             {% if ProductClass.SaleType.id == 2 %}補助金(割引額){% else %}お値引き{% endif %}
  4332.                             <span class="float-right" id="mitsumori_off">{{ mitsumori_json.mitsumori_off|default('---円') }}</span>
  4333.                           </a>
  4334.                         </li>
  4335.                         {# 補助金窓のみ: 当店が申請代行する旨を補助金行の直下に小さく表示 (消費者の手続き勘違い防止) #}
  4336.                         {% if ProductClass.SaleType.id == 2 %}
  4337.                         <li class="nav-item">
  4338.                           <div style="padding:4px 12px 8px;color:#6c757d;font-size:11px;line-height:1.4;">
  4339.                             ※当店が補助金事業者として申請代行し、契約代金に充当します
  4340.                           </div>
  4341.                         </li>
  4342.                         {% endif %}
  4343.                         <li class="nav-item">
  4344.                           <a class="nav-link">
  4345.                             <i class="far fa-file-alt"></i> 基本工事費
  4346.                             <span class="float-right" id="mitsumori_ct">{{ mitsumori_json.mitsumori_ct|default('---円') }}</span>
  4347.                           </a>
  4348.                         </li>
  4349.                         <li class="nav-item">
  4350.                           <a class="nav-link">
  4351.                             <i class="far fa-file-alt"></i> 施工オプション
  4352.                             <span class="float-right"id="mitsumori_option">{{ mitsumori_json.mitsumori_option|default('---円') }}</span>
  4353.                           </a>
  4354.                         </li>
  4355.                       </ul>
  4356.                     </div>
  4357.                     <div class="card-footer">
  4358.                         <button type="button" id="mitsumori_btn" class="btn btn-info" data-toggle="modal" data-target="#modal-mitsumori">
  4359.                           見積書表示
  4360.                         </button>
  4361.                         <button type="submit" id="cart_btn2" class="btn btn-info add-cart">カートに入れる</button>
  4362.                     </div>
  4363.                 </div>
  4364.             </div>
  4365.         </div>
  4366.     </div>
  4367.   <div class="modal" id="modal-mitsumori">
  4368.     <div class="modal-dialog modal-mitsumori" style="max-width:1000px">
  4369.       <div class="modal-content">
  4370.         <div class="modal-header">
  4371.           <h4 class="modal-title">お見積書</h4>
  4372.           <button type="button" class="close" data-dismiss="modal" aria-label="Close">
  4373.             <span aria-hidden="true">&times;</span>
  4374.           </button>
  4375.         </div>
  4376.         <div class="modal-body">
  4377.             <div class="invoice p-3 mb-5" style="max-width:1000px; margin:auto;">
  4378.               <!-- title row -->
  4379.               <div class="row">
  4380.                 <div class="col-12">
  4381.                   <h2>概算お見積書 
  4382.                     <small class="float-right" style="font-size:14px;">発行日: <span id="mitsumori_issue_date">---</span></small>
  4383.                   </h2>
  4384.                 </div>
  4385.                 <!-- /.col -->
  4386.               </div>
  4387.               <!-- info row -->
  4388.               <div class="row invoice-info">
  4389.                 <div class="col-sm-8 invoice-col">
  4390.                   <h3>お客様</h3>
  4391.                   <span>下記の通り、お見積もり申し上げます。</span>
  4392.                   <br />
  4393.                   <br / >
  4394.                   <br />
  4395.                   <h2>お見積金額: <span id="mitsumori_kei">{{ mitsumori_json.mitsumori_goukei|default('---円') }}</span></h2>
  4396.                   <span>
  4397.                     <br/>
  4398.                     <br/>
  4399.                     <br/>
  4400.                     <br/>
  4401.                   </span>
  4402.                 </div>
  4403.                 <div class="col-sm-4 invoice-col">
  4404.                   <p>
  4405.                     <img alt="" src="/html/user_data/js/images/logo.png" style="width: 260px; max-width: 100%; max-height: 10mm;">
  4406.                   </p>
  4407.                   <p>有限会社プラス</p>
  4408.                   <p>〒400-0334</p>
  4409.                   <p>山梨県南アルプス市藤田1450番地2</p>
  4410.                   <p>TEL: 055-284-6480</p>
  4411.                   <img alt="" src="/html/user_data/js/images/seal.svg" style="z-index: 2; position: absolute; width: 21mm; left: 58mm; top: 36mm;">
  4412.                 </div>
  4413.                 <!-- /.col -->
  4414.                 <!-- /.col -->
  4415.               </div>
  4416.               <!-- /.row -->
  4417.               <!-- Table row -->
  4418.               <div class="row">
  4419.                 <div class="col-12 table-responsive">
  4420.                   <table class="table table-striped">
  4421.                     <thead>
  4422.                       <tr>
  4423.                         <th>項目</th>
  4424.                         <th>数量</th>
  4425.                         <th>単位</th>
  4426.                         <th>単価</th>
  4427.                         <th>小計</th>
  4428.                       </tr>
  4429.                     </thead>
  4430.                     <tbody>
  4431.                       <tr>
  4432.                         <td id="mitsumori_item_name">{{ Product.name }}</td>
  4433.                         <td>1</td>
  4434.                         <td>式</td>
  4435.                         <td id="mitsumori_price_01">---</td>
  4436.                         <td id="mitsumori_price_02">---</td>
  4437.                       </tr>
  4438.                       <tr>
  4439.                         <td>基本工事費</td>
  4440.                         <td>1</td>
  4441.                         <td>式</td>
  4442.                         <td id="mitsumori_ct_01">---</td>
  4443.                         <td id="mitsumori_ct_02">---</td>
  4444.                       </tr>
  4445.                       <tr>
  4446.                         <td>残土・ガラ処理</td>
  4447.                         <td>1</td>
  4448.                         <td>式</td>
  4449.                         <td>0円</td>
  4450.                         <td></td>
  4451.                       </tr>
  4452.                       <tr>
  4453.                         <td> </td>
  4454.                         <td></td>
  4455.                         <td></td>
  4456.                         <td></td>
  4457.                         <td></td>
  4458.                       </tr>
  4459.                     </tbody>
  4460.                   </table>
  4461.                 </div>
  4462.                 <!-- /.col -->
  4463.               </div>
  4464.               <!-- /.row -->
  4465.               <div class="row">
  4466.                 <!-- accepted payments column -->
  4467.                 <div class="col-6">
  4468.                   <p class="lead">お支払い方法</p>
  4469.                   <p class="text-muted well well-sm shadow-none" style="margin-top: 10px;">銀行振込、クレジットカード決済、PAYPAY決済
  4470.                     <br>銀行振込:山梨中央銀行 白根支店 普通口座 391402
  4471.                     <br>※商品代金と工事代金の総額が金100万円(税込)を超える場合、着手金として代金の半額をご契約後お支払いいただきます。 
  4472.                   </p>
  4473.                 </div>
  4474.                 <!-- /.col -->
  4475.                 <div class="col-6">
  4476.                   <div class="table-responsive">
  4477.                     <table class="table">
  4478.                       <tbody>
  4479.                         <tr>
  4480.                           <th style="width:50%">小計:</th>
  4481.                           <td id="mitsumori_shoukei">---</td>
  4482.                         </tr>
  4483.                         <tr>
  4484.                           <th>消費税 (10%)</th>
  4485.                           <td id="mitsumori_tax">---</td>
  4486.                         </tr>
  4487.                         <tr>
  4488.                           <th>合計:</th>
  4489.                           <td id="mitsumori_goukei_02">---</td>
  4490.                         </tr>
  4491.                       </tbody>
  4492.                     </table>
  4493.                   </div>
  4494.                 </div>
  4495.                 <!-- /.col -->
  4496.               </div>
  4497.               <!-- /.row -->
  4498.             </div>
  4499.         </div>
  4500.         <div class="modal-footer justify-content-between">
  4501.                   {# PDF出力: mitsumori_json を POST して PDF ダウンロード #}
  4502.                   <form method="post" action="{{ url('mitsumori_pdf') }}" target="_blank" id="pdf_download_form">
  4503.                     <input type="hidden" name="_token" value="{{ csrf_token('mitsumori_pdf') }}">
  4504.                     <input type="hidden" name="mitsumori_json" id="pdf_mitsumori_json">
  4505.                     <button type="submit" class="btn btn-primary float-right" style="margin-right: 5px;" onclick="syncPdfJson();">
  4506.                       <i class="fas fa-download"></i>PDF出力
  4507.                     </button>
  4508.                   </form>
  4509.                   <button type="submit" id="cart_btn3" class="btn btn-info add-cart">カートに入れる</button>
  4510.         </div>
  4511.       </div>
  4512.       <!-- /.modal-content -->
  4513.     </div>
  4514.     <!-- /.modal-dialog -->
  4515.   </div>
  4516.   <!-- /.modal -->
  4517.     <!-- Image zoom modal: custom lightweight lightbox (no CDN dependency) -->
  4518.     <div id="img-modal" role="dialog" aria-modal="true" aria-label="画像拡大">
  4519.         <button id="img-modal__close" aria-label="閉じる">&times;</button>
  4520.         <button id="img-modal__prev" aria-label="前の画像">&#10094;</button>
  4521.         <img id="img-modal__img" src="" alt="">
  4522.         <button id="img-modal__next" aria-label="次の画像">&#10095;</button>
  4523.     </div>
  4524.     <style>
  4525.     #img-modal {
  4526.         display: none;
  4527.         position: fixed;
  4528.         inset: 0;
  4529.         z-index: 9999;
  4530.         background: rgba(0,0,0,0.88);
  4531.         align-items: center;
  4532.         justify-content: center;
  4533.         cursor: zoom-out;
  4534.     }
  4535.     #img-modal.is-open { display: flex; }
  4536.     #img-modal__img {
  4537.         max-width: 92vw;
  4538.         max-height: 88vh;
  4539.         object-fit: contain;
  4540.         border-radius: 4px;
  4541.         cursor: default;
  4542.         user-select: none;
  4543.         touch-action: pinch-zoom;
  4544.         display: block;
  4545.     }
  4546.     #img-modal__close {
  4547.         position: absolute;
  4548.         top: 14px;
  4549.         right: 18px;
  4550.         font-size: 38px;
  4551.         color: #fff;
  4552.         cursor: pointer;
  4553.         background: none;
  4554.         border: none;
  4555.         padding: 0;
  4556.         line-height: 1;
  4557.         z-index: 1;
  4558.         opacity: 0.85;
  4559.     }
  4560.     #img-modal__close:hover { opacity: 1; }
  4561.     #img-modal__prev,
  4562.     #img-modal__next {
  4563.         position: absolute;
  4564.         top: 50%;
  4565.         transform: translateY(-50%);
  4566.         font-size: 36px;
  4567.         color: #fff;
  4568.         cursor: pointer;
  4569.         background: rgba(0,0,0,0.32);
  4570.         border: none;
  4571.         padding: 10px 16px;
  4572.         line-height: 1;
  4573.         border-radius: 6px;
  4574.         z-index: 1;
  4575.         opacity: 0.8;
  4576.         user-select: none;
  4577.     }
  4578.     #img-modal__prev:hover, #img-modal__next:hover { opacity: 1; }
  4579.     #img-modal__prev { left: 12px; }
  4580.     #img-modal__next { right: 12px; }
  4581.     @media (max-width: 767px) {
  4582.         #img-modal__prev, #img-modal__next { font-size: 28px; padding: 8px 12px; }
  4583.     }
  4584.     </style>
  4585.     <script>
  4586.     (function(){
  4587.         var images = [];
  4588.         var current = 0;
  4589.         // Collect original (non-cloned) slide images at click time.
  4590.         // After Slick init, slides are wrapped in .slick-slide; clones have .slick-cloned.
  4591.         function collectImages() {
  4592.             images = [];
  4593.             var $slides = $('.item_visual .slick-slide:not(.slick-cloned)');
  4594.             if (!$slides.length) {
  4595.                 // Slick not yet initialized (fallback)
  4596.                 $slides = $('.item_visual .slide-item');
  4597.             }
  4598.             $slides.each(function() {
  4599.                 var $a = $(this).find('a.js-zoom');
  4600.                 if ($a.length) {
  4601.                     images.push({ src: $a.attr('href'), alt: $a.find('img').attr('alt') || '' });
  4602.                 }
  4603.             });
  4604.         }
  4605.         function showImage(idx) {
  4606.             current = ((idx % images.length) + images.length) % images.length;
  4607.             $('#img-modal__img').attr({ src: images[current].src, alt: images[current].alt });
  4608.         }
  4609.         function openModal(href) {
  4610.             collectImages();
  4611.             if (!images.length) return;
  4612.             var idx = 0;
  4613.             for (var i = 0; i < images.length; i++) {
  4614.                 if (images[i].src === href) { idx = i; break; }
  4615.             }
  4616.             showImage(idx);
  4617.             $('#img-modal').addClass('is-open');
  4618.             $('body').css('overflow', 'hidden');
  4619.         }
  4620.         function closeModal() {
  4621.             $('#img-modal').removeClass('is-open');
  4622.             $('body').css('overflow', '');
  4623.         }
  4624.         // Click handler: works for both original and Slick-cloned anchors
  4625.         $(document).on('click', '.item_visual a.js-zoom', function(e) {
  4626.             e.preventDefault();
  4627.             openModal($(this).attr('href'));
  4628.         });
  4629.         // Close on backdrop click
  4630.         $('#img-modal').on('click', function(e) {
  4631.             if (e.target === this) closeModal();
  4632.         });
  4633.         $('#img-modal__close').on('click', closeModal);
  4634.         $('#img-modal__prev').on('click', function(e) { e.stopPropagation(); showImage(current - 1); });
  4635.         $('#img-modal__next').on('click', function(e) { e.stopPropagation(); showImage(current + 1); });
  4636.         // Keyboard navigation
  4637.         $(document).on('keydown', function(e) {
  4638.             if (!$('#img-modal').hasClass('is-open')) return;
  4639.             if (e.key === 'Escape') closeModal();
  4640.             if (e.key === 'ArrowLeft') showImage(current - 1);
  4641.             if (e.key === 'ArrowRight') showImage(current + 1);
  4642.         });
  4643.         // Touch swipe (horizontal only)
  4644.         var _tx = 0;
  4645.         var $modal = document.getElementById('img-modal');
  4646.         $modal.addEventListener('touchstart', function(e) {
  4647.             _tx = e.changedTouches[0].clientX;
  4648.         }, { passive: true });
  4649.         $modal.addEventListener('touchend', function(e) {
  4650.             var dx = e.changedTouches[0].clientX - _tx;
  4651.             if (Math.abs(dx) > 48) {
  4652.                 dx < 0 ? showImage(current + 1) : showImage(current - 1);
  4653.             }
  4654.         }, { passive: true });
  4655.     })();
  4656.     </script>
  4657. {% endblock %}