app/template/BlackCherry/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 title %}
  11.   {{ Product.name }}|{{ Product.ProductCategories|first.Category.name ?? '' }}|BlackCherry
  12. {% endblock %}
  13. {% set keywords_sentence = '無修正DVD通販のBlackCherry。人気女優出演作を匿名配送・即日発送!' %}
  14. {% set desc = keywords_sentence ~ ' ' ~
  15.   Product.name ~
  16.   (Product.Maker.name ? '(' ~ Product.Maker.name ~ ')' : '') 
  17. %}
  18. {% block description %}
  19.     <meta name="description" content="{{ desc|striptags|slice(0, 120) }}" >
  20. {% endblock %}
  21. {#
  22. {% set page_description_raw = desc %}
  23. {% block description %}
  24.     <meta name="description" content="{{ Product.description_list|striptags|slice(0, 120) }}{% if Product.search_word %} {{ Product.search_word }}{% endif %}">
  25.     {% if Product.search_word %}
  26.     <meta name="keywords" content="{{ Product.search_word }}">
  27.     {% endif %}
  28. {% endblock %}
  29. #}
  30. {% block meta %}
  31.     <link rel="canonical" href="{{ url('product_detail', { id : Product.id }) }}">
  32. {% endblock %}
  33. {% block stylesheet %}
  34.     <style>
  35.         .slick-slider {
  36.             margin-bottom: 30px;
  37.         }
  38.         .slick-dots {
  39.             position: absolute;
  40.             bottom: -45px;
  41.             display: block;
  42.             width: 100%;
  43.             padding: 0;
  44.             list-style: none;
  45.             text-align: center;
  46.         }
  47.         .slick-dots li {
  48.             position: relative;
  49.             display: inline-block;
  50.             width: 20px;
  51.             height: 20px;
  52.             margin: 0 5px;
  53.             padding: 0;
  54.             cursor: pointer;
  55.         }
  56.         .slick-dots li button {
  57.             font-size: 0;
  58.             line-height: 0;
  59.             display: block;
  60.             width: 20px;
  61.             height: 20px;
  62.             padding: 5px;
  63.             cursor: pointer;
  64.             color: transparent;
  65.             border: 0;
  66.             outline: none;
  67.             background: transparent;
  68.         }
  69.         .slick-dots li button:hover,
  70.         .slick-dots li button:focus {
  71.             outline: none;
  72.         }
  73.         .slick-dots li button:hover:before,
  74.         .slick-dots li button:focus:before {
  75.             opacity: 1;
  76.         }
  77.         .slick-dots li button:before {
  78.             content: " ";
  79.             line-height: 20px;
  80.             position: absolute;
  81.             top: 0;
  82.             left: 0;
  83.             width: 12px;
  84.             height: 12px;
  85.             text-align: center;
  86.             opacity: .25;
  87.             background-color: black;
  88.             border-radius: 50%;
  89.         }
  90.         .slick-dots li.slick-active button:before {
  91.             opacity: .75;
  92.             background-color: black;
  93.         }
  94.         .slick-dots li button.thumbnail img {
  95.             width: 0;
  96.             height: 0;
  97.         }
  98.     </style>
  99. {% endblock %}
  100. {% block javascript %}
  101.     <script>
  102.         eccube.classCategories = {{ class_categories_as_json(Product)|raw }};
  103.         // 規格2に選択肢を割り当てる。
  104.         function fnSetClassCategories(form, classcat_id2_selected) {
  105.             var $form = $(form);
  106.             var product_id = $form.find('input[name=product_id]').val();
  107.             var $sele1 = $form.find('select[name=classcategory_id1]');
  108.             var $sele2 = $form.find('select[name=classcategory_id2]');
  109.             eccube.setClassCategories($form, product_id, $sele1, $sele2, classcat_id2_selected);
  110.         }
  111.         {% if form.classcategory_id2 is defined %}
  112.         fnSetClassCategories(
  113.             $('#form1'), {{ form.classcategory_id2.vars.value|json_encode|raw }}
  114.         );
  115.         {% elseif form.classcategory_id1 is defined %}
  116.         eccube.checkStock($('#form1'), {{ Product.id }}, {{ form.classcategory_id1.vars.value|json_encode|raw }}, null);
  117.         {% endif %}
  118.     </script>
  119.     <script>
  120.         $(function() {
  121.             // bfcache無効化
  122.             $(window).bind('pageshow', function(event) {
  123.                 if (event.originalEvent.persisted) {
  124.                     location.reload(true);
  125.                 }
  126.             });
  127.             // Core Web Vital の Cumulative Layout Shift(CLS)対策のため
  128.             // img タグに width, height が付与されている.
  129.             // 630px 未満の画面サイズでは縦横比が壊れるための対策
  130.             // see https://github.com/EC-CUBE/ec-cube/pull/5023
  131.             $('.ec-grid2__cell').hide();
  132.             var removeSize = function () {
  133.                 $('.slide-item').height('');
  134.                 $('.slide-item img')
  135.                     .removeAttr('width')
  136.                     .removeAttr('height')
  137.                     .removeAttr('style');
  138.             };
  139.             var slickInitial = function(slick) {
  140.                 $('.ec-grid2__cell').fadeIn(1500);
  141.                 var baseHeight = $(slick.target).height();
  142.                 var baseWidth = $(slick.target).width();
  143.                 var rate = baseWidth / baseHeight;
  144.                 $('.slide-item').height(baseHeight * rate); // 余白を削除する
  145.                 // transform を使用することでCLSの影響を受けないようにする
  146.                 $('.slide-item img')
  147.                     .css(
  148.                         {
  149.                             'transform-origin': 'top left',
  150.                             'transform': 'scaleY(' + rate + ')',
  151.                             'transition': 'transform .1s'
  152.                         }
  153.                     );
  154.                 // 正しいサイズに近くなったら属性を解除する
  155.                 setTimeout(removeSize, 500);
  156.             };
  157.             $('.item_visual').on('init', slickInitial);
  158.             // リサイズ時は CLS の影響を受けないため属性を解除する
  159.             $(window).resize(removeSize);
  160.             $('.item_visual').slick({
  161.                 dots: false,
  162.                 arrows: false,
  163.                 responsive: [{
  164.                     breakpoint: 768,
  165.                     settings: {
  166.                         dots: true
  167.                     }
  168.                 }]
  169.             });
  170.             $('.slideThumb').on('click', function() {
  171.                 var index = $(this).attr('data-index');
  172.                 $('.item_visual').slick('slickGoTo', index, false);
  173.             })
  174.         });
  175.     </script>
  176.     <script>
  177.         $(function() {
  178.             // お気に入り追加
  179.             $('.add_favorite').on('click', function(e) {
  180.                 var data = $(this).data();
  181.                 if (confirm(data.product_name + '\r\n\r\n' + 'こちらの商品をお気に入りに登録しますか?') ) {
  182.                 } else {
  183.                     return false;
  184.                 }
  185.             });
  186.             // カート追加
  187.             $('.add-cart').on('click', function(event) {
  188.                 
  189.                 {% if form.classcategory_id1 is defined %}
  190.                 // 規格1フォームの必須チェック
  191.                 if ($('#classcategory_id1').val() == '__unselected' || $('#classcategory_id1').val() == '') {
  192.                     $('#classcategory_id1')[0].setCustomValidity('{{ '項目が選択されていません'|trans }}');
  193.                     return true;
  194.                 } else {
  195.                     $('#classcategory_id1')[0].setCustomValidity('');
  196.                 }
  197.                 {% endif %}
  198.                 {% if form.classcategory_id2 is defined %}
  199.                 // 規格2フォームの必須チェック
  200.                 if ($('#classcategory_id2').val() == '__unselected' || $('#classcategory_id2').val() == '') {
  201.                     $('#classcategory_id2')[0].setCustomValidity('{{ '項目が選択されていません'|trans }}');
  202.                     return true;
  203.                 } else {
  204.                     $('#classcategory_id2')[0].setCustomValidity('');
  205.                 }
  206.                 {% endif %}
  207.                 // 個数フォームのチェック
  208.                 if ($('#quantity').val() < 1) {
  209.                     $('#quantity')[0].setCustomValidity('{{ '1以上で入力してください。'|trans }}');
  210.                     return true;
  211.                 } else {
  212.                     $('#quantity')[0].setCustomValidity('');
  213.                 }
  214.                 event.preventDefault();
  215.                 $form = $('#form1');
  216.                 $.ajax({
  217.                     url: $form.attr('action'),
  218.                     type: $form.attr('method'),
  219.                     data: $form.serialize(),
  220.                     dataType: 'json',
  221.                     beforeSend: function(xhr, settings) {
  222.                         // Buttonを無効にする
  223.                         $('.add-cart').prop('disabled', true);
  224.                     }
  225.                 }).done(function(data) {
  226.                     // レスポンス内のメッセージをalertで表示
  227.                     $.each(data.messages, function() {
  228.                         $('#ec-modal-header').text(this);
  229.                     });
  230.                     $('.ec-modal').show()
  231.                     // カートブロックを更新する
  232.                     $.ajax({
  233.                         url: "{{ url('block_cart') }}",
  234.                         type: 'GET',
  235.                         dataType: 'html'
  236.                     }).done(function(html) {
  237.                         const $html = $('<div>').html(html);
  238.                         // ① ヘッダー用のカート部分を更新
  239.                         const $headerCart = $html.find('.ec-headerRole__cart');
  240.                         if ($headerCart.length) {
  241.                             $('.ec-headerRole__cart').replaceWith($headerCart);
  242.                         }
  243.                         // ② サイドカート(ec-cartNaviWrap)も更新
  244.                         const $sideCart = $html.find('.ec-cartNaviWrap');
  245.                         if ($sideCart.length) {
  246.                             $('.ec-cartNaviWrap').replaceWith($sideCart);
  247.                         }
  248.                     });
  249.                     
  250.                 }).fail(function(data) {
  251.                     alert('{{ 'カートへの追加に失敗しました。'|trans }}');
  252.                 }).always(function(data) {
  253.                     // Buttonを有効にする
  254.                     $('.add-cart').prop('disabled', false);
  255.                 });
  256.             });
  257.         });
  258.         $('.ec-modal-wrap').on('click', function(e) {
  259.             // モーダル内の処理は外側にバブリングさせない
  260.             e.stopPropagation();
  261.         });
  262.         $('.ec-modal-overlay, .ec-modal, .ec-modal-close, .ec-inlineBtn--cancel').on('click', function() {
  263.             $('.ec-modal').hide()
  264.         });
  265.     </script>
  266.     <script type="application/ld+json">
  267.     {
  268.         "@context": "https://schema.org/",
  269.         "@type": "Product",
  270.         "name": "{{ Product.name }}",
  271.         "image": [
  272.             {% for img in Product.ProductImage %}
  273.                 "{{ app.request.schemeAndHttpHost }}{{ asset(img, 'save_image') }}"{% if not loop.last %},{% endif %}
  274.             {% else %}
  275.                 "{{ app.request.schemeAndHttpHost }}{{ asset(''|no_image_product, 'save_image') }}"
  276.             {% endfor %}
  277.         ],
  278.         "description": "{{ Product.description_list | default(Product.description_detail) | replace({'\n': '', '\r': ''}) | slice(0,300) }}",
  279.         {% if Product.code_min %}
  280.         "sku": "{{ Product.code_min }}",
  281.         {% endif %}
  282.         "offers": {
  283.             "@type": "Offer",
  284.             "url": "{{ url('product_detail', {'id': Product.id}) }}",
  285.             "priceCurrency": "{{ eccube_config.currency }}",
  286.             "price": {{ Product.getPrice02IncTaxMin ? Product.getPrice02IncTaxMin : 0}},
  287.             "availability": "{{ Product.stock_find ? "InStock" : "OutOfStock" }}"
  288.         }
  289.     }
  290.     </script>
  291. <script>
  292. $(function () {
  293.   var $modal = $('.bc-zoom-modal');
  294.   var $stage = $modal.find('.bc-zoom-stage');
  295.   var $img   = $modal.find('.bc-zoom-img');
  296.   var $close = $modal.find('.bc-zoom-close');
  297.   
  298.   // 要素の存在確認
  299.   if (!$modal.length || !$stage.length || !$img.length) {
  300.     console.error('Zoom modal elements not found');
  301.     return;
  302.   }
  303.   // サムネ(スライダー内を含む)クリックで開く
  304.   $(document).on('click', '.item_visual img.bc-zoom-thumb', function () {
  305.     var src = $(this).attr('data-zoom-src') || $(this).attr('src');
  306.     $img.attr('src', src);
  307.     
  308.     // 画像読み込み後に初期拡大率を設定
  309.     $img.on('load', function() {
  310.       setInitialScale();
  311.       $img.off('load'); // 一度だけ実行
  312.     });
  313.     
  314.     // 既に読み込み済みの場合
  315.     if ($img[0].complete && $img[0].naturalWidth > 0) {
  316.       setInitialScale();
  317.     }
  318.     
  319.     resetTransform();
  320.     $('body').addClass('bc-no-scroll');
  321.     $modal.addClass('is-open').attr('aria-hidden', 'false');
  322.   });
  323.   // 閉じる(×/ESC/背景タップ)
  324.   function closeModal() {
  325.     $modal.removeClass('is-open').attr('aria-hidden', 'true');
  326.     $('body').removeClass('bc-no-scroll');
  327.   }
  328.   $close.on('click', closeModal);
  329.   $modal.on('click', function (e) { if (e.target === this) closeModal(); });
  330.   $(document).on('keydown', function (e) { if (e.key === 'Escape') closeModal(); });
  331.   // 変換状態
  332.   var scale = 1, minScale = 0.1, maxScale = 5; // minScaleを0.1に設定(SP版で0.5倍以下も可能に)
  333.   var tx = 0, ty = 0; // タップ拡大時の位置調整用
  334.   var isPC = window.innerWidth >= 768;
  335.   var initialScale = 1;
  336.   var pinch = { active:false, d0:0, cx:0, cy:0 };
  337.   
  338.   // 2点間の距離を計算する関数
  339.   function dist2(touches) {
  340.     if (!touches || touches.length < 2) return 0;
  341.     try {
  342.       var dx = touches[0].clientX - touches[1].clientX;
  343.       var dy = touches[0].clientY - touches[1].clientY;
  344.       return Math.hypot(dx, dy);
  345.     } catch (e) {
  346.       return 0;
  347.     }
  348.   }
  349.   function applyTransform() {
  350.     $img.css('transform', 'translate(calc(-50% + ' + tx + 'px), calc(-50% + ' + ty + 'px)) scale(' + scale + ')');
  351.   }
  352.   
  353.   function resetTransform() {
  354.     scale = initialScale;
  355.     tx = 0;
  356.     ty = 0;
  357.     applyTransform();
  358.   }
  359.   
  360.   // 初期拡大率を設定(PC: 画面内に収まるように、SP: 0.5倍)
  361.   function setInitialScale() {
  362.     if (!$img[0] || $img[0].naturalWidth === 0) {
  363.       initialScale = 1;
  364.       scale = 1;
  365.       return;
  366.     }
  367.     
  368.     var stageRect = $stage[0].getBoundingClientRect();
  369.     var stageWidth = stageRect.width;
  370.     var stageHeight = stageRect.height;
  371.     var imgNaturalWidth = $img[0].naturalWidth;
  372.     var imgNaturalHeight = $img[0].naturalHeight;
  373.     
  374.     // デバイス判定を再計算
  375.     isPC = window.innerWidth >= 768;
  376.     
  377.     if (isPC) {
  378.       // PC版: 画面内に完全に収まるようにする(幅・高さの両方を考慮)
  379.       var scaleByWidth = (stageWidth * 0.9) / imgNaturalWidth; // 90%で余裕を持たせる
  380.       var scaleByHeight = (stageHeight * 0.9) / imgNaturalHeight;
  381.       initialScale = Math.min(scaleByWidth, scaleByHeight);
  382.       // 最小・最大拡大率の制限
  383.       initialScale = Math.min(maxScale, Math.max(0.1, initialScale)); // PC版もminScaleを0.1に
  384.     } else {
  385.       // SP版: 0.5倍
  386.       initialScale = 0.5;
  387.     }
  388.     
  389.     scale = initialScale;
  390.     tx = 0;
  391.     ty = 0;
  392.     applyTransform();
  393.   }
  394.   
  395.   // SP版: ドラッグ移動用の変数
  396.   var dragging = false;
  397.   var dragStartX = 0;
  398.   var dragStartY = 0;
  399.   var dragStartTx = 0;
  400.   var dragStartTy = 0;
  401.   var rafId = null;
  402.   // タップで拡大(SP/タッチデバイス用)
  403.   var lastTapTime = 0;
  404.   var lastTapX = 0;
  405.   var lastTapY = 0;
  406.   var tapThreshold = 300; // ダブルタップ判定の時間(ms)
  407.   var tapDistanceThreshold = 50; // ダブルタップ判定の距離(px)
  408.   var isTap = true; // タップ判定用フラグ
  409.   var dragDistance = 0; // ドラッグ距離
  410.   
  411.   // SP版: タッチ開始(ドラッグ/タップ/ピンチズーム)
  412.   // ネイティブイベントを使用(jQueryのイベントオブジェクトの問題を回避)
  413.   function handleTouchStart(e) {
  414.     try {
  415.       // 要素の存在確認
  416.       if (!$modal.length || !$stage.length || !$stage[0]) return;
  417.       
  418.       // モーダルが開いていない場合は無視
  419.       if (!$modal.hasClass('is-open')) return;
  420.       
  421.       // デバイス判定を再計算
  422.       isPC = window.innerWidth >= 768;
  423.       
  424.       // タッチイベントを取得
  425.       var touches = e.touches || [];
  426.       
  427.       if (!touches || touches.length === 0) return;
  428.     
  429.     // PC版の場合は無効(ピンチズームはSP版のみ)
  430.     if (isPC && touches.length === 2) return;
  431.     
  432.     // 2点タッチ時はピンチズーム
  433.     if (touches.length === 2) {
  434.       dragging = false; // ドラッグを無効化
  435.       isTap = false;
  436.       
  437.       var d = dist2(touches);
  438.       if (d === 0 || d === undefined || !isFinite(d)) return; // 無効な値はスキップ
  439.       
  440.       // ピンチ開始(常に初期化・リセット)
  441.       pinch.active = true;
  442.       pinch.d0 = d; // 基準距離を設定
  443.       var rect = $stage[0].getBoundingClientRect();
  444.       pinch.cx = (touches[0].clientX + touches[1].clientX)/2 - rect.left;
  445.       pinch.cy = (touches[0].clientY + touches[1].clientY)/2 - rect.top;
  446.       
  447.       e.preventDefault();
  448.       e.stopPropagation();
  449.       return;
  450.     }
  451.     
  452.     // PC版の場合は無効
  453.     if (isPC) return;
  454.     
  455.     // シングルタッチのみ処理
  456.     if (touches.length !== 1) return;
  457.     
  458.     var touch = touches[0];
  459.     var rect = $stage[0].getBoundingClientRect();
  460.     
  461.     // 座標を統一(相対座標)
  462.     var touchX = touch.clientX - rect.left;
  463.     var touchY = touch.clientY - rect.top;
  464.     
  465.     // 拡大時(initialScaleより大きい場合)は常にドラッグ可能
  466.     if (scale > initialScale) {
  467.       dragging = true;
  468.       isTap = true; // ドラッグ開始時は一旦trueに(距離判定でfalseになる)
  469.       dragDistance = 0;
  470.       dragStartX = touch.clientX;
  471.       dragStartY = touch.clientY;
  472.       dragStartTx = tx;
  473.       dragStartTy = ty;
  474.       // タップ開始位置を記録(相対座標、ダブルタップ判定用)
  475.       lastTapX = touchX;
  476.       lastTapY = touchY;
  477.       e.preventDefault();
  478.       e.stopPropagation();
  479.     } else {
  480.       // 縮小時はタップのみ
  481.       isTap = true;
  482.       dragging = false;
  483.       // タップ開始位置を記録(相対座標)
  484.       lastTapX = touchX;
  485.       lastTapY = touchY;
  486.     }
  487.     } catch (err) {
  488.       console.error('Error in handleTouchStart:', err);
  489.     }
  490.   }
  491.   
  492.   // SP版: タッチ終了(ドラッグ/タップ/ピンチズーム)
  493.   function handleTouchEnd(e) {
  494.     try {
  495.       // 要素の存在確認
  496.       if (!$modal.length || !$stage.length || !$stage[0]) return;
  497.       
  498.       // モーダルが開いていない場合は無視
  499.       if (!$modal.hasClass('is-open')) return;
  500.       
  501.       // デバイス判定を再計算
  502.       isPC = window.innerWidth >= 768;
  503.       
  504.       // タッチイベントを取得
  505.       var touches = e.touches || [];
  506.       var changedTouches = e.changedTouches || [];
  507.     
  508.     // ピンチズーム終了(2点タッチが1点以下になった場合)
  509.     if (!touches || touches.length < 2) {
  510.       pinch.active = false;
  511.       pinch.d0 = 0; // リセット
  512.       dragging = false; // ドラッグもリセット
  513.     }
  514.     
  515.     // requestAnimationFrameのクリーンアップ
  516.     if (rafId) {
  517.       cancelAnimationFrame(rafId);
  518.       rafId = null;
  519.     }
  520.     
  521.     // PC版の場合は無効
  522.     if (isPC) return;
  523.     
  524.     // シングルタッチのみ処理
  525.     if (!changedTouches || changedTouches.length !== 1) {
  526.       dragging = false;
  527.       return;
  528.     }
  529.     
  530.     var touch = changedTouches[0];
  531.     var now = Date.now();
  532.     var rect = $stage[0].getBoundingClientRect();
  533.     var tapX = touch.clientX - rect.left;
  534.     var tapY = touch.clientY - rect.top;
  535.     
  536.     // ドラッグ終了処理
  537.     var wasDragging = dragging;
  538.     if (dragging) {
  539.       dragging = false;
  540.       
  541.       // ドラッグ距離が小さかった場合(10px以下)はタップとして扱う
  542.       if (dragDistance <= 10) {
  543.         // 拡大時はダブルタップでリセット
  544.         if (scale > initialScale) {
  545.           var isDoubleTap = (now - lastTapTime < tapThreshold) &&
  546.                             (Math.abs(tapX - lastTapX) < tapDistanceThreshold) &&
  547.                             (Math.abs(tapY - lastTapY) < tapDistanceThreshold);
  548.           
  549.           if (isDoubleTap) {
  550.             // ダブルタップでリセット
  551.             e.preventDefault();
  552.             resetTransform();
  553.             lastTapTime = 0;
  554.             dragDistance = 0;
  555.             return;
  556.           }
  557.         }
  558.         
  559.         // タップとして処理
  560.         isTap = true;
  561.       } else {
  562.         // ドラッグした場合はタップ処理をスキップ
  563.         isTap = false;
  564.         dragDistance = 0;
  565.         return;
  566.       }
  567.     }
  568.     
  569.     // ピンチズーム中はタップ処理を無効化
  570.     if (pinch.active) {
  571.       return;
  572.     }
  573.     
  574.     // ドラッグしていない場合のタップ処理(ダブルタップリセットのみ)
  575.     if (!wasDragging) {
  576.       // ダブルタップ判定(拡大時のみリセット)
  577.       if (scale > initialScale) {
  578.         var isDoubleTap = (now - lastTapTime < tapThreshold) &&
  579.                           (Math.abs(tapX - lastTapX) < tapDistanceThreshold) &&
  580.                           (Math.abs(tapY - lastTapY) < tapDistanceThreshold);
  581.         
  582.         if (isDoubleTap) {
  583.           // ダブルタップでリセット
  584.           e.preventDefault();
  585.           resetTransform();
  586.           lastTapTime = 0;
  587.           return;
  588.         }
  589.       }
  590.       // シングルタップでの拡大機能は削除
  591.     }
  592.     
  593.     // タップ情報を記録(次のタップとの比較用)
  594.     lastTapTime = now;
  595.     lastTapX = tapX;
  596.     lastTapY = tapY;
  597.     dragDistance = 0;
  598.     } catch (err) {
  599.       console.error('Error in handleTouchEnd:', err);
  600.     }
  601.   }
  602.   
  603.   // タッチ移動処理(ピンチズーム/ドラッグ)
  604.   function handleTouchMove(e) {
  605.     try {
  606.       // 要素の存在確認
  607.       if (!$modal.length || !$stage.length || !$stage[0]) return;
  608.       
  609.       // モーダルが開いていない場合は無視
  610.       if (!$modal.hasClass('is-open')) return;
  611.       
  612.       // デバイス判定を再計算
  613.       isPC = window.innerWidth >= 768;
  614.       
  615.       // PC版の場合は無効
  616.       if (isPC) return;
  617.       
  618.       // タッチイベントを取得
  619.       var touches = e.touches || [];
  620.       
  621.       if (!touches || touches.length === 0) return;
  622.     
  623.     // ピンチズーム処理(2点タッチ時)
  624.     if (touches.length === 2) {
  625.       e.preventDefault();
  626.       e.stopPropagation();
  627.       
  628.       dragging = false; // ドラッグを無効化
  629.       
  630.       var d1 = dist2(touches);
  631.       if (d1 === 0 || d1 === undefined || !isFinite(d1)) return; // 無効な値はスキップ
  632.       
  633.       // ピンチが開始されていない、または基準距離が未設定の場合は初期化
  634.       if (!pinch.active || !pinch.d0 || pinch.d0 === 0) {
  635.         pinch.active = true;
  636.         pinch.d0 = d1; // 基準距離を設定
  637.         var rect = $stage[0].getBoundingClientRect();
  638.         pinch.cx = (touches[0].clientX + touches[1].clientX)/2 - rect.left;
  639.         pinch.cy = (touches[0].clientY + touches[1].clientY)/2 - rect.top;
  640.         // 初回は基準を設定するだけで、計算は次回のtouchmoveで実行
  641.         return;
  642.       }
  643.       
  644.       // ピンチ距離の変化から拡大率を計算(基準距離pinch.d0に対する現在の距離d1の比率)
  645.       var scaleChange = d1 / pinch.d0;
  646.       if (!isFinite(scaleChange) || scaleChange <= 0) {
  647.         // 無効な値の場合はスキップ
  648.         return;
  649.       }
  650.       
  651.       // 現在の拡大率を基準に新しい拡大率を計算
  652.       var oldScale = scale;
  653.       var newScale = oldScale * scaleChange;
  654.       
  655.       // 拡大率の制限
  656.       newScale = Math.min(maxScale, Math.max(minScale, newScale));
  657.       
  658.       // ピンチ中心を基準に拡大(ピンチ中心がズームの中心になるように)
  659.       var rect = $stage[0].getBoundingClientRect();
  660.       var stageCenterX = rect.width / 2;
  661.       var stageCenterY = rect.height / 2;
  662.       
  663.       // ピンチ中心から画像中心への相対位置を計算
  664.       var offsetX = pinch.cx - stageCenterX - tx;
  665.       var offsetY = pinch.cy - stageCenterY - ty;
  666.       
  667.       // 拡大率の変化比
  668.       var scaleRatio = newScale / oldScale;
  669.       if (scaleRatio > 0 && isFinite(scaleRatio)) {
  670.         // ピンチ中心を基準に位置を調整
  671.         tx -= offsetX * (scaleRatio - 1);
  672.         ty -= offsetY * (scaleRatio - 1);
  673.       }
  674.       
  675.       scale = newScale;
  676.       applyTransform();
  677.       
  678.       // 基準距離を更新(次回の計算用に現在の距離を基準にする - 累積方式)
  679.       pinch.d0 = d1;
  680.       return;
  681.     }
  682.     
  683.     // ドラッグ処理(1点タッチ時、拡大時のみ)
  684.     if (dragging && touches.length === 1 && !pinch.active) {
  685.       // 拡大時のみドラッグ可能(initialScaleより大きい場合)
  686.       if (scale > initialScale) {
  687.         e.preventDefault();
  688.         e.stopPropagation();
  689.         
  690.         var touch = touches[0];
  691.         var dx = touch.clientX - dragStartX;
  692.         var dy = touch.clientY - dragStartY;
  693.         
  694.         // ドラッグ距離を計算
  695.         dragDistance = Math.sqrt(dx * dx + dy * dy);
  696.         // ドラッグ距離が大きい場合はタップ扱いしない
  697.         if (dragDistance > 10) {
  698.           isTap = false;
  699.         }
  700.         
  701.         // 移動量を適用
  702.         tx = dragStartTx + dx;
  703.         ty = dragStartTy + dy;
  704.         
  705.         // requestAnimationFrameでスムーズに更新(カクつきを防止)
  706.         if (rafId) cancelAnimationFrame(rafId);
  707.         rafId = requestAnimationFrame(function() {
  708.           applyTransform();
  709.         });
  710.       }
  711.       return;
  712.     }
  713.     } catch (err) {
  714.       console.error('Error in handleTouchMove:', err);
  715.     }
  716.   }
  717.   
  718.   // ネイティブイベントリスナーを登録
  719.   if ($stage.length && $stage[0]) {
  720.     $stage[0].addEventListener('touchstart', handleTouchStart, { passive: false });
  721.     $stage[0].addEventListener('touchmove', handleTouchMove, { passive: false });
  722.     $stage[0].addEventListener('touchend', handleTouchEnd, { passive: false });
  723.     $stage[0].addEventListener('touchcancel', handleTouchEnd, { passive: false });
  724.   }
  725.   // ホイールでズーム(PC用、マウス位置を中心に拡大)
  726.   if ($stage.length && $stage[0]) {
  727.     $stage[0].addEventListener('wheel', function (e) {
  728.       try {
  729.         // 要素の存在確認
  730.         if (!$stage.length || !$stage[0]) return;
  731.         
  732.         e.preventDefault();
  733.         var delta = e.deltaY;
  734.         var factor = (delta > 0) ? -0.1 : 0.1;
  735.         var oldScale = scale;
  736.         var newScale = Math.min(maxScale, Math.max(minScale, scale + factor));
  737.         
  738.         // マウス位置を中心に拡大
  739.         var rect = $stage[0].getBoundingClientRect();
  740.         var mouseX = e.clientX - rect.left;
  741.         var mouseY = e.clientY - rect.top;
  742.         var stageCenterX = rect.width / 2;
  743.         var stageCenterY = rect.height / 2;
  744.         var offsetX = mouseX - stageCenterX - tx;
  745.         var offsetY = mouseY - stageCenterY - ty;
  746.         
  747.         var scaleRatio = newScale / oldScale;
  748.         tx -= offsetX * (scaleRatio - 1);
  749.         ty -= offsetY * (scaleRatio - 1);
  750.         
  751.         scale = newScale;
  752.         applyTransform();
  753.       } catch (err) {
  754.         console.error('Error in wheel handler:', err);
  755.       }
  756.     }, { passive: false });
  757.   }
  758.   
  759.   // リサイズ時に初期拡大率を再計算
  760.   $(window).on('resize', function() {
  761.     isPC = window.innerWidth >= 768;
  762.     if ($modal.hasClass('is-open')) {
  763.       setInitialScale();
  764.     }
  765.   });
  766. });
  767. </script>
  768. {% endblock %}
  769. {% block main %}
  770.     {# 商品拡大 #}
  771.     <div class="bc-zoom-modal" aria-hidden="true">
  772.         <button type="button" class="bc-zoom-close" aria-label="閉じる">&times;</button>
  773.         <div class="bc-zoom-stage">
  774.             <img class="bc-zoom-img" src="" alt="{{ Product.name }}">
  775.         </div>
  776.     </div>
  777.     <div class="ec-productRole">
  778.         <div class="ec-grid2">
  779.             <div class="ec-grid2__cell">
  780.                 <div class="ec-sliderItemRole">
  781.                     {#
  782.                     <!--▼商品画像▼-->
  783.                     <div class="item_visual">
  784.                         {% for ProductImage in Product.ProductImage %}
  785.                             <div class="slide-item"><img src="{{ asset(ProductImage, 'save_image') }}" 
  786.                                                          alt="{{ loop.first ? Product.name : '' }}" 
  787.                                                          width="550" 
  788.                                                          height="550"
  789.                                                          {% if loop.index == 1 %}loading="eager" fetchpriority="high"{% else %}loading="lazy"{% endif %}
  790.                                                          sizes="(max-width: 768px) 100vw, 50vw" /></div>
  791.                         {% else %}
  792.                             <div class="slide-item"><img src="{{ asset(''|no_image_product, 'save_image') }}" alt="{{ loop.first ? Product.name : '' }}" width="550" height="550"></div>
  793.                         {% endfor %}
  794.                     </div>
  795.                     <!--▲商品画像▲-->
  796.                     #}
  797.                     <!--▼商品画像▼-->
  798.                     <div class="item_visual">
  799.                         {% for ProductImage in Product.ProductImage %}
  800.                             <div class="slide-item">
  801.                                 <img
  802.                                     src="{{ asset(ProductImage, 'save_image') }}"
  803.                                     data-zoom-src="{{ asset(ProductImage, 'save_image') }}"
  804.                                     class="bc-zoom-thumb"
  805.                                     alt="{{ loop.first ? Product.name : '' }}"
  806.                                     width="550"
  807.                                     height="550"
  808.                                     {% if loop.index == 1 %}loading="eager" fetchpriority="high"{% else %}loading="lazy"{% endif %}
  809.                                     sizes="(max-width: 768px) 100vw, 50vw" />
  810.                             </div>
  811.                         {% else %}
  812.                             <div class="slide-item">
  813.                                 <img
  814.                                     src="{{ asset(''|no_image_product, 'save_image') }}"
  815.                                     data-zoom-src="{{ asset(''|no_image_product, 'save_image') }}"
  816.                                     class="bc-zoom-thumb"
  817.                                     alt="{{ loop.first ? Product.name : '' }}"
  818.                                     width="550" height="550" />
  819.                             </div>
  820.                         {% endfor %}
  821.                     </div>
  822.                     <!--▲商品画像▲-->
  823.                 </div>
  824.             </div>
  825.             <!--▼商品紹介テーブル▼-->
  826.             <div class="ec-grid2__cell">
  827.               <table class="detail-table">
  828.                 <!--★商品名★-->
  829.                 <tr class="detail-table__title">
  830.                   <th>商品名</th>
  831.                   <td>
  832.                     <p>{{ Product.name }}</p>
  833.                   </td>
  834.                 </tr>
  835.                 <!--★キャスト★-->
  836.                 <tr class="detail-table__item">
  837.                   <th>出演女優</th>
  838.                     {% if Product.actress is not empty %}
  839.                         <td>
  840.                             {% set actresses = Product.actress|split(',') %}
  841.                             {% for actress in actresses %}
  842.                                 {% set actress = actress|trim %}
  843.                                 <a href="{{ url('product_list') }}?date=&maker_id=&keyword={{ actress|url_encode }}">{{ actress }}</a>{% if not loop.last %}, {% endif %}
  844.                             {% endfor %}
  845.                         </td>
  846.                     {% endif %}
  847.                   
  848.                 </tr>
  849.                 <!--★発売日★-->
  850.                 <tr class="detail-table__item">
  851.                   <th>入荷日</th>
  852.                   <td>
  853.                     {% for productCategory in Product.ProductCategories %}
  854.                         {% set parent_category = productCategory.Category %}
  855.                         {% if parent_category is not empty %}
  856.                             {% set category_name = parent_category.name %}
  857.                             {# 8桁なら YY年MM月DD日 に変換 #}
  858.                             <a href="{{ url('product_list') }}?date={{ category_name[:4] ~ '-' ~ category_name[4:2] ~ '-' ~ category_name[6:2] }}&maker_id=&keyword=">
  859.                                 {{ category_name[:4] ~ '年' ~ category_name[4:2] ~ '月' ~ category_name[6:2] ~ '日' }}
  860.                             </a>
  861.                         {% endif %}
  862.                     {% endfor %}
  863.                   </td>
  864.                 </tr>
  865.                 <!--★収録時間★-->
  866.                 <tr class="detail-table__item">
  867.                   <th>収録時間</th>
  868.                   <td>{{ Product.recording_time }}</td>
  869.                 </tr>
  870.                 <!--★メーカー★-->
  871.                 {% if Product.Maker is not empty %}
  872.                 <tr class="detail-table__item">
  873.                     <th>メーカー</th>
  874.                     <td>
  875.                         <a href="{{ url('product_list') }}?date=&maker_id={{ Product.Maker.id }}&keyword=">{{ Product.Maker.name }}</a>
  876.                     </td>
  877.                 </tr>
  878.                 {% endif %}
  879.                 <!--★詳細メインコメント★-->
  880.                 <tr class="detail-table__desc">
  881.                   <th>商品コメント</th>
  882.                   <td>
  883.                     <p>{{ Product.description_detail|raw|nl2br }}</p>
  884.                     <br>
  885.                     <p>※当作品の出演者は全て18歳以上です。</p>
  886.                   </td>
  887.                 </tr>
  888.               </table>
  889.               <!--▲商品紹介テーブル▲-->
  890.               <br>
  891.               <!--▼買い物かごエリア▼-->
  892.               <form action="{{ url('product_add_cart', {id:Product.id}) }}" method="post" id="form1" name="form1">
  893.                 <table class="detail-class">
  894.                   <tr class="detail-class_plan">
  895.                     {% if form.classcategory_id1 is defined %}
  896.                       <th>{{ form_label(form.classcategory_id1) }}</th>
  897.                       <td>
  898.                         <div class="ec-select">
  899.                           {{ form_widget(form.classcategory_id1 ) }}
  900.                           {{ form_errors(form.classcategory_id1) }}
  901.                         </div>
  902.                       </td>
  903.                       {% if form.classcategory_id2 is defined %}
  904.                         <th>{{ form_label(form.classcategory_id2) }}</th>
  905.                         <td>
  906.                           <div class="ec-select">
  907.                             {{ form_row(form.classcategory_id2) }}
  908.                             {{ form_errors(form.classcategory_id2) }}
  909.                           </div>
  910.                         </td>
  911.                       {% endif %}
  912.                     {% endif %}
  913.                   </tr>
  914.                   <tr class="detail-class_price">
  915.                     <th>価格(税込)</th>
  916.                     <td>
  917.                       <div class="ec-productRole__price">
  918.                                 <div class="ec-price">
  919.                                     <span class="ec-price__price price02-default">{{ Product.getPrice02IncTaxMin|price }}</span>
  920.                                     <span class="ec-price__tax">{{ '円'|trans }}</span>
  921.                                 </div>
  922.                       </div>
  923.                     </td>
  924.                   </tr>
  925.                 </table>
  926.                 <div class="ec-numberInput">
  927.                   {{ form_widget(form.quantity, { data: '1', type: 'hidden'} ) }}
  928.                 </div>
  929.                 {{ form_rest(form) }}
  930.                 </form>
  931.                 
  932.                 
  933.                <!-- Button -->
  934.                <div class="cart-btn">
  935.                  {% if BaseInfo.option_favorite_product %}
  936.                    <form action="{{ url('product_add_favorite', {id:Product.id}) }}" method="post">
  937.                      <div class="ec-productRole__btn">
  938.                       {% if favorite == false %}
  939.                         <input type="image" id="favorite" class="ec-blockBtn--cancel add_favorite" src="{{ asset('assets/img/new/img_favorite-add.png') }}" alt="お気に入りに追加" data-product_name="{{ Product.name}}" />
  940.                       {% else %}
  941.                         <input type="image" class="ec-blockBtn--cancel" disabled="disabled" src="{{ asset('assets/img/new/img_favorite-added.png') }}" alt="お気に入りに追加済" />
  942.                       {% endif %}
  943.                      </div>
  944.                    </form>
  945.                  {% endif %}
  946.                 <div class="ec-productRole__btn">
  947.                   <input type="image" src="{{ asset('assets/img/new/img_cart-add.png') }}" alt="カートに入れる" class="ec-blockBtn--action add-cart" data-cartid="{{ Product.id }}" form="productForm{{ Product.id }}" />
  948.                 </div>
  949.              </div>
  950.              
  951.              <!-- モーダルウィンドウ -->
  952.              <div class="ec-modal">
  953.                 <div class="ec-modal-overlay">
  954.                   <div class="ec-modal-wrap">
  955.                     <span class="ec-modal-close"><span class="ec-icon"><img src="{{ asset('assets/icon/cross-dark.svg') }}" alt=""/></span></span>
  956.                     <div id="ec-modal-header" class="text-center">{{ 'カートに追加しました。'|trans }}</div>
  957.                       <div class="ec-modal-box">
  958.                         <div class="ec-role">
  959.                           <span class="ec-inlineBtn--cancel">{{ 'お買い物を続ける'|trans }}</span>
  960.                           <a href="{{ url('cart') }}" class="ec-inlineBtn--action">{{ 'カートへ進む'|trans }}</a>
  961.                         </div>
  962.                       </div>
  963.                     </div>
  964.                   </div>
  965.                 </div>
  966.              </div>
  967.         </div>
  968.     </div>
  969.     
  970.     {# 関連商品(同じメーカー) #}
  971.     {% if RelatedProducts is defined and RelatedProducts|length > 0 %}
  972.         <style>
  973.             .related-products-section {
  974.                 margin-top: 60px;
  975.                 padding: 40px 20px;
  976.                 background-color: #f8f8f8;
  977.             }
  978.             .related-products-section h2 {
  979.                 text-align: center;
  980.                 margin-bottom: 30px;
  981.                 font-size: 1.5rem;
  982.                 font-weight: bold;
  983.                 background-color: #c60d69;
  984.                 color: white;
  985.                 padding: 15px 20px;
  986.                 border-radius: 10px;
  987.                 width: 100%;
  988.                 box-sizing: border-box;
  989.             }
  990.             @media only screen and (max-width: 768px) {
  991.                 .related-products-section h2 {
  992.                     font-size: 1.2rem;
  993.                     padding: 12px 15px;
  994.                 }
  995.             }
  996.             .related-products-grid {
  997.                 display: grid;
  998.                 gap: 16px;
  999.                 grid-template-columns: repeat(2, 1fr);
  1000.             }
  1001.             @media only screen and (min-width: 1200px) {
  1002.                 .related-products-grid {
  1003.                     grid-template-columns: repeat(4, 1fr);
  1004.                     gap: 20px;
  1005.                 }
  1006.             }
  1007.             .related-product-item {
  1008.                 background: #fc7cb3;
  1009.                 border-radius: 8px;
  1010.                 overflow: hidden;
  1011.                 box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  1012.                 transition: transform 0.2s;
  1013.             }
  1014.             .related-product-item:hover {
  1015.                 transform: translateY(-4px);
  1016.                 box-shadow: 0 4px 8px rgba(0,0,0,0.15);
  1017.             }
  1018.             .related-product-image {
  1019.                 width: 100%;
  1020.                 overflow: hidden;
  1021.                 background: #f0f0f0;
  1022.             }
  1023.             .related-product-image img {
  1024.                 width: 100%;
  1025.                 height: 100%;
  1026.                 object-fit: cover;
  1027.             }
  1028.             .related-product-info {
  1029.                 padding: 12px;
  1030.             }
  1031.             .related-product-date {
  1032.                 font-size: 0.75rem;
  1033.                 color: #666;
  1034.                 margin-bottom: 8px;
  1035.             }
  1036.             .related-product-title {
  1037.                 font-size: 0.75rem;
  1038.                 line-height: 1.3;
  1039.                 margin-bottom: 8px;
  1040.                 display: -webkit-box;
  1041.                 -webkit-line-clamp: 2;
  1042.                 -webkit-box-orient: vertical;
  1043.                 overflow: hidden;
  1044.                 min-height: calc(1.3em * 2);
  1045.             }
  1046.             .related-product-desc {
  1047.                 font-size: 0.7rem;
  1048.                 color: white;
  1049.                 line-height: 1.3;
  1050.                 margin-bottom: 8px;
  1051.                 display: -webkit-box;
  1052.                 -webkit-line-clamp: 3;
  1053.                 -webkit-box-orient: vertical;
  1054.                 overflow: hidden;
  1055.             }
  1056.             .related-product-link {
  1057.                 display: block;
  1058.                 text-align: center;
  1059.                 padding: 8px;
  1060.                 background-color: #28a745;
  1061.                 color: white;
  1062.                 text-decoration: none;
  1063.                 font-size: 0.8rem;
  1064.                 border-radius: 4px;
  1065.                 transition: background-color 0.2s;
  1066.                 white-space: nowrap;
  1067.             }
  1068.             .related-product-link:hover {
  1069.                 background-color: #218838;
  1070.                 color: white;
  1071.             }
  1072.             @media only screen and (max-width: 768px) {
  1073.                 .related-product-link {
  1074.                     font-size: 0.7rem;
  1075.                     padding: 6px;
  1076.                 }
  1077.             }
  1078.         </style>
  1079.     
  1080.     <div class="related-products-section">
  1081.         <h2>関連商品({{ Product.Maker is not empty ? Product.Maker.name : '同じメーカー' }})</h2>
  1082.         <div class="related-products-grid">
  1083.             {% for P in RelatedProducts %}
  1084.                 <div class="related-product-item">
  1085.                     <a href="{{ url('product_detail', {'id': P.id}) }}">
  1086.                         <div class="related-product-image">
  1087.                             <img src="{{ asset(P.list_image|no_image_product, 'save_image') }}" 
  1088.                                  alt="{{ P.name }}" 
  1089.                                  width="300" 
  1090.                                  height="300"
  1091.                                  loading="lazy"
  1092.                                  sizes="(max-width: 768px) 33vw, (max-width: 1200px) 20vw, 15vw" />
  1093.                         </div>
  1094.                     </a>
  1095.                     <div class="related-product-info">
  1096.                         <div class="related-product-desc">
  1097.                             {{ P.description_detail ? (P.description_detail | striptags | slice(0, 50) ~ '…') : '' }}
  1098.                         </div>
  1099.                         <a class="related-product-link" href="{{ url('product_detail', {'id': P.id}) }}">商品詳細へ</a>
  1100.                     </div>
  1101.                 </div>
  1102.             {% endfor %}
  1103.         </div>
  1104.     </div>
  1105.     {% endif %}
  1106.     
  1107.     {# よく見られる商品 #}
  1108.     {% if PopularProducts is defined and PopularProducts|length > 0 %}
  1109.     <div class="related-products-section">
  1110.         <h2>関連商品(よく見られる商品)</h2>
  1111.         <div class="related-products-grid">
  1112.             {% for P in PopularProducts %}
  1113.                 <div class="related-product-item">
  1114.                     <a href="{{ url('product_detail', {'id': P.id}) }}">
  1115.                         <div class="related-product-image">
  1116.                             <img src="{{ asset(P.list_image|no_image_product, 'save_image') }}" 
  1117.                                  alt="{{ P.name }}" 
  1118.                                  width="300" 
  1119.                                  height="300"
  1120.                                  loading="lazy"
  1121.                                  sizes="(max-width: 768px) 33vw, (max-width: 1200px) 20vw, 15vw" />
  1122.                         </div>
  1123.                     </a>
  1124.                     <div class="related-product-info">
  1125.                         <div class="related-product-desc">
  1126.                             {{ P.description_detail ? (P.description_detail | striptags | slice(0, 50) ~ '…') : '' }}
  1127.                         </div>
  1128.                         <a class="related-product-link" href="{{ url('product_detail', {'id': P.id}) }}">商品詳細へ</a>
  1129.                     </div>
  1130.                 </div>
  1131.             {% endfor %}
  1132.         </div>
  1133.     </div>
  1134.     {% endif %}
  1135.     
  1136. {% endblock %}